use bellbook::*;
use std::collections::BTreeMap;
const SPACE_NAME: &str = "bellbook.spec.test-vectors.space";
const THREAD_NAME: &str = "bellbook.spec.test-vectors.thread";
const SCOPE_NAME: &str = "bellbook.spec.test-vectors.scope";
fn bytes_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
fn author(id: &str, type_: AuthorType) -> Author {
Author {
id: id.into(),
type_,
signature: None,
}
}
fn proposal(kind: Kind, schema: &str, data: Vec<u8>, refs: Vec<Ref>, author_: Author) -> Proposal {
Proposal {
space: sha256_utf8(SPACE_NAME),
thread: sha256_utf8(THREAD_NAME),
author: author_,
kind,
schema: schema_id(schema),
data,
refs,
}
}
fn cause(target: RecordId) -> Ref {
Ref {
type_: RefType::Cause,
target,
}
}
fn require(target: RecordId) -> Ref {
Ref {
type_: RefType::Require,
target,
}
}
fn scripted_log(dir: &std::path::Path) -> Vec<Record> {
let space = sha256_utf8(SPACE_NAME);
let scope = sha256_utf8(SCOPE_NAME);
let mut rules = VerifierRules::new(space, 200)
.with_author_role("human", AuthorType::User)
.with_author_role("agent", AuthorType::Provider)
.with_author_role("tool-executor", AuthorType::Executor)
.with_author_role("host", AuthorType::System);
rules.admin_retraction_actors.insert("human".into());
let mut writer = LogWriter::open(dir, &rules).unwrap();
let mut state = State::default();
let commit = |p: Proposal, writer: &mut LogWriter, state: &mut State| -> RecordId {
let (id, verdict) = writer.commit(p, &rules, state).unwrap();
assert_eq!(
verdict.result,
VerdictResult::Accept,
"vector log commit rejected: {:?}",
verdict.reason
);
id
};
let request_id = commit(
proposal(
Kind::Request,
SCHEMA_REQUEST,
encode(&RequestData {
objective: "demonstrate the bellbook record format".into(),
scope,
attachments: vec![],
parent_request_id: None,
})
.unwrap(),
vec![],
author("human", AuthorType::User),
),
&mut writer,
&mut state,
);
let capability_id = commit(
proposal(
Kind::Capability,
SCHEMA_CAPABILITY,
encode(&CapabilityData {
actor_id: "agent".into(),
action_class: "tool".into(),
scope,
mode: CapabilityMode::Auto,
expiry: None,
})
.unwrap(),
vec![],
author("human", AuthorType::User),
),
&mut writer,
&mut state,
);
commit(
proposal(
Kind::Approval,
SCHEMA_APPROVAL,
encode(&ApprovalData {
target_action: None,
action_class: Some("tool".into()),
scope,
actor_id: None,
expiry: None,
})
.unwrap(),
vec![],
author("human", AuthorType::User),
),
&mut writer,
&mut state,
);
let response_id = commit(
proposal(
Kind::Response,
SCHEMA_RESPONSE,
encode(&ResponseData {
request_id,
content: "I will run the tool and summarize the outcome.".into(),
turn_index: 0,
closes_request: false,
})
.unwrap(),
vec![],
author("agent", AuthorType::Provider),
),
&mut writer,
&mut state,
);
commit(
proposal(
Kind::Plan,
SCHEMA_PLAN,
encode(&PlanData {
request_id,
tasks: vec![PlanTask {
id: "t1".into(),
description: "run the tool".into(),
kind: PlanTaskKind::Generic,
tool_hint: None,
inputs_from: vec![],
produces: None,
done_when: TaskDoneWhen::ToolSuccess,
status: TaskStatus::Pending,
result_record_id: None,
depends_on: vec![],
on_failure: FailurePolicy::Abort,
}],
status: PlanStatus::Running,
})
.unwrap(),
vec![cause(request_id)],
author("agent", AuthorType::Provider),
),
&mut writer,
&mut state,
);
let action1_id = commit(
proposal(
Kind::Action,
SCHEMA_ACTION,
encode(&ActionData {
request_id,
action_class: "tool".into(),
scope,
exec_mode: ExecMode::Internal,
params: serde_json::json!({"path": "demo.txt"}),
})
.unwrap(),
vec![require(capability_id)],
author("agent", AuthorType::Provider),
),
&mut writer,
&mut state,
);
let action2_id = commit(
proposal(
Kind::Action,
SCHEMA_ACTION,
encode(&ActionData {
request_id,
action_class: "tool".into(),
scope,
exec_mode: ExecMode::Internal,
params: serde_json::json!({"path": "other.txt"}),
})
.unwrap(),
vec![require(capability_id)],
author("agent", AuthorType::Provider),
),
&mut writer,
&mut state,
);
let result_id = commit(
proposal(
Kind::Result,
SCHEMA_RESULT,
encode(&ResultData {
action_id: action1_id,
status: ResultStatus::Success,
output: "wrote demo.txt".into(),
})
.unwrap(),
vec![cause(action1_id)],
author("tool-executor", AuthorType::Executor),
),
&mut writer,
&mut state,
);
let summary_id = commit(
proposal(
Kind::Summary,
SCHEMA_SUMMARY,
encode(&SummaryData {
summary_type: SummaryType::Lesson,
subject: sha256_utf8("demo.txt"),
scope,
claim_payload: b"demo.txt was written successfully".to_vec(),
})
.unwrap(),
vec![
cause(result_id),
Ref {
type_: RefType::Use,
target: result_id,
},
],
author("agent", AuthorType::Provider),
),
&mut writer,
&mut state,
);
commit(
proposal(
Kind::Usage,
SCHEMA_USAGE,
encode(&UsageData {
actor: "host".into(),
used_record: response_id,
consuming_record: result_id,
role: "input".into(),
outcome: UsageOutcome::Done,
})
.unwrap(),
vec![Ref {
type_: RefType::Use,
target: response_id,
}],
author("host", AuthorType::System),
),
&mut writer,
&mut state,
);
commit(
proposal(
Kind::Refusal,
SCHEMA_REFUSAL,
encode(&RefusalData {
target_id: action2_id,
target_kind: RefusalTarget::Action,
reason_code: None,
})
.unwrap(),
vec![cause(action2_id)],
author("human", AuthorType::User),
),
&mut writer,
&mut state,
);
commit(
proposal(
Kind::Retraction,
SCHEMA_RETRACTION,
encode(&RetractionData {
target_id: result_id,
reason: "demo.txt was later found unchanged".into(),
})
.unwrap(),
vec![cause(result_id)],
author("human", AuthorType::User),
),
&mut writer,
&mut state,
);
let report = verify_log(writer.records(), &rules, None);
assert_eq!(report.result, VerdictResult::Accept);
assert!(state.tainted_records.contains(&summary_id));
writer.records().to_vec()
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct Vector {
kind: String,
schema: String,
time: u64,
canonical_hash_form: String,
id: String,
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct SignedVector {
kind: String,
schema: String,
time: u64,
secret_key_seed_hex: String,
public_key_hex: String,
signing_form: String,
signature_hex: String,
canonical_id_form: String,
id: String,
substitute_public_key_hex: String,
substitute_signature_hex: String,
substitute_canonical_id_form: String,
substitute_id: String,
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct VectorFile {
spec_version: String,
description: String,
space_name: String,
thread_name: String,
scope_name: String,
space: String,
vectors: Vec<Vector>,
signed_vector: SignedVector,
}
fn build_signed_vector(unsigned: &Record) -> SignedVector {
let seed = [7u8; 32];
let signer = Ed25519Signer::from_secret_bytes(&seed);
let signing_form = String::from_utf8(unsigned.signing_bytes().unwrap()).unwrap();
let mut signed = unsigned.clone();
signed.author.signature = Some(signer.sign(&signed).unwrap());
signed = signed.with_computed_id().unwrap();
assert!(signature_verifies(&signed));
let substitute_signer = Ed25519Signer::from_secret_bytes(&[8u8; 32]);
let mut substitute = unsigned.clone();
substitute.author.signature = Some(substitute_signer.sign(&substitute).unwrap());
substitute = substitute.with_computed_id().unwrap();
assert!(signature_verifies(&substitute));
assert_ne!(signed.id, substitute.id);
let mut key_only_substitution = signed.clone();
key_only_substitution
.author
.signature
.as_mut()
.unwrap()
.key_id = substitute_signer.public_key_hex();
assert!(!signature_verifies(&key_only_substitution));
SignedVector {
kind: format!("{:?}", signed.kind),
schema: schema_name_for_id(&signed.schema).unwrap().to_string(),
time: signed.time,
secret_key_seed_hex: hex_encode(&seed),
public_key_hex: signer.public_key_hex(),
signing_form,
signature_hex: bytes_hex(&signed.author.signature.as_ref().unwrap().sig),
canonical_id_form: String::from_utf8(signed.canonical_id_form().unwrap()).unwrap(),
id: hex_encode(&signed.id),
substitute_public_key_hex: substitute_signer.public_key_hex(),
substitute_signature_hex: bytes_hex(&substitute.author.signature.as_ref().unwrap().sig),
substitute_canonical_id_form: String::from_utf8(substitute.canonical_id_form().unwrap())
.unwrap(),
substitute_id: hex_encode(&substitute.id),
}
}
fn build_vector_file(records: &[Record]) -> VectorFile {
let mut seen: BTreeMap<Kind, ()> = BTreeMap::new();
let mut vectors = Vec::new();
for r in records {
if seen.contains_key(&r.kind) {
continue;
}
seen.insert(r.kind, ());
vectors.push(Vector {
kind: format!("{:?}", r.kind),
schema: schema_name_for_id(&r.schema).unwrap().to_string(),
time: r.time,
canonical_hash_form: String::from_utf8(r.canonical_id_form().unwrap()).unwrap(),
id: hex_encode(&r.id),
});
}
vectors.sort_by_key(|v| v.time);
VectorFile {
spec_version: "0.2".into(),
description: "One unsigned record of each kind from a fixed scripted log, plus a deterministic signed Request and valid alternate-key substitution. id = SHA-256(canonical id form); the domain-separated signing form wraps the record with id and author.signature omitted, while a signed canonical id form omits only id. space/thread/scope ids are SHA-256 of the given UTF-8 names."
.into(),
space_name: SPACE_NAME.into(),
thread_name: THREAD_NAME.into(),
scope_name: SCOPE_NAME.into(),
space: hex_encode(&sha256_utf8(SPACE_NAME)),
vectors,
signed_vector: build_signed_vector(&records[0]),
}
}
#[test]
fn spec_vectors_match() {
let dir = tempfile::tempdir().unwrap();
let records = scripted_log(dir.path());
assert_eq!(records.len(), 24);
let built = build_vector_file(&records);
assert_eq!(built.vectors.len(), 12, "one vector per kind");
for v in &built.vectors {
let recomputed = hex_encode(&sha256(v.canonical_hash_form.as_bytes()));
assert_eq!(recomputed, v.id, "id mismatch for {}", v.kind);
}
assert_eq!(
hex_encode(&sha256(built.signed_vector.canonical_id_form.as_bytes())),
built.signed_vector.id
);
assert_eq!(
hex_encode(&sha256(
built.signed_vector.substitute_canonical_id_form.as_bytes()
)),
built.signed_vector.substitute_id
);
assert_ne!(built.signed_vector.id, built.signed_vector.substitute_id);
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("spec/test-vectors-v0.2.json");
if std::env::var("UPDATE_VECTORS").is_ok() {
let mut out = serde_json::to_string_pretty(&built).unwrap();
out.push('\n');
std::fs::write(&path, out).unwrap();
return;
}
let stored: VectorFile = serde_json::from_str(&std::fs::read_to_string(&path).expect(
"spec/test-vectors-v0.2.json missing - run UPDATE_VECTORS=1 cargo test --test spec_vectors",
))
.unwrap();
assert_eq!(
built, stored,
"canonical form drifted from spec/test-vectors-v0.2.json; if the \
change is an intentional format change, regenerate with \
UPDATE_VECTORS=1 and document it in CHANGELOG/SPEC"
);
}