use crate::crypto::{self, Signature, SigningKey, VerifyingKey};
use crate::enums::GovernanceLogEntryType;
use crate::ids::{GovernanceLogId, GovernanceLogPrefix};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
pub use crate::enums::{AmendmentKind, KeyStatus, Standing};
mod texts;
pub use texts::{
AmendmentText, AmendmentTextStatus, AmendmentTexts, CommittedText,
TextCommitment, TextStatus, WITHHELD_TEXT,
};
mod council;
pub use council::{
AgendaRanking, Ballot, CouncilAttachment, CouncilDecisionRecord,
CouncilRound, CouncilSeat, CouncilVote, DecisionCategory, FinalVotes,
PlacedProposal, SeatRanking, SeatResponse,
};
mod redactable;
pub use redactable::{REDACTION_MARKER_PATTERN, Redactable};
pub mod reading;
pub use json_patch;
mod record;
pub use record::{
RecordAttachment, RecordParticipant, STEWARD_RECORD_VERSION, StewardRecord,
};
mod root;
pub use root::{
CertPurpose, CertificateError, KEY_CERT_VERSION, KeyCertStatement,
KeyCertificate, ROOT_DOMAIN, ROOT_KEYS, ROOT_THRESHOLD, RootSet,
RootSignature,
};
#[cfg(test)]
mod vectors;
pub const ENVELOPE_VERSION: u32 = 1;
pub const RETROACTIVE_AFTER: chrono::Duration = chrono::Duration::seconds(60);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{type_name}: expected {expected} bytes of hex, got {got:?}")]
pub struct HexLengthError {
pub type_name: &'static str,
pub expected: usize,
pub got: String,
}
macro_rules! hex_bytes {
($(#[$meta:meta])* $name:ident, $len:expr) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
#[cfg_attr(feature = "sqlx", sqlx(transparent))]
pub struct $name([u8; $len]);
impl $name {
pub fn as_bytes(&self) -> &[u8; $len] {
&self.0
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
}
impl From<[u8; $len]> for $name {
fn from(bytes: [u8; $len]) -> Self {
Self(bytes)
}
}
impl TryFrom<&[u8]> for $name {
type Error = HexLengthError;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
<[u8; $len]>::try_from(bytes).map(Self).map_err(|_| {
HexLengthError {
type_name: stringify!($name),
expected: $len,
got: hex::encode(bytes),
}
})
}
}
impl TryFrom<Vec<u8>> for $name {
type Error = HexLengthError;
fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(bytes.as_slice())
}
}
impl std::str::FromStr for $name {
type Err = HexLengthError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = hex::decode(s.trim()).map_err(|_| HexLengthError {
type_name: stringify!($name),
expected: $len,
got: s.to_string(),
})?;
Self::try_from(bytes.as_slice())
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_hex())
}
}
impl std::fmt::Debug for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl Serialize for $name {
fn serialize<S: serde::Serializer>(
&self,
s: S,
) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for $name {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(stringify!($name))
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(
module_path!(),
"::",
stringify!($name)
))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"pattern": format!("^[0-9a-f]{{{}}}$", $len * 2),
"description": format!("{} bytes, lowercase hex", $len),
})
}
}
};
}
hex_bytes!(
Sha256Hex,
32
);
hex_bytes!(
SignatureHex,
64
);
hex_bytes!(
PublicKeyHex,
32
);
hex_bytes!(
Blind,
32
);
hex_bytes!(
TextSalt,
32
);
impl Blind {
pub fn random() -> Self {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
Self(bytes)
}
}
impl TextSalt {
pub fn random() -> Self {
Self(*Blind::random().as_bytes())
}
}
impl From<Signature> for SignatureHex {
fn from(sig: Signature) -> Self {
Self(sig.to_bytes())
}
}
impl From<&SignatureHex> for Signature {
fn from(sig: &SignatureHex) -> Self {
Signature::from_bytes(&sig.0)
}
}
impl From<&VerifyingKey> for PublicKeyHex {
fn from(key: &VerifyingKey) -> Self {
Self(key.to_bytes())
}
}
impl PublicKeyHex {
pub fn to_verifying_key(
&self,
) -> Result<VerifyingKey, ed25519_dalek::SignatureError> {
VerifyingKey::from_bytes(&self.0)
}
}
pub fn canonical_json(value: &serde_json::Value) -> Vec<u8> {
let mut out = Vec::new();
write_canonical(value, &mut out);
out
}
fn write_canonical(value: &serde_json::Value, out: &mut Vec<u8>) {
use serde_json::Value;
match value {
Value::Null => out.extend_from_slice(b"null"),
Value::Bool(b) => {
out.extend_from_slice(if *b { b"true" } else { b"false" })
}
Value::Number(n) => serde_json::to_writer(&mut *out, n)
.expect("a number always serializes"),
Value::String(s) => serde_json::to_writer(&mut *out, s)
.expect("a string always serializes"),
Value::Array(items) => {
out.push(b'[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_canonical(item, out);
}
out.push(b']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
out.push(b'{');
for (i, key) in keys.into_iter().enumerate() {
if i > 0 {
out.push(b',');
}
serde_json::to_writer(&mut *out, key)
.expect("a string always serializes");
out.push(b':');
write_canonical(&map[key], out);
}
out.push(b'}');
}
}
}
pub fn non_integer_number(data: &serde_json::Value) -> Option<String> {
fn find(value: &serde_json::Value, path: &mut String) -> bool {
use serde_json::Value::{Array, Number, Object};
let mark = path.len();
match value {
Number(n) => return n.is_f64(),
Array(items) => {
for (i, item) in items.iter().enumerate() {
path.push_str(&format!("/{i}"));
if find(item, path) {
return true;
}
path.truncate(mark);
}
}
Object(map) => {
for (key, item) in map {
path.push('/');
path.push_str(&key.replace('~', "~0").replace('/', "~1"));
if find(item, path) {
return true;
}
path.truncate(mark);
}
}
_ => {}
}
false
}
let mut path = String::new();
find(data, &mut path).then_some(path)
}
pub fn data_hash(data: &serde_json::Value) -> Sha256Hex {
Sha256Hex(Sha256::digest(canonical_json(data)).into())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Envelope {
pub agora_governance_log: u32,
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub created_at: i64,
pub prev_hash: Option<Sha256Hex>,
pub data_hash: Sha256Hex,
}
impl Envelope {
pub fn new(
id: GovernanceLogId,
entry_type: GovernanceLogEntryType,
created_at: DateTime<Utc>,
prev_hash: Option<Sha256Hex>,
data_hash: Sha256Hex,
) -> Self {
Self {
agora_governance_log: ENVELOPE_VERSION,
id,
entry_type,
created_at: created_at.timestamp_micros(),
prev_hash,
data_hash,
}
}
pub fn created_at(&self) -> DateTime<Utc> {
DateTime::from_timestamp_micros(self.created_at)
.expect("an Envelope only ever holds an in-range timestamp")
}
pub fn preimage(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("an Envelope always serializes")
}
pub fn entry_hash(&self) -> Sha256Hex {
Sha256Hex(Sha256::digest(self.preimage()).into())
}
}
pub fn truncate_to_micros(t: DateTime<Utc>) -> DateTime<Utc> {
DateTime::from_timestamp_micros(t.timestamp_micros())
.expect("a timestamp that came from a DateTime is in range")
}
pub fn truncate_to_seconds(t: DateTime<Utc>) -> DateTime<Utc> {
DateTime::from_timestamp(t.timestamp(), 0)
.expect("a timestamp that came from a DateTime is in range")
}
pub fn is_retroactive(
created_at: DateTime<Utc>,
signed_at: DateTime<Utc>,
) -> bool {
signed_at - created_at > RETROACTIVE_AFTER
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct GovernanceAttestation {
pub envelope_version: u32,
pub chain_seq: u64,
pub prev_hash: Option<Sha256Hex>,
pub data_hash: Sha256Hex,
pub entry_hash: Sha256Hex,
pub signature: SignatureHex,
pub signed_at: DateTime<Utc>,
pub retroactive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct GovernanceChainLink {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub created_at: DateTime<Utc>,
pub attestation: GovernanceAttestation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "read_as_written"
)]
pub texts: Option<AmendmentTexts>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct GovernanceSigningKey {
pub algorithm: String,
pub public_key: PublicKeyHex,
pub envelope_version: u32,
}
impl GovernanceSigningKey {
pub fn new(key: &VerifyingKey) -> Self {
Self {
algorithm: "ed25519".to_string(),
public_key: key.into(),
envelope_version: ENVELOPE_VERSION,
}
}
}
pub const AMENDMENT_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AmendmentError {
#[error("agora_governance_amendment is {0}, not 1 or {AMENDMENT_VERSION}")]
UnsupportedVersion(u32),
#[error(
"a version 1 amendment carries its texts and a version \
{AMENDMENT_VERSION} one commits to them; this does neither \
consistently"
)]
TextShape,
#[error("`{0}` beside the entry is not the text the entry committed to")]
TextMismatch(&'static str),
#[error("texts beside an entry that commits to none")]
UncommittedText,
#[error("kind `redaction` requires a `redaction`")]
MissingRedaction,
#[error("`redaction` is only valid on kind `redaction`")]
UnexpectedRedaction,
#[error("kind `revision` requires a `revision`")]
MissingRevision,
#[error("`revision` is only valid on kind `revision`")]
UnexpectedRevision,
#[error("the `revision` is malformed: {0}")]
RevisionShape(ReviseError),
#[error("amendment target {0} is not an entry of this chain")]
UnknownTarget(GovernanceLogId),
#[error("amendment target {0} is not an earlier entry")]
ForwardReference(GovernanceLogId),
#[error("target_entry_hash is not {0}'s entry_hash")]
WrongTargetHash(GovernanceLogId),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct Amendment {
pub agora_governance_amendment: u32,
pub target: GovernanceLogId,
pub target_entry_hash: Sha256Hex,
pub kind: AmendmentKind,
#[serde(default)]
pub authority: Option<GovernanceLogId>,
pub basis: AmendmentText,
pub note: AmendmentText,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<AmendmentText>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redaction: Option<Redaction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<Revision>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct Redaction {
pub fields: Vec<String>,
pub resulting_data_hash: Sha256Hex,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resulting_latest_hash: Option<Sha256Hex>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Revision {
pub patch: json_patch::Patch,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub duplicates: Vec<(String, String)>,
pub resulting_data_hash: Sha256Hex,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Edit {
pub patch: json_patch::Patch,
pub duplicates: Vec<(String, String)>,
}
impl From<json_patch::Patch> for Edit {
fn from(patch: json_patch::Patch) -> Self {
Self {
patch,
duplicates: Vec::new(),
}
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for Revision {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
"Revision".into()
}
fn json_schema(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
let hash = generator.subschema_for::<Sha256Hex>();
schemars::json_schema!({
"type": "object",
"properties": {
"patch": {
"description": "RFC 6902 JSON Patch from the previous version to this one",
"type": "array",
"items": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": ["add", "remove", "replace", "move", "copy", "test"]
},
"path": {"type": "string"},
"from": {"type": "string"},
"value": true
},
"required": ["op", "path"]
}
},
"duplicates": {
"description": "(removed path, the path it duplicated) for each duplicate removed",
"type": "array",
"items": {
"type": "array",
"items": {"type": "string"},
"minItems": 2,
"maxItems": 2
}
},
"resulting_data_hash": hash
},
"required": ["patch", "resulting_data_hash"]
})
}
}
impl Revision {
pub fn validate(&self) -> Result<(), ReviseError> {
if self.patch.is_empty() {
return Err(ReviseError::EmptyPatch);
}
for (path, _) in &self.duplicates {
let removed = self.patch.iter().any(|op| {
matches!(op, json_patch::PatchOperation::Remove(r) if r.path.as_str() == path)
});
if !removed {
return Err(ReviseError::NotRemoved(path.clone()));
}
}
Ok(())
}
pub fn remove_duplicates(
data: &serde_json::Value,
pairs: &[(&str, &str)],
) -> Result<Edit, ReviseError> {
let mut ops = Vec::with_capacity(pairs.len());
for (path, _) in pairs {
let path = json_patch::jsonptr::PointerBuf::parse(*path)
.map_err(|e| ReviseError::Patch(e.to_string()))?;
ops.push(json_patch::PatchOperation::Remove(
json_patch::RemoveOperation { path },
));
}
let edit = Edit {
patch: json_patch::Patch(ops),
duplicates: pairs
.iter()
.map(|(p, s)| (p.to_string(), s.to_string()))
.collect(),
};
check_duplicates(data, &edit)?;
Ok(edit)
}
}
fn check_duplicates(
data: &serde_json::Value,
edit: &Edit,
) -> Result<(), ReviseError> {
for (path, same_as) in &edit.duplicates {
let resolve = |pointer: &str| {
data.pointer(pointer)
.ok_or_else(|| ReviseError::Unresolved(pointer.to_string()))
};
let (removed, kept) = (resolve(path)?, resolve(same_as)?);
if canonical_json(removed) != canonical_json(kept) {
return Err(ReviseError::NotIdentical {
path: path.clone(),
same_as: same_as.clone(),
});
}
}
let revised = apply_patch(data, &edit.patch)?;
for (path, same_as) in &edit.duplicates {
let kept = data.pointer(same_as);
let removes = |op: &json_patch::PatchOperation| matches!(op, json_patch::PatchOperation::Remove(r) if r.path.as_str() == path);
if !edit.patch.iter().any(removes) {
return Err(ReviseError::NotRemoved(path.clone()));
}
let survives = revised.pointer(same_as).is_some_and(|v| {
kept.is_some_and(|k| canonical_json(v) == canonical_json(k))
});
if !survives {
return Err(ReviseError::SourceRemoved {
path: path.clone(),
same_as: same_as.clone(),
});
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AmendmentDraft {
pub amendment: Amendment,
pub texts: AmendmentTexts,
}
impl AmendmentDraft {
pub fn new(
target: GovernanceLogId,
target_entry_hash: Sha256Hex,
kind: AmendmentKind,
basis: impl Into<CommittedText>,
note: impl Into<CommittedText>,
) -> Result<Self, AmendmentError> {
match kind {
AmendmentKind::Redaction => {
return Err(AmendmentError::MissingRedaction);
}
AmendmentKind::Revision => {
return Err(AmendmentError::MissingRevision);
}
_ => {}
}
let (basis, note) = (basis.into(), note.into());
Ok(Self {
amendment: Amendment {
agora_governance_amendment: AMENDMENT_VERSION,
target,
target_entry_hash,
kind,
authority: None,
basis: AmendmentText::Committed(basis.commitment()),
note: AmendmentText::Committed(note.commitment()),
rationale: None,
redaction: None,
revision: None,
},
texts: AmendmentTexts {
basis: Some(basis),
note: Some(note),
rationale: None,
},
})
}
#[allow(clippy::too_many_arguments)]
pub fn redaction(
amendment_id: &GovernanceLogId,
target: GovernanceLogId,
target_entry_hash: Sha256Hex,
basis: impl Into<CommittedText>,
note: impl Into<CommittedText>,
mut fields: Vec<String>,
data: &serde_json::Value,
blind: Blind,
revisions: &[&Revision],
) -> Result<(Self, serde_json::Value), RedactError> {
for extra in duplicates_of(&fields, revisions) {
let covered = fields.iter().any(|f| {
extra.strip_prefix(f.as_str()).is_some_and(|rest| {
rest.is_empty() || rest.starts_with('/')
})
});
if !covered && data.pointer(&extra).is_some() {
fields.push(extra);
}
}
let redacted = redact_data(data, &fields, amendment_id, blind)?;
let resulting_latest_hash = match revisions {
[] => None,
_ => Some(data_hash(
&latest(&redacted, revisions.iter().copied())
.map_err(|e| RedactError::Rebase(e.to_string()))?,
)),
};
let (basis, note) = (basis.into(), note.into());
Ok((
Self {
amendment: Amendment {
agora_governance_amendment: AMENDMENT_VERSION,
target,
target_entry_hash,
kind: AmendmentKind::Redaction,
authority: None,
basis: AmendmentText::Committed(basis.commitment()),
note: AmendmentText::Committed(note.commitment()),
rationale: None,
redaction: Some(Redaction {
fields,
resulting_data_hash: data_hash(&redacted),
resulting_latest_hash,
}),
revision: None,
},
texts: AmendmentTexts {
basis: Some(basis),
note: Some(note),
rationale: None,
},
},
redacted,
))
}
pub fn revision(
target: GovernanceLogId,
target_type: GovernanceLogEntryType,
target_entry_hash: Sha256Hex,
basis: impl Into<CommittedText>,
note: impl Into<CommittedText>,
latest: &serde_json::Value,
edit: impl Into<Edit>,
) -> Result<(Self, serde_json::Value), ReviseError> {
let Edit {
mut patch,
duplicates,
} = edit.into();
if !is_revisable(target_type) {
return Err(ReviseError::NotRevisable(target_type));
}
if patch.is_empty() {
return Err(ReviseError::EmptyPatch);
}
let blind_pointer = format!("/{BLIND_KEY}");
for op in patch.iter() {
let from = match op {
json_patch::PatchOperation::Move(m) => Some(m.from.as_str()),
json_patch::PatchOperation::Copy(c) => Some(c.from.as_str()),
_ => None,
};
for pointer in
[Some(op.path().as_str()), from].into_iter().flatten()
{
if overlaps(pointer, &blind_pointer) {
return Err(ReviseError::BlindPointer(pointer.to_string()));
}
}
}
let adds = patch.iter().any(|op| {
matches!(
op,
json_patch::PatchOperation::Add(_)
| json_patch::PatchOperation::Replace(_)
)
});
if adds && latest.is_object() && latest.get(BLIND_KEY).is_none() {
patch.0.push(json_patch::PatchOperation::Add(
json_patch::AddOperation {
path: json_patch::jsonptr::PointerBuf::from_tokens([
BLIND_KEY,
]),
value: Blind::random().to_hex().into(),
},
));
}
let edit = Edit { patch, duplicates };
check_duplicates(latest, &edit)?;
let Edit { patch, duplicates } = edit;
let revised = apply_patch(latest, &patch)?;
let (basis, note) = (basis.into(), note.into());
Ok((
Self {
amendment: Amendment {
agora_governance_amendment: AMENDMENT_VERSION,
target,
target_entry_hash,
kind: AmendmentKind::Revision,
authority: None,
basis: AmendmentText::Committed(basis.commitment()),
note: AmendmentText::Committed(note.commitment()),
rationale: None,
redaction: None,
revision: Some(Revision {
patch,
duplicates,
resulting_data_hash: data_hash(&revised),
}),
},
texts: AmendmentTexts {
basis: Some(basis),
note: Some(note),
rationale: None,
},
},
revised,
))
}
pub fn with_authority(mut self, authority: GovernanceLogId) -> Self {
self.amendment.authority = Some(authority);
self
}
pub fn with_rationale(
mut self,
rationale: impl Into<CommittedText>,
) -> Self {
let rationale = rationale.into();
self.amendment.rationale =
Some(AmendmentText::Committed(rationale.commitment()));
self.texts.rationale = Some(rationale);
self
}
}
impl Amendment {
pub fn validate(&self) -> Result<(), AmendmentError> {
let plain = match self.agora_governance_amendment {
1 => true,
AMENDMENT_VERSION => false,
other => return Err(AmendmentError::UnsupportedVersion(other)),
};
let texts =
[Some(&self.basis), Some(&self.note), self.rationale.as_ref()];
if texts.into_iter().flatten().any(|t| t.is_plain() != plain) {
return Err(AmendmentError::TextShape);
}
match (self.kind, &self.redaction) {
(AmendmentKind::Redaction, None) => {
return Err(AmendmentError::MissingRedaction);
}
(k, Some(_)) if k != AmendmentKind::Redaction => {
return Err(AmendmentError::UnexpectedRedaction);
}
_ => {}
}
match (self.kind, &self.revision) {
(AmendmentKind::Revision, None) => {
Err(AmendmentError::MissingRevision)
}
(AmendmentKind::Revision, Some(r)) => {
r.validate().map_err(AmendmentError::RevisionShape)
}
(_, Some(_)) => Err(AmendmentError::UnexpectedRevision),
_ => Ok(()),
}
}
pub fn text_status(
&self,
beside: Option<&AmendmentTexts>,
) -> Result<Option<AmendmentTextStatus>, AmendmentError> {
let empty = AmendmentTexts::default();
let beside = beside.unwrap_or(&empty);
let (Some(basis), Some(note)) = (
self.basis.status(beside.basis.as_ref()),
self.note.status(beside.note.as_ref()),
) else {
return if beside.is_empty() {
Ok(None)
} else {
Err(AmendmentError::UncommittedText)
};
};
let rationale = match (&self.rationale, &beside.rationale) {
(None, Some(_)) => return Err(AmendmentError::UncommittedText),
(None, None) => None,
(Some(text), beside) => text.status(beside.as_ref()),
};
for (name, status) in [
("basis", Some(basis)),
("note", Some(note)),
("rationale", rationale),
] {
if status == Some(TextStatus::Mismatch) {
return Err(AmendmentError::TextMismatch(name));
}
}
Ok(Some(AmendmentTextStatus {
basis,
note,
rationale,
}))
}
}
pub fn kind_standing(kind: AmendmentKind) -> Option<Standing> {
match kind {
AmendmentKind::NonPrecedential => Some(Standing::NonPrecedential),
AmendmentKind::Overruled => Some(Standing::Overruled),
AmendmentKind::Superseded => Some(Standing::Superseded),
AmendmentKind::Reinstated => Some(Standing::InForce),
AmendmentKind::Correction
| AmendmentKind::Redaction
| AmendmentKind::Reattested
| AmendmentKind::Revision => None,
}
}
pub fn standing(kinds: impl IntoIterator<Item = AmendmentKind>) -> Standing {
kinds
.into_iter()
.filter_map(kind_standing)
.last()
.unwrap_or_default()
}
pub const BLIND_KEY: &str = "_blind";
pub fn is_redactable(entry_type: GovernanceLogEntryType) -> bool {
!matches!(
entry_type,
GovernanceLogEntryType::Amendment | GovernanceLogEntryType::KeyRotation
)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BlindError {
#[error("a redactable entry's data must be a JSON object")]
NotAnObject,
#[error(
"data already has a {BLIND_KEY:?} key; the writer supplies it, not the caller"
)]
AlreadyBlinded,
#[error(
"{0:?} is a number that is not a 64-bit integer; governance data \
never contains one (put a fraction in a string)"
)]
NonIntegerNumber(String),
}
pub fn blind_data(
data: &serde_json::Value,
blind: Blind,
) -> Result<serde_json::Value, BlindError> {
if let Some(pointer) = non_integer_number(data) {
return Err(BlindError::NonIntegerNumber(pointer));
}
let mut out = data.clone();
let object = out.as_object_mut().ok_or(BlindError::NotAnObject)?;
if object.contains_key(BLIND_KEY) {
return Err(BlindError::AlreadyBlinded);
}
object.insert(BLIND_KEY.to_string(), blind.to_hex().into());
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RedactError {
#[error("pointer {0:?} does not resolve in the entry's data")]
Unresolved(String),
#[error("the empty pointer would redact the whole entry")]
WholeEntry,
#[error("a redaction names at least one pointer")]
NoFields,
#[error("{0:?} is the entry's blind; every redaction replaces it already")]
BlindPointer(String),
#[error("a revision no longer applies to the redacted data: {0}")]
Rebase(String),
#[error(
"{0:?} is a number that is not a 64-bit integer; governance data \
never contains one"
)]
NonIntegerNumber(String),
}
pub fn redaction_marker(amendment_id: &GovernanceLogId) -> String {
format!("[redacted by {amendment_id}]")
}
pub fn redact_data(
data: &serde_json::Value,
fields: &[String],
amendment_id: &GovernanceLogId,
blind: Blind,
) -> Result<serde_json::Value, RedactError> {
if fields.is_empty() {
return Err(RedactError::NoFields);
}
if let Some(pointer) = non_integer_number(data) {
return Err(RedactError::NonIntegerNumber(pointer));
}
let blind_pointer = format!("/{BLIND_KEY}");
let marker = serde_json::Value::String(redaction_marker(amendment_id));
let mut out = data.clone();
for pointer in fields {
if pointer.is_empty() {
return Err(RedactError::WholeEntry);
}
if *pointer == blind_pointer {
return Err(RedactError::BlindPointer(pointer.clone()));
}
let slot = out
.pointer_mut(pointer)
.ok_or_else(|| RedactError::Unresolved(pointer.clone()))?;
*slot = marker.clone();
}
if let Some(object) = out.as_object_mut() {
object.insert(BLIND_KEY.to_string(), blind.to_hex().into());
}
Ok(out)
}
pub fn is_revisable(entry_type: GovernanceLogEntryType) -> bool {
is_redactable(entry_type)
&& entry_type != GovernanceLogEntryType::StewardRecord
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ReviseError {
#[error("a revision's patch has at least one op")]
EmptyPatch,
#[error("the patch does not apply: {0}")]
Patch(String),
#[error("{0:?} is the entry's blind, which a revision never touches")]
BlindPointer(String),
#[error("pointer {0:?} does not resolve")]
Unresolved(String),
#[error("{path:?} is not byte-identical to {same_as:?}")]
NotIdentical { path: String, same_as: String },
#[error("{same_as:?}, which {path:?} duplicates, has to survive the patch")]
SourceRemoved { path: String, same_as: String },
#[error("{0:?} is listed as a duplicate the patch does not remove")]
NotRemoved(String),
#[error("{0} entries are never revised")]
NotRevisable(GovernanceLogEntryType),
#[error(
"{0:?} is a number that is not a 64-bit integer; governance data \
never contains one"
)]
NonIntegerNumber(String),
}
fn overlaps(a: &str, b: &str) -> bool {
let within = |inner: &str, outer: &str| {
inner
.strip_prefix(outer)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
};
within(a, b) || within(b, a)
}
pub fn apply_patch(
data: &serde_json::Value,
patch: &json_patch::Patch,
) -> Result<serde_json::Value, ReviseError> {
let mut out = data.clone();
json_patch::patch(&mut out, patch)
.map_err(|e| ReviseError::Patch(e.to_string()))?;
if let Some(pointer) = non_integer_number(&out) {
return Err(ReviseError::NonIntegerNumber(pointer));
}
Ok(out)
}
pub fn latest<'a>(
data: &serde_json::Value,
revisions: impl IntoIterator<Item = &'a Revision>,
) -> Result<serde_json::Value, ReviseError> {
let mut current = data.clone();
for revision in revisions {
current = apply_patch(¤t, &revision.patch)?;
}
Ok(current)
}
fn duplicates_of(fields: &[String], revisions: &[&Revision]) -> Vec<String> {
let mut extra = Vec::new();
for (removed, same_as) in revisions.iter().flat_map(|r| &r.duplicates) {
for field in fields {
if let Some(rest) = field.strip_prefix(same_as.as_str())
&& (rest.is_empty() || rest.starts_with('/'))
{
extra.push(format!("{removed}{rest}"));
} else if overlaps(field, same_as) {
extra.push(removed.clone());
}
}
}
extra
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RevisionHistory {
pub revisions: Vec<(GovernanceLogId, Revision)>,
pub rebased: usize,
pub rebased_hash: Option<Sha256Hex>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct AmendmentNotice {
pub id: GovernanceLogId,
pub kind: AmendmentKind,
#[serde(default)]
pub authority: Option<GovernanceLogId>,
pub basis: String,
pub note: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
pub created_at: DateTime<Utc>,
}
impl AmendmentNotice {
pub fn new(
id: GovernanceLogId,
created_at: DateTime<Utc>,
amendment: &Amendment,
beside: Option<&AmendmentTexts>,
) -> Self {
let beside = beside.cloned().unwrap_or_default();
Self {
id,
kind: amendment.kind,
authority: amendment.authority.clone(),
basis: amendment.basis.resolve(beside.basis.as_ref()).into(),
note: amendment.note.resolve(beside.note.as_ref()).into(),
rationale: amendment
.rationale
.as_ref()
.map(|r| r.resolve(beside.rationale.as_ref()).into()),
created_at,
}
}
}
pub const KEY_ROTATION_VERSION: u32 = 2;
pub const PUBLISHED_KEYS: &[&str] =
&["ebb3091dd328f1463362c171121921b2fe14628e3fc4c145deaccefb85c0e78a"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(rename_all = "snake_case")]
pub enum RotationReason {
Routine,
Compromise,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(deny_unknown_fields)]
pub struct TrustedHead {
pub id: GovernanceLogId,
pub chain_seq: u64,
pub entry_hash: Sha256Hex,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RotationError {
#[error("agora_governance_key_rotation is {0}, not {KEY_ROTATION_VERSION}")]
UnsupportedVersion(u32),
#[error("new_key is not a valid Ed25519 public key")]
BadNewKey,
#[error(
"the proof of possession does not verify for this rotation at this position"
)]
BadProof,
#[error("a compromise certificate must name last_trusted")]
MissingLastTrusted,
#[error("certificate: {0}")]
Certificate(#[from] CertificateError),
#[error("outgoing_certificate: {0}")]
OutgoingCertificate(CertificateError),
#[error(
"the chain's first rotation must carry the genesis key's \
outgoing_certificate"
)]
MissingGenesisCertificate,
#[error(
"outgoing_certificate belongs on the chain's first rotation and \
nowhere else"
)]
UnexpectedOutgoingCertificate,
#[error("old_key is not the key that was in force")]
WrongOldKey,
#[error("last_trusted does not name an earlier entry of this chain")]
UnknownLastTrusted,
#[error("new_key has already held this chain; a key is never brought back")]
ReusedKey,
#[error("last_trusted names an entry an earlier compromise repudiated")]
RepudiatedLastTrusted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(deny_unknown_fields)]
pub struct KeyRotation {
pub agora_governance_key_rotation: u32,
pub reason: RotationReason,
pub old_key: PublicKeyHex,
pub new_key: PublicKeyHex,
pub proof: SignatureHex,
pub proof_signed_at: i64,
pub certificate: KeyCertificate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outgoing_certificate: Option<KeyCertificate>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RotationStatement {
pub agora_governance_key_rotation: u32,
pub reason: RotationReason,
pub old_key: PublicKeyHex,
pub new_key: PublicKeyHex,
pub prev_hash: Option<Sha256Hex>,
}
impl RotationStatement {
pub fn new(
reason: RotationReason,
old_key: PublicKeyHex,
new_key: PublicKeyHex,
prev_hash: Option<Sha256Hex>,
) -> Self {
Self {
agora_governance_key_rotation: KEY_ROTATION_VERSION,
reason,
old_key,
new_key,
prev_hash,
}
}
pub fn preimage(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("a RotationStatement always serializes")
}
pub fn hash(&self) -> Sha256Hex {
Sha256Hex(Sha256::digest(self.preimage()).into())
}
}
impl KeyRotation {
pub fn routine(
old_key: PublicKeyHex,
new_signing_key: &SigningKey,
prev_hash: Option<Sha256Hex>,
now: DateTime<Utc>,
certificate: KeyCertificate,
) -> Self {
Self::build(
RotationReason::Routine,
old_key,
new_signing_key,
prev_hash,
now,
certificate,
)
}
pub fn compromise(
old_key: PublicKeyHex,
new_signing_key: &SigningKey,
prev_hash: Option<Sha256Hex>,
now: DateTime<Utc>,
certificate: KeyCertificate,
) -> Self {
Self::build(
RotationReason::Compromise,
old_key,
new_signing_key,
prev_hash,
now,
certificate,
)
}
fn build(
reason: RotationReason,
old_key: PublicKeyHex,
new_signing_key: &SigningKey,
prev_hash: Option<Sha256Hex>,
now: DateTime<Utc>,
certificate: KeyCertificate,
) -> Self {
let new_key = PublicKeyHex::from(&new_signing_key.verifying_key());
let proof_signed_at = truncate_to_seconds(now).timestamp();
let statement =
RotationStatement::new(reason, old_key, new_key, prev_hash);
let proof = crypto::sign(
new_signing_key,
statement.hash().as_bytes(),
proof_signed_at,
);
Self {
agora_governance_key_rotation: KEY_ROTATION_VERSION,
reason,
old_key,
new_key,
proof: proof.into(),
proof_signed_at,
certificate,
outgoing_certificate: None,
}
}
pub fn with_outgoing(mut self, certificate: KeyCertificate) -> Self {
self.outgoing_certificate = Some(certificate);
self
}
pub fn statement(&self, prev_hash: Option<Sha256Hex>) -> RotationStatement {
RotationStatement::new(
self.reason,
self.old_key,
self.new_key,
prev_hash,
)
}
pub fn last_trusted(&self) -> Option<&TrustedHead> {
self.certificate.statement.last_trusted.as_ref()
}
pub fn expected_statement(
&self,
seq: u64,
prev_hash: Option<Sha256Hex>,
) -> Result<KeyCertStatement, RotationError> {
match self.reason {
RotationReason::Routine => {
Ok(KeyCertStatement::routine(self.new_key, seq, prev_hash))
}
RotationReason::Compromise => Ok(KeyCertStatement::compromise(
self.new_key,
seq,
prev_hash,
self.last_trusted()
.cloned()
.ok_or(RotationError::MissingLastTrusted)?,
)),
}
}
pub fn verify_proof(
&self,
prev_hash: Option<Sha256Hex>,
) -> Result<(), RotationError> {
if self.agora_governance_key_rotation != KEY_ROTATION_VERSION {
return Err(RotationError::UnsupportedVersion(
self.agora_governance_key_rotation,
));
}
let new_key = self
.new_key
.to_verifying_key()
.map_err(|_| RotationError::BadNewKey)?;
crypto::verify(
&new_key,
self.statement(prev_hash).hash().as_bytes(),
self.proof_signed_at,
&Signature::from(&self.proof),
)
.then_some(())
.ok_or(RotationError::BadProof)
}
pub fn verify_certified(
&self,
seq: u64,
prev_hash: Option<Sha256Hex>,
roots: &RootSet,
) -> Result<(), RotationError> {
self.verify_proof(prev_hash)?;
let expected = self.expected_statement(seq, prev_hash)?;
Ok(self.certificate.verify_for(&expected, roots)?)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeyAnchor {
keys: HashSet<PublicKeyHex>,
}
impl KeyAnchor {
pub fn published() -> Self {
PUBLISHED_KEYS
.iter()
.map(|k| {
k.parse()
.expect("PUBLISHED_KEYS are valid 32-byte hex keys")
})
.collect()
}
pub fn pinned(key: PublicKeyHex) -> Self {
std::iter::once(key).collect()
}
pub fn with(mut self, key: PublicKeyHex) -> Self {
self.keys.insert(key);
self
}
pub fn contains(&self, key: &PublicKeyHex) -> bool {
self.keys.contains(key)
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn keys(&self) -> impl Iterator<Item = &PublicKeyHex> {
self.keys.iter()
}
}
impl FromIterator<PublicKeyHex> for KeyAnchor {
fn from_iter<I: IntoIterator<Item = PublicKeyHex>>(iter: I) -> Self {
Self {
keys: iter.into_iter().collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct GovernanceKeyRecord {
pub public_key: PublicKeyHex,
pub from_seq: u64,
#[serde(default)]
pub through_seq: Option<u64>,
pub status: KeyStatus,
#[serde(default)]
pub introduced_by: Option<GovernanceLogId>,
#[serde(default)]
pub retired_by: Option<GovernanceLogId>,
#[serde(default)]
pub certified: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct GovernanceSigningKeys {
pub keys: Vec<GovernanceKeyRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct EntryVerdict {
pub id: GovernanceLogId,
pub chain_seq: u64,
pub signature_valid: bool,
pub link_valid: bool,
#[serde(default)]
pub content_matches: Option<bool>,
pub retroactive: bool,
pub out_of_order: bool,
#[serde(default)]
pub amended_by: Vec<GovernanceLogId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_by: Option<PublicKeyHex>,
#[serde(default)]
pub redacted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redacted_data_hash: Option<Sha256Hex>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub revisions: Vec<GovernanceLogId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_data_hash: Option<Sha256Hex>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub superseded_revisions: Vec<GovernanceLogId>,
#[serde(skip)]
pub history: RevisionHistory,
#[serde(default)]
pub repudiated: bool,
#[serde(default)]
pub reattested_by: Vec<GovernanceLogId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texts: Option<AmendmentTextStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
}
impl EntryVerdict {
fn content_check(
&mut self,
attested: Sha256Hex,
data: &serde_json::Value,
) -> bool {
if non_integer_number(data).is_some() {
return false;
}
let hash = data_hash(data);
if hash == attested && self.redacted {
return true;
}
if hash != attested && Some(hash) != self.redacted_data_hash {
return Some(hash) == self.latest_data_hash;
}
let history = &self.history;
let mut current = data.clone();
let mut failures = Vec::new();
for (i, (id, revision)) in history.revisions.iter().enumerate() {
if i == history.rebased {
break;
}
if let Some(failure) = false_duplicate(id, revision, ¤t) {
failures.push(failure);
break;
}
match apply_patch(¤t, &revision.patch) {
Ok(next) => current = next,
Err(_) => {
failures.push(format!(
"revision {id} does not apply to the redacted data"
));
break;
}
}
}
if failures.is_empty()
&& let Some(expected) = history.rebased_hash
&& data_hash(¤t) != expected
{
failures.push(
"the revisions rebased over the redacted data do not \
produce the redaction's resulting_latest_hash"
.to_string(),
);
}
for (id, revision) in history.revisions.iter().skip(history.rebased) {
if !failures.is_empty() {
break;
}
if let Some(failure) = false_duplicate(id, revision, ¤t) {
failures.push(failure);
break;
}
match apply_patch(¤t, &revision.patch) {
Ok(next)
if data_hash(&next) == revision.resulting_data_hash =>
{
current = next;
}
Ok(_) => failures.push(format!(
"revision {id} does not produce its resulting_data_hash"
)),
Err(_) => {
failures.push(format!("revision {id} does not apply"))
}
}
}
for failure in failures {
add_problem(&mut self.problem, failure);
}
true
}
}
fn add_problem(problems: &mut Option<String>, problem: String) {
*problems = Some(match problems.take() {
Some(p) if p.contains(&problem) => p,
Some(p) => format!("{p}; {problem}"),
None => problem,
});
}
fn false_duplicate(
id: &GovernanceLogId,
revision: &Revision,
current: &serde_json::Value,
) -> Option<String> {
revision.duplicates.iter().find_map(|(path, same_as)| {
let same = match (current.pointer(path), current.pointer(same_as)) {
(Some(a), Some(b)) => canonical_json(a) == canonical_json(b),
_ => false,
};
(!same).then(|| {
format!(
"revision {id} removed {path} as a duplicate of {same_as}, \
but they differ"
)
})
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct GovernanceVerification {
pub public_key: PublicKeyHex,
pub ok: bool,
#[serde(default)]
pub head: Option<GovernanceLogId>,
pub entries: Vec<EntryVerdict>,
#[serde(default)]
pub keys: Vec<GovernanceKeyRecord>,
#[serde(default)]
pub unanchored_keys: Vec<PublicKeyHex>,
#[serde(default)]
pub repudiated: Vec<GovernanceLogId>,
}
impl GovernanceVerification {
pub fn settle(mut self) -> Self {
self.ok = self.entries.iter().all(|e| {
e.signature_valid
&& e.link_valid
&& e.content_matches != Some(false)
&& e.problem.is_none()
});
self
}
pub fn check_content(
&mut self,
link: &GovernanceChainLink,
data: &serde_json::Value,
) -> bool {
let Some(entry) = self.entries.iter_mut().find(|e| e.id == link.id)
else {
return false;
};
let ok = entry.content_check(link.attestation.data_hash, data);
entry.content_matches = Some(ok);
ok
}
}
pub fn attest(
key: &SigningKey,
envelope: &Envelope,
chain_seq: u64,
signed_at: DateTime<Utc>,
) -> GovernanceAttestation {
let signed_at = truncate_to_seconds(signed_at);
let entry_hash = envelope.entry_hash();
let signature =
crypto::sign(key, entry_hash.as_bytes(), signed_at.timestamp());
GovernanceAttestation {
envelope_version: envelope.agora_governance_log,
chain_seq,
prev_hash: envelope.prev_hash,
data_hash: envelope.data_hash,
entry_hash,
signature: signature.into(),
signed_at,
retroactive: is_retroactive(envelope.created_at(), signed_at),
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LinkError {
#[error(
"envelope version {0} is not supported (this verifier knows {ENVELOPE_VERSION})"
)]
UnsupportedVersion(u32),
#[error("entry_hash does not recompute from the envelope fields")]
HashMismatch,
#[error("signature does not verify under the published key")]
BadSignature,
}
pub fn recompute_entry_hash(link: &GovernanceChainLink) -> Sha256Hex {
Envelope::new(
link.id.clone(),
link.entry_type,
link.created_at,
link.attestation.prev_hash,
link.attestation.data_hash,
)
.entry_hash()
}
pub fn verify_link(
link: &GovernanceChainLink,
key: &VerifyingKey,
) -> Result<(), LinkError> {
let a = &link.attestation;
if a.envelope_version != ENVELOPE_VERSION {
return Err(LinkError::UnsupportedVersion(a.envelope_version));
}
if recompute_entry_hash(link) != a.entry_hash {
return Err(LinkError::HashMismatch);
}
if !crypto::verify(
key,
a.entry_hash.as_bytes(),
a.signed_at.timestamp(),
&Signature::from(&a.signature),
) {
return Err(LinkError::BadSignature);
}
Ok(())
}
pub fn verify_data(
link: &GovernanceChainLink,
data: &serde_json::Value,
) -> bool {
data_hash(data) == link.attestation.data_hash
}
fn same_shape(written: &serde_json::Value, read: &serde_json::Value) -> bool {
use serde_json::Value::{Array, Null, Object};
match (written, read) {
(Object(w), Object(r)) => r.iter().all(|(k, r)| match w.get(k) {
Some(w) => same_shape(w, r),
None => r.is_null(),
}),
(Array(w), Array(r)) => {
w.len() == r.len() && w.iter().zip(r).all(|(w, r)| same_shape(w, r))
}
(_, Object(_) | Array(_)) => false,
(Object(_) | Array(_), Null) => false,
_ => true,
}
}
fn read_strictly<T>(written: &serde_json::Value) -> Result<T, String>
where
T: Serialize + serde::de::DeserializeOwned,
{
let read: T =
serde_json::from_value(written.clone()).map_err(|e| e.to_string())?;
let again = serde_json::to_value(&read).map_err(|e| e.to_string())?;
if same_shape(written, &again) {
Ok(read)
} else {
Err("an array where an object belongs, or the reverse".into())
}
}
pub fn links_from_json(
chain: &serde_json::Value,
) -> Result<Vec<GovernanceChainLink>, String> {
chain
.as_array()
.ok_or("a chain is an array of links")?
.iter()
.map(read_strictly)
.collect()
}
fn read_as_written<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Serialize + serde::de::DeserializeOwned,
{
let written = serde_json::Value::deserialize(deserializer)?;
if written.is_null() {
return Ok(None);
}
read_strictly(&written)
.map(Some)
.map_err(serde::de::Error::custom)
}
fn reserved_prefix(
entry_type: GovernanceLogEntryType,
) -> Option<GovernanceLogPrefix> {
match entry_type {
GovernanceLogEntryType::Amendment => Some(GovernanceLogPrefix::Amd),
GovernanceLogEntryType::KeyRotation => Some(GovernanceLogPrefix::Key),
GovernanceLogEntryType::StewardRecord => Some(GovernanceLogPrefix::Rec),
GovernanceLogEntryType::CouncilDecision
| GovernanceLogEntryType::AppealsCourtDecision
| GovernanceLogEntryType::EmergencyAction
| GovernanceLogEntryType::PolicyChange
| GovernanceLogEntryType::StewardVeto => None,
}
}
fn reserved_for(prefix: GovernanceLogPrefix) -> Option<GovernanceLogEntryType> {
match prefix {
GovernanceLogPrefix::Amd => Some(GovernanceLogEntryType::Amendment),
GovernanceLogPrefix::Key => Some(GovernanceLogEntryType::KeyRotation),
GovernanceLogPrefix::Rec => Some(GovernanceLogEntryType::StewardRecord),
GovernanceLogPrefix::Gov | GovernanceLogPrefix::App => None,
}
}
fn prefix_problem(link: &GovernanceChainLink) -> Option<String> {
let prefix = link.id.prefix();
match (reserved_prefix(link.entry_type), reserved_for(prefix)) {
(Some(want), _) if prefix != want => Some(format!(
"the id of a {} entry must be in the {want}- series, not {}",
link.entry_type, link.id
)),
(None, Some(owner)) => Some(format!(
"{prefix}- ids are reserved for {owner} entries, but {} is a {}",
link.id, link.entry_type
)),
_ => None,
}
}
fn amendment_target(
amendment: &Amendment,
seq: u64,
seq_of: &HashMap<&str, u64>,
links: &[&GovernanceChainLink],
) -> Result<u64, AmendmentError> {
amendment.validate()?;
let target = *seq_of.get(amendment.target.as_str()).ok_or_else(|| {
AmendmentError::UnknownTarget(amendment.target.clone())
})?;
if target >= seq {
return Err(AmendmentError::ForwardReference(amendment.target.clone()));
}
if links[target as usize - 1].attestation.entry_hash
!= amendment.target_entry_hash
{
return Err(AmendmentError::WrongTargetHash(amendment.target.clone()));
}
Ok(target)
}
struct KeyWalk {
history: Vec<(GovernanceKeyRecord, VerifyingKey)>,
unanchored: Vec<PublicKeyHex>,
seen: HashSet<PublicKeyHex>,
repudiated: HashSet<u64>,
genesis: PublicKeyHex,
genesis_certified: bool,
}
impl KeyWalk {
fn new(genesis: &VerifyingKey, anchor: &KeyAnchor) -> Self {
let public_key = PublicKeyHex::from(genesis);
Self {
genesis: public_key,
genesis_certified: false,
history: vec![(
GovernanceKeyRecord {
public_key,
from_seq: 1,
through_seq: None,
status: KeyStatus::Active,
introduced_by: None,
retired_by: None,
certified: false,
},
*genesis,
)],
unanchored: if anchor.contains(&public_key) {
Vec::new()
} else {
vec![public_key]
},
seen: HashSet::from([public_key]),
repudiated: HashSet::new(),
}
}
fn in_force(&self, seq: u64) -> (PublicKeyHex, VerifyingKey) {
let (record, key) = self
.history
.iter()
.rev()
.find(|(r, _)| r.from_seq <= seq)
.unwrap_or(&self.history[0]);
(record.public_key, *key)
}
fn active(&self) -> PublicKeyHex {
self.history
.last()
.map(|(r, _)| r.public_key)
.expect("the genesis key is always in the history")
}
fn close(
&mut self,
through_seq: u64,
status: KeyStatus,
by: &GovernanceLogId,
) {
if let Some((record, _)) = self.history.last_mut() {
record.through_seq = Some(through_seq);
record.status = status;
record.retired_by = Some(by.clone());
}
}
fn open(
&mut self,
public_key: PublicKeyHex,
key: VerifyingKey,
from_seq: u64,
by: &GovernanceLogId,
) {
self.history.push((
GovernanceKeyRecord {
public_key,
from_seq,
through_seq: None,
status: KeyStatus::Active,
introduced_by: Some(by.clone()),
retired_by: None,
certified: true,
},
key,
));
}
fn apply(
&mut self,
rotation: &KeyRotation,
link: &GovernanceChainLink,
seq: u64,
links: &[&GovernanceChainLink],
roots: &RootSet,
) -> Result<(), RotationError> {
rotation.verify_certified(seq, link.attestation.prev_hash, roots)?;
let new_key = rotation
.new_key
.to_verifying_key()
.map_err(|_| RotationError::BadNewKey)?;
if self.seen.contains(&rotation.new_key) {
return Err(RotationError::ReusedKey);
}
match (&rotation.outgoing_certificate, self.genesis_certified) {
(None, false) => {
return Err(RotationError::MissingGenesisCertificate);
}
(Some(_), true) => {
return Err(RotationError::UnexpectedOutgoingCertificate);
}
(Some(certificate), false) => certificate
.verify_for(&KeyCertStatement::genesis(self.genesis), roots)
.map_err(RotationError::OutgoingCertificate)?,
(None, true) => {}
}
match rotation.reason {
RotationReason::Routine => {
if rotation.old_key != self.in_force(seq).0 {
return Err(RotationError::WrongOldKey);
}
self.close(seq, KeyStatus::Retired, &link.id);
self.open(rotation.new_key, new_key, seq + 1, &link.id);
}
RotationReason::Compromise => {
let head = rotation
.last_trusted()
.ok_or(RotationError::MissingLastTrusted)?;
let trusted_seq = head.chain_seq;
let names_an_earlier_entry = trusted_seq >= 1
&& trusted_seq < seq
&& links[trusted_seq as usize - 1].id == head.id
&& links[trusted_seq as usize - 1].attestation.entry_hash
== head.entry_hash;
if !names_an_earlier_entry {
return Err(RotationError::UnknownLastTrusted);
}
if self.repudiated.contains(&trusted_seq) {
return Err(RotationError::RepudiatedLastTrusted);
}
if rotation.old_key != self.in_force(trusted_seq).0 {
return Err(RotationError::WrongOldKey);
}
self.history.retain(|(r, _)| r.from_seq <= trusted_seq);
self.close(trusted_seq, KeyStatus::Compromised, &link.id);
self.open(rotation.new_key, new_key, seq, &link.id);
self.repudiated.extend(trusted_seq + 1..seq);
}
}
self.seen.insert(rotation.new_key);
if !self.genesis_certified {
self.genesis_certified = true;
self.history[0].0.certified = true;
self.unanchored.clear();
}
Ok(())
}
}
pub fn verify_chain(
links: &[GovernanceChainLink],
genesis_key: &VerifyingKey,
anchor: &KeyAnchor,
roots: &RootSet,
) -> GovernanceVerification {
let mut links: Vec<&GovernanceChainLink> = links.iter().collect();
links.sort_by_key(|l| l.attestation.chain_seq);
let mut seq_of: HashMap<&str, u64> = HashMap::new();
let mut duplicates: HashSet<&str> = HashSet::new();
for (i, link) in links.iter().enumerate() {
if seq_of.insert(link.id.as_str(), i as u64 + 1).is_some() {
duplicates.insert(link.id.as_str());
}
}
let mut walk = KeyWalk::new(genesis_key, anchor);
let mut amendments: Vec<(u64, GovernanceLogId, Amendment, u64)> =
Vec::new();
let mut entries: Vec<EntryVerdict> = Vec::with_capacity(links.len());
let mut prev: Option<&GovernanceChainLink> = None;
for (i, link) in links.iter().enumerate() {
let a = &link.attestation;
let expected_seq = i as u64 + 1;
let mut problems: Vec<String> = Vec::new();
if let Some(problem) = prefix_problem(link) {
problems.push(problem);
}
if duplicates.contains(link.id.as_str()) {
problems.push(format!("{} appears more than once", link.id));
}
let carries_meaning = matches!(
link.entry_type,
GovernanceLogEntryType::Amendment
| GovernanceLogEntryType::KeyRotation
);
let mut amendment: Option<Amendment> = None;
let mut rotation: Option<KeyRotation> = None;
let mut content_matches: Option<bool> = None;
match &link.data {
Some(data) if non_integer_number(data).is_some() => {
content_matches = Some(false);
problems.push(format!(
"`data` has a number that is not a 64-bit integer at {:?}; \
governance data never contains one",
non_integer_number(data).unwrap_or_default()
));
}
Some(data) => {
let matched = data_hash(data) == a.data_hash;
content_matches = Some(matched);
if !matched {
if carries_meaning {
problems.push(
"`data` does not hash to the attested data_hash"
.into(),
);
}
} else {
match link.entry_type {
GovernanceLogEntryType::Amendment => {
match read_strictly(data) {
Ok(v) => amendment = Some(v),
Err(e) => problems.push(format!(
"amendment `data` is malformed: {e}"
)),
}
}
GovernanceLogEntryType::KeyRotation => {
match read_strictly(data) {
Ok(v) => rotation = Some(v),
Err(e) => problems.push(format!(
"key_rotation `data` is malformed: {e}"
)),
}
}
_ => {}
}
}
}
None if carries_meaning => problems.push(format!(
"a {} entry must carry its `data`",
link.entry_type
)),
None => {}
}
let declared = rotation
.as_ref()
.filter(|r| r.reason == RotationReason::Compromise)
.map(|r| {
if walk.seen.contains(&r.new_key) {
return Err(RotationError::ReusedKey);
}
r.verify_certified(expected_seq, a.prev_hash, roots)?;
r.new_key
.to_verifying_key()
.map_err(|_| RotationError::BadNewKey)
});
let (key_hex, key) = match &declared {
Some(Ok(k)) => (PublicKeyHex::from(k), *k),
_ => walk.in_force(expected_seq),
};
if let Some(Err(e)) = &declared {
problems.push(e.to_string());
}
let (hash_ok, signature_valid) = match verify_link(link, &key) {
Ok(()) => (true, true),
Err(LinkError::BadSignature) => {
problems.push(LinkError::BadSignature.to_string());
(true, false)
}
Err(e) => {
problems.push(e.to_string());
(false, false)
}
};
let mut link_valid = hash_ok;
if a.chain_seq != expected_seq {
link_valid = false;
problems.push(format!(
"chain_seq {} where {expected_seq} was expected",
a.chain_seq
));
}
let expected_prev = prev.map(|p| p.attestation.entry_hash);
if a.prev_hash != expected_prev {
link_valid = false;
problems.push(match (a.prev_hash, expected_prev) {
(Some(_), None) => "first entry names a predecessor".into(),
(None, Some(_)) => "prev_hash is null mid-chain".into(),
_ => "prev_hash is not the previous entry's entry_hash".into(),
});
}
let out_of_order = prev.is_some_and(|p| link.created_at < p.created_at);
let authentic = signature_valid && link_valid;
if let Some(rotation) = rotation.as_ref().filter(|_| authentic)
&& let Err(e) =
walk.apply(rotation, link, expected_seq, &links, roots)
{
let problem = e.to_string();
if !problems.contains(&problem) {
problems.push(problem);
}
}
let mut texts = None;
if let Some(amendment) = amendment
.as_ref()
.filter(|a| authentic && a.validate().is_ok())
{
match amendment.text_status(link.texts.as_ref()) {
Ok(status) => texts = status,
Err(e) => problems.push(e.to_string()),
}
} else if link.texts.as_ref().is_some_and(|t| !t.is_empty()) {
problems.push(AmendmentError::UncommittedText.to_string());
}
if let Some(amendment) = amendment.filter(|_| authentic) {
match amendment_target(&amendment, expected_seq, &seq_of, &links) {
Ok(target) => amendments.push((
expected_seq,
link.id.clone(),
amendment,
target,
)),
Err(e) => problems.push(e.to_string()),
}
}
entries.push(EntryVerdict {
id: link.id.clone(),
chain_seq: a.chain_seq,
signature_valid,
link_valid,
content_matches,
retroactive: is_retroactive(link.created_at, a.signed_at),
out_of_order,
amended_by: Vec::new(),
signed_by: Some(key_hex),
redacted: false,
redacted_data_hash: None,
revisions: Vec::new(),
latest_data_hash: None,
superseded_revisions: Vec::new(),
history: RevisionHistory::default(),
repudiated: false,
reattested_by: Vec::new(),
texts,
problem: (!problems.is_empty()).then(|| problems.join("; ")),
});
prev = Some(link);
}
let mut applied = vec![false; amendments.len()];
loop {
let mut changed = false;
for (i, (seq, id, amendment, target)) in amendments.iter().enumerate() {
if applied[i]
|| amendment.kind != AmendmentKind::Reattested
|| walk.repudiated.contains(seq)
{
continue;
}
applied[i] = true;
entries[*target as usize - 1].reattested_by.push(id.clone());
walk.repudiated.remove(target);
changed = true;
}
if !changed {
break;
}
}
for (seq, id, amendment, target) in &amendments {
if walk.repudiated.contains(seq) {
continue;
}
let entry = &mut entries[*target as usize - 1];
entry.amended_by.push(id.clone());
let mut unrebased = false;
if let Some(redaction) = &amendment.redaction {
unrebased = !entry.revisions.is_empty()
&& redaction.resulting_latest_hash.is_none();
entry.redacted = true;
entry.redacted_data_hash = Some(redaction.resulting_data_hash);
entry.history.rebased = entry.history.revisions.len();
entry.history.rebased_hash = redaction.resulting_latest_hash;
entry.latest_data_hash = redaction.resulting_latest_hash;
entry.superseded_revisions = entry.revisions.clone();
}
if let Some(revision) = &amendment.revision {
entry.revisions.push(id.clone());
entry.latest_data_hash = Some(revision.resulting_data_hash);
entry.history.revisions.push((id.clone(), revision.clone()));
}
if unrebased {
let target = entry.id.clone();
add_problem(
&mut entries[*seq as usize - 1].problem,
format!(
"the redaction of {target}, which has revisions, names no \
resulting_latest_hash"
),
);
}
}
for (i, link) in links.iter().enumerate() {
let entry = &mut entries[i];
if let Some(data) = &link.data
&& entry.content_matches.is_some()
&& (entry.redacted || !entry.history.revisions.is_empty())
{
entry.content_matches =
Some(entry.content_check(link.attestation.data_hash, data));
}
}
let mut seen = HashSet::new();
walk.unanchored.retain(|key| seen.insert(*key));
let mut repudiated = Vec::new();
for (i, entry) in entries.iter_mut().enumerate() {
if walk.repudiated.contains(&(i as u64 + 1)) {
entry.repudiated = true;
repudiated.push(entry.id.clone());
}
}
GovernanceVerification {
public_key: walk.active(),
ok: false,
head: prev.map(|p| p.id.clone()),
entries,
keys: walk.history.into_iter().map(|(record, _)| record).collect(),
unanchored_keys: walk.unanchored,
repudiated,
}
.settle()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::generate_keypair;
use serde_json::json;
pub(super) fn gov(n: u32) -> GovernanceLogId {
format!("GOV-2026-{n:04}").parse().unwrap()
}
pub(super) fn amd(n: u32) -> GovernanceLogId {
format!("AMD-2026-{n:04}").parse().unwrap()
}
pub(super) fn key_id(n: u32) -> GovernanceLogId {
format!("KEY-2026-{n:04}").parse().unwrap()
}
pub(super) fn rec(n: u32) -> GovernanceLogId {
format!("REC-2026-{n:04}").parse().unwrap()
}
pub(super) fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000 + secs, 123_456_789).unwrap()
}
fn anchored(key: &VerifyingKey) -> KeyAnchor {
KeyAnchor::pinned(key.into())
}
pub(super) fn resalted(draft: &AmendmentDraft, seq: u64) -> AmendmentDraft {
let fix = |field: &str, t: &Option<CommittedText>| {
t.as_ref().map(|t| {
let salt = Sha256::digest(format!("{seq}/{field}/{}", t.text));
CommittedText::with_salt(
TextSalt::from(<[u8; 32]>::from(salt)),
t.text.clone(),
)
})
};
let texts = AmendmentTexts {
basis: fix("basis", &draft.texts.basis),
note: fix("note", &draft.texts.note),
rationale: fix("rationale", &draft.texts.rationale),
};
let commit = |t: &Option<CommittedText>| {
t.as_ref().map(|t| AmendmentText::Committed(t.commitment()))
};
AmendmentDraft {
amendment: Amendment {
basis: commit(&texts.basis).unwrap(),
note: commit(&texts.note).unwrap(),
rationale: commit(&texts.rationale),
..draft.amendment.clone()
},
texts,
}
}
pub(super) fn v1(draft: AmendmentDraft) -> Amendment {
let plain =
|t: Option<CommittedText>| t.map(|t| AmendmentText::Plain(t.text));
Amendment {
agora_governance_amendment: 1,
basis: plain(draft.texts.basis).unwrap(),
note: plain(draft.texts.note).unwrap(),
rationale: plain(draft.texts.rationale),
..draft.amendment
}
}
pub(super) fn root(n: u8) -> SigningKey {
SigningKey::from_bytes(&[0xA0 + n; 32])
}
pub(super) fn roots() -> RootSet {
RootSet::new(
[1, 2].map(|n| PublicKeyHex::from(&root(n).verifying_key())),
1,
)
}
pub(super) fn certify(
signers: &[&SigningKey],
statement: KeyCertStatement,
) -> KeyCertificate {
use ed25519_dalek::Signer;
let message = statement.signed_bytes();
signers.iter().fold(
KeyCertificate::unsigned(statement),
|certificate, signer| {
certificate.with(RootSignature {
root_key: (&signer.verifying_key()).into(),
signature: signer.sign(&message).into(),
})
},
)
}
fn for_new_at(c: &Chain, key: &VerifyingKey) -> KeyCertificate {
certify(
&[&root(1)],
KeyCertStatement::routine(key.into(), c.next_seq(), c.prev_hash()),
)
}
pub(super) fn link(
key: &SigningKey,
n: u32,
prev: Option<&GovernanceChainLink>,
data: &serde_json::Value,
signed_at: DateTime<Utc>,
) -> GovernanceChainLink {
let created_at = truncate_to_micros(at(n as i64 * 10));
let envelope = Envelope::new(
gov(n),
GovernanceLogEntryType::CouncilDecision,
created_at,
prev.map(|p| p.attestation.entry_hash),
data_hash(data),
);
let attestation = attest(
key,
&envelope,
prev.map_or(1, |p| p.attestation.chain_seq + 1),
signed_at,
);
GovernanceChainLink {
id: gov(n),
entry_type: GovernanceLogEntryType::CouncilDecision,
created_at,
attestation,
data: None,
texts: None,
}
}
pub(super) fn chain(key: &SigningKey, n: u32) -> Vec<GovernanceChainLink> {
let mut out: Vec<GovernanceChainLink> = Vec::new();
for i in 1..=n {
let data = json!({"title": format!("Decision {i}"), "outcome": "approved"});
let l = link(key, i, out.last(), &data, at(i as i64 * 10 + 1));
out.push(l);
}
out
}
pub(super) struct Chain {
pub(super) links: Vec<GovernanceChainLink>,
gov: u32,
amd: u32,
key: u32,
genesis: Option<PublicKeyHex>,
}
impl Chain {
pub(super) fn new() -> Self {
Self {
links: Vec::new(),
gov: 0,
amd: 0,
key: 0,
genesis: None,
}
}
pub(super) fn prev_hash(&self) -> Option<Sha256Hex> {
self.links.last().map(|l| l.attestation.entry_hash)
}
pub(super) fn hash_at(&self, seq: usize) -> Sha256Hex {
self.links[seq - 1].attestation.entry_hash
}
pub(super) fn next_seq(&self) -> u64 {
self.links.len() as u64 + 1
}
pub(super) fn head(&self, seq: usize) -> TrustedHead {
TrustedHead {
id: self.links[seq - 1].id.clone(),
chain_seq: seq as u64,
entry_hash: self.hash_at(seq),
}
}
fn outgoing(&self, rotation: KeyRotation) -> KeyRotation {
match (self.key, self.genesis) {
(0, Some(genesis)) => rotation.with_outgoing(certify(
&[&root(1)],
KeyCertStatement::genesis(genesis),
)),
_ => rotation,
}
}
pub(super) fn routine(
&self,
old: &VerifyingKey,
new: &SigningKey,
) -> KeyRotation {
let statement = KeyCertStatement::routine(
(&new.verifying_key()).into(),
self.next_seq(),
self.prev_hash(),
);
self.outgoing(KeyRotation::routine(
old.into(),
new,
self.prev_hash(),
at(self.next_seq() as i64 * 10 + 5),
certify(&[&root(1)], statement),
))
}
pub(super) fn compromise(
&self,
old: &VerifyingKey,
new: &SigningKey,
trusted: usize,
) -> KeyRotation {
let statement = KeyCertStatement::compromise(
(&new.verifying_key()).into(),
self.next_seq(),
self.prev_hash(),
self.head(trusted),
);
self.outgoing(KeyRotation::compromise(
old.into(),
new,
self.prev_hash(),
at(self.next_seq() as i64 * 10 + 5),
certify(&[&root(1)], statement),
))
}
pub(super) fn next_amd(&self) -> GovernanceLogId {
amd(self.amd + 1)
}
pub(super) fn push(
&mut self,
signer: &SigningKey,
id: GovernanceLogId,
entry_type: GovernanceLogEntryType,
data: serde_json::Value,
carry: bool,
) -> GovernanceLogId {
self.genesis
.get_or_insert_with(|| (&signer.verifying_key()).into());
let n = self.links.len() as i64 + 1;
let created_at = truncate_to_micros(at(n * 10));
let envelope = Envelope::new(
id.clone(),
entry_type,
created_at,
self.prev_hash(),
data_hash(&data),
);
let attestation =
attest(signer, &envelope, n as u64, at(n * 10 + 1));
self.links.push(GovernanceChainLink {
id: id.clone(),
entry_type,
created_at,
attestation,
data: carry.then_some(data),
texts: None,
});
id
}
pub(super) fn entry(
&mut self,
signer: &SigningKey,
data: serde_json::Value,
) -> GovernanceLogId {
self.gov += 1;
let id = gov(self.gov);
self.push(
signer,
id,
GovernanceLogEntryType::CouncilDecision,
data,
false,
)
}
pub(super) fn decision(
&mut self,
signer: &SigningKey,
) -> GovernanceLogId {
let data = json!({"title": format!("Decision {}", self.gov + 1)});
self.entry(signer, data)
}
pub(super) fn amend(
&mut self,
signer: &SigningKey,
draft: &AmendmentDraft,
) -> GovernanceLogId {
let draft = resalted(draft, self.next_seq());
let id = self.amend_v1(signer, &draft.amendment);
let link = self.links.last_mut().unwrap();
link.texts = Some(draft.texts);
id
}
pub(super) fn amend_v1(
&mut self,
signer: &SigningKey,
amendment: &Amendment,
) -> GovernanceLogId {
self.amd += 1;
let id = amd(self.amd);
self.push(
signer,
id,
GovernanceLogEntryType::Amendment,
serde_json::to_value(amendment).unwrap(),
true,
)
}
pub(super) fn rotate(
&mut self,
signer: &SigningKey,
rotation: &KeyRotation,
) -> GovernanceLogId {
self.key += 1;
let id = key_id(self.key);
self.push(
signer,
id,
GovernanceLogEntryType::KeyRotation,
serde_json::to_value(rotation).unwrap(),
true,
)
}
}
#[test]
fn canonical_json_sorts_keys_at_every_level() {
let v = json!({"b": {"z": 1, "a": [{"y": 2, "x": 3}]}, "a": null});
assert_eq!(
canonical_json(&v),
br#"{"a":null,"b":{"a":[{"x":3,"y":2}],"z":1}}"#
);
}
#[test]
fn canonical_json_is_compact_and_escapes_like_serde() {
let v = json!({"s": "tab\there \"q\" ünïcode \u{1F600}", "n": [1, -2, 3.5, true, false]});
let bytes = canonical_json(&v);
let text = std::str::from_utf8(&bytes).unwrap();
assert_eq!(
text,
r#"{"n":[1,-2,3.5,true,false],"s":"tab\there \"q\" ünïcode 😀"}"#
);
}
#[test]
fn canonical_json_ignores_insertion_order() {
let mut a = serde_json::Map::new();
a.insert("z".into(), json!(1));
a.insert("a".into(), json!(2));
let mut b = serde_json::Map::new();
b.insert("a".into(), json!(2));
b.insert("z".into(), json!(1));
assert_eq!(
canonical_json(&serde_json::Value::Object(a)),
canonical_json(&serde_json::Value::Object(b))
);
}
#[test]
fn canonical_json_empty_containers() {
assert_eq!(canonical_json(&json!({})), b"{}");
assert_eq!(canonical_json(&json!([])), b"[]");
assert_eq!(
canonical_json(&json!({"a": {}, "b": []})),
br#"{"a":{},"b":[]}"#
);
}
#[test]
fn preimage_is_declaration_ordered_json() {
let e = Envelope::new(
gov(1),
GovernanceLogEntryType::AppealsCourtDecision,
at(0),
None,
data_hash(&json!({})),
);
let text = String::from_utf8(e.preimage()).unwrap();
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}");
}
#[test]
fn every_envelope_field_changes_the_hash() {
let base = Envelope::new(
gov(1),
GovernanceLogEntryType::CouncilDecision,
at(0),
None,
data_hash(&json!({"a":1})),
);
let h = base.entry_hash();
let mut e = base.clone();
e.id = gov(2);
assert_ne!(e.entry_hash(), h);
let mut e = base.clone();
e.entry_type = GovernanceLogEntryType::PolicyChange;
assert_ne!(e.entry_hash(), h);
let mut e = base.clone();
e.created_at += 1;
assert_ne!(e.entry_hash(), h);
let mut e = base.clone();
e.prev_hash = Some(h);
assert_ne!(e.entry_hash(), h);
let mut e = base.clone();
e.data_hash = data_hash(&json!({"a":2}));
assert_ne!(e.entry_hash(), h);
assert_eq!(base.entry_hash(), h, "and it is deterministic");
}
#[test]
fn truncation_matches_what_the_envelope_carries() {
let t = at(0);
assert_eq!(truncate_to_micros(t).timestamp_subsec_nanos(), 123_456_000);
assert_eq!(truncate_to_seconds(t).timestamp_subsec_nanos(), 0);
assert_eq!(
Envelope::new(
gov(1),
GovernanceLogEntryType::CouncilDecision,
t,
None,
data_hash(&json!(null))
)
.created_at,
truncate_to_micros(t).timestamp_micros()
);
}
#[test]
fn hex_newtypes_round_trip_and_reject_wrong_lengths() {
let h = data_hash(&json!(1));
let s = serde_json::to_string(&h).unwrap();
assert_eq!(s.len(), 66);
let back: Sha256Hex = serde_json::from_str(&s).unwrap();
assert_eq!(back, h);
assert!(serde_json::from_str::<Sha256Hex>("\"abcd\"").is_err());
assert!("zz".repeat(32).parse::<Sha256Hex>().is_err());
assert!(Sha256Hex::try_from(vec![0u8; 31]).is_err());
assert_eq!(format!("{h:?}"), format!("Sha256Hex({h})"));
}
#[test]
fn attest_then_verify_link() {
let (key, pk) = generate_keypair();
let l = link(&key, 1, None, &json!({"a": 1}), at(5));
assert_eq!(verify_link(&l, &pk), Ok(()));
assert!(verify_data(&l, &json!({"a": 1})));
assert!(!verify_data(&l, &json!({"a": 2})));
assert!(!l.attestation.retroactive);
}
#[test]
fn wrong_key_fails_signature_only() {
let (key, _) = generate_keypair();
let (_, other) = generate_keypair();
let l = link(&key, 1, None, &json!({}), at(5));
assert_eq!(verify_link(&l, &other), Err(LinkError::BadSignature));
}
#[test]
fn tampering_with_any_attested_field_is_detected() {
let (key, pk) = generate_keypair();
let l = link(&key, 1, None, &json!({"a": 1}), at(5));
let mut t = l.clone();
t.created_at += chrono::Duration::microseconds(1);
assert_eq!(verify_link(&t, &pk), Err(LinkError::HashMismatch));
let mut t = l.clone();
t.entry_type = GovernanceLogEntryType::StewardVeto;
assert_eq!(verify_link(&t, &pk), Err(LinkError::HashMismatch));
let mut t = l.clone();
t.attestation.data_hash = data_hash(&json!({"a": 2}));
assert_eq!(verify_link(&t, &pk), Err(LinkError::HashMismatch));
let mut t = l.clone();
t.attestation.signed_at -= chrono::Duration::seconds(1);
assert_eq!(verify_link(&t, &pk), Err(LinkError::BadSignature));
let mut t = l.clone();
t.attestation.envelope_version = 2;
assert_eq!(verify_link(&t, &pk), Err(LinkError::UnsupportedVersion(2)));
}
#[test]
fn a_good_chain_verifies_in_any_input_order() {
let (key, pk) = generate_keypair();
let mut c = chain(&key, 4);
c.reverse();
let v = verify_chain(&c, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
assert_eq!(v.head, Some(gov(4)));
assert_eq!(
v.entries.iter().map(|e| e.chain_seq).collect::<Vec<_>>(),
[1, 2, 3, 4]
);
assert!(v.entries.iter().all(|e| e.content_matches.is_none()
&& e.problem.is_none()
&& !e.out_of_order));
assert_eq!(v.public_key, PublicKeyHex::from(&pk));
}
#[test]
fn empty_chain_is_ok_with_no_head() {
let (_, pk) = generate_keypair();
let v = verify_chain(&[], &pk, &anchored(&pk), &roots());
assert!(v.ok);
assert!(v.head.is_none());
assert!(v.entries.is_empty());
}
#[test]
fn a_changed_entry_breaks_its_signature_and_the_next_link() {
let (key, pk) = generate_keypair();
let mut c = chain(&key, 3);
let rewritten = link(
&key,
2,
Some(&c[0]),
&json!({"title": "Decision 2", "outcome": "REJECTED"}),
at(21),
);
c[1] = rewritten;
let v = verify_chain(&c, &pk, &anchored(&pk), &roots());
assert!(!v.ok);
assert!(
v.entries[1].signature_valid && v.entries[1].link_valid,
"the rewrite itself is well-formed: {:#?}",
v.entries[1]
);
assert!(
!v.entries[2].link_valid,
"but entry 3 no longer points at it: {:#?}",
v.entries[2]
);
assert!(
v.entries[2]
.problem
.as_deref()
.unwrap()
.contains("prev_hash")
);
}
#[test]
fn a_removed_entry_is_a_gap_and_a_broken_link() {
let (key, pk) = generate_keypair();
let mut c = chain(&key, 3);
c.remove(1);
let v = verify_chain(&c, &pk, &anchored(&pk), &roots());
assert!(!v.ok);
assert!(v.entries[0].link_valid);
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("chain_seq 3 where 2 was expected"), "{p}");
assert!(p.contains("prev_hash"), "{p}");
}
#[test]
fn a_second_genesis_is_rejected() {
let (key, pk) = generate_keypair();
let mut c = chain(&key, 2);
let rogue = link(&key, 2, None, &json!({}), at(21));
c[1] = rogue;
let v = verify_chain(&c, &pk, &anchored(&pk), &roots());
assert!(!v.ok);
assert!(
v.entries[1]
.problem
.as_deref()
.unwrap()
.contains("null mid-chain")
);
}
#[test]
fn retroactive_and_out_of_order_are_recomputed_not_copied() {
let (key, pk) = generate_keypair();
let first = link(&key, 1, None, &json!({}), at(10 + 3600));
let created = truncate_to_micros(at(5));
let envelope = Envelope::new(
gov(2),
GovernanceLogEntryType::CouncilDecision,
created,
Some(first.attestation.entry_hash),
data_hash(&json!({})),
);
let attestation = attest(&key, &envelope, 2, at(6));
let mut second = GovernanceChainLink {
id: gov(2),
entry_type: GovernanceLogEntryType::CouncilDecision,
created_at: created,
attestation,
data: None,
texts: None,
};
second.attestation.retroactive = true; let v = verify_chain(&[first, second], &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
assert!(v.entries[0].retroactive);
assert!(!v.entries[1].retroactive, "recomputed from timestamps");
assert!(v.entries[1].out_of_order);
}
#[test]
fn content_mismatch_settles_to_not_ok() {
let (key, pk) = generate_keypair();
let c = chain(&key, 1);
let mut v = verify_chain(&c, &pk, &anchored(&pk), &roots());
v.entries[0].content_matches = Some(true);
assert!(v.clone().settle().ok);
v.entries[0].content_matches = Some(false);
assert!(!v.settle().ok);
}
#[test]
fn an_amendment_fills_amended_by_on_its_target() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
let target = c.decision(&key);
c.decision(&key);
let amendment = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::NonPrecedential,
"§1 (Red Team Cases Recharacterized)",
"diagnostic finding — not citable as moderation precedent",
)
.unwrap()
.with_authority(gov(5))
.with_rationale("§5 leaves the ruling itself standing");
let id = c.amend(&key, &amendment);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
assert_eq!(v.entries[0].amended_by, vec![id]);
assert!(v.entries[1].amended_by.is_empty());
assert!(!v.entries[0].redacted);
assert_eq!(v.head, Some(amd(1)));
assert_eq!(v.entries[2].content_matches, Some(true));
assert_eq!(v.entries[0].content_matches, None);
assert_eq!(
standing([amendment.amendment.kind]),
Standing::NonPrecedential
);
}
#[test]
fn an_amendment_must_name_an_earlier_entry_by_its_exact_hash() {
let (key, pk) = generate_keypair();
let problem = |c: &Chain, at: usize| -> String {
verify_chain(&c.links, &pk, &anchored(&pk), &roots()).entries[at]
.problem
.clone()
.unwrap_or_default()
};
let mut c = Chain::new();
c.decision(&key);
let unknown = AmendmentDraft::new(
gov(99),
c.hash_at(1),
AmendmentKind::Overruled,
"b",
"n",
)
.unwrap();
c.amend(&key, &unknown);
assert!(
problem(&c, 1).contains("is not an entry of this chain"),
"{}",
problem(&c, 1)
);
assert!(!verify_chain(&c.links, &pk, &anchored(&pk), &roots()).ok);
let mut c = Chain::new();
c.decision(&key);
let forward = AmendmentDraft::new(
gov(2),
c.hash_at(1),
AmendmentKind::Overruled,
"b",
"n",
)
.unwrap();
c.amend(&key, &forward);
c.decision(&key);
assert!(
problem(&c, 1).contains("not an earlier entry"),
"{}",
problem(&c, 1)
);
let mut c = Chain::new();
let target = c.decision(&key);
let wrong_hash = AmendmentDraft::new(
target,
data_hash(&json!("some other entry")),
AmendmentKind::Overruled,
"b",
"n",
)
.unwrap();
c.amend(&key, &wrong_hash);
assert!(problem(&c, 1).contains("entry_hash"), "{}", problem(&c, 1));
assert!(
verify_chain(&c.links, &pk, &anchored(&pk), &roots()).entries[0]
.amended_by
.is_empty()
);
}
#[test]
fn redaction_shape_violations_fail_verification() {
let (key, pk) = generate_keypair();
let amend_with = |mutate: &dyn Fn(&mut Amendment)| -> String {
let mut c = Chain::new();
let target = c.decision(&key);
let mut amendment = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::Correction,
"b",
"n",
)
.unwrap();
mutate(&mut amendment.amendment);
c.amend_v1(&key, &amendment.amendment);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok, "{v:#?}");
v.entries[1].problem.clone().unwrap_or_default()
};
assert_eq!(
AmendmentDraft::new(
gov(1),
data_hash(&json!(null)),
AmendmentKind::Redaction,
"b",
"n"
),
Err(AmendmentError::MissingRedaction)
);
let p = amend_with(&|a| a.kind = AmendmentKind::Redaction);
assert!(p.contains("requires a `redaction`"), "{p}");
let p = amend_with(&|a| {
a.redaction = Some(Redaction {
fields: vec!["/x".into()],
resulting_data_hash: data_hash(&json!({})),
resulting_latest_hash: None,
})
});
assert!(p.contains("only valid on kind"), "{p}");
let p = amend_with(&|a| a.agora_governance_amendment = 3);
assert!(p.contains("agora_governance_amendment is 3"), "{p}");
let p = amend_with(&|a| a.agora_governance_amendment = 1);
assert!(p.contains("does neither consistently"), "{p}");
let p = amend_with(&|a| a.note = AmendmentText::Plain("n".into()));
assert!(p.contains("does neither consistently"), "{p}");
}
#[test]
fn governance_data_holds_no_number_that_is_not_a_64_bit_integer() {
let fine = json!({"a": [1, -2, u64::MAX, i64::MIN], "b": {"c": "0.5"}});
assert_eq!(non_integer_number(&fine), None);
assert!(blind_data(&fine, Blind::random()).is_ok());
for (text, pointer) in [
(r#"{"a": {"b/c": [1, 0.5]}}"#, "/a/b~1c/1"),
(r#"{"n": 1.0}"#, "/n"),
(r#"{"n": 1e3}"#, "/n"),
(r#"{"n": 18446744073709551616}"#, "/n"),
(r#"{"n": -9223372036854775809}"#, "/n"),
] {
let data: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(non_integer_number(&data).as_deref(), Some(pointer));
assert_eq!(
blind_data(&data, Blind::random()),
Err(BlindError::NonIntegerNumber(pointer.into())),
"the writer refuses it"
);
assert_eq!(
redact_data(&data, &["/n".into()], &amd(1), Blind::random()),
Err(RedactError::NonIntegerNumber(pointer.into()))
);
}
}
#[test]
fn standing_is_the_last_amendment_that_changes_it() {
use AmendmentKind::*;
assert_eq!(standing([]), Standing::InForce);
assert_eq!(standing([Correction, Redaction]), Standing::InForce);
assert_eq!(
standing([Correction, NonPrecedential, Redaction]),
Standing::NonPrecedential
);
assert_eq!(standing([Overruled, Reinstated]), Standing::InForce);
assert_eq!(standing([Reinstated, Superseded]), Standing::Superseded);
assert_eq!(kind_standing(Reattested), None);
assert_eq!(kind_standing(Reinstated), Some(Standing::InForce));
}
#[test]
fn a_redaction_verifies_against_the_amendment_and_nothing_else() {
let (key, pk) = generate_keypair();
let data = json!({
"finding": "upheld",
"subject": {"handle": "someone", "detail": "personal"},
});
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let amendment_id = c.next_amd();
let (amendment, redacted) = AmendmentDraft::redaction(
&amendment_id,
target,
c.hash_at(1),
"GDPR Art. 17(1)(a)",
"personal data removed on request",
vec!["/subject/handle".into(), "/subject/detail".into()],
&data,
Blind::from([7; 32]),
&[],
)
.unwrap();
assert_eq!(c.amend(&key, &amendment), amendment_id);
assert_eq!(redacted[BLIND_KEY], json!(Blind::from([7; 32])));
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
assert!(v.entries[0].redacted);
assert_eq!(v.entries[0].redacted_data_hash, Some(data_hash(&redacted)));
assert_eq!(
redacted["subject"]["handle"],
json!(format!("[redacted by {amendment_id}]"))
);
assert_eq!(redacted["finding"], json!("upheld"), "and nothing else");
assert!(v.check_content(&c.links[0], &redacted));
assert_eq!(v.entries[0].content_matches, Some(true));
assert!(v.clone().settle().ok);
assert!(v.check_content(&c.links[0], &data));
let mut tampered = redacted.clone();
tampered["finding"] = json!("overturned");
assert!(!v.check_content(&c.links[0], &tampered));
assert_eq!(v.entries[0].content_matches, Some(false));
assert!(!v.settle().ok);
}
fn seats() -> serde_json::Value {
json!({
"title": "A motion",
"rounds": [{
"number": 1,
"responses": [
{"role": "lawyer", "rationale": "Because.", "raw_text": "Because.", "vote": "yes"},
{"role": "artist", "rationale": "Why not.", "raw_text": "Why not?", "vote": "no"},
],
}],
"subject": {"handle": "someone"},
BLIND_KEY: Blind::from([3; 32]),
})
}
const LAWYER: &str = "/rounds/0/responses/0";
fn patch(ops: serde_json::Value) -> json_patch::Patch {
serde_json::from_value(ops).unwrap()
}
fn dedup(data: &serde_json::Value) -> Edit {
let (raw, rationale) =
(format!("{LAWYER}/raw_text"), format!("{LAWYER}/rationale"));
Revision::remove_duplicates(data, &[(&raw, &rationale)]).unwrap()
}
fn revise(
c: &Chain,
target: &GovernanceLogId,
latest: &serde_json::Value,
patch: impl Into<Edit>,
) -> (AmendmentDraft, serde_json::Value) {
AmendmentDraft::revision(
target.clone(),
GovernanceLogEntryType::CouncilDecision,
c.hash_at(1),
"Steward's record REC-2026-0001",
"raw_text identical to the rationale removed",
latest,
patch,
)
.unwrap()
}
fn revision_of(draft: &AmendmentDraft) -> &Revision {
draft.amendment.revision.as_ref().unwrap()
}
#[test]
fn a_revision_overwrites_nothing_and_folds_to_the_latest() {
let (key, pk) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let (first, v1) = revise(&c, &target, &data, dedup(&data));
c.amend(&key, &first);
let (second, v2) = revise(
&c,
&target,
&v1,
patch(json!([{"op": "move", "from": "/title", "path": "/motion"}])),
);
c.amend(&key, &second);
let seat = &v1["rounds"][0]["responses"];
assert!(seat[0].get("raw_text").is_none(), "removed, no marker");
assert_eq!(seat[1]["raw_text"], json!("Why not?"), "the other stays");
assert_eq!(v2["motion"], json!("A motion"));
assert_eq!(v2[BLIND_KEY], data[BLIND_KEY], "blind kept");
assert_eq!(
latest(&data, [revision_of(&first), revision_of(&second)]).unwrap(),
v2
);
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
let entry = &v.entries[0];
assert_eq!(entry.revisions, [amd(1), amd(2)]);
assert_eq!(entry.latest_data_hash, Some(data_hash(&v2)));
assert!(!entry.redacted && entry.redacted_data_hash.is_none());
assert!(v.check_content(&c.links[0], &data));
assert!(v.check_content(&c.links[0], &v2));
assert!(!v.check_content(&c.links[0], &v1), "not the latest");
v.check_content(&c.links[0], &data);
assert!(v.clone().settle().ok);
let wire = serde_json::to_value(&v).unwrap();
assert!(wire["entries"][0].get("history").is_none());
let mut old = wire.clone();
for field in ["revisions", "latest_data_hash", "superseded_revisions"] {
old["entries"][0].as_object_mut().unwrap().remove(field);
}
let old: GovernanceVerification = serde_json::from_value(old).unwrap();
assert!(old.entries[0].revisions.is_empty());
}
#[test]
fn a_revision_that_does_not_produce_its_hash_fails() {
let (key, pk) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let (mut draft, _) = revise(&c, &target, &data, dedup(&data));
draft
.amendment
.revision
.as_mut()
.unwrap()
.resulting_data_hash = data_hash(&json!({"something": "else"}));
c.amend(&key, &draft);
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "the chain itself is sound: {v:#?}");
assert!(v.check_content(&c.links[0], &data), "the stored data is");
assert!(
v.entries[0]
.problem
.as_deref()
.is_some_and(|p| p.contains("does not produce")),
"{:?}",
v.entries[0].problem
);
assert!(!v.settle().ok);
c.links[0].data = Some(data);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok);
}
fn redact(
c: &Chain,
target: &GovernanceLogId,
fields: &[&str],
data: &serde_json::Value,
revisions: &[&Revision],
) -> Result<(AmendmentDraft, serde_json::Value), RedactError> {
AmendmentDraft::redaction(
&c.next_amd(),
target.clone(),
c.hash_at(1),
"GDPR Art. 17(1)(a)",
"removed on request",
fields.iter().map(|f| f.to_string()).collect(),
data,
Blind::from([9; 32]),
revisions,
)
}
#[test]
fn a_revision_then_a_redaction() {
let (key, pk) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let (revision, _) = revise(&c, &target, &data, dedup(&data));
c.amend(&key, &revision);
let rationale = format!("{LAWYER}/rationale");
let (redaction, redacted) = redact(
&c,
&target,
&[&rationale],
&data,
&[revision_of(&revision)],
)
.unwrap();
c.amend(&key, &redaction);
let fields = &redaction.amendment.redaction.as_ref().unwrap().fields;
assert_eq!(fields, &[rationale, format!("{LAWYER}/raw_text")]);
let seat = &redacted["rounds"][0]["responses"][0];
assert_eq!(seat["raw_text"], seat["rationale"]);
assert!(seat["raw_text"].as_str().unwrap().starts_with("[redacted"));
let rebased = latest(&redacted, [revision_of(&revision)]).unwrap();
assert!(
rebased["rounds"][0]["responses"][0]
.get("raw_text")
.is_none()
);
assert_eq!(
redaction
.amendment
.redaction
.as_ref()
.unwrap()
.resulting_latest_hash,
Some(data_hash(&rebased))
);
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
let entry = &v.entries[0];
assert_eq!(entry.latest_data_hash, Some(data_hash(&rebased)));
assert_eq!(entry.superseded_revisions, [amd(1)]);
assert!(v.check_content(&c.links[0], &redacted));
assert!(v.check_content(&c.links[0], &rebased));
assert!(
v.check_content(&c.links[0], &data),
"for whoever kept a copy"
);
v.check_content(&c.links[0], &redacted);
assert!(v.clone().settle().ok, "{v:#?}");
let mut c2 = Chain::new();
let target = c2.entry(&key, data.clone());
c2.amend(&key, &revision);
let (mut lying, _) = redact(
&c2,
&target,
&["/subject/handle"],
&data,
&[revision_of(&revision)],
)
.unwrap();
let (_, honest) = redact(
&c2,
&target,
&["/subject/handle"],
&data,
&[revision_of(&revision)],
)
.unwrap();
lying
.amendment
.redaction
.as_mut()
.unwrap()
.resulting_latest_hash = Some(data_hash(&json!({})));
c2.amend(&key, &lying);
let mut v = verify_chain(&c2.links, &pk, &anchored(&pk), &roots());
assert!(v.check_content(&c2.links[0], &honest));
assert!(!v.settle().ok);
}
#[test]
fn a_redaction_of_a_revised_entry_must_name_the_latest_hash() {
let (key, pk) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let (revision, _) = revise(&c, &target, &data, dedup(&data));
c.amend(&key, &revision);
let (redaction, _) =
redact(&c, &target, &["/subject/handle"], &data, &[]).unwrap();
c.amend(&key, &redaction);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok);
assert!(
v.entries[2]
.problem
.as_deref()
.is_some_and(|p| p.contains("names no resulting_latest_hash")),
"{v:#?}"
);
}
#[test]
fn a_false_duplicate_fails() {
let (key, pk) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let artist = "/rounds/0/responses/1";
let (raw, rationale) =
(format!("{artist}/raw_text"), format!("{artist}/rationale"));
let (mut lying, _) = revise(
&c,
&target,
&data,
patch(json!([{"op": "remove", "path": raw}])),
);
let revised = apply_patch(&data, &revision_of(&lying).patch).unwrap();
let r = lying.amendment.revision.as_mut().unwrap();
r.duplicates = vec![(raw, rationale)];
r.resulting_data_hash = data_hash(&revised);
c.amend(&key, &lying);
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "unread, the claim is unchecked: {v:#?}");
assert!(v.check_content(&c.links[0], &data));
assert!(
v.entries[0]
.problem
.as_deref()
.is_some_and(|p| p.contains("but they differ")),
"{v:#?}"
);
assert!(!v.settle().ok);
}
#[test]
fn a_redaction_that_breaks_a_revision_is_refused() {
let (key, _) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let (moved, _) = revise(
&c,
&target,
&data,
patch(
json!([{"op": "move", "from": "/subject/handle", "path": "/handle"}]),
),
);
c.amend(&key, &moved);
let err =
redact(&c, &target, &["/subject"], &data, &[revision_of(&moved)])
.unwrap_err();
assert!(matches!(err, RedactError::Rebase(_)), "{err}");
assert!(
redact(
&c,
&target,
&["/subject/handle"],
&data,
&[revision_of(&moved)]
)
.is_ok()
);
}
#[test]
fn a_redaction_follows_every_duplicate_of_what_it_erases() {
let revision = Revision {
patch: patch(json!([{"op": "remove", "path": "/copy"}])),
duplicates: vec![("/copy".into(), "/source/inner".into())],
resulting_data_hash: data_hash(&json!(null)),
};
let extra = |fields: &[&str]| {
let fields: Vec<String> =
fields.iter().map(|f| f.to_string()).collect();
duplicates_of(&fields, &[&revision])
};
assert_eq!(extra(&["/source/inner"]), ["/copy"]);
assert_eq!(extra(&["/source/inner/name"]), ["/copy/name"]);
assert_eq!(extra(&["/source"]), ["/copy"]);
assert!(extra(&["/source/other"]).is_empty());
assert!(extra(&["/source/inn"]).is_empty());
}
#[test]
fn a_redaction_then_a_revision() {
let (key, pk) = generate_keypair();
let data = seats();
let mut c = Chain::new();
let target = c.entry(&key, data.clone());
let (redaction, redacted) =
redact(&c, &target, &["/subject/handle"], &data, &[]).unwrap();
assert_eq!(
redaction
.amendment
.redaction
.as_ref()
.unwrap()
.resulting_latest_hash,
None,
"nothing to rebase"
);
c.amend(&key, &redaction);
let (revision, revised) =
revise(&c, &target, &redacted, dedup(&redacted));
c.amend(&key, &revision);
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
assert_eq!(v.entries[0].latest_data_hash, Some(data_hash(&revised)));
assert!(v.entries[0].superseded_revisions.is_empty());
assert!(v.check_content(&c.links[0], &redacted));
assert!(v.check_content(&c.links[0], &revised));
assert!(v.settle().ok);
}
#[test]
fn a_revision_refuses_what_it_should() {
let data = seats();
let raw = &format!("{LAWYER}/raw_text");
let rationale = &format!("{LAWYER}/rationale");
let artist = "/rounds/0/responses/1";
let dedup =
|pairs: &[(&str, &str)]| Revision::remove_duplicates(&data, pairs);
assert_eq!(
dedup(&[(
&format!("{artist}/raw_text"),
&format!("{artist}/rationale")
)]),
Err(ReviseError::NotIdentical {
path: format!("{artist}/raw_text"),
same_as: format!("{artist}/rationale"),
})
);
assert_eq!(
dedup(&[(raw, rationale), (rationale, raw)]),
Err(ReviseError::SourceRemoved {
path: raw.clone(),
same_as: rationale.clone(),
}),
"a same_as the patch removes"
);
assert_eq!(
dedup(&[("/nope", rationale)]),
Err(ReviseError::Unresolved("/nope".into()))
);
let make = |target_type, p: Edit| {
AmendmentDraft::revision(
gov(1),
target_type,
data_hash(&json!(null)),
"b",
"n",
&data,
p,
)
.map(|(_, v)| v)
};
let council = GovernanceLogEntryType::CouncilDecision;
assert_eq!(
make(council, patch(json!([])).into()),
Err(ReviseError::EmptyPatch)
);
assert!(matches!(
make(
council,
patch(json!([{"op": "remove", "path": "/nope"}])).into()
),
Err(ReviseError::Patch(_))
));
assert_eq!(
make(
council,
patch(json!([{"op": "remove", "path": "/_blind"}])).into()
),
Err(ReviseError::BlindPointer("/_blind".into()))
);
assert_eq!(
make(
council,
patch(json!([{"op": "copy", "from": "/_blind", "path": "/b"}]))
.into()
),
Err(ReviseError::BlindPointer("/_blind".into()))
);
for entry_type in [
GovernanceLogEntryType::Amendment,
GovernanceLogEntryType::KeyRotation,
GovernanceLogEntryType::StewardRecord,
] {
assert!(!is_revisable(entry_type));
assert_eq!(
make(entry_type, dedup(&[(raw, rationale)]).unwrap()),
Err(ReviseError::NotRevisable(entry_type))
);
}
assert!(is_revisable(council));
}
#[test]
fn a_revision_that_adds_content_blinds_the_target() {
let unblinded =
json!({"title": "Old", "rationale": "r", "raw_text": "r"});
let revise = |data: &serde_json::Value, p: serde_json::Value| {
AmendmentDraft::revision(
gov(1),
GovernanceLogEntryType::CouncilDecision,
data_hash(&json!(null)),
"b",
"n",
data,
patch(p),
)
.unwrap()
};
let add = json!([{"op": "add", "path": "/attachments", "value": []}]);
let (draft, revised) = revise(&unblinded, add.clone());
assert!(revised.get(BLIND_KEY).is_some());
assert_eq!(revision_of(&draft).patch.len(), 2, "in the same patch");
assert_eq!(
latest(&unblinded, [revision_of(&draft)]).unwrap(),
revised,
"so the fold reproduces it"
);
let (_, revised) =
revise(&unblinded, json!([{"op": "remove", "path": "/raw_text"}]));
assert!(revised.get(BLIND_KEY).is_none(), "a removal adds nothing");
let (draft, revised) = revise(&seats(), add);
assert_eq!(revised[BLIND_KEY], seats()[BLIND_KEY], "never rotated");
assert_eq!(revision_of(&draft).patch.len(), 1);
}
#[test]
fn revision_shape_violations_fail_verification() {
let (key, pk) = generate_keypair();
let amend_with = |mutate: &dyn Fn(&mut Amendment)| -> String {
let mut c = Chain::new();
let target = c.entry(&key, seats());
let (mut draft, _) = revise(&c, &target, &seats(), dedup(&seats()));
mutate(&mut draft.amendment);
c.amend_v1(&key, &draft.amendment);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok, "{v:#?}");
v.entries[1].problem.clone().unwrap_or_default()
};
assert_eq!(
AmendmentDraft::new(
gov(1),
data_hash(&json!(null)),
AmendmentKind::Revision,
"b",
"n"
),
Err(AmendmentError::MissingRevision)
);
let p = amend_with(&|a| a.revision = None);
assert!(p.contains("requires a `revision`"), "{p}");
let p = amend_with(&|a| a.kind = AmendmentKind::Correction);
assert!(p.contains("only valid on kind `revision`"), "{p}");
let p = amend_with(&|a| a.revision.as_mut().unwrap().patch.0.clear());
assert!(p.contains("at least one op"), "{p}");
}
#[cfg(feature = "schemars")]
#[test]
fn the_revision_schema_describes_a_patch() {
let schema = crate::responses::inline_schema_for::<Amendment>();
let revision = &schema["properties"]["revision"]["properties"];
assert_eq!(revision["patch"]["type"], json!("array"), "{schema}");
assert_eq!(
revision["patch"]["items"]["properties"]["op"]["enum"],
json!(["add", "remove", "replace", "move", "copy", "test"])
);
assert_eq!(revision["resulting_data_hash"]["type"], json!("string"));
}
#[test]
fn content_that_matches_nothing_fails_without_an_amendment() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
c.entry(&key, json!({"a": 1}));
let mut v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.check_content(&c.links[0], &json!({"a": 2})));
assert!(!v.clone().settle().ok);
let other = link(&key, 9, None, &json!({}), at(9));
assert!(!v.check_content(&other, &json!({})));
}
#[test]
fn tampering_with_an_amendments_data_is_caught_by_the_data_hash() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
let target = c.decision(&key);
let amendment = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::Overruled,
"b",
"overruled by a later decision",
)
.unwrap();
c.amend(&key, &amendment);
c.links[1].data.as_mut().unwrap()["note"] =
json!("reinstated, actually");
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok, "{v:#?}");
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("does not hash to the attested data_hash"), "{p}");
assert!(
v.entries[0].amended_by.is_empty(),
"an unreadable amendment has no effect"
);
assert!(
v.entries[1].signature_valid && v.entries[1].link_valid,
"the envelope is untouched — only the content is not what it \
committed to"
);
}
#[test]
fn an_amendment_or_rotation_must_carry_its_data() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
let target = c.decision(&key);
let amendment = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::Correction,
"b",
"n",
)
.unwrap();
c.amend(&key, &amendment);
let rotation = c.routine(&pk, &generate_keypair().0);
c.rotate(&key, &rotation);
c.links[1].data = None;
c.links[2].data = None;
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok, "{v:#?}");
for (i, entry_type) in [(1, "amendment"), (2, "key_rotation")] {
let p = v.entries[i].problem.as_deref().unwrap();
assert!(p.contains(&format!("a {entry_type} entry")), "{p}");
assert!(p.contains("must carry its `data`"), "{p}");
}
}
#[test]
fn the_id_series_must_match_the_entry_type() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
let target = c.decision(&key);
let amendment = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::Correction,
"b",
"n",
)
.unwrap();
c.push(
&key,
gov(7),
GovernanceLogEntryType::Amendment,
serde_json::to_value(&amendment.amendment).unwrap(),
true,
);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok, "{v:#?}");
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("must be in the AMD- series"), "{p}");
let mut c = Chain::new();
c.push(
&key,
key_id(1),
GovernanceLogEntryType::CouncilDecision,
json!({}),
false,
);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
let p = v.entries[0].problem.as_deref().unwrap();
assert!(p.contains("reserved"), "{p}");
}
#[test]
fn redact_data_replaces_whole_values_and_refuses_the_rest() {
let id = amd(3);
let blind = Blind::from([9; 32]);
let data = json!({"a": {"b": [1, {"c": "secret"}]}, "d/e": "slash"});
let out = redact_data(
&data,
&["/a/b/1/c".into(), "/d~1e".into()],
&id,
blind,
)
.unwrap();
assert_eq!(out["a"]["b"][1]["c"], json!(redaction_marker(&id)));
assert_eq!(out["d/e"], json!(redaction_marker(&id)));
assert_eq!(out["a"]["b"][0], json!(1), "untouched");
assert_eq!(out[BLIND_KEY], json!(blind), "a legacy entry gains one");
assert_eq!(
redact_data(&data, &["/a/nope".into()], &id, blind),
Err(RedactError::Unresolved("/a/nope".into()))
);
assert_eq!(
redact_data(&data, &["".into()], &id, blind),
Err(RedactError::WholeEntry)
);
assert_eq!(
redact_data(&data, &[], &id, blind),
Err(RedactError::NoFields)
);
assert_eq!(
redact_data(&data, &["/_blind".into()], &id, blind),
Err(RedactError::BlindPointer("/_blind".into()))
);
}
#[test]
fn blind_data_is_for_objects_and_is_the_writers_to_supply() {
let blind = Blind::from([1; 32]);
let out = blind_data(&json!({"finding": "upheld"}), blind).unwrap();
assert_eq!(out, json!({"finding": "upheld", "_blind": blind}));
assert_eq!(
blind_data(&json!([1]), blind),
Err(BlindError::NotAnObject)
);
assert_eq!(blind_data(&out, blind), Err(BlindError::AlreadyBlinded));
assert_ne!(Blind::random(), Blind::random());
use GovernanceLogEntryType::*;
assert!(!is_redactable(Amendment) && !is_redactable(KeyRotation));
assert!(
is_redactable(CouncilDecision) && is_redactable(EmergencyAction)
);
}
#[test]
fn a_removed_value_cannot_be_confirmed_by_guessing_it() {
fn confirms(
public: &serde_json::Value,
pointer: &str,
guess: &str,
hash: Sha256Hex,
) -> bool {
let mut attempt = public.clone();
*attempt.pointer_mut(pointer).unwrap() = json!(guess);
data_hash(&attempt) == hash
}
let legacy = json!({"finding": "upheld", "handle": "someone", "city": "Utrecht"});
let written = blind_data(&legacy, Blind::random()).unwrap();
let first = redact_data(
&written,
&["/handle".into()],
&amd(1),
Blind::random(),
)
.unwrap();
assert!(!confirms(&first, "/handle", "someone", data_hash(&written)));
let second =
redact_data(&first, &["/city".into()], &amd(2), Blind::random())
.unwrap();
assert!(!confirms(&second, "/city", "Utrecht", data_hash(&first)));
let mut stripped =
redact_data(&legacy, &["/handle".into()], &amd(3), Blind::random())
.unwrap();
let legacy_first = stripped.clone();
stripped.as_object_mut().unwrap().remove(BLIND_KEY);
assert!(confirms(
&stripped,
"/handle",
"someone",
data_hash(&legacy)
));
let legacy_second = redact_data(
&legacy_first,
&["/city".into()],
&amd(4),
Blind::random(),
)
.unwrap();
assert!(!confirms(
&legacy_second,
"/city",
"Utrecht",
data_hash(&legacy_first)
));
}
#[test]
fn a_routine_rotation_moves_the_chain_to_the_new_key() {
let (old, old_pk) = generate_keypair();
let (new, new_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let rotation = c.routine(&old_pk, &new);
let rotation_id = c.rotate(&old, &rotation);
c.decision(&new);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(v.ok, "{v:#?}");
assert_eq!(v.public_key, (&new_pk).into());
assert_eq!(
v.entries[1].signed_by,
Some((&old_pk).into()),
"the rotation itself is signed by the old key"
);
assert_eq!(v.entries[2].signed_by, Some((&new_pk).into()));
assert!(v.unanchored_keys.is_empty());
assert!(v.repudiated.is_empty());
assert_eq!(v.keys.len(), 2);
assert_eq!(v.keys[0].public_key, (&old_pk).into());
assert_eq!(v.keys[0].from_seq, 1);
assert_eq!(v.keys[0].through_seq, Some(2));
assert_eq!(v.keys[0].status, KeyStatus::Retired);
assert_eq!(v.keys[0].introduced_by, None);
assert_eq!(v.keys[0].retired_by.as_ref(), Some(&rotation_id));
assert!(v.keys[0].certified, "retroactively, by the rotation");
assert_eq!(v.keys[1].from_seq, 3);
assert_eq!(v.keys[1].through_seq, None);
assert_eq!(v.keys[1].status, KeyStatus::Active);
assert_eq!(v.keys[1].introduced_by.as_ref(), Some(&rotation_id));
assert!(v.keys[1].certified);
}
#[test]
fn the_old_key_cannot_sign_after_a_routine_rotation() {
let (old, old_pk) = generate_keypair();
let (new, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let rotation = c.routine(&old_pk, &new);
c.rotate(&old, &rotation);
c.decision(&old);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(!v.ok, "{v:#?}");
assert!(!v.entries[2].signature_valid);
assert!(
v.entries[2].link_valid,
"the linkage is fine; the key is not"
);
}
#[test]
fn the_online_key_cannot_certify_its_own_successor() {
let (old, old_pk) = generate_keypair();
let (thief, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let mut rotation = c.routine(&old_pk, &thief);
let statement = rotation.certificate.statement.clone();
rotation.certificate = certify(&[&old], statement);
c.rotate(&old, &rotation);
c.decision(&thief);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(!v.ok, "{v:#?}");
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("0 valid root signature"), "{p}");
assert_eq!(v.public_key, (&old_pk).into(), "the chain does not move");
assert!(!v.entries[2].signature_valid);
assert_eq!(v.keys.len(), 1);
}
#[test]
fn a_certificate_counts_distinct_known_roots_only() {
let (old, old_pk) = generate_keypair();
let (new, _) = generate_keypair();
let (stranger, _) = generate_keypair();
let two_of_two = RootSet::new(
[
(&root(1).verifying_key()).into(),
(&root(2).verifying_key()).into(),
],
2,
);
let verdict = |signers: &[&SigningKey], roots: &RootSet| {
let mut c = Chain::new();
c.decision(&old);
let mut rotation = c.routine(&old_pk, &new);
let statement = rotation.certificate.statement.clone();
rotation.certificate = certify(signers, statement);
let genesis = KeyCertStatement::genesis((&old_pk).into());
rotation.outgoing_certificate =
Some(certify(&[&root(1), &root(2)], genesis));
c.rotate(&old, &rotation);
verify_chain(&c.links, &old_pk, &anchored(&old_pk), roots)
};
assert!(verdict(&[&root(1), &root(2)], &two_of_two).ok);
assert!(verdict(&[&root(2)], &roots()).ok, "either root, 1-of-2");
for (signers, why) in [
(vec![&root(1)], "below the threshold"),
(vec![&root(1), &root(1)], "one root twice is one root"),
(vec![&root(1), &stranger], "an unknown root is nobody"),
(vec![], "unsigned"),
] {
let v = verdict(&signers, &two_of_two);
assert!(!v.ok, "{why}: {v:#?}");
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("where 2 are needed"), "{why}: {p}");
}
assert!(verdict(&[&stranger, &root(1)], &roots()).ok);
}
#[test]
fn a_certificate_is_good_for_one_statement_at_one_position() {
let (old, old_pk) = generate_keypair();
let (new, new_pk) = generate_keypair();
let (other, other_pk) = generate_keypair();
let anchor = anchored(&old_pk);
let mut c = Chain::new();
c.decision(&old);
let early = c.routine(&old_pk, &new);
c.decision(&old);
let mut rotation = c.routine(&old_pk, &new);
rotation.certificate = early.certificate;
c.rotate(&old, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
assert!(!v.ok, "{v:#?}");
let p = v.entries[2].problem.as_deref().unwrap();
assert!(
p.contains("different key, purpose or chain position"),
"{p}"
);
assert_eq!(v.public_key, (&old_pk).into());
let mut c = Chain::new();
c.decision(&old);
let for_new = c.routine(&old_pk, &new);
let mut rotation = c.routine(&old_pk, &other);
rotation.certificate = for_new.certificate;
c.rotate(&old, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
let p = v.entries[1].problem.as_deref().unwrap();
assert!(
p.contains("different key, purpose or chain position"),
"{p}"
);
let mut c = Chain::new();
c.decision(&old);
let mut rotation = c.routine(&old_pk, &other);
rotation.certificate = for_new_at(&c, &new_pk);
rotation.certificate.statement.key = (&other_pk).into();
c.rotate(&old, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("0 valid root signature"), "{p}");
let mut c = Chain::new();
c.decision(&old);
c.decision(&old);
let mut rotation = c.compromise(&old_pk, &new, 1);
rotation.certificate = for_new_at(&c, &new_pk);
c.rotate(&new, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
assert!(!v.ok);
assert!(v.repudiated.is_empty(), "{v:#?}");
assert_eq!(v.public_key, (&old_pk).into());
}
#[test]
fn a_root_signature_without_the_domain_prefix_certifies_nothing() {
use ed25519_dalek::Signer;
let (old, old_pk) = generate_keypair();
let (new, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let mut rotation = c.routine(&old_pk, &new);
let statement = rotation.certificate.statement.clone();
let bare = canonical_json(&serde_json::to_value(&statement).unwrap());
assert_eq!(
statement.signed_bytes(),
[ROOT_DOMAIN, bare.as_slice()].concat()
);
rotation.certificate =
KeyCertificate::unsigned(statement).with(RootSignature {
root_key: (&root(1).verifying_key()).into(),
signature: root(1).sign(&bare).into(),
});
c.rotate(&old, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(!v.ok, "{v:#?}");
assert_eq!(v.public_key, (&old_pk).into());
}
#[test]
fn the_first_rotation_carries_the_genesis_certificate_and_only_it_does() {
let (k1, k1_pk) = generate_keypair();
let (k2, k2_pk) = generate_keypair();
let (k3, _) = generate_keypair();
let genesis =
|| certify(&[&root(1)], KeyCertStatement::genesis((&k1_pk).into()));
let mut c = Chain::new();
c.decision(&k1);
let mut rotation = c.routine(&k1_pk, &k2);
rotation.outgoing_certificate = None;
c.rotate(&k1, &rotation);
let v = verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
assert!(!v.ok);
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("genesis key's outgoing_certificate"), "{p}");
assert_eq!(v.public_key, (&k1_pk).into());
let mut c = Chain::new();
c.decision(&k1);
let rotation = c.routine(&k1_pk, &k2).with_outgoing(certify(
&[&root(1)],
KeyCertStatement::genesis((&k2_pk).into()),
));
c.rotate(&k1, &rotation);
let v = verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.starts_with("outgoing_certificate:"), "{p}");
let mut c = Chain::new();
c.decision(&k1);
let rotation = c.routine(&k1_pk, &k2);
assert_eq!(rotation.outgoing_certificate, Some(genesis()));
c.rotate(&k1, &rotation);
let v = verify_chain(&c.links, &k1_pk, &KeyAnchor::default(), &roots());
assert!(v.ok, "{v:#?}");
assert!(v.unanchored_keys.is_empty(), "{v:#?}");
let again = c.routine(&k2_pk, &k3).with_outgoing(genesis());
c.rotate(&k2, &again);
let v = verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
assert!(!v.ok);
let p = v.entries[2].problem.as_deref().unwrap();
assert!(p.contains("nowhere else"), "{p}");
}
#[test]
fn a_forged_proof_of_possession_is_rejected() {
let (old, old_pk) = generate_keypair();
let (_, new_pk) = generate_keypair();
let (thief, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let mut rotation = c.routine(&old_pk, &thief);
rotation.new_key = (&new_pk).into();
rotation.certificate = for_new_at(&c, &new_pk);
let statement = rotation.statement(c.prev_hash());
rotation.proof = crypto::sign(
&old,
statement.hash().as_bytes(),
rotation.proof_signed_at,
)
.into();
c.rotate(&old, &rotation);
assert_eq!(
rotation.verify_proof(c.links[1].attestation.prev_hash),
Err(RotationError::BadProof)
);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(!v.ok, "{v:#?}");
assert!(
v.entries[1]
.problem
.as_deref()
.unwrap()
.contains("proof of possession")
);
assert_eq!(v.public_key, (&old_pk).into(), "the chain does not move");
}
#[test]
fn a_compromise_repudiates_the_window_and_a_reattestation_restores_one() {
let (old, old_pk) = generate_keypair();
let (new, new_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&old); c.decision(&old); let reattested = c.decision(&old); let rotation = c.compromise(&old_pk, &new, 1);
let rotation_id = c.rotate(&new, &rotation); let vouch = AmendmentDraft::new(
reattested,
c.hash_at(3),
AmendmentKind::Reattested,
"Art. VII",
"independently verified; the Steward vouches for it",
)
.unwrap();
let vouch_id = c.amend(&new, &vouch);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(
v.ok,
"repudiation is a declared state, not a defect: {v:#?}"
);
assert_eq!(v.repudiated, vec![gov(2)]);
assert!(v.entries[1].repudiated);
assert!(!v.entries[2].repudiated);
assert_eq!(v.entries[2].reattested_by, vec![vouch_id.clone()]);
assert_eq!(v.entries[2].amended_by, vec![vouch_id]);
assert_eq!(v.entries[3].signed_by, Some((&new_pk).into()));
assert_eq!(v.public_key, (&new_pk).into());
assert_eq!(v.keys.len(), 2);
assert_eq!(v.keys[0].status, KeyStatus::Compromised);
assert_eq!(
v.keys[0].through_seq,
Some(1),
"trusted through the last trusted entry, not through the \
declaration"
);
assert_eq!(v.keys[0].retired_by.as_ref(), Some(&rotation_id));
assert_eq!(v.keys[1].from_seq, 4, "the declaration is its own first");
}
#[test]
fn last_trusted_is_the_roots_to_say() {
let (old, old_pk) = generate_keypair();
let (new, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&old); c.decision(&old); let mut rotation = c.compromise(&old_pk, &new, 1);
rotation.certificate.statement.last_trusted = Some(c.head(2));
c.rotate(&new, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchored(&old_pk), &roots());
assert!(!v.ok, "{v:#?}");
assert!(v.repudiated.is_empty());
assert_eq!(v.public_key, (&old_pk).into());
}
#[test]
fn a_stolen_key_cannot_be_rotated_back_in() {
let (k1, k1_pk) = generate_keypair();
let (k2, k2_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&k1); let real = c.compromise(&k1_pk, &k2, 1);
c.rotate(&k2, &real); c.decision(&k2); let honest =
verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
assert!(honest.ok, "{honest:#?}");
let hijack = c.compromise(&k2_pk, &k1, 3);
c.rotate(&k1, &hijack); c.decision(&k1);
let v = verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
assert!(!v.ok);
assert_eq!(v.public_key, (&k2_pk).into(), "the chain stays with K2");
let p = v.entries[3].problem.as_deref().unwrap();
assert!(p.contains("never brought back"), "{p}");
assert!(!v.entries[3].signature_valid, "{:#?}", v.entries[3]);
assert!(!v.entries[4].signature_valid, "K1 signs nothing again");
assert_eq!(v.keys.len(), 2);
assert_eq!(v.keys[1].status, KeyStatus::Active);
}
#[test]
fn a_routine_rotation_cannot_reuse_a_key_either() {
let (k1, k1_pk) = generate_keypair();
let (k2, k2_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&k1);
let out = c.routine(&k1_pk, &k2);
c.rotate(&k1, &out);
let back = c.routine(&k2_pk, &k1);
c.rotate(&k2, &back);
let v = verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
assert!(!v.ok);
assert!(
v.entries[2]
.problem
.as_deref()
.unwrap()
.contains("never brought back"),
"{:#?}",
v.entries[2]
);
assert_eq!(v.public_key, (&k2_pk).into());
}
#[test]
fn a_forged_entry_has_no_effects() {
let (steward, steward_pk) = generate_keypair();
let (forger, _) = generate_keypair();
let anchor = anchored(&steward_pk);
let mut c = Chain::new();
let target = c.decision(&steward); let fake = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::Overruled,
"none",
"overruled, says nobody with the key",
)
.unwrap();
c.amend(&forger, &fake); let grab = c.routine(&steward_pk, &forger);
c.rotate(&forger, &grab);
let v = verify_chain(&c.links, &steward_pk, &anchor, &roots());
assert!(!v.ok);
assert!(v.entries[0].amended_by.is_empty(), "{:#?}", v.entries[0]);
assert_eq!(v.public_key, (&steward_pk).into());
assert_eq!(v.keys.len(), 1);
assert!(v.unanchored_keys.is_empty());
}
#[test]
fn a_repeated_id_is_a_problem() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&key);
c.gov = 0;
c.decision(&key); let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(!v.ok);
assert!(v.entries.iter().all(|e| {
e.problem
.as_deref()
.is_some_and(|p| p.contains("more than once"))
}));
}
#[test]
fn a_second_compromise_cannot_anchor_inside_the_first_window() {
let (k1, k1_pk) = generate_keypair();
let (k2, _) = generate_keypair();
let (k3, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&k1); c.decision(&k1); let first = c.compromise(&k1_pk, &k2, 1);
c.rotate(&k2, &first); let second = c.compromise(&k1_pk, &k3, 2);
c.rotate(&k3, &second);
let v = verify_chain(&c.links, &k1_pk, &anchored(&k1_pk), &roots());
assert!(!v.ok);
let p = v.entries[3].problem.as_deref().unwrap();
assert!(p.contains("repudiated"), "{p}");
assert_eq!(v.keys.len(), 2, "K1 and K2; K3 never took the chain");
assert_eq!(v.keys[0].through_seq, Some(1));
}
#[test]
fn an_uncertified_compromise_fails_closed() {
let (old, old_pk) = generate_keypair();
let (new, new_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
c.decision(&old);
let mut rotation = c.compromise(&old_pk, &new, 1);
let statement = rotation.certificate.statement.clone();
rotation.certificate = certify(&[&new], statement);
c.rotate(&new, &rotation);
let anchor = anchored(&old_pk).with((&new_pk).into());
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
assert!(!v.ok, "{v:#?}");
let p = v.entries[2].problem.as_deref().unwrap();
assert!(p.contains("0 valid root signature"), "{p}");
assert!(!v.entries[2].signature_valid, "checked under the old key");
assert!(v.repudiated.is_empty(), "and nothing is repudiated");
assert_eq!(v.public_key, (&old_pk).into());
}
#[test]
fn a_rotation_inside_the_window_is_void_with_it() {
let (steward, steward_pk) = generate_keypair();
let (successor, _) = generate_keypair();
let (recovery, recovery_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&steward); c.decision(&steward); let routine = c.routine(&steward_pk, &successor);
c.rotate(&steward, &routine); c.decision(&successor);
let declaration = c.compromise(&steward_pk, &recovery, 1);
c.rotate(&recovery, &declaration);
let v = verify_chain(
&c.links,
&steward_pk,
&anchored(&steward_pk),
&roots(),
);
assert!(v.ok, "{v:#?}");
assert_eq!(v.repudiated, vec![gov(2), key_id(1), gov(3)]);
assert_eq!(v.public_key, (&recovery_pk).into());
assert_eq!(v.keys.len(), 2, "the successor is not part of history");
assert_eq!(v.keys[0].public_key, (&steward_pk).into());
assert_eq!(v.keys[0].status, KeyStatus::Compromised);
assert!(
v.keys[0].certified,
"the genesis certificate rode in on the voided rotation and \
is the root's word all the same"
);
assert_eq!(v.keys[1].public_key, (&recovery_pk).into());
}
#[test]
fn a_compromise_must_name_a_real_head_and_the_key_that_held_it() {
let (old, old_pk) = generate_keypair();
let (new, new_pk) = generate_keypair();
let anchor = anchored(&old_pk);
let mut c = Chain::new();
c.decision(&old);
let mut trusted = c.head(1);
trusted.entry_hash = data_hash(&json!("nope"));
let statement = KeyCertStatement::compromise(
(&new_pk).into(),
2,
c.prev_hash(),
trusted,
);
let mut rotation = c.compromise(&old_pk, &new, 1);
rotation.certificate = certify(&[&root(1)], statement);
c.rotate(&new, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("last_trusted"), "{p}");
let (_, other_pk) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let rotation = c.compromise(&other_pk, &new, 1);
c.rotate(&new, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("old_key is not the key"), "{p}");
let mut c = Chain::new();
c.decision(&old);
let mut rotation = c.compromise(&old_pk, &new, 1);
rotation.certificate.statement.last_trusted = None;
c.rotate(&new, &rotation);
let v = verify_chain(&c.links, &old_pk, &anchor, &roots());
let p = v.entries[1].problem.as_deref().unwrap();
assert!(p.contains("must name last_trusted"), "{p}");
}
#[test]
fn a_rotation_carries_no_free_text() {
let (old, old_pk) = generate_keypair();
let (new, _) = generate_keypair();
let mut c = Chain::new();
c.decision(&old);
let rotation = c.routine(&old_pk, &new);
let mut value = serde_json::to_value(&rotation).unwrap();
assert!(serde_json::from_value::<KeyRotation>(value.clone()).is_ok());
value["note"] = json!("at the request of …");
assert!(serde_json::from_value::<KeyRotation>(value).is_err());
let mut head = serde_json::to_value(c.head(1)).unwrap();
head["comment"] = json!("the last one I remember signing");
assert!(serde_json::from_value::<TrustedHead>(head).is_err());
let mut statement =
serde_json::to_value(&rotation.certificate.statement).unwrap();
statement["comment"] = json!("signed in the kitchen");
assert!(serde_json::from_value::<KeyCertStatement>(statement).is_err());
}
#[test]
fn the_root_statement_bytes_are_pinned() {
let key: PublicKeyHex = PUBLISHED_KEYS[0].parse().unwrap();
assert_eq!(
String::from_utf8(KeyCertStatement::genesis(key).signed_bytes())
.unwrap(),
"agora-governance-root-v1\n\
{\"agora_governance_key_cert\":1,\"from_seq\":1,\
\"key\":\"ebb3091dd328f1463362c171121921b2fe14628e3fc4c145deaccefb85c0e78a\",\
\"last_trusted\":null,\"prev_hash\":null,\"purpose\":\"genesis\"}"
);
let statement = KeyCertStatement::compromise(
Sha256Hex::from([0xab; 32]).to_string().parse().unwrap(),
15,
Some([0xcd; 32].into()),
TrustedHead {
id: gov(10),
chain_seq: 11,
entry_hash: [0xef; 32].into(),
},
);
assert_eq!(
Sha256Hex::from(<[u8; 32]>::from(Sha256::digest(
statement.signed_bytes()
)))
.to_string(),
"633685771e08be126fd12ca4eb98c77120e5868236ff541ecba85ce6a21e6b68"
);
}
#[test]
fn root_keys_are_curve_points_and_make_a_root_set() {
let roots = RootSet::published();
assert_eq!(roots.keys().count(), ROOT_KEYS.len());
assert_eq!(roots.threshold(), ROOT_THRESHOLD);
assert!(ROOT_THRESHOLD >= 1 && ROOT_THRESHOLD <= ROOT_KEYS.len());
for root in ROOT_KEYS {
let key: PublicKeyHex = root.parse().unwrap();
assert!(roots.contains(&key));
assert!(
key.to_verifying_key().is_ok(),
"{root} is not a valid Ed25519 public key"
);
assert!(
!PUBLISHED_KEYS.contains(root),
"a root key never signs entries"
);
}
assert_eq!(RootSet::new([], 0).threshold(), 1);
}
#[test]
fn a_genesis_key_outside_the_anchor_is_reported_not_rejected() {
let (key, pk) = generate_keypair();
let c = chain(&key, 2);
let v = verify_chain(&c, &pk, &KeyAnchor::default(), &roots());
assert!(v.ok, "{v:#?}");
assert_eq!(v.unanchored_keys, vec![PublicKeyHex::from(&pk)]);
assert_eq!(v.keys.len(), 1);
assert_eq!(v.keys[0].status, KeyStatus::Active);
assert_eq!(v.keys[0].from_seq, 1);
assert_eq!(v.keys[0].through_seq, None);
}
#[test]
fn published_keys_are_curve_points_and_make_an_anchor() {
let anchor = KeyAnchor::published();
assert!(!anchor.is_empty());
assert_eq!(anchor.keys().count(), PUBLISHED_KEYS.len());
for published in PUBLISHED_KEYS {
let key: PublicKeyHex = published.parse().unwrap();
assert!(anchor.contains(&key));
assert!(
key.to_verifying_key().is_ok(),
"{published} is not a valid Ed25519 public key"
);
}
let (_, other) = generate_keypair();
assert!(!anchor.contains(&(&other).into()));
assert!(
anchor
.clone()
.with((&other).into())
.contains(&(&other).into())
);
}
#[test]
fn amendments_and_rotations_round_trip_as_entry_data() {
let (key, pk) = generate_keypair();
let amendment = AmendmentDraft::new(
gov(1),
data_hash(&json!("x")),
AmendmentKind::Superseded,
"Art. VI § 2",
"superseded by GOV-2026-0009",
)
.unwrap()
.with_authority(gov(9))
.with_rationale("the later decision covers the same subject");
let AmendmentDraft { amendment, texts } = amendment;
let value = serde_json::to_value(&amendment).unwrap();
assert_eq!(value["kind"], "superseded");
assert_eq!(value["agora_governance_amendment"], 2);
assert!(value.get("redaction").is_none(), "{value}");
let text = value.to_string();
assert!(!text.contains("Art. VI"), "no text in signed data: {text}");
assert!(!text.contains("salt"), "and no salt: {text}");
assert_eq!(
value["note"]["commitment"],
texts
.note
.as_ref()
.unwrap()
.commitment()
.commitment
.to_string()
);
assert_eq!(
serde_json::from_value::<Amendment>(value).unwrap(),
amendment
);
let beside = serde_json::to_value(&texts).unwrap();
assert_eq!(beside["basis"]["text"], "Art. VI § 2");
assert_eq!(
serde_json::from_value::<AmendmentTexts>(beside).unwrap(),
texts
);
let legacy = json!({
"agora_governance_amendment": 1,
"target": "GOV-2026-0001",
"target_entry_hash": data_hash(&json!("x")),
"kind": "non_precedential",
"authority": "GOV-2026-0005",
"basis": "§1",
"note": "diagnostic finding",
});
let legacy: Amendment = serde_json::from_value(legacy).unwrap();
assert_eq!(legacy.validate(), Ok(()));
assert_eq!(legacy.basis, AmendmentText::Plain("§1".into()));
let mut c = Chain::new();
c.decision(&key);
let rotation = c.compromise(&pk, &generate_keypair().0, 1);
let value = serde_json::to_value(&rotation).unwrap();
assert_eq!(value["agora_governance_key_rotation"], 2);
assert_eq!(value["reason"], "compromise");
let statement = &value["certificate"]["statement"];
assert_eq!(statement["purpose"], "compromise");
assert_eq!(statement["last_trusted"]["chain_seq"], 1);
assert_eq!(
value["outgoing_certificate"]["statement"]["purpose"],
"genesis"
);
assert!(value.get("note").is_none(), "{value}");
assert_eq!(
serde_json::from_value::<KeyRotation>(value).unwrap(),
rotation
);
let notice =
AmendmentNotice::new(amd(1), at(0), &amendment, Some(&texts));
let value = serde_json::to_value(¬ice).unwrap();
assert_eq!(value["id"], "AMD-2026-0001");
assert_eq!(value["note"], "superseded by GOV-2026-0009");
assert_eq!(
serde_json::from_value::<AmendmentNotice>(value).unwrap(),
notice
);
let erased = AmendmentTexts {
rationale: None,
..texts.clone()
};
let notice =
AmendmentNotice::new(amd(1), at(0), &amendment, Some(&erased));
assert_eq!(notice.note, "superseded by GOV-2026-0009");
assert_eq!(notice.rationale.as_deref(), Some(WITHHELD_TEXT));
let notice = AmendmentNotice::new(amd(1), at(0), &amendment, None);
assert_eq!(notice.basis, WITHHELD_TEXT);
let notice = AmendmentNotice::new(amd(1), at(0), &legacy, None);
assert_eq!(notice.note, "diagnostic finding");
}
#[test]
fn an_amendment_verifies_with_or_without_its_optional_fields() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
let target = c.decision(&key);
let bare = AmendmentDraft::new(
target.clone(),
c.hash_at(1),
AmendmentKind::Correction,
"clerical",
"typo in the citation",
)
.unwrap();
assert!(
serde_json::to_value(&bare.amendment)
.unwrap()
.get("rationale")
.is_none()
);
c.amend(&key, &bare);
let full = bare.clone().with_rationale("at length: …");
c.amend(&key, &full);
let mut future = serde_json::to_value(&full.amendment).unwrap();
future["superseded_by_something_new"] = json!(["later"]);
c.push(
&key,
amd(3),
GovernanceLogEntryType::Amendment,
future,
true,
);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
assert!(v.ok, "{v:#?}");
assert_eq!(
v.entries[0].amended_by,
vec![amd(1), amd(2), amd(3)],
"all three name the target"
);
}
#[test]
fn pre_0_26_wire_still_deserializes() {
let link: GovernanceChainLink = serde_json::from_value(json!({
"id": "GOV-2026-0001",
"entry_type": "council_decision",
"created_at": "2023-11-14T22:13:20.123456Z",
"attestation": {
"envelope_version": 1,
"chain_seq": 1,
"prev_hash": null,
"data_hash": "00".repeat(32),
"entry_hash": "11".repeat(32),
"signature": "22".repeat(64),
"signed_at": "2023-11-14T22:13:21Z",
"retroactive": false,
},
}))
.unwrap();
assert!(link.data.is_none());
assert!(
serde_json::to_value(&link).unwrap().get("data").is_none(),
"and a link without data does not grow a null field"
);
let verdict: EntryVerdict = serde_json::from_value(json!({
"id": "GOV-2026-0001",
"chain_seq": 1,
"signature_valid": true,
"link_valid": true,
"content_matches": null,
"retroactive": false,
"out_of_order": false,
"amended_by": [],
}))
.unwrap();
assert!(!verdict.repudiated && !verdict.redacted);
assert!(verdict.signed_by.is_none());
assert!(verdict.reattested_by.is_empty());
let verification: GovernanceVerification =
serde_json::from_value(json!({
"public_key": "33".repeat(32),
"ok": true,
"head": "GOV-2026-0001",
"entries": [],
}))
.unwrap();
assert!(verification.keys.is_empty());
assert!(verification.unanchored_keys.is_empty());
assert!(verification.repudiated.is_empty());
}
#[test]
fn the_report_round_trips() {
let (key, pk) = generate_keypair();
let mut c = Chain::new();
let target = c.decision(&key);
let amendment = AmendmentDraft::new(
target,
c.hash_at(1),
AmendmentKind::Overruled,
"b",
"n",
)
.unwrap();
c.amend(&key, &amendment);
let v = verify_chain(&c.links, &pk, &anchored(&pk), &roots());
let text = serde_json::to_string(&v).unwrap();
assert_eq!(
serde_json::from_str::<GovernanceVerification>(&text).unwrap(),
v
);
let keys = GovernanceSigningKeys { keys: v.keys };
let value = serde_json::to_value(&keys).unwrap();
assert!(value["keys"].is_array(), "an object, not a bare array");
assert_eq!(value["keys"][0]["status"], "active");
assert_eq!(
serde_json::from_value::<GovernanceSigningKeys>(value).unwrap(),
keys
);
}
#[test]
fn envelope_v1_preimage_and_hash_are_pinned() {
let envelope = Envelope::new(
gov(6),
GovernanceLogEntryType::CouncilDecision,
at(0),
Some(Sha256Hex::from([0x11; 32])),
data_hash(&json!({"outcome": "approved", "title": "Ratification"})),
);
assert_eq!(
String::from_utf8(envelope.preimage()).unwrap(),
"{\"agora_governance_log\":1,\"id\":\"GOV-2026-0006\",\
\"entry_type\":\"council_decision\",\
\"created_at\":1700000000123456,\
\"prev_hash\":\"1111111111111111111111111111111111111111111111111111111111111111\",\
\"data_hash\":\"a4adf645ae3f60c56484d01aea87d6d490321d7fc66b1607df14b023fe567c7b\"}"
);
assert_eq!(
envelope.entry_hash().to_hex(),
"ba27577432f81e415f1c01cc4cfabab6070e3ac50fd468fffe195ef19c0e9464"
);
}
#[cfg(feature = "agora-client")]
#[tokio::test]
#[ignore = "networked: hits the live platform"]
async fn the_published_key_is_the_one_the_platform_serves() {
let client = crate::client::Client::new(
url::Url::parse("https://subliminal.technology").unwrap(),
)
.unwrap();
let served = client.get_governance_signing_key().await.unwrap();
assert_eq!(served.algorithm, "ed25519");
let genesis: PublicKeyHex = PUBLISHED_KEYS
.first()
.expect("PUBLISHED_KEYS is never empty")
.parse()
.unwrap();
let links = client.get_governance_chain().await.unwrap();
let report = verify_chain(
&links,
&genesis.to_verifying_key().unwrap(),
&KeyAnchor::published(),
&RootSet::published(),
);
let problems: Vec<_> = report
.entries
.iter()
.filter_map(|e| {
e.problem.as_ref().map(|p| (e.id.clone(), p.clone()))
})
.collect();
assert!(
report.ok,
"the live chain does not verify under this build's genesis key \
and roots: {problems:#?}"
);
assert!(report.unanchored_keys.is_empty(), "{report:#?}");
assert_eq!(
served.public_key, report.public_key,
"the platform serves {} but the chain, followed under the \
published roots, is held by {}",
served.public_key, report.public_key
);
}
#[cfg(feature = "schemars")]
#[test]
fn wire_schemas_are_ref_free() {
use crate::responses::inline_schema_for;
for (name, schema) in [
(
"GovernanceAttestation",
inline_schema_for::<GovernanceAttestation>(),
),
(
"GovernanceChainLink",
inline_schema_for::<GovernanceChainLink>(),
),
(
"GovernanceSigningKey",
inline_schema_for::<GovernanceSigningKey>(),
),
(
"GovernanceVerification",
inline_schema_for::<GovernanceVerification>(),
),
(
"Vec<GovernanceChainLink>",
inline_schema_for::<Vec<GovernanceChainLink>>(),
),
("Amendment", inline_schema_for::<Amendment>()),
("Redaction", inline_schema_for::<Redaction>()),
("Revision", inline_schema_for::<Revision>()),
("AmendmentNotice", inline_schema_for::<AmendmentNotice>()),
("KeyRotation", inline_schema_for::<KeyRotation>()),
("TrustedHead", inline_schema_for::<TrustedHead>()),
(
"GovernanceKeyRecord",
inline_schema_for::<GovernanceKeyRecord>(),
),
(
"GovernanceSigningKeys",
inline_schema_for::<GovernanceSigningKeys>(),
),
("AmendmentKind", inline_schema_for::<AmendmentKind>()),
("Standing", inline_schema_for::<Standing>()),
("KeyStatus", inline_schema_for::<KeyStatus>()),
("RotationReason", inline_schema_for::<RotationReason>()),
] {
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref"), "{name} must be $ref-free: {text}");
assert!(
!text.contains("$defs"),
"{name} must be $defs-free: {text}"
);
}
let text =
serde_json::to_string(&inline_schema_for::<Sha256Hex>()).unwrap();
assert!(text.contains("^[0-9a-f]{64}$"), "{text}");
let text = serde_json::to_string(&inline_schema_for::<SignatureHex>())
.unwrap();
assert!(text.contains("^[0-9a-f]{128}$"), "{text}");
}
}