-
Notifications
You must be signed in to change notification settings - Fork 0
feat(evidence): bind WARC records to PROV JSON-LD #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
seonghobae
wants to merge
34
commits into
feat/warc-resource-record-contract
Choose a base branch
from
feat/prov-jsonld-warc-bundle
base: feat/warc-resource-record-contract
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
291b55c
test(evidence): require WARC PROV JSON-LD bundle
seonghobae e34b44a
feat(evidence): add bounded WARC PROV bundle
seonghobae fced5ba
feat(evidence): expose WARC PROV bundle
seonghobae d1003d2
test(evidence): cover provenance debug redaction
seonghobae a59e28f
test(evidence): preserve WARC completeness in PROV
seonghobae fc4e916
fix(evidence): retain WARC completeness in PROV
seonghobae 39b29e2
test(evidence): pin completeness-aware PROV output
seonghobae 93b2497
style(evidence): apply canonical Rust formatting
seonghobae 008f526
style(evidence): format completeness regression
seonghobae c9130a2
docs(evidence): record completeness-aware PROV bundle
seonghobae e22ced7
docs(evidence): doctor PROV completeness semantics
seonghobae f9a1efb
test(evidence): prove PROV binds exact WARC record
seonghobae 18a3096
fix(evidence): bind PROV to serialized WARC record
seonghobae 72a91bb
test(evidence): pin serialized WARC digest in PROV
seonghobae d02ed44
test(evidence): require offline WARC PROV verification
seonghobae 2102cf3
feat(evidence): verify WARC PROV bindings offline
seonghobae 8d158b7
feat(evidence): export offline WARC PROV verification error
seonghobae 080d002
docs(changelog): record offline WARC PROV verification
seonghobae 22dbd91
test(evidence): require exact WARC provenance binding
seonghobae a6e61c1
fix(evidence): bind offline PROV verification to exact source evidence
seonghobae 3af43d9
style(evidence): apply canonical rustfmt
seonghobae 0f781cd
test(evidence): keep provenance regression clippy-clean
seonghobae e41bf2c
test(prov): reject null software revision identity
seonghobae 133289e
fix(prov): reject null Git software identity
seonghobae bb7fb76
Merge updated WARC identity prerequisite
seonghobae 56fcfa5
chore(evidence): realign PROV stack to WARC parent
seonghobae 003a83d
chore(evidence): realign PROV bundle to current WARC prerequisite
seonghobae c11e733
feat(evidence): expose PROV bundle on current stack
seonghobae 4647915
chore(evidence): realign PROV bundle with WARC parent
seonghobae 85eef87
docs(evidence): document WARC PROV bundle boundary
seonghobae 33e6c6e
docs(evidence): trace WARC PROV bundle boundary
seonghobae f1fcdd0
refactor(evidence): share WARC truncation tokens
seonghobae 6d5851b
merge: sync WARC provenance base
seonghobae 1d5d293
fix: make WARC provenance digest binding authoritative
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,273 @@ | ||
| use std::fmt; | ||
|
|
||
| use sha2::{Digest, Sha256}; | ||
|
|
||
| use crate::{ProvenanceRecord, WarcPayloadCompleteness, WarcResourceRecord}; | ||
|
|
||
| const ORIGINWEAVE_COMMIT_URL_PREFIX: &str = | ||
| "https://github.com/ContextualWisdomLab/OriginWeave/commit/"; | ||
| const WARC_RECORD_DIGEST_IRI: &str = | ||
| "tag:contextualwisdomlab.github.io,2026:OriginWeave/warcRecordDigest"; | ||
| const WARC_PAYLOAD_COMPLETENESS_IRI: &str = | ||
| "tag:contextualwisdomlab.github.io,2026:OriginWeave/warcPayloadCompleteness"; | ||
| const WARC_TRUNCATION_REASON_IRI: &str = | ||
| "tag:contextualwisdomlab.github.io,2026:OriginWeave/warcTruncationReason"; | ||
|
|
||
| /// Exact byte length accepted for a canonical Git SHA-1 software revision. | ||
| pub const MAX_PROV_SOFTWARE_COMMIT_SHA_BYTES: usize = 40; | ||
|
|
||
| /// A validation failure while constructing a deterministic WARC provenance bundle. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum WarcProvBundleError { | ||
| /// The software revision was not one canonical lower-case 40-byte Git SHA-1. | ||
| InvalidSoftwareCommitSha, | ||
| /// A bounded provenance field exceeded its allowed size. | ||
| LimitExceeded, | ||
| } | ||
|
|
||
| impl fmt::Display for WarcProvBundleError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter.write_str(match self { | ||
| Self::InvalidSoftwareCommitSha => "invalid OriginWeave software commit SHA", | ||
| Self::LimitExceeded => "WARC PROV bundle limit exceeded", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for WarcProvBundleError {} | ||
|
|
||
| /// A deterministic offline verification failure between a PROV bundle and a WARC record. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum WarcProvBundleVerificationError { | ||
| /// The WARC record identifier differs from the identifier bound into the PROV bundle. | ||
| RecordIdentityMismatch, | ||
| /// The independently verified source provenance differs from the provenance bound into the bundle. | ||
| SourceEvidenceMismatch, | ||
| /// The WARC capture timestamp differs from the timestamp bound into the PROV bundle. | ||
| CaptureTimeMismatch, | ||
| /// The WARC complete-versus-truncated state differs from the state bound into the bundle. | ||
| PayloadCompletenessMismatch, | ||
| /// The digest of the deterministic WARC serialization differs from the bundle binding. | ||
| WarcRecordDigestMismatch, | ||
| } | ||
|
|
||
| impl fmt::Display for WarcProvBundleVerificationError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter.write_str(match self { | ||
| Self::RecordIdentityMismatch => "WARC PROV record identity does not match", | ||
| Self::SourceEvidenceMismatch => "WARC PROV source evidence does not match", | ||
| Self::CaptureTimeMismatch => "WARC PROV capture time does not match", | ||
| Self::PayloadCompletenessMismatch => "WARC PROV payload completeness does not match", | ||
| Self::WarcRecordDigestMismatch => "WARC PROV serialized record digest does not match", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for WarcProvBundleVerificationError {} | ||
|
|
||
| /// A deterministic PROV-O JSON-LD projection over one validated WARC resource record. | ||
| /// | ||
| /// The bundle contains identifiers, exact validated source provenance, record hashes, source | ||
| /// location, capture time, explicit WARC payload completeness, and the exact OriginWeave software | ||
| /// revision. It deliberately does not retain or emit the WARC payload. | ||
| #[derive(Clone, PartialEq, Eq)] | ||
| pub struct WarcProvBundle { | ||
| record_entity_id: String, | ||
| source_entity_id: String, | ||
| capture_activity_id: String, | ||
| software_agent_id: String, | ||
| software_commit_sha: String, | ||
| source_provenance: ProvenanceRecord, | ||
| warc_date: String, | ||
| block_digest: String, | ||
| warc_record_digest: String, | ||
| payload_completeness: WarcPayloadCompleteness, | ||
| } | ||
|
|
||
| impl fmt::Debug for WarcProvBundle { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter | ||
| .debug_struct("WarcProvBundle") | ||
| .field("record_entity_id", &self.record_entity_id) | ||
| .field("source_entity_id", &self.source_entity_id) | ||
| .field("capture_activity_id", &self.capture_activity_id) | ||
| .field("software_agent_id", &self.software_agent_id) | ||
| .field("payload_completeness", &self.payload_completeness) | ||
| .finish_non_exhaustive() | ||
| } | ||
| } | ||
|
|
||
| impl WarcProvBundle { | ||
| /// Construct a provenance bundle from one already-validated WARC resource record. | ||
| /// | ||
| /// `software_commit_sha` is an immutable canonical Git SHA-1 identifier. This constructor | ||
| /// does not contact GitHub and does not treat the identifier as authentication or authority. | ||
| pub fn new( | ||
| record: &WarcResourceRecord, | ||
| software_commit_sha: &str, | ||
| ) -> Result<Self, WarcProvBundleError> { | ||
| if software_commit_sha.len() > MAX_PROV_SOFTWARE_COMMIT_SHA_BYTES { | ||
| return Err(WarcProvBundleError::LimitExceeded); | ||
| } | ||
| if !valid_software_commit_sha(software_commit_sha) { | ||
| return Err(WarcProvBundleError::InvalidSoftwareCommitSha); | ||
| } | ||
|
|
||
| let record_entity_id = record.record_id().to_owned(); | ||
| let source_entity_id = format!("{}#source", record.record_id()); | ||
| let capture_activity_id = format!("{}#capture", record.record_id()); | ||
| let software_agent_id = format!("{ORIGINWEAVE_COMMIT_URL_PREFIX}{software_commit_sha}"); | ||
| let warc_record_digest = sha256_digest(&record.to_warc_bytes()); | ||
|
|
||
| Ok(Self { | ||
| record_entity_id, | ||
| source_entity_id, | ||
| capture_activity_id, | ||
| software_agent_id, | ||
| software_commit_sha: software_commit_sha.to_owned(), | ||
| source_provenance: record.provenance().clone(), | ||
| warc_date: record.warc_date().to_owned(), | ||
| block_digest: record.block_digest().to_owned(), | ||
| warc_record_digest, | ||
| payload_completeness: record.completeness(), | ||
| }) | ||
| } | ||
|
|
||
| /// Return the PROV entity identifier for the WARC record. | ||
| #[must_use] | ||
| pub fn record_entity_id(&self) -> &str { | ||
| &self.record_entity_id | ||
| } | ||
|
|
||
| /// Return the PROV entity identifier for the independently verified source. | ||
| #[must_use] | ||
| pub fn source_entity_id(&self) -> &str { | ||
| &self.source_entity_id | ||
| } | ||
|
|
||
| /// Return the PROV activity identifier for this capture. | ||
| #[must_use] | ||
| pub fn capture_activity_id(&self) -> &str { | ||
| &self.capture_activity_id | ||
| } | ||
|
|
||
| /// Return the immutable OriginWeave commit URL used as the PROV software-agent identifier. | ||
| #[must_use] | ||
| pub fn software_agent_id(&self) -> &str { | ||
| &self.software_agent_id | ||
| } | ||
|
|
||
| /// Return the canonical lower-case Git SHA-1 of the OriginWeave revision. | ||
| #[must_use] | ||
| pub fn software_commit_sha(&self) -> &str { | ||
| &self.software_commit_sha | ||
| } | ||
|
|
||
| /// Verify offline that one validated WARC record is exactly the record bound by this bundle. | ||
| /// | ||
| /// Verification includes the complete validated [`ProvenanceRecord`] rather than only the | ||
| /// source URL and digest, so locator or evidence-channel drift cannot collapse into a match. | ||
| /// It is deterministic and performs no network, DNS, browser, model, persistence, or authority | ||
| /// operation. A matching digest proves byte identity only; it does not authenticate the actor | ||
| /// that produced either value or establish factual correctness. | ||
| pub fn verify_record( | ||
| &self, | ||
| record: &WarcResourceRecord, | ||
| ) -> Result<(), WarcProvBundleVerificationError> { | ||
| if self.record_entity_id != record.record_id() { | ||
| return Err(WarcProvBundleVerificationError::RecordIdentityMismatch); | ||
| } | ||
| if self.source_provenance != *record.provenance() { | ||
| return Err(WarcProvBundleVerificationError::SourceEvidenceMismatch); | ||
| } | ||
| if self.warc_date != record.warc_date() { | ||
| return Err(WarcProvBundleVerificationError::CaptureTimeMismatch); | ||
| } | ||
| // `warc_record_digest` covers the complete deterministic serialization, including the | ||
| // WARC-Block-Digest header, so a block-digest drift is reported by that binding below. | ||
| if self.payload_completeness != record.completeness() { | ||
| return Err(WarcProvBundleVerificationError::PayloadCompletenessMismatch); | ||
| } | ||
| if self.warc_record_digest != sha256_digest(&record.to_warc_bytes()) { | ||
| return Err(WarcProvBundleVerificationError::WarcRecordDigestMismatch); | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
| Ok(()) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
|
|
||
| /// Serialize the bundle as deterministic compact W3C PROV-O JSON-LD. | ||
| /// | ||
| /// All interpolated values originate from the validated WARC record or the canonical | ||
| /// lower-case software commit identifier, so no raw payload bytes enter this document. The | ||
| /// payload block digest binds the retained resource bytes while `warcRecordDigest` binds the | ||
| /// complete deterministic WARC serialization, including its headers. WARC payload completeness | ||
| /// is retained as an OriginWeave-owned absolute-IRI attribute; truncated records also retain | ||
| /// the exact WARC truncation token. The JSON-LD projection exposes source URL and digest while | ||
| /// offline verification additionally preserves the exact validated source locator and channel. | ||
| #[must_use] | ||
| pub fn to_json_ld(&self) -> String { | ||
| let completeness_attributes = | ||
| warc_payload_completeness_attributes(self.payload_completeness); | ||
| format!( | ||
| "{{\"@context\":{{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\"}},\"@graph\":[{{\"@id\":\"{}\",\"@type\":\"prov:Entity\",\"prov:atLocation\":{{\"@id\":\"{}\"}},\"prov:value\":\"{}\"}},{{\"@id\":\"{}\",\"@type\":\"prov:Activity\",\"prov:startedAtTime\":{{\"@value\":\"{}\",\"@type\":\"xsd:dateTime\"}},\"prov:used\":{{\"@id\":\"{}\"}},\"prov:wasAssociatedWith\":{{\"@id\":\"{}\"}}}},{{\"@id\":\"{}\",\"@type\":\"prov:SoftwareAgent\"}},{{\"@id\":\"{}\",\"@type\":\"prov:Entity\",\"prov:value\":\"{}\",\"{WARC_RECORD_DIGEST_IRI}\":\"{}\",{},\"prov:wasDerivedFrom\":{{\"@id\":\"{}\"}},\"prov:wasGeneratedBy\":{{\"@id\":\"{}\"}}}}]}}", | ||
| self.source_entity_id, | ||
| self.source_provenance.source_url(), | ||
| self.source_provenance.source_hash(), | ||
| self.capture_activity_id, | ||
| self.warc_date, | ||
| self.source_entity_id, | ||
| self.software_agent_id, | ||
| self.software_agent_id, | ||
| self.record_entity_id, | ||
| self.block_digest, | ||
| self.warc_record_digest, | ||
| completeness_attributes, | ||
| self.source_entity_id, | ||
| self.capture_activity_id, | ||
| ) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
| } | ||
|
|
||
| fn warc_payload_completeness_attributes(completeness: WarcPayloadCompleteness) -> String { | ||
| match completeness { | ||
| WarcPayloadCompleteness::Complete => { | ||
| format!("\"{WARC_PAYLOAD_COMPLETENESS_IRI}\":\"complete\"") | ||
| } | ||
| WarcPayloadCompleteness::Truncated(reason) => format!( | ||
| "\"{WARC_PAYLOAD_COMPLETENESS_IRI}\":\"truncated\",\"{WARC_TRUNCATION_REASON_IRI}\":\"{}\"", | ||
| reason.warc_token() | ||
| ), | ||
| } | ||
| } | ||
|
|
||
| fn sha256_digest(bytes: &[u8]) -> String { | ||
| let digest = Sha256::digest(bytes); | ||
| let mut encoded = String::from("sha256:"); | ||
| for byte in digest { | ||
| encoded.push_str(&format!("{byte:02x}")); | ||
| } | ||
| encoded | ||
| } | ||
|
|
||
| fn valid_software_commit_sha(software_commit_sha: &str) -> bool { | ||
| software_commit_sha.len() == MAX_PROV_SOFTWARE_COMMIT_SHA_BYTES | ||
| && software_commit_sha | ||
| .bytes() | ||
| .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) | ||
| && software_commit_sha.bytes().any(|byte| byte != b'0') | ||
|
seonghobae marked this conversation as resolved.
|
||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use crate::WarcTruncationReason; | ||
|
|
||
| #[test] | ||
| fn truncation_tokens_use_the_shared_warc_mapping() { | ||
| assert_eq!(WarcTruncationReason::Length.warc_token(), "length"); | ||
| assert_eq!(WarcTruncationReason::Time.warc_token(), "time"); | ||
| assert_eq!(WarcTruncationReason::Disconnect.warc_token(), "disconnect"); | ||
| assert_eq!( | ||
| WarcTruncationReason::Unspecified.warc_token(), | ||
| "unspecified" | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.