From 51434025d696ee80e4193e51a04f192e7fe78e7e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:03:50 +0000 Subject: [PATCH 1/4] fix(provenance): preserve required fields and isolate blocking verification --- crates/provenance/examples/record.rs | 8 +- crates/provenance/src/lib.rs | 248 ++++++++++++++---------- crates/provenance/tests/tamper.rs | 23 ++- src-gossamer/src/provenance/commands.rs | 8 +- 4 files changed, 173 insertions(+), 114 deletions(-) diff --git a/crates/provenance/examples/record.rs b/crates/provenance/examples/record.rs index 5d2cf80..1022645 100644 --- a/crates/provenance/examples/record.rs +++ b/crates/provenance/examples/record.rs @@ -5,7 +5,7 @@ use fips204::{ ml_dsa_65, traits::{KeyGen, SerDes}, }; -use panll_provenance::*; +use panll_provenance::{attest, hash, sign, Envelope, PublicKey, Record, Registry, ALGORITHM}; use std::{fs, io::Write, path::Path}; fn read(path: &str) -> Result { @@ -43,8 +43,8 @@ fn run(args: &[String]) -> Result<(), String> { [command, identity, from, until, public, private] if command == "keygen" => { // Refuse a private key inside any Git working tree. let directory = Path::new(private).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - if std::process::Command::new("git").args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(directory).output().map_err(|e| e.to_string())?.status.success() { + let directory = fs::canonicalize(directory).map_err(|e| e.to_string())?; + if directory.ancestors().any(|ancestor| ancestor.join(".git").exists()) { return Err("store the private key outside a Git working tree".into()); } let (pk, sk) = ml_dsa_65::KG::try_keygen().map_err(str::to_owned)?; @@ -58,7 +58,7 @@ fn run(args: &[String]) -> Result<(), String> { [command, record, private, output] if command == "sign" => { let record: Record = read(record)?; let signature = Some(sign(&record, &secret(private)?)?); - let envelope = Envelope { record, signature, attestations: vec![] }; + let envelope = Envelope { record, signature: signature.into(), attestations: vec![] }; write_new(output, &serde_json::to_vec_pretty(&envelope).map_err(|e| e.to_string())?, false) }, [command, input, public, private, output] if command == "witness" => { diff --git a/crates/provenance/src/lib.rs b/crates/provenance/src/lib.rs index 25a0081..6448e44 100644 --- a/crates/provenance/src/lib.rs +++ b/crates/provenance/src/lib.rs @@ -30,10 +30,8 @@ pub struct Record { pub author_identity: String, pub timestamp: String, pub content_hash: String, - #[serde(deserialize_with = "present_option")] - pub parent_hash: Option, - #[serde(deserialize_with = "present_option")] - pub platform: Option, + pub parent_hash: NullableString, + pub platform: NullableString, /// Required witness key fingerprints, covered by the author's signature. pub witnesses: Vec, } @@ -49,19 +47,35 @@ pub struct Attestation { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Envelope { pub record: Record, - #[serde(deserialize_with = "present_option")] - pub signature: Option, + pub signature: NullableString, pub attestations: Vec, } -// `Option` normally accepts an absent JSON field. The v1 payload requires an -// explicit null so deleting a field cannot be silently normalised away. -fn present_option<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, - T: Deserialize<'de>, -{ - Option::::deserialize(deserializer) +/// A required wire field whose value can be a string or explicit JSON null. +/// Unlike Option, an absent field is an error: deleting it is not normalised. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NullableString { + Value(String), + Null, +} + +impl NullableString { + pub fn as_ref(&self) -> Option<&String> { + match self { + Self::Value(value) => Some(value), + Self::Null => None, + } + } +} + +impl From> for NullableString { + fn from(value: Option) -> Self { + match value { + Some(value) => Self::Value(value), + None => Self::Null, + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -145,7 +159,7 @@ fn digest_format(value: &str) -> bool { .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) } -/// Registry construction fails closed on duplicate identities/fingerprints, +/// Registry construction fails closed on duplicate fingerprints, /// malformed keys or validity periods longer than the annual rotation limit. pub struct Registry(HashMap); impl Registry { @@ -213,6 +227,98 @@ impl Registry { } } +struct ChainState { + parent: Option, + parent_status: Status, + parent_time: Option>, + seen: HashSet, + earliest: DateTime, + latest: DateTime, +} + +fn verify_witnesses( + envelope: &Envelope, + keys: &Registry, + time: DateTime, + message: &[u8], +) -> Result { + let record = &envelope.record; + let declared: HashSet<_> = record.witnesses.iter().collect(); + if declared.len() != record.witnesses.len() + || declared.contains(&record.key_id) + || record.witnesses.iter().any(|id| !digest_format(id)) + { + return Err("duplicate, malformed or self witness".into()); + } + let mut supplied = HashSet::new(); + let mut complete = !declared.is_empty(); + for attestation in &envelope.attestations { + if !declared.contains(&attestation.key_id) || !supplied.insert(attestation.key_id.clone()) { + return Err("undeclared or duplicate witness attestation".into()); + } + if let (Some(author), Some(witness)) = + (keys.0.get(&record.key_id), keys.0.get(&attestation.key_id)) + { + if author.author_identity == witness.author_identity { + return Err("author cannot witness own record".into()); + } + } + complete &= keys.verify_signature( + &attestation.key_id, + None, + time, + message, + &attestation.signature, + )?; + } + Ok(complete && supplied.len() == declared.len()) +} + +fn verify_entry( + entry: &Contribution, + keys: &Registry, + id: &str, + state: &mut ChainState, +) -> Result<(bool, DateTime), String> { + let record = &entry.envelope.record; + if record.version != 1 + || record.algorithm != ALGORITHM + || record.author_identity.trim().is_empty() + || !digest_format(&record.key_id) + || !digest_format(&record.content_hash) + { + return Err("unsupported or malformed record".into()); + } + let time = timestamp(&record.timestamp)?; + if time < state.earliest || time > state.latest || state.parent_time.is_some_and(|p| time < p) { + return Err("timestamp outside policy window or before parent".into()); + } + if record.parent_hash.as_ref() != state.parent.as_ref() || !state.seen.insert(id.to_owned()) { + return Err("missing, reordered, duplicate or spliced chain link".into()); + } + let content = STANDARD + .decode(&entry.content_base64) + .map_err(|_| "invalid content encoding")?; + if hash(&content) != record.content_hash { + return Err("content hash mismatch".into()); + } + let message = canonical(record)?; + let author_verified = match entry.envelope.signature.as_ref() { + Some(sig) => keys.verify_signature( + &record.key_id, + Some(&record.author_identity), + time, + &message, + sig, + )?, + None => false, + }; + // Evaluate witnesses even when the author is unknown: a supplied invalid + // witness signature is invalid, rather than merely incomplete evidence. + let witnesses_verified = verify_witnesses(&entry.envelope, keys, time, &message)?; + Ok((author_verified && witnesses_verified, time)) +} + /// Verify a complete, ordered chain from its adoption root. The caller supplies /// a trusted time window; no record is permitted to select its own clock. /// A verified child cannot promote an unverified ancestor to verified. @@ -227,99 +333,43 @@ pub fn verify_chain( if earliest > latest { return Err("inverted timestamp window".into()); } + let mut state = ChainState { + parent: None, + parent_status: Status::Verified, + parent_time: None, + seen: HashSet::new(), + earliest, + latest, + }; let mut results = Vec::new(); - let mut parent: Option = None; - let mut parent_status = Status::Verified; - let mut parent_time = None; - let mut seen = HashSet::new(); for entry in entries { - let record = &entry.envelope.record; - let id = record_hash(record)?; + let id = record_hash(&entry.envelope.record)?; let mut reasons = Vec::new(); - let checked = (|| -> Result { - if record.version != 1 - || record.algorithm != ALGORITHM - || record.author_identity.trim().is_empty() - || !digest_format(&record.key_id) - || !digest_format(&record.content_hash) - { - return Err("unsupported or malformed record".into()); - } - let time = timestamp(&record.timestamp)?; - if time < earliest || time > latest || parent_time.is_some_and(|p| time < p) { - return Err("timestamp outside policy window or before parent".into()); - } - if record.parent_hash != parent || !seen.insert(id.clone()) { - return Err("missing, reordered, duplicate or spliced chain link".into()); - } - let content = STANDARD - .decode(&entry.content_base64) - .map_err(|_| "invalid content encoding")?; - if hash(&content) != record.content_hash { - return Err("content hash mismatch".into()); - } - let message = canonical(record)?; - let mut complete = match &entry.envelope.signature { - Some(sig) => keys.verify_signature( - &record.key_id, - Some(&record.author_identity), - time, - &message, - sig, - )?, - None => false, - }; - let declared: HashSet<_> = record.witnesses.iter().collect(); - if declared.len() != record.witnesses.len() - || declared.contains(&record.key_id) - || record.witnesses.iter().any(|id| !digest_format(id)) - { - return Err("duplicate, malformed or self witness".into()); - } - let mut supplied = HashSet::new(); - for attestation in &entry.envelope.attestations { - if !declared.contains(&attestation.key_id) - || !supplied.insert(attestation.key_id.clone()) - { - return Err("undeclared or duplicate witness attestation".into()); - } - if let (Some(author), Some(witness)) = - (keys.0.get(&record.key_id), keys.0.get(&attestation.key_id)) - { - if author.author_identity == witness.author_identity { - return Err("author cannot witness own record".into()); - } - } - complete &= keys.verify_signature( - &attestation.key_id, - None, - time, - &message, - &attestation.signature, - )?; - } - complete &= !declared.is_empty() && supplied.len() == declared.len(); - parent_time = Some(time); - Ok(complete) - })(); - let status = match checked { + let status = match verify_entry(entry, keys, &id, &mut state) { Err(reason) => { reasons.push(reason); Status::Invalid } - Ok(_) if parent_status == Status::Invalid => { - reasons.push("invalid ancestor".into()); - Status::Invalid - } - Ok(true) if parent_status == Status::Verified => Status::Verified, - Ok(_) => { - reasons - .push("unsigned, unknown key, incomplete witnesses or claimed ancestor".into()); - Status::Claimed + Ok((complete, time)) => { + state.parent_time = Some(time); + match (state.parent_status, complete) { + (Status::Invalid, _) => { + reasons.push("invalid ancestor".into()); + Status::Invalid + } + (Status::Verified, true) => Status::Verified, + _ => { + reasons.push( + "unsigned, unknown key, incomplete witnesses or claimed ancestor" + .into(), + ); + Status::Claimed + } + } } }; - parent = Some(id.clone()); - parent_status = status; + state.parent = Some(id.clone()); + state.parent_status = status; results.push(Verdict { status, record_hash: id, diff --git a/crates/provenance/tests/tamper.rs b/crates/provenance/tests/tamper.rs index bad91b1..4404550 100644 --- a/crates/provenance/tests/tamper.rs +++ b/crates/provenance/tests/tamper.rs @@ -35,8 +35,8 @@ fn fixture() -> (Contribution, Vec) { author_identity: keys[0].author_identity.clone(), timestamp: "2026-11-06T12:00:00Z".into(), content_hash: hash(b"contribution"), - parent_hash: None, - platform: Some("test".into()), + parent_hash: NullableString::Null, + platform: Some("test".into()).into(), witnesses: vec![keys[1].key_id.clone()], }; let signature = Some(sign(&record, &secret).unwrap()); @@ -45,7 +45,7 @@ fn fixture() -> (Contribution, Vec) { Contribution { envelope: Envelope { record, - signature, + signature: signature.into(), attestations, }, content_base64: STANDARD.encode(b"contribution"), @@ -87,6 +87,9 @@ fn signed_witnessed_round_trip_and_json_order() { "missing {field}" ); } + let mut envelope = serde_json::to_value(&entry.envelope).unwrap(); + envelope.as_object_mut().unwrap().remove("signature"); + assert!(serde_json::from_value::(envelope).is_err()); } #[test] @@ -142,13 +145,13 @@ fn trust_expiry_revocation_missing_witnesses_and_bad_signatures() { Status::Invalid ); let mut unsigned = entry.clone(); - unsigned.envelope.signature = None; + unsigned.envelope.signature = NullableString::Null; assert_eq!(verify(&[unsigned], keys.clone())[0].status, Status::Claimed); let mut missing = entry.clone(); missing.envelope.attestations.clear(); assert_eq!(verify(&[missing], keys.clone())[0].status, Status::Claimed); let mut bad = entry.clone(); - bad.envelope.signature = Some(STANDARD.encode([0; ml_dsa_65::SIG_LEN])); + bad.envelope.signature = Some(STANDARD.encode([0; ml_dsa_65::SIG_LEN])).into(); assert_eq!(verify(&[bad], keys.clone())[0].status, Status::Invalid); let mut bad = entry.clone(); bad.envelope.attestations[0].signature = "bad".into(); @@ -161,11 +164,11 @@ fn trust_expiry_revocation_missing_witnesses_and_bad_signatures() { fn child(parent: &Contribution, keys: &[PublicKey]) -> Contribution { let mut entry = parent.clone(); - entry.envelope.record.parent_hash = Some(record_hash(&parent.envelope.record).unwrap()); + entry.envelope.record.parent_hash = Some(record_hash(&parent.envelope.record).unwrap()).into(); entry.envelope.record.timestamp = "2026-11-06T13:00:00Z".into(); let (_, author) = ml_dsa_65::KG::keygen_from_seed(&[1; 32]); let (_, witness) = ml_dsa_65::KG::keygen_from_seed(&[2; 32]); - entry.envelope.signature = Some(sign(&entry.envelope.record, &author).unwrap()); + entry.envelope.signature = Some(sign(&entry.envelope.record, &author).unwrap()).into(); entry.envelope.attestations = vec![attest(&entry.envelope.record, keys[1].key_id.clone(), &witness).unwrap()]; entry @@ -191,7 +194,7 @@ fn chain_removal_splice_reorder_and_untrusted_ancestors() { Status::Invalid ); let mut claimed = root.clone(); - claimed.envelope.signature = None; + claimed.envelope.signature = NullableString::Null; assert_eq!( verify(&[claimed, next.clone()], keys.clone())[1].status, Status::Claimed @@ -213,7 +216,7 @@ proptest! { #[test] fn arbitrary_metadata_mutations_never_verify(suffix in "[a-zA-Z0-9]{1,40}") { let (mut entry, keys) = fixture(); - entry.envelope.record.platform = Some(format!("test{suffix}")); + entry.envelope.record.platform = Some(format!("test{suffix}")).into(); prop_assert_eq!(verify(&[entry], keys)[0].status, Status::Invalid); } #[test] @@ -222,7 +225,7 @@ proptest! { let mut next = child(&root, &keys); let spliced = hash(&bytes); prop_assume!(Some(&spliced) != next.envelope.record.parent_hash.as_ref()); - next.envelope.record.parent_hash = Some(spliced); + next.envelope.record.parent_hash = Some(spliced).into(); prop_assert_eq!(verify(&[root, next], keys)[1].status, Status::Invalid); } } diff --git a/src-gossamer/src/provenance/commands.rs b/src-gossamer/src/provenance/commands.rs index 1d7e9a6..122d7a9 100644 --- a/src-gossamer/src/provenance/commands.rs +++ b/src-gossamer/src/provenance/commands.rs @@ -107,6 +107,12 @@ pub async fn provenance_verify_records( repo_path: String, records_json: String, ) -> Result { + tokio::task::spawn_blocking(move || verify_records_sync(repo_path, records_json)) + .await + .map_err(|e| format!("provenance worker failed: {e}"))? +} + +fn verify_records_sync(repo_path: String, records_json: String) -> Result { use panll_provenance::{Contribution, PublicKey, Registry}; if records_json.len() > 16 * 1024 * 1024 { return Err("record batch exceeds 16 MiB".into()); @@ -122,7 +128,7 @@ pub async fn provenance_verify_records( Ok(files) => { for file in files { let file = file.map_err(|e| e.to_string())?; - if file.path().extension().and_then(|s| s.to_str()) == Some("json") { + if file.path().extension().and_then(std::ffi::OsStr::to_str) == Some("json") { if file.metadata().map_err(|e| e.to_string())?.len() > 65536 { return Err("public key file exceeds 64 KiB".into()); } From f3e9b192576e48ddcf066fddbfd43755750330b4 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:35:52 +0000 Subject: [PATCH 2/4] fix(provenance): preserve required fields and isolate blocking verification --- crates/provenance/examples/record.rs | 120 ++++++++++++++++++++++++++- crates/provenance/tests/tamper.rs | 11 +++ 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/provenance/examples/record.rs b/crates/provenance/examples/record.rs index 1022645..df2c6e1 100644 --- a/crates/provenance/examples/record.rs +++ b/crates/provenance/examples/record.rs @@ -8,6 +8,68 @@ use fips204::{ use panll_provenance::{attest, hash, sign, Envelope, PublicKey, Record, Registry, ALGORITHM}; use std::{fs, io::Write, path::Path}; +fn private_directory( + directory: &Path, + configured: &[(&str, std::path::PathBuf)], +) -> Result<(), String> { + let directory = fs::canonicalize(directory).map_err(|e| e.to_string())?; + if directory + .ancestors() + .any(|ancestor| ancestor.join(".git").exists()) + { + return Err("store the private key outside a Git working tree".into()); + } + // Git also supports markerless worktrees through external metadata. + // Filesystem checks alone cannot establish whether this is tracked storage. + let mut command = std::process::Command::new("git"); + command + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(directory) + .env("LC_ALL", "C"); + for name in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] { + command.env_remove(name); + } + for (name, value) in configured { + command.env(name, value); + } + let output = command + .output() + .map_err(|_| "Git is required to validate the private-key destination")?; + if output.status.success() { + return match String::from_utf8_lossy(&output.stdout).trim() { + "true" => Err("store the private key outside a Git working tree".into()), + "false" => Ok(()), + _ => Err("Git returned an indeterminate working-tree result".into()), + }; + } + if configured.is_empty() + && String::from_utf8_lossy(&output.stderr).starts_with("fatal: not a git repository") + { + return Ok(()); + } + Err("Git could not validate the private-key destination".into()) +} + +fn git_directories() -> Result, String> { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + Ok(["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] + .into_iter() + .filter_map(|name| { + std::env::var_os(name).map(|path| { + let path = std::path::PathBuf::from(path); + ( + name, + if path.is_absolute() { + path + } else { + cwd.join(path) + }, + ) + }) + }) + .collect()) +} + fn read(path: &str) -> Result { serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string()) } @@ -43,10 +105,7 @@ fn run(args: &[String]) -> Result<(), String> { [command, identity, from, until, public, private] if command == "keygen" => { // Refuse a private key inside any Git working tree. let directory = Path::new(private).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let directory = fs::canonicalize(directory).map_err(|e| e.to_string())?; - if directory.ancestors().any(|ancestor| ancestor.join(".git").exists()) { - return Err("store the private key outside a Git working tree".into()); - } + private_directory(directory, &git_directories()?)?; let (pk, sk) = ml_dsa_65::KG::try_keygen().map_err(str::to_owned)?; let bytes = pk.into_bytes(); let key = PublicKey { key_id: hash(&bytes), author_identity: identity.clone(), algorithm: ALGORITHM.into(), @@ -78,3 +137,56 @@ fn main() { std::process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::private_directory; + use std::{fs, process::Command}; + + #[test] + fn rejects_external_git_metadata_and_accepts_an_untracked_private_directory() { + let root = std::env::temp_dir().join(format!( + "panll-keyguard-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let work = root.join("work"); + let metadata = root.join("metadata"); + let outside = root.join("outside"); + fs::create_dir_all(&work).unwrap(); + fs::create_dir_all(&outside).unwrap(); + assert!(Command::new("git") + .args(["init", "--bare", "--quiet"]) + .arg(&metadata) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .arg("--git-dir") + .arg(&metadata) + .args(["config", "core.bare", "false"]) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .arg("--git-dir") + .arg(&metadata) + .args(["config", "core.worktree"]) + .arg(&work) + .status() + .unwrap() + .success()); + assert!(!work.join(".git").exists()); + assert!(private_directory(&work, &[("GIT_DIR", metadata.clone())]).is_err()); + assert!(private_directory( + &work, + &[("GIT_DIR", metadata), ("GIT_WORK_TREE", work.clone())] + ) + .is_err()); + assert!(private_directory(&outside, &[]).is_ok()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/provenance/tests/tamper.rs b/crates/provenance/tests/tamper.rs index 4404550..bf7f71e 100644 --- a/crates/provenance/tests/tamper.rs +++ b/crates/provenance/tests/tamper.rs @@ -156,6 +156,17 @@ fn trust_expiry_revocation_missing_witnesses_and_bad_signatures() { let mut bad = entry.clone(); bad.envelope.attestations[0].signature = "bad".into(); assert_eq!(verify(&[bad], keys.clone())[0].status, Status::Invalid); + let mut bad_witness = entry.clone(); + bad_witness.envelope.attestations[0].signature = "bad".into(); + assert_eq!( + verify(&[bad_witness.clone()], vec![keys[1].clone()])[0].status, + Status::Invalid + ); + bad_witness.envelope.signature = NullableString::Null; + assert_eq!( + verify(&[bad_witness], keys.clone())[0].status, + Status::Invalid + ); assert!(Registry::new(vec![keys[0].clone(), keys[0].clone()]).is_err()); let mut same_identity = keys.clone(); same_identity[1].author_identity = same_identity[0].author_identity.clone(); From 3512e110b1afc0ae71ed990a31ae2cf53883b838 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:03:13 +0000 Subject: [PATCH 3/4] fix(provenance): preserve required fields and isolate blocking verification --- docs/PROVENANCE-EXHIBIT-B.adoc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/PROVENANCE-EXHIBIT-B.adoc b/docs/PROVENANCE-EXHIBIT-B.adoc index 6ca172a..dc28c2b 100644 --- a/docs/PROVENANCE-EXHIBIT-B.adoc +++ b/docs/PROVENANCE-EXHIBIT-B.adoc @@ -107,6 +107,15 @@ nor a proof of all implementation paths. Production adoption requires an approved contributor key and independent witness; historical records are never silently re-signed. +CI admission repair, 2026-11-06: the repository allowed GitHub-owned and +verified actions but rejected its already configured standards reusable +workflows after their owner moved. The selected-actions policy now admits +only the existing `metadatastician/standards` revisions +`412a7031577112b31ee287cc6060179d638d6500` and +`e064539116c2dcfdf282a4749dbd58c69961c7e6`. Existing action allowances and the +mandatory SHA-pinning setting are preserved. Admission is not a test receipt; +the actual hosted runs still determine whether the workflows passed. + Validation receipt, 2026-11-06: the Linux workspace suite passed 295 backend tests and seven signature/property tests (two existing documentation examples are ignored). The protocol crate also passed Windows tests and Clippy with From e3281701c82dd01fd209239ce46ea0ce52da6e66 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:17:41 +0000 Subject: [PATCH 4/4] fix(provenance): preserve required fields and isolate blocking verification --- docs/PROVENANCE-EXHIBIT-B.adoc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/PROVENANCE-EXHIBIT-B.adoc b/docs/PROVENANCE-EXHIBIT-B.adoc index dc28c2b..535813b 100644 --- a/docs/PROVENANCE-EXHIBIT-B.adoc +++ b/docs/PROVENANCE-EXHIBIT-B.adoc @@ -110,9 +110,11 @@ never silently re-signed. CI admission repair, 2026-11-06: the repository allowed GitHub-owned and verified actions but rejected its already configured standards reusable workflows after their owner moved. The selected-actions policy now admits -only the existing `metadatastician/standards` revisions +the full reusable-workflow paths at the existing `metadatastician/standards` revisions `412a7031577112b31ee287cc6060179d638d6500` and -`e064539116c2dcfdf282a4749dbd58c69961c7e6`. Existing action allowances and the +`e064539116c2dcfdf282a4749dbd58c69961c7e6`, plus the exact third-party action +commits already recorded in `actions.lock`. The admissions were exported +through the canonical Kyaml parser. Existing action allowances and the mandatory SHA-pinning setting are preserved. Admission is not a test receipt; the actual hosted runs still determine whether the workflows passed.