diff --git a/crates/graphforge-api/src/lib.rs b/crates/graphforge-api/src/lib.rs index 89976f479..d7cc0c93f 100644 --- a/crates/graphforge-api/src/lib.rs +++ b/crates/graphforge-api/src/lib.rs @@ -307,13 +307,13 @@ mod write_modes; pub use graphforge_core::portable::{ PortableV2Authenticity, PortableV2Compatibility, PortableV2Error, PortableV2ErrorCode, - PortableV2ExportProgress, PortableV2GraphSelector, PortableV2GraphSubsetMeta, - PortableV2Integrity, PortableV2Limits, PortableV2Mode, PortableV2Output, - PortableV2PackageClass, PortableV2ParticipantId, PortableV2PropertyProjection, - PortableV2Representation, PortableV2SelectionEntry, PortableV2SelectionPlan, - PortableV2SelectionProfile, PortableV2SelectionReason, PortableV2SelectionRequest, - PortableV2SubsetClosure, PortableV2SubsetPreview as PortableV2SubsetPlan, - PortableV2SubsetRequest, + PortableV2ExactIdentity, PortableV2ExportProgress, PortableV2GraphSelector, + PortableV2GraphSubsetMeta, PortableV2Integrity, PortableV2Limits, PortableV2Mode, + PortableV2Output, PortableV2PackageClass, PortableV2ParticipantId, + PortableV2PropertyProjection, PortableV2Representation, PortableV2SelectionEntry, + PortableV2SelectionPlan, PortableV2SelectionProfile, PortableV2SelectionReason, + PortableV2SelectionRequest, PortableV2SubsetClosure, + PortableV2SubsetPreview as PortableV2SubsetPlan, PortableV2SubsetRequest, }; pub use graphforge_core::storage_receipt::{ ArtifactCategory, ArtifactStorageTotals, StorageAttributionReceipt, diff --git a/crates/graphforge-cli/examples/generate_hub_fixture.rs b/crates/graphforge-cli/examples/generate_hub_fixture.rs index ff73c4383..aaba88781 100644 --- a/crates/graphforge-cli/examples/generate_hub_fixture.rs +++ b/crates/graphforge-cli/examples/generate_hub_fixture.rs @@ -1,22 +1,49 @@ //! Generate or check the deterministic Rust-owned Hub fixture artifacts. +//! +//! - no flag: check the checked-in artifacts for drift +//! - `--rebuild-source`: rebuild `openalex-source` through the public facade +//! - `--update`: regenerate `generated/v1` from the checked-in source +//! +//! The flags combine. After `--rebuild-source`, run `--update` because the +//! generated artifacts bind the source tree. +use graphforge_cli::hub_fixture_artifacts::{ + DEFAULT_LOCATION_BASE, check, generate, rebuild_source, +}; use std::path::PathBuf; +fn regenerate(source: &std::path::Path, expected: &std::path::Path) -> Result<(), String> { + std::fs::remove_dir_all(expected) + .or_else(|error| { + (error.kind() == std::io::ErrorKind::NotFound) + .then_some(()) + .ok_or(error) + }) + .map_err(|error| error.to_string()) + .and_then(|()| generate(source, expected, DEFAULT_LOCATION_BASE)) +} + fn main() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let source = root.join("tests/fixtures/hub/openalex-source"); let expected = root.join("tests/fixtures/hub/generated/v1"); - let result = if std::env::args().any(|argument| argument == "--update") { - std::fs::remove_dir_all(&expected) - .or_else(|error| { - (error.kind() == std::io::ErrorKind::NotFound) - .then_some(()) - .ok_or(error) - }) - .map_err(|error| error.to_string()) - .and_then(|()| graphforge_cli::hub_fixture_artifacts::generate(&source, &expected)) + let arguments: Vec = std::env::args().collect(); + let has = |flag: &str| arguments.iter().any(|argument| argument == flag); + let result = if has("--rebuild-source") || has("--update") { + let rebuilt = if has("--rebuild-source") { + rebuild_source(&source) + } else { + Ok(()) + }; + rebuilt.and_then(|()| { + if has("--update") { + regenerate(&source, &expected) + } else { + Ok(()) + } + }) } else { - graphforge_cli::hub_fixture_artifacts::check(&source, &expected) + check(&source, &expected) }; if let Err(error) = result { eprintln!("{error}"); diff --git a/crates/graphforge-cli/src/hub_clone.rs b/crates/graphforge-cli/src/hub_clone.rs index bd22b738a..c1e13d3ea 100644 --- a/crates/graphforge-cli/src/hub_clone.rs +++ b/crates/graphforge-cli/src/hub_clone.rs @@ -1951,6 +1951,88 @@ mod tests { ]) } + /// Scripted responses that also record every requested URL. + struct RecordingTransport { + inner: Scripted, + requested: Mutex>, + } + + impl Transport for RecordingTransport { + fn get( + &self, + url: &Url, + range: Option, + if_range: Option<&str>, + limit: u64, + ) -> Result { + self.requested.lock().unwrap().push(url.as_str().to_owned()); + self.inner.get(url, range, if_range, limit) + } + } + + #[test] + fn clone_succeeds_against_the_checked_in_hub_fixture() { + macro_rules! fixture { + ($name:literal) => { + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/hub/generated/v1/", + $name + )) + }; + } + let refs = fixture!("refs.json"); + let manifest_bytes = fixture!("manifest.json"); + let project = fixture!("objects/openalex-openalex.gfpb"); + let manifest = graphforge_discovery::DiscoveryManifest::from_json( + manifest_bytes, + graphforge_discovery::DiscoveryLimits::default(), + ) + .unwrap(); + // The fixture advertises a summary and a module package besides the + // Project package; clone needs neither and must not fetch them. + assert!(manifest.summary.is_some()); + assert!(manifest.ontology.is_some()); + assert!(manifest.objects.len() > 1); + let transport = RecordingTransport { + inner: Scripted::new(vec![ + response(200, None, refs), + response(200, None, manifest_bytes), + response(200, None, project), + ]), + requested: Mutex::new(Vec::new()), + }; + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("openalex"); + let mut output = Vec::new(); + run_clone_with( + &transport, + CloneArgs { + repository: "openalex/openalex".into(), + destination: Some(destination.clone()), + telemetry_endpoint: None, + }, + true, + &mut output, + ) + .unwrap(); + assert_eq!(transport.inner.remaining(), 0); + let requested = transport.requested.lock().unwrap().clone(); + assert_eq!(requested.len(), 3, "{requested:?}"); + assert_eq!( + requested[2], + manifest.package_object().unwrap().locations[0], + "only the Project package object is downloaded" + ); + let result: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(result["contract"], "graphforge-hub-clone/1"); + assert_eq!(result["package_digest"], manifest.package.package_digest.0); + let cloned = GraphForge::new(destination.to_str()).expect("clone reopens"); + let metadata = cloned.research_project_metadata().unwrap(); + assert_eq!(metadata.title.as_deref(), Some("OpenAlex")); + assert_eq!(metadata.license.as_deref(), Some("CC0-1.0")); + } + #[test] fn interrupted_clone_retains_resumed_and_transferred_bytes_once() { let bundle = b"0123456789abcdef"; diff --git a/crates/graphforge-cli/src/hub_fixture_artifacts.rs b/crates/graphforge-cli/src/hub_fixture_artifacts.rs index b0b156c7e..226e8e41b 100644 --- a/crates/graphforge-cli/src/hub_fixture_artifacts.rs +++ b/crates/graphforge-cli/src/hub_fixture_artifacts.rs @@ -1,13 +1,21 @@ //! Rust-owned deterministic artifact generation for the public Hub fixture. use graphforge_api::{ - GraphForge, OperationId, PortableV2ImportRequest, PortableVerifyRequest, verify_portable_v2, + ActivationMode, ActivationProfileChangeRequest, DiscoveryOntologyModuleRequest, GraphForge, + ModuleAdoptionRequest, OntologyAuthorityExpectation, OntologyDoc, OperationId, + PortableSelection, PortableV2ExactIdentity, PortableV2ExportRequest, PortableV2ImportRequest, + PortableV2Limits, PortableV2Mode, PortableV2Output, PortableV2SelectionProfile, + PortableVerifyRequest, ProjectSummaryRequest, ResearchAccessPolicyMetadata, ResearchCorpusSize, + UpdateResearchMetadataRequest, WorkspaceResearchMetadata, WriteContext, + repack_verified_expanded_portable_v2, resolve_discovered_ontology_module, + summarize_verified_portable_v2, verify_portable_v2, }; -use graphforge_api::{PortableV2Limits, PortableV2Mode, repack_verified_expanded_portable_v2}; use graphforge_discovery::{ - DISCOVERY_FORMAT, DiscoveryManifest, ObjectDescriptor, PORTABLE_V2_FORMAT, - PORTABLE_V2_MEDIA_TYPE, PortablePackageReference, ProtocolRequirement, ProtocolVersion, RefSet, - RepositoryIdentity, RepositoryRef, Sha256Digest, + DISCOVERY_FORMAT, DiscoveryLimits, DiscoveryManifest, ExactIdentity, ObjectDescriptor, + OntologyInventory, OntologyModuleDescriptor, PORTABLE_V2_FORMAT, PORTABLE_V2_MEDIA_TYPE, + PROJECT_SUMMARY_FORMAT, PROJECT_SUMMARY_MEDIA_TYPE, PortablePackageReference, ProjectSummary, + ProjectSummaryReference, ProtocolRequirement, ProtocolVersion, RefSet, RepositoryIdentity, + RepositoryRef, Sha256Digest, }; use serde::Serialize; use sha2::{Digest as _, Sha256}; @@ -20,7 +28,27 @@ use uuid::Uuid; /// Stable generator contract. Git commit evidence belongs to the invoking checkout, /// rather than caller-controlled artifact metadata. pub const GENERATOR_CONTRACT: &str = "graphforge-hub-fixture-generator/1"; -const OBJECT_NAME: &str = "openalex-openalex.gfpb"; +/// Object location base used by the checked-in fixture. Locations are transport, +/// never identity, so a different base changes only `objects[].locations`. +pub const DEFAULT_LOCATION_BASE: &str = "https://data.graphforge.sh/objects/sha256/"; +const OBJECT_PATH: &str = "objects/openalex-openalex.gfpb"; +const SUMMARY_PATH: &str = "objects/openalex-openalex.summary.json"; +const MODULE_OBJECT_PREFIX: &str = "objects/ontology-module-"; +const MODULE_OBJECT_SUFFIX: &str = ".gfpb"; +const FIXED_FILES: [&str; 5] = [ + "fixture.json", + "manifest.json", + "refs.json", + OBJECT_PATH, + SUMMARY_PATH, +]; + +const MODULE_ID: &str = "https://openalex.org/ontology/works"; +const MODULE_VERSION: &str = "2026.01"; +const SOURCE_METADATA_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_a000_0000_0000_0001; +const SOURCE_ADOPTION_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_a000_0000_0000_0002; +const SOURCE_PROFILE_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_a000_0000_0000_0003; +const MODULE_EXPORT_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_b000_0000_0000_0001; #[derive(Debug, Serialize)] struct GeneratorIdentity<'a> { @@ -35,6 +63,18 @@ struct SourceIdentity { package_digest: String, } +/// One per-module package object advertised by the manifest. +#[derive(Debug, PartialEq, Serialize)] +struct ModuleObjectRecord { + ontology_id: String, + version: String, + content_digest: String, + package_digest: String, + object_digest: String, + object_length: u64, + object_path: String, +} + #[derive(Debug, Serialize)] struct FixtureMetadata<'a> { format: &'a str, @@ -44,26 +84,223 @@ struct FixtureMetadata<'a> { object_digest: String, object_length: u64, package_digest: String, + summary_path: &'a str, + summary_digest: String, + summary_object_digest: String, + summary_object_length: u64, + module_objects: Vec, manifest_digest: String, } -/// Generate canonical object, discovery, and provenance artifacts from the checked-in source. -#[expect( - clippy::too_many_lines, - reason = "one transaction binds portable and discovery artifacts" -)] -pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { +fn err(error: impl std::fmt::Display) -> String { + error.to_string() +} + +fn module_document() -> Result { + serde_json::from_value(serde_json::json!({ + "ontology_id": MODULE_ID, + "version": MODULE_VERSION, + "entity_types": [ + {"name": "Work", "abstract": false, "parent": null}, + {"name": "Author", "abstract": false, "parent": null}, + {"name": "Institution", "abstract": false, "parent": null} + ], + "relation_types": [{"name": "AUTHORED", "src": "Author", "dst": "Work"}], + "properties": [], + "constraints": [], + "migrations": [] + })) + .map_err(err) +} + +/// Synthetic public research metadata. These are fixture values, not product +/// behavior; `update_research_metadata` canonicalizes them. +fn fixture_research_metadata() -> WorkspaceResearchMetadata { + let mut metadata = WorkspaceResearchMetadata::empty(); + metadata.title = Some("OpenAlex".into()); + metadata.authors = vec!["OurResearch".into()]; + metadata.languages = vec!["en".into()]; + metadata.subjects = vec!["scholarly-communication".into()]; + metadata.source_types = vec!["bibliographic-database".into()]; + metadata.ontologies = vec![MODULE_ID.into()]; + metadata.license = Some("CC0-1.0".into()); + metadata.corpus_size = Some(ResearchCorpusSize { + node_count: Some(1_000), + relationship_count: Some(2_500), + source_count: Some(1), + artifact_count: Some(0), + }); + metadata.access = ResearchAccessPolicyMetadata { + visibility: Some("public".into()), + access_policy: Some("open".into()), + collaborators: Vec::new(), + }; + metadata.created_at = Some("2026-01-01T00:00:00.000000Z".into()); + metadata.updated_at = Some("2026-01-01T00:00:00.000000Z".into()); + metadata +} + +/// Rebuild the synthetic expanded source package through the public facade and +/// replace `destination` with it. +/// +/// The Project is durable, every write identity is fixed, and the export is the +/// complete expanded package, so repeated rebuilds are byte-identical. The new +/// tree is built and fully verified beside `destination` and swapped in only +/// when complete; a failed swap restores the previous tree. +pub fn rebuild_source(destination: &Path) -> Result<(), String> { let limits = PortableV2Limits::default(); - let source_report = verify_portable_v2( + let parent = destination + .parent() + .ok_or("fixture source destination has no parent directory")?; + fs::create_dir_all(parent).map_err(err)?; + let scratch = tempfile::tempdir().map_err(err)?; + let project = scratch.path().join("project"); + fs::create_dir(&project).map_err(err)?; + let mut graph = GraphForge::new(project.to_str()).map_err(err)?; + graph + .update_research_metadata(UpdateResearchMetadataRequest { + context: write_context(SOURCE_METADATA_OPERATION), + metadata: fixture_research_metadata(), + }) + .map_err(err)?; + let candidate = graph + .create_ontology_module(module_document()?, Vec::new(), None) + .map_err(err)?; + graph + .adopt_ontology_module( + &ModuleAdoptionRequest { + authority: authority(&graph, SOURCE_ADOPTION_OPERATION)?, + candidate, + }, + None, + ) + .map_err(err)?; + let (_, activation) = graph.ontology_activation_profile().map_err(err)?; + graph + .change_ontology_activation_profile( + &ActivationProfileChangeRequest { + authority: authority(&graph, SOURCE_PROFILE_OPERATION)?, + profile_default: ActivationMode::Advisory, + activation, + }, + None, + ) + .map_err(err)?; + // Stage beside the destination so the final swap is a same-volume rename. + let staging = tempfile::Builder::new() + .prefix(".hub-source-rebuild-") + .tempdir_in(parent) + .map_err(err)?; + let staged = staging.path().join("source"); + graph + .export_portable_v2( + &PortableV2ExportRequest { + selection: PortableSelection::Current, + output_path: staged.clone(), + representation: PortableV2Output::Expanded, + profile: PortableV2SelectionProfile::Complete, + subset: None, + limits, + }, + None, + |_| {}, + ) + .map_err(err)?; + verify_full(&staged)?; + let previous = staging.path().join("previous"); + let had_previous = match fs::rename(destination, &previous) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(err(error)), + }; + if let Err(error) = fs::rename(&staged, destination) { + if had_previous { + fs::rename(&previous, destination).map_err(|restore| { + format!("{error}; restoring the previous source also failed: {restore}") + })?; + } + return Err(err(error)); + } + Ok(()) +} + +fn write_context(operation: u128) -> WriteContext { + WriteContext { + operation_uuid: OperationId(Uuid::from_u128(operation)), + actor_uuid: None, + } +} + +fn authority(graph: &GraphForge, operation: u128) -> Result { + let state = graph.ontology_authority_state().map_err(err)?; + Ok(OntologyAuthorityExpectation { + context: write_context(operation), + expected_project_generation_uuid: state.project_generation_uuid, + expected_composition_fingerprint: state.composition_fingerprint, + }) +} + +fn verify_full(input: &Path) -> Result { + verify_portable_v2( &PortableVerifyRequest { - input: source.to_path_buf(), + input: input.to_path_buf(), mode: PortableV2Mode::Full, - limits, + limits: PortableV2Limits::default(), }, None, ) - .map_err(|error| error.to_string())?; - let scratch = tempfile::tempdir().map_err(|error| error.to_string())?; + .map_err(err) +} + +fn location(location_base: &str, digest: &str) -> Result { + let hex = digest + .strip_prefix("sha256:") + .ok_or("object digest prefix is invalid")?; + Ok(format!("{location_base}{hex}")) +} + +fn descriptor( + bytes: &[u8], + media_type: &str, + location_base: &str, +) -> Result { + let digest = digest_bytes(bytes); + Ok(ObjectDescriptor { + locations: vec![location(location_base, &digest)?], + digest: Sha256Digest(digest), + length: u64::try_from(bytes.len()).map_err(|_| "object length overflow")?, + media_type: media_type.into(), + }) +} + +fn module_object_path(content_digest: &str) -> Result { + let hex = content_digest + .strip_prefix("sha256:") + .filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or("module content digest is invalid")?; + Ok(format!("{MODULE_OBJECT_PREFIX}{hex}{MODULE_OBJECT_SUFFIX}")) +} + +fn repository_identity() -> RepositoryIdentity { + RepositoryIdentity { + owner: "openalex".into(), + repository: "openalex".into(), + } +} + +/// Generate canonical object, discovery, and provenance artifacts from the checked-in source. +/// +/// `location_base` prefixes every digest-addressed object location. It is +/// transport only: the summary bytes and the manifest's `summary` and `ontology` +/// descriptors do not depend on it. +#[expect( + clippy::too_many_lines, + reason = "one transaction binds portable and discovery artifacts" +)] +pub fn generate(source: &Path, destination: &Path, location_base: &str) -> Result<(), String> { + let limits = PortableV2Limits::default(); + let source_report = verify_full(source)?; + let scratch = tempfile::tempdir().map_err(err)?; let imported = scratch.path().join("imported"); let operation_id = OperationId(Uuid::from_u128(0x91f2_1a60_d89e_54e1_9000_0000_0000_0001)); GraphForge::import_portable_v2( @@ -75,10 +312,22 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { }, None, ) - .map_err(|error| error.to_string())?; - GraphForge::new(imported.to_str()).map_err(|error| error.to_string())?; - fs::create_dir_all(destination.join("objects")).map_err(|error| error.to_string())?; - let object_path = destination.join("objects").join(OBJECT_NAME); + .map_err(err)?; + let mut imported_project = GraphForge::new(imported.to_str()).map_err(err)?; + // A component-selective package records the exporting generation's manifest + // hash, and the manifest of a first import binds its parent: the random + // initial generation. Re-committing the identical research metadata under a + // fixed identity gives the exporting generation a deterministic parent, so + // module packages are reproducible byte for byte. + let research_metadata = imported_project.research_project_metadata().map_err(err)?; + imported_project + .update_research_metadata(UpdateResearchMetadataRequest { + context: write_context(MODULE_EXPORT_OPERATION), + metadata: research_metadata, + }) + .map_err(err)?; + fs::create_dir_all(destination.join("objects")).map_err(err)?; + let object_path = destination.join(OBJECT_PATH); let exported = repack_verified_expanded_portable_v2( &graphforge_api::PortableV2RepackRequest { source: source.to_path_buf(), @@ -87,16 +336,8 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { }, &std::sync::atomic::AtomicBool::new(false), ) - .map_err(|error| error.to_string())?; - let verified = verify_portable_v2( - &PortableVerifyRequest { - input: object_path.clone(), - mode: PortableV2Mode::Full, - limits, - }, - None, - ) - .map_err(|error| error.to_string())?; + .map_err(err)?; + let verified = verify_full(&object_path)?; let imported_bundle = scratch.path().join("imported-bundle"); let bundle_import = GraphForge::import_portable_v2( &imported_bundle, @@ -107,12 +348,11 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { }, None, ) - .map_err(|error| error.to_string())?; - let reopened_bundle = - GraphForge::new(imported_bundle.to_str()).map_err(|error| error.to_string())?; + .map_err(err)?; + let reopened_bundle = GraphForge::new(imported_bundle.to_str()).map_err(err)?; if reopened_bundle .committed_generation_identity() - .map_err(|error| error.to_string())? + .map_err(err)? .generation_uuid != bundle_import.generation_uuid { @@ -125,21 +365,98 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { { return Err("portable export and verification receipts disagree".into()); } - let bytes = fs::read(&object_path).map_err(|error| error.to_string())?; + let bytes = fs::read(&object_path).map_err(err)?; let object_digest = digest_bytes(&bytes); - let object_location = format!( - "https://data.graphforge.sh/objects/sha256/{}", - object_digest - .strip_prefix("sha256:") - .ok_or("transport digest prefix is invalid")? - ); if object_digest != exported_transport_digest { return Err("transport digest does not bind emitted object bytes".into()); } - let repository = RepositoryIdentity { - owner: "openalex".into(), - repository: "openalex".into(), - }; + let repository = repository_identity(); + let immutable_version = Sha256Digest(exported_package_digest.clone()); + + // The summary is derived from the verified bundle, never entered by hand. + let summary = summarize_verified_portable_v2(&ProjectSummaryRequest { + repository: &repository, + immutable_version: &immutable_version, + package: &object_path, + discovery_limits: DiscoveryLimits::default(), + portable_limits: limits, + cancelled: None, + }) + .map_err(err)?; + let summary_bytes = summary.to_canonical_json().map_err(err)?; + let summary_digest = summary.canonical_digest().map_err(err)?; + write(destination.join(SUMMARY_PATH), &summary_bytes)?; + + // One component-selective package per advertised module, exported from the + // Project reopened from the same checked-in source. + let mut module_objects = Vec::new(); + let mut module_descriptors = Vec::new(); + let mut module_records = Vec::new(); + let composition = summary.facts.ontology_composition.as_ref(); + for module in composition.map_or(&[][..], |composition| composition.modules.as_slice()) { + let path = module_object_path(&module.content_digest.0)?; + let module_path = destination.join(&path); + let receipt = imported_project + .export_portable_v2( + &PortableV2ExportRequest { + selection: PortableSelection::Current, + output_path: module_path.clone(), + representation: PortableV2Output::Bundle, + profile: PortableV2SelectionProfile::OntologyComposition(vec![ + PortableV2ExactIdentity { + id: module.id.clone(), + version: module.version.clone(), + content_digest: module.content_digest.0.clone(), + }, + ]), + subset: None, + limits, + }, + None, + |_| {}, + ) + .map_err(err)?; + let module_report = verify_full(&module_path)?; + let module_bytes = fs::read(&module_path).map_err(err)?; + let module_object = descriptor(&module_bytes, PORTABLE_V2_MEDIA_TYPE, location_base)?; + if module_report.package_digest != receipt.package_digest + || module_report.transport_digest.as_deref() != Some(&receipt.transport_digest) + || module_object.digest.0 != receipt.transport_digest + { + return Err("module export and verification receipts disagree".into()); + } + module_records.push(ModuleObjectRecord { + ontology_id: module.id.clone(), + version: module.version.clone(), + content_digest: module.content_digest.0.clone(), + package_digest: receipt.package_digest.clone(), + object_digest: module_object.digest.0.clone(), + object_length: module_object.length, + object_path: path, + }); + module_descriptors.push(OntologyModuleDescriptor { + id: module.id.clone(), + version: module.version.clone(), + content_digest: module.content_digest.clone(), + package: Some(PortablePackageReference { + format: PORTABLE_V2_FORMAT.into(), + package_digest: Sha256Digest(receipt.package_digest), + object_digest: module_object.digest.clone(), + }), + }); + module_objects.push(module_object); + } + let ontology = composition.map(|composition| OntologyInventory { + composition_digest: composition.composition_digest.clone(), + modules: module_descriptors, + bridge_sets: composition.bridge_sets.clone(), + }); + + let project_object = descriptor(&bytes, PORTABLE_V2_MEDIA_TYPE, location_base)?; + let summary_object = descriptor(&summary_bytes, PROJECT_SUMMARY_MEDIA_TYPE, location_base)?; + let mut objects = vec![project_object.clone(), summary_object.clone()]; + objects.extend(module_objects); + objects.sort_by(|left, right| left.digest.0.cmp(&right.digest.0)); let manifest = DiscoveryManifest { format: DISCOVERY_FORMAT.into(), version: ProtocolVersion::CURRENT, @@ -150,30 +467,24 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { package: PortablePackageReference { format: PORTABLE_V2_FORMAT.into(), package_digest: Sha256Digest(exported_package_digest.clone()), - object_digest: Sha256Digest(object_digest.clone()), + object_digest: project_object.digest.clone(), }, - summary: None, - ontology: None, + summary: Some(ProjectSummaryReference { + format: PROJECT_SUMMARY_FORMAT.into(), + summary_digest: summary_digest.clone(), + object_digest: summary_object.digest.clone(), + }), + ontology, requirements: vec![ProtocolRequirement { capability: "portable-v2".into(), major: 1, }], capabilities: vec![], - objects: vec![ObjectDescriptor { - digest: Sha256Digest(object_digest.clone()), - length: u64::try_from(bytes.len()).map_err(|_| "object length overflow")?, - media_type: PORTABLE_V2_MEDIA_TYPE.into(), - locations: vec![object_location], - }], + objects, extensions: BTreeMap::new(), }; - let manifest_bytes = manifest - .to_canonical_json() - .map_err(|error| error.to_string())?; - let manifest_digest = manifest - .canonical_digest() - .map_err(|error| error.to_string())? - .0; + let manifest_bytes = manifest.to_canonical_json().map_err(err)?; + let manifest_digest = manifest.canonical_digest().map_err(err)?.0; let refs = RefSet { format: DISCOVERY_FORMAT.into(), version: ProtocolVersion::CURRENT, @@ -186,11 +497,9 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { }], extensions: BTreeMap::new(), }; - refs.validate_manifest(&manifest) - .map_err(|error| error.to_string())?; - let refs_bytes = refs - .to_canonical_json() - .map_err(|error| error.to_string())?; + refs.validate_manifest(&manifest).map_err(err)?; + manifest.bind_summary(&summary).map_err(err)?; + let refs_bytes = refs.to_canonical_json().map_err(err)?; let metadata = FixtureMetadata { format: "graphforge-hub-fixture/1", generator: GeneratorIdentity { @@ -202,15 +511,20 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { tree_digest: tree_digest(source)?, package_digest: source_report.package_digest, }, - object_path: "objects/openalex-openalex.gfpb", + object_path: OBJECT_PATH, object_digest, - object_length: u64::try_from(bytes.len()).map_err(|_| "object length overflow")?, + object_length: project_object.length, package_digest: exported_package_digest, + summary_path: SUMMARY_PATH, + summary_digest: summary_digest.0, + summary_object_digest: summary_object.digest.0, + summary_object_length: summary_object.length, + module_objects: module_records, manifest_digest, }; write(destination.join("manifest.json"), &manifest_bytes)?; write(destination.join("refs.json"), &refs_bytes)?; - let mut metadata_bytes = serde_json::to_vec(&metadata).map_err(|error| error.to_string())?; + let mut metadata_bytes = serde_json::to_vec(&metadata).map_err(err)?; metadata_bytes.push(b'\n'); write(destination.join("fixture.json"), &metadata_bytes) } @@ -218,17 +532,15 @@ pub fn generate(source: &Path, destination: &Path) -> Result<(), String> { /// Regenerate in a private directory and compare every checked-in artifact byte. pub fn check(source: &Path, expected: &Path) -> Result<(), String> { validate(source, expected)?; - let scratch = tempfile::tempdir().map_err(|error| error.to_string())?; - generate(source, scratch.path())?; - for relative in [ - "fixture.json", - "manifest.json", - "refs.json", - "objects/openalex-openalex.gfpb", - ] { - let actual = fs::read(scratch.path().join(relative)).map_err(|error| error.to_string())?; - let expected_bytes = - fs::read(expected.join(relative)).map_err(|error| error.to_string())?; + let scratch = tempfile::tempdir().map_err(err)?; + generate(source, scratch.path(), DEFAULT_LOCATION_BASE)?; + let regenerated = artifact_files(scratch.path())?; + if regenerated != artifact_files(expected)? { + return Err("generated Hub fixture artifact set drifted".into()); + } + for relative in regenerated { + let actual = fs::read(scratch.path().join(&relative)).map_err(err)?; + let expected_bytes = fs::read(expected.join(&relative)).map_err(err)?; if actual != expected_bytes { return Err(format!("generated Hub fixture artifact drift: {relative}")); } @@ -236,36 +548,45 @@ pub fn check(source: &Path, expected: &Path) -> Result<(), String> { Ok(()) } -/// Validate all generator, source, portable, discovery, and metadata bindings. -#[expect( - clippy::too_many_lines, - reason = "one validator must check every cross-artifact fixture binding" -)] +/// Sorted relative paths of every regular file under `root`. +fn artifact_files(root: &Path) -> Result, String> { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).map_err(err)? { + let path = entry.map_err(err)?.path(); + if fs::symlink_metadata(&path).map_err(err)?.is_dir() { + pending.push(path); + } else { + files.push(canonical_relative_path( + path.strip_prefix(root).map_err(err)?, + )?); + } + } + } + files.sort(); + Ok(files) +} + +/// Validate all generator, source, portable, discovery, summary, and metadata bindings. fn validate(source: &Path, artifacts: &Path) -> Result<(), String> { - validate_expected_tree(artifacts)?; - let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(artifacts.join("fixture.json")).map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; - let manifest_bytes = - fs::read(artifacts.join("manifest.json")).map_err(|error| error.to_string())?; - let refs_bytes = fs::read(artifacts.join("refs.json")).map_err(|error| error.to_string())?; - let manifest = DiscoveryManifest::from_json( - &manifest_bytes, - graphforge_discovery::DiscoveryLimits::default(), - ) - .map_err(|error| error.to_string())?; - let refs = RefSet::from_json( - &refs_bytes, - graphforge_discovery::DiscoveryLimits::default(), - ) - .map_err(|error| error.to_string())?; - refs.validate_manifest(&manifest) - .map_err(|error| error.to_string())?; - let expected_repository = RepositoryIdentity { - owner: "openalex".into(), - repository: "openalex".into(), - }; + let metadata: serde_json::Value = + serde_json::from_slice(&fs::read(artifacts.join("fixture.json")).map_err(err)?) + .map_err(err)?; + let manifest_bytes = fs::read(artifacts.join("manifest.json")).map_err(err)?; + let refs_bytes = fs::read(artifacts.join("refs.json")).map_err(err)?; + let manifest = + DiscoveryManifest::from_json(&manifest_bytes, DiscoveryLimits::default()).map_err(err)?; + let refs = RefSet::from_json(&refs_bytes, DiscoveryLimits::default()).map_err(err)?; + refs.validate_manifest(&manifest).map_err(err)?; + let module_paths = manifest + .ontology + .iter() + .flat_map(|inventory| &inventory.modules) + .map(|module| module_object_path(&module.content_digest.0)) + .collect::, _>>()?; + validate_expected_tree(artifacts, &module_paths)?; + let expected_repository = repository_identity(); if manifest.repository != expected_repository || manifest.default_ref != "main" || manifest.resolved_ref != "main" @@ -275,10 +596,7 @@ fn validate(source: &Path, artifacts: &Path) -> Result<(), String> { { return Err("fixture repository, ref, or immutable version binding is invalid".into()); } - let manifest_digest = manifest - .canonical_digest() - .map_err(|error| error.to_string())? - .0; + let manifest_digest = manifest.canonical_digest().map_err(err)?.0; let reference = refs .refs .iter() @@ -287,37 +605,19 @@ fn validate(source: &Path, artifacts: &Path) -> Result<(), String> { if reference.validator.0 != manifest_digest { return Err("fixture validator does not bind canonical manifest bytes".into()); } - let selected = manifest - .package_object() - .map_err(|error| error.to_string())?; - let expected_location = format!( - "https://data.graphforge.sh/objects/sha256/{}", - selected - .digest - .0 - .strip_prefix("sha256:") - .ok_or("object digest prefix is invalid")? - ); - if selected.locations != [expected_location] { + let selected = manifest.package_object().map_err(err)?; + if selected.locations != [location(DEFAULT_LOCATION_BASE, &selected.digest.0)?] { return Err("fixture object location is not digest addressed".into()); } let object_path = metadata["object_path"] .as_str() .ok_or("fixture object path is absent")?; - if object_path != "objects/openalex-openalex.gfpb" { + if object_path != OBJECT_PATH { return Err("fixture object path is invalid".into()); } let object = artifacts.join(object_path); - let bytes = fs::read(&object).map_err(|error| error.to_string())?; - let report = verify_portable_v2( - &PortableVerifyRequest { - input: object, - mode: PortableV2Mode::Full, - limits: PortableV2Limits::default(), - }, - None, - ) - .map_err(|error| error.to_string())?; + let bytes = fs::read(&object).map_err(err)?; + let report = verify_full(&object)?; if digest_bytes(&bytes) != selected.digest.0 || bytes.len() as u64 != selected.length || report.package_digest != manifest.package.package_digest.0 @@ -330,37 +630,134 @@ fn validate(source: &Path, artifacts: &Path) -> Result<(), String> { || metadata["generator"]["crate_name"] != "graphforge-cli" || metadata["generator"]["source_digest"] != generator_source_digest() || metadata["source"]["tree_digest"] != tree_digest(source)? - || metadata["source"]["package_digest"] - != verify_portable_v2( - &PortableVerifyRequest { - input: source.to_path_buf(), - mode: PortableV2Mode::Full, - limits: PortableV2Limits::default(), - }, - None, - ) - .map_err(|error| error.to_string())? - .package_digest + || metadata["source"]["package_digest"] != verify_full(source)?.package_digest { return Err( "fixture portable, discovery, metadata, provenance, or source binding is invalid" .into(), ); } + validate_summary(artifacts, &manifest, &metadata)?; + validate_modules( + artifacts, + &manifest, + &manifest_bytes, + &refs_bytes, + &metadata, + ) +} + +/// The summary object must hash to the manifest descriptor, parse through the +/// public contract, and bind to the manifest it is advertised by. +fn validate_summary( + artifacts: &Path, + manifest: &DiscoveryManifest, + metadata: &serde_json::Value, +) -> Result<(), String> { + let reference = manifest + .summary + .as_ref() + .ok_or("fixture manifest does not advertise a summary")?; + let selected = manifest.summary_object().map_err(err)?; + if selected.locations != [location(DEFAULT_LOCATION_BASE, &selected.digest.0)?] { + return Err("fixture summary location is not digest addressed".into()); + } + let bytes = fs::read(artifacts.join(SUMMARY_PATH)).map_err(err)?; + if digest_bytes(&bytes) != reference.object_digest.0 || bytes.len() as u64 != selected.length { + return Err("fixture summary object bytes do not match the manifest descriptor".into()); + } + let summary = ProjectSummary::from_json(&bytes, DiscoveryLimits::default()).map_err(err)?; + manifest.bind_summary(&summary).map_err(err)?; + if summary.to_canonical_json().map_err(err)? != bytes { + return Err("fixture summary object is not canonical".into()); + } + if metadata["summary_path"] != SUMMARY_PATH + || metadata["summary_digest"] != reference.summary_digest.0 + || metadata["summary_object_digest"] != reference.object_digest.0 + || metadata["summary_object_length"] != selected.length + { + return Err("fixture summary metadata binding is invalid".into()); + } Ok(()) } -fn validate_expected_tree(root: &Path) -> Result<(), String> { +/// Every advertised module package must verify and resolve to exactly the +/// advertised identity, and `fixture.json` must record it. +fn validate_modules( + artifacts: &Path, + manifest: &DiscoveryManifest, + manifest_bytes: &[u8], + refs_bytes: &[u8], + metadata: &serde_json::Value, +) -> Result<(), String> { + let mut records = Vec::new(); + for module in manifest + .ontology + .iter() + .flat_map(|inventory| &inventory.modules) + { + let package = module + .package + .as_ref() + .ok_or("fixture ontology module has no package")?; + let identity = ExactIdentity { + id: module.id.clone(), + version: module.version.clone(), + content_digest: module.content_digest.clone(), + }; + let (_, selected) = manifest.ontology_module_object(&identity).map_err(err)?; + if selected.locations != [location(DEFAULT_LOCATION_BASE, &selected.digest.0)?] { + return Err("fixture module object location is not digest addressed".into()); + } + let path = module_object_path(&module.content_digest.0)?; + let file = artifacts.join(&path); + let bytes = fs::read(&file).map_err(err)?; + if digest_bytes(&bytes) != selected.digest.0 || bytes.len() as u64 != selected.length { + return Err("fixture module object bytes do not match the manifest descriptor".into()); + } + let resolved = resolve_discovered_ontology_module(&DiscoveryOntologyModuleRequest { + manifest_json: manifest_bytes, + refs_json: refs_bytes, + expected_repository: &repository_identity(), + module: &identity, + package: &file, + discovery_limits: DiscoveryLimits::default(), + portable_limits: PortableV2Limits::default(), + cancelled: None, + }) + .map_err(err)?; + if resolved.module.ontology_id != module.id + || resolved.module.authored_version != module.version + || format!("sha256:{}", resolved.module.canonical_digest) != module.content_digest.0 + || resolved.package_digest != package.package_digest.0 + { + return Err("fixture module package does not resolve to the advertised module".into()); + } + records.push(ModuleObjectRecord { + ontology_id: module.id.clone(), + version: module.version.clone(), + content_digest: module.content_digest.0.clone(), + package_digest: package.package_digest.0.clone(), + object_digest: selected.digest.0.clone(), + object_length: selected.length, + object_path: path, + }); + } + if metadata["module_objects"] != serde_json::to_value(&records).map_err(err)? { + return Err("fixture module object records do not match the manifest".into()); + } + Ok(()) +} + +fn validate_expected_tree(root: &Path, module_paths: &[String]) -> Result<(), String> { let mut actual = Vec::new(); let mut pending = vec![root.to_path_buf()]; while let Some(directory) = pending.pop() { - for entry in fs::read_dir(&directory).map_err(|error| error.to_string())? { - let entry = entry.map_err(|error| error.to_string())?; + for entry in fs::read_dir(&directory).map_err(err)? { + let entry = entry.map_err(err)?; let path = entry.path(); - let relative = canonical_relative_path( - path.strip_prefix(root).map_err(|error| error.to_string())?, - )?; - let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + let relative = canonical_relative_path(path.strip_prefix(root).map_err(err)?)?; + let metadata = fs::symlink_metadata(&path).map_err(err)?; if metadata.file_type().is_symlink() { return Err(format!( "fixture artifact tree contains a symlink: {relative}" @@ -377,13 +774,13 @@ fn validate_expected_tree(root: &Path) -> Result<(), String> { } } actual.sort(); - let expected = [ - "fixture.json", - "manifest.json", - "objects/", - "objects/openalex-openalex.gfpb", - "refs.json", - ]; + let mut expected: Vec = FIXED_FILES + .iter() + .map(|path| (*path).to_owned()) + .chain(module_paths.iter().cloned()) + .chain(["objects/".to_owned()]) + .collect(); + expected.sort(); if actual != expected { return Err("fixture artifact tree does not match the exact generated contract".into()); } @@ -392,9 +789,9 @@ fn validate_expected_tree(root: &Path) -> Result<(), String> { fn write(path: PathBuf, bytes: &[u8]) -> Result<(), String> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; + fs::create_dir_all(parent).map_err(err)?; } - fs::write(path, bytes).map_err(|error| error.to_string()) + fs::write(path, bytes).map_err(err) } fn digest_bytes(bytes: &[u8]) -> String { @@ -539,7 +936,7 @@ mod tests { fn check_rejects_tampered_artifact() { let source = private_source(); let output = tempfile::tempdir().unwrap(); - generate(source.path(), output.path()).unwrap(); + generate(source.path(), output.path(), DEFAULT_LOCATION_BASE).unwrap(); fs::write(output.path().join("refs.json"), b"{}\n").unwrap(); let error = check(source.path(), output.path()).unwrap_err(); assert!(!error.is_empty()); @@ -597,17 +994,14 @@ mod tests { let source = private_source(); let first = tempfile::tempdir().unwrap(); let second = tempfile::tempdir().unwrap(); - generate(source.path(), first.path()).unwrap(); - generate(source.path(), second.path()).unwrap(); - for relative in [ - "fixture.json", - "manifest.json", - "refs.json", - "objects/openalex-openalex.gfpb", - ] { + generate(source.path(), first.path(), DEFAULT_LOCATION_BASE).unwrap(); + generate(source.path(), second.path(), DEFAULT_LOCATION_BASE).unwrap(); + let files = artifact_files(first.path()).unwrap(); + assert_eq!(files, artifact_files(second.path()).unwrap()); + for relative in files { assert_eq!( - fs::read(first.path().join(relative)).unwrap(), - fs::read(second.path().join(relative)).unwrap(), + fs::read(first.path().join(&relative)).unwrap(), + fs::read(second.path().join(&relative)).unwrap(), "{relative}" ); } @@ -707,4 +1101,260 @@ mod tests { ); } } + + /// Rewrite a JSON artifact in place. + fn mutate_json(path: &Path, mutate: impl FnOnce(&mut serde_json::Value)) { + let mut value: serde_json::Value = + serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + mutate(&mut value); + fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap(); + } + + /// Re-bind the ref validator and fixture metadata to the current manifest so a + /// test reaches the check it targets instead of the manifest-digest binding. + fn reseal_manifest_digest(artifacts: &Path) { + let manifest = DiscoveryManifest::from_json( + &fs::read(artifacts.join("manifest.json")).unwrap(), + DiscoveryLimits::default(), + ) + .unwrap(); + let digest = manifest.canonical_digest().unwrap().0; + mutate_json(&artifacts.join("refs.json"), |value| { + value["refs"][0]["validator"] = serde_json::json!(digest); + }); + mutate_json(&artifacts.join("fixture.json"), |value| { + value["manifest_digest"] = serde_json::json!(digest); + }); + } + + fn other_digest() -> serde_json::Value { + serde_json::json!(format!("sha256:{}", "7".repeat(64))) + } + + /// Apply `mutate` to a pristine copy and require `validate` to reject it with + /// a message containing `expected`, and `check` to reject it too. + fn assert_rejected(name: &str, expected: &str, mutate: impl FnOnce(&Path)) { + let source = private_source(); + let candidate = copy_artifacts(&root().join("tests/fixtures/hub/generated/v1")); + mutate(candidate.path()); + let error = validate(source.path(), candidate.path()).unwrap_err(); + assert!(error.contains(expected), "{name}: {error}"); + assert!(check(source.path(), candidate.path()).is_err(), "{name}"); + } + + fn mutate_manifest(dir: &Path, mutate: impl FnOnce(&mut serde_json::Value)) { + mutate_json(&dir.join("manifest.json"), mutate); + } + + #[test] + fn manifest_summary_and_ontology_descriptors_are_each_enforced() { + assert_rejected("summary-digest", "disagrees with manifest", |dir| { + mutate_manifest(dir, |value| { + value["summary"]["summary_digest"] = other_digest(); + }); + reseal_manifest_digest(dir); + }); + assert_rejected("summary-removed", "does not advertise a summary", |dir| { + mutate_manifest(dir, |value| { + value.as_object_mut().unwrap().remove("summary"); + }); + reseal_manifest_digest(dir); + }); + assert_rejected( + "ontology-composition-digest", + "disagrees with manifest", + |dir| { + mutate_manifest(dir, |value| { + value["ontology"]["composition_digest"] = other_digest(); + }); + reseal_manifest_digest(dir); + }, + ); + assert_rejected("ontology-removed", "exact generated contract", |dir| { + mutate_manifest(dir, |value| { + value.as_object_mut().unwrap().remove("ontology"); + }); + reseal_manifest_digest(dir); + }); + assert_rejected( + "module-package-digest", + "reference mismatch: PackageDigest", + |dir| { + mutate_manifest(dir, |value| { + value["ontology"]["modules"][0]["package"]["package_digest"] = other_digest(); + }); + reseal_manifest_digest(dir); + }, + ); + // The manifest itself no longer parses, so no reseal is possible. + assert_rejected( + "module-package-is-project-package", + "module package object is incompatible", + |dir| { + mutate_manifest(dir, |value| { + let project = value["package"]["object_digest"].clone(); + value["ontology"]["modules"][0]["package"]["object_digest"] = project; + }); + }, + ); + } + + #[test] + fn summary_and_module_objects_and_records_are_each_enforced() { + let pristine = root().join("tests/fixtures/hub/generated/v1"); + let module_object = fs::read_dir(pristine.join("objects")) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .find(|name| name.starts_with("ontology-module-")) + .unwrap(); + assert_rejected("summary-object-bytes", "summary object bytes", |dir| { + let path = dir.join(SUMMARY_PATH); + let mut bytes = fs::read(&path).unwrap(); + bytes[10] ^= 1; + fs::write(path, bytes).unwrap(); + }); + assert_rejected("module-object-bytes", "module object bytes", |dir| { + let path = dir.join("objects").join(&module_object); + let mut bytes = fs::read(&path).unwrap(); + bytes[1024] ^= 1; + fs::write(path, bytes).unwrap(); + }); + assert_rejected("fixture-summary-digest", "summary metadata", |dir| { + mutate_json(&dir.join("fixture.json"), |value| { + value["summary_digest"] = other_digest(); + }); + }); + assert_rejected("fixture-module-record", "module object records", |dir| { + mutate_json(&dir.join("fixture.json"), |value| { + value["module_objects"][0]["package_digest"] = other_digest(); + }); + }); + } + + #[test] + fn summary_and_descriptors_do_not_depend_on_the_object_location_base() { + let source = private_source(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + generate(source.path(), first.path(), DEFAULT_LOCATION_BASE).unwrap(); + let mirror = "https://mirror.example.org/content/"; + generate(source.path(), second.path(), mirror).unwrap(); + + assert_eq!( + fs::read(first.path().join(SUMMARY_PATH)).unwrap(), + fs::read(second.path().join(SUMMARY_PATH)).unwrap() + ); + let manifest = |dir: &Path| -> serde_json::Value { + serde_json::from_slice(&fs::read(dir.join("manifest.json")).unwrap()).unwrap() + }; + let (first_manifest, second_manifest) = (manifest(first.path()), manifest(second.path())); + for field in [ + "summary", + "ontology", + "package", + "immutable_version", + "repository", + ] { + assert_eq!(first_manifest[field], second_manifest[field], "{field}"); + assert!(!first_manifest[field].is_null(), "{field}"); + } + // Only transport differs: same objects, same digests, different locations. + let objects = + |manifest: &serde_json::Value| -> Vec<(serde_json::Value, serde_json::Value)> { + manifest["objects"] + .as_array() + .unwrap() + .iter() + .map(|object| (object["digest"].clone(), object["locations"].clone())) + .collect() + }; + let (default_objects, mirror_objects) = + (objects(&first_manifest), objects(&second_manifest)); + assert_eq!(default_objects.len(), 3); + for ((first_digest, first_locations), (second_digest, second_locations)) in + default_objects.iter().zip(&mirror_objects) + { + assert_eq!(first_digest, second_digest); + assert_ne!(first_locations, second_locations); + assert!( + first_locations[0] + .as_str() + .unwrap() + .starts_with(DEFAULT_LOCATION_BASE) + ); + assert_eq!( + second_locations[0].as_str().unwrap(), + format!( + "{mirror}{}", + first_digest + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + ) + ); + } + // Every non-manifest artifact is byte-identical across bases. + for relative in artifact_files(first.path()).unwrap() { + if relative == "manifest.json" || relative == "refs.json" || relative == "fixture.json" + { + continue; + } + assert_eq!( + fs::read(first.path().join(&relative)).unwrap(), + fs::read(second.path().join(&relative)).unwrap(), + "{relative}" + ); + } + let metadata = |dir: &Path| -> serde_json::Value { + serde_json::from_slice(&fs::read(dir.join("fixture.json")).unwrap()).unwrap() + }; + let (first_metadata, second_metadata) = (metadata(first.path()), metadata(second.path())); + for field in [ + "summary_digest", + "summary_object_digest", + "module_objects", + "package_digest", + ] { + assert_eq!(first_metadata[field], second_metadata[field], "{field}"); + } + // The mirror manifest is self-consistent but is not the checked-in + // location contract. + assert!(validate(source.path(), second.path()).is_err()); + } + + fn tree_bytes(root: &Path) -> BTreeMap> { + artifact_files(root) + .unwrap() + .into_iter() + .map(|relative| { + let bytes = fs::read(root.join(&relative)).unwrap(); + (relative, bytes) + }) + .collect() + } + + #[test] + fn rebuilt_source_is_deterministic_and_matches_the_checked_in_tree() { + let workspace = tempfile::tempdir().unwrap(); + let first = workspace.path().join("first"); + let second = workspace.path().join("second"); + rebuild_source(&first).unwrap(); + rebuild_source(&second).unwrap(); + // Rebuilding over an existing tree replaces it with identical bytes. + rebuild_source(&first).unwrap(); + let rebuilt = tree_bytes(&first); + assert_eq!(rebuilt, tree_bytes(&second)); + assert_eq!( + rebuilt, + tree_bytes(&root().join("tests/fixtures/hub/openalex-source")), + "checked-in source is not the Rust rebuild; run the generator with --rebuild-source" + ); + assert_eq!( + fs::read_dir(workspace.path()).unwrap().count(), + 2, + "rebuild left staging directories behind" + ); + verify_full(&first).unwrap(); + } } diff --git a/crates/graphforge-cli/tests/hub_fixture.rs b/crates/graphforge-cli/tests/hub_fixture.rs new file mode 100644 index 000000000..fc9e1e68c --- /dev/null +++ b/crates/graphforge-cli/tests/hub_fixture.rs @@ -0,0 +1,160 @@ +//! The public Hub fixture: drift gate, and the Project page facts a Hub renders +//! from the checked-in `generated/v1` artifacts alone. + +use graphforge_api::{ + DiscoveryOntologyModuleRequest, PortableV2Limits, resolve_discovered_ontology_module, +}; +use graphforge_cli::hub_fixture_artifacts::{DEFAULT_LOCATION_BASE, check, generate}; +use graphforge_discovery::{ + DiscoveryLimits, DiscoveryManifest, ExactIdentity, ProjectSummary, RefSet, RepositoryIdentity, +}; +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn source() -> PathBuf { + root().join("tests/fixtures/hub/openalex-source") +} + +fn generated() -> PathBuf { + root().join("tests/fixtures/hub/generated/v1") +} + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .fold(String::from("sha256:"), |mut output, byte| { + write!(output, "{byte:02x}").unwrap(); + output + }) +} + +#[test] +fn checked_in_fixture_has_no_drift() { + // Regenerates every artifact from the checked-in source and compares bytes. + // On failure: `cargo run -p graphforge-cli --example generate_hub_fixture -- --update`. + check(&source(), &generated()).unwrap(); +} + +#[test] +fn project_page_facts_come_from_the_checked_in_artifacts_alone() { + let manifest_bytes = fs::read(generated().join("manifest.json")).unwrap(); + let refs_bytes = fs::read(generated().join("refs.json")).unwrap(); + let manifest = + DiscoveryManifest::from_json(&manifest_bytes, DiscoveryLimits::default()).unwrap(); + RefSet::from_json(&refs_bytes, DiscoveryLimits::default()) + .unwrap() + .validate_manifest(&manifest) + .unwrap(); + + // Select the summary object the way a consumer does: by manifest descriptor. + let descriptor = manifest.summary_object().unwrap(); + let summary_bytes = + fs::read(generated().join("objects/openalex-openalex.summary.json")).unwrap(); + assert_eq!(digest(&summary_bytes), descriptor.digest.0); + assert_eq!(summary_bytes.len() as u64, descriptor.length); + let summary = ProjectSummary::from_json(&summary_bytes, DiscoveryLimits::default()).unwrap(); + manifest.bind_summary(&summary).unwrap(); + + assert_eq!(summary.repository.owner, "openalex"); + assert_eq!(summary.repository.repository, "openalex"); + assert_eq!(summary.metadata.title.as_deref(), Some("OpenAlex")); + assert_eq!(summary.metadata.license.as_deref(), Some("CC0-1.0")); + assert_eq!(summary.metadata.authors, ["OurResearch"]); + assert_eq!(summary.metadata.languages, ["en"]); + assert_eq!(summary.metadata.subjects, ["scholarly-communication"]); + assert_eq!(summary.metadata.source_types, ["bibliographic-database"]); + assert_eq!( + summary.metadata.ontologies, + ["https://openalex.org/ontology/works"] + ); + let size = summary.metadata.corpus_size.as_ref().unwrap(); + assert_eq!(size.node_count, Some(1_000)); + assert_eq!(size.relationship_count, Some(2_500)); + assert_eq!(size.source_count, Some(1)); + assert_eq!(size.artifact_count, Some(0)); + assert_eq!( + summary.metadata.access.visibility.as_deref(), + Some("public") + ); + assert_eq!( + summary.metadata.access.access_policy.as_deref(), + Some("open") + ); + assert_eq!(summary.facts.ontology_mode, "advisory"); + + let composition = summary.facts.ontology_composition.as_ref().unwrap(); + assert_eq!(composition.modules.len(), 1); + let module = &composition.modules[0]; + assert_eq!(module.id, "https://openalex.org/ontology/works"); + assert_eq!(module.version, "2026.01"); + + // The manifest inventory advertises the same exact module with its own + // package object, which is not the Project package. + let inventory = manifest.ontology.as_ref().unwrap(); + assert_eq!(inventory.modules.len(), 1); + let identity = ExactIdentity { + id: module.id.clone(), + version: module.version.clone(), + content_digest: module.content_digest.clone(), + }; + let (advertised, module_object) = manifest.ontology_module_object(&identity).unwrap(); + assert_eq!(advertised.content_digest, module.content_digest); + assert_ne!(module_object.digest, manifest.package.object_digest); + + // The advertised module package resolves to exactly that module without + // any Project graph data. + let module_package = generated().join(format!( + "objects/ontology-module-{}.gfpb", + module.content_digest.0.strip_prefix("sha256:").unwrap() + )); + assert_eq!( + digest(&fs::read(&module_package).unwrap()), + module_object.digest.0 + ); + let resolved = resolve_discovered_ontology_module(&DiscoveryOntologyModuleRequest { + manifest_json: &manifest_bytes, + refs_json: &refs_bytes, + expected_repository: &RepositoryIdentity::parse("openalex/openalex").unwrap(), + module: &identity, + package: &module_package, + discovery_limits: DiscoveryLimits::default(), + portable_limits: PortableV2Limits::default(), + cancelled: None, + }) + .unwrap(); + assert_eq!(resolved.module.ontology_id, module.id); + assert_eq!(resolved.module.authored_version, module.version); + assert!(!resolved.document.is_empty()); +} + +#[test] +fn summary_and_descriptors_survive_a_different_object_location_base() { + let regenerated = tempfile::tempdir().unwrap(); + generate( + &source(), + regenerated.path(), + "https://mirror.example.org/content/", + ) + .unwrap(); + assert_ne!(DEFAULT_LOCATION_BASE, "https://mirror.example.org/content/"); + let relative = "objects/openalex-openalex.summary.json"; + assert_eq!( + fs::read(regenerated.path().join(relative)).unwrap(), + fs::read(generated().join(relative)).unwrap() + ); + let manifest = |dir: &Path| -> serde_json::Value { + serde_json::from_slice(&fs::read(dir.join("manifest.json")).unwrap()).unwrap() + }; + let (mirrored, checked_in) = (manifest(regenerated.path()), manifest(&generated())); + for field in ["summary", "ontology"] { + assert!(!checked_in[field].is_null(), "{field}"); + assert_eq!(mirrored[field], checked_in[field], "{field}"); + } + assert_ne!(mirrored["objects"], checked_in["objects"]); +} diff --git a/docs/reference/discovery/v1/README.md b/docs/reference/discovery/v1/README.md index 204d4bd3a..e552dfe2b 100644 --- a/docs/reference/discovery/v1/README.md +++ b/docs/reference/discovery/v1/README.md @@ -84,7 +84,10 @@ strictly ascending entries. `facts` holds verified package facts that need no graph payload: `ontology_mode`, a component-kind histogram (only kinds with at least one component), `payload_bytes`, `research_present`, `evidence_present`, and an optional ontology composition. The presence flags must agree with the -histogram. +histogram: `research_present` means the package carries a `research`-kind +component (the research-interchange registry), and `evidence_present` an +`evidence`-kind component. Research *metadata* presence shows up as non-null +`metadata` fields, not as `research_present`. `DiscoveryManifest::bind_summary` requires a summary to name the manifest's repository, `immutable_version`, and `package_digest`, to hash to diff --git a/tests/fixtures/hub/README.md b/tests/fixtures/hub/README.md index a5d651cdf..71d4651c8 100644 --- a/tests/fixtures/hub/README.md +++ b/tests/fixtures/hub/README.md @@ -2,29 +2,86 @@ This directory is the single GraphForge authority for the public Hub closeout fixture. `openalex-source/` is the complete, synthetic, expanded portable-v2 -source state. `generated/v1/` contains the deterministic bundle and canonical +source state of the `openalex/openalex` Project. `generated/v1/` contains the +deterministic bundle, Project summary, per-module package, and canonical discovery manifest and refs emitted by Rust. -Check drift: +## Commands + +Check drift (CI runs the same check in `crates/graphforge-cli/tests/hub_fixture.rs`): ```bash cargo run -p graphforge-cli --example generate_hub_fixture ``` -Regenerate after an intentional source or contract change: +Regenerate `generated/v1/` after an intentional source or contract change: + +```bash +cargo run -p graphforge-cli --example generate_hub_fixture -- --update +``` + +Rebuild `openalex-source/` itself, then regenerate (the generated artifacts bind +the source tree, so always follow `--rebuild-source` with `--update`): ```bash +cargo run -p graphforge-cli --example generate_hub_fixture -- --rebuild-source cargo run -p graphforge-cli --example generate_hub_fixture -- --update ``` +After the artifacts change, regenerate the shared verification receipts as +described in `tests/fixtures/portable-v2/README.md`. + +## The source + +`--rebuild-source` builds the source through the public GraphForge facade: a +durable Project with fixed operation identities, synthetic research metadata +written by `update_research_metadata` (title "OpenAlex", license "CC0-1.0", +public/open access, declared corpus size 1000 nodes and 2500 relationships), +one adopted ontology module (`https://openalex.org/ontology/works` version +`2026.01`: `Work`, `Author`, `Institution`, one `AUTHORED` relationship) with an +advisory activation profile, then a complete expanded export. The new tree is +fully verified and swapped in only when complete. The values are fixture +content, not product behavior. Nothing under `openalex-source/` is edited by +hand; running `--rebuild-source` twice produces byte-identical trees, and a unit +test asserts that the rebuild equals the checked-in tree. + +## The generated artifacts + +`generated/v1/` is exactly these files: + +| File | Contents | +| --- | --- | +| `manifest.json` | canonical discovery manifest, including `summary` and `ontology` | +| `refs.json` | canonical refs; the `main` validator is the manifest digest | +| `objects/openalex-openalex.gfpb` | the complete Project package (bundle) | +| `objects/openalex-openalex.summary.json` | the canonical `graphforge-project-summary/1` document | +| `objects/ontology-module-.gfpb` | a component-selective package carrying exactly one ontology module | +| `fixture.json` | provenance and the digests that bind the files above | + The generator fully verifies the source, imports and reopens it through the public GraphForge facade with a fixed operation identity, exports and fully -verifies the canonical bundle, then constructs and validates manifest and refs -through `graphforge-discovery`. Its metadata binds the checked-in source tree, -the compiled generator source, and the package, transport, manifest, -and byte-length identities. It does not accept a caller-supplied Git commit. -The invoking checkout commit is the review and release provenance evidence. +verifies the canonical bundle, derives the summary from that verified bundle +(`summarize_verified_portable_v2`, never from hand-entered metadata), exports one +package per advertised module, then constructs and validates manifest and refs +through `graphforge-discovery`. The manifest advertises the summary +(`summary.object_digest`) and each module (`ontology.modules[].package`) as +digest-addressed objects, and `bind_summary` proves the summary matches the +manifest. The check also verifies that every module package resolves, through +`resolve_discovered_ontology_module`, to exactly the advertised module identity. + +`fixture.json` binds the checked-in source tree, the compiled generator source, +and the package, transport, summary, module, manifest, and byte-length +identities. It does not accept a caller-supplied Git commit. The invoking +checkout commit is the review and release provenance evidence. + +Object locations are transport, never identity. `generate` takes a location base +(default `https://data.graphforge.sh/objects/sha256/`); a different base changes +only `objects[].locations`, the manifest and refs digests that cover them, and +nothing else: the summary bytes and the `summary` and `ontology` descriptors are +identical. + +## What downstream may copy -Downstream TypeScript may copy or import the four files in `generated/v1/` as +Downstream TypeScript may copy or import the six files in `generated/v1/` as opaque versioned artifacts. It must not reconstruct protocol fields, validators, -digests, or compatibility rules. +digests, or compatibility rules, and it must not read `openalex-source/`. diff --git a/tests/fixtures/hub/generated/v1/fixture.json b/tests/fixtures/hub/generated/v1/fixture.json index 6c88fb14c..219d69af5 100644 --- a/tests/fixtures/hub/generated/v1/fixture.json +++ b/tests/fixtures/hub/generated/v1/fixture.json @@ -1 +1 @@ -{"format":"graphforge-hub-fixture/1","generator":{"contract":"graphforge-hub-fixture-generator/1","crate_name":"graphforge-cli","source_digest":"sha256:246883a44ea9412650b11eb776e56ef7b641585047216a05e701246798f59bef"},"source":{"tree_digest":"sha256:a060bd49fc5d2b66d74f39499c165a8e9db464bd776e2614060c3f4204a01ae9","package_digest":"sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e"},"object_path":"objects/openalex-openalex.gfpb","object_digest":"sha256:f11322419e4c10c33365f07b28f24d6a348a105e61a152f6e5fcd6750daaea35","object_length":11776,"package_digest":"sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e","manifest_digest":"sha256:db5bd3e84171ca80ccca3f1e9e92d6acf76054238a5de6fd33c5f987fe2f1feb"} +{"format":"graphforge-hub-fixture/1","generator":{"contract":"graphforge-hub-fixture-generator/1","crate_name":"graphforge-cli","source_digest":"sha256:edca6aadd37abb6ef0af5eaff89c9a243e8444a3d9847f759baaaf54f1868226"},"source":{"tree_digest":"sha256:161859d73a69b66b1be52a7e7fc13c84506060415b789280e2dcbb69e8639053","package_digest":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35"},"object_path":"objects/openalex-openalex.gfpb","object_digest":"sha256:52cbffc43ab2bd081aefa297970a4f060fb52b8a8ab601b77e2bca9ca6dda54d","object_length":21504,"package_digest":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35","summary_path":"objects/openalex-openalex.summary.json","summary_digest":"sha256:a2e21e7a1635c010652296cb6996df1d70833af4a9812c75f0c996b7e1eace5d","summary_object_digest":"sha256:a2e21e7a1635c010652296cb6996df1d70833af4a9812c75f0c996b7e1eace5d","summary_object_length":1657,"module_objects":[{"ontology_id":"https://openalex.org/ontology/works","version":"2026.01","content_digest":"sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4","package_digest":"sha256:8528501d155c5e4212f930c3ae8d298d51fe3deff2298431cb8ecd5dc53b77b0","object_digest":"sha256:94941cf9a402f2c662258fe66c2f1b170016b92ab3e495200284177103c70a9b","object_length":12288,"object_path":"objects/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4.gfpb"}],"manifest_digest":"sha256:ca714a09673bb3b353dd903628816472ed58eedcd40c8b687401163977d71a36"} diff --git a/tests/fixtures/hub/generated/v1/manifest.json b/tests/fixtures/hub/generated/v1/manifest.json index 0a0bc2ae0..614b6d55e 100644 --- a/tests/fixtures/hub/generated/v1/manifest.json +++ b/tests/fixtures/hub/generated/v1/manifest.json @@ -1 +1 @@ -{"capabilities":[],"default_ref":"main","format":"graphforge-discovery/1","immutable_version":"sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e","objects":[{"digest":"sha256:f11322419e4c10c33365f07b28f24d6a348a105e61a152f6e5fcd6750daaea35","length":11776,"locations":["https://data.graphforge.sh/objects/sha256/f11322419e4c10c33365f07b28f24d6a348a105e61a152f6e5fcd6750daaea35"],"media_type":"application/vnd.graphforge.project"}],"package":{"format":"graphforge-project/2","object_digest":"sha256:f11322419e4c10c33365f07b28f24d6a348a105e61a152f6e5fcd6750daaea35","package_digest":"sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e"},"repository":{"owner":"openalex","repository":"openalex"},"requirements":[{"capability":"portable-v2","major":1}],"resolved_ref":"main","version":{"major":1,"minor":1}} \ No newline at end of file +{"capabilities":[],"default_ref":"main","format":"graphforge-discovery/1","immutable_version":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35","objects":[{"digest":"sha256:52cbffc43ab2bd081aefa297970a4f060fb52b8a8ab601b77e2bca9ca6dda54d","length":21504,"locations":["https://data.graphforge.sh/objects/sha256/52cbffc43ab2bd081aefa297970a4f060fb52b8a8ab601b77e2bca9ca6dda54d"],"media_type":"application/vnd.graphforge.project"},{"digest":"sha256:94941cf9a402f2c662258fe66c2f1b170016b92ab3e495200284177103c70a9b","length":12288,"locations":["https://data.graphforge.sh/objects/sha256/94941cf9a402f2c662258fe66c2f1b170016b92ab3e495200284177103c70a9b"],"media_type":"application/vnd.graphforge.project"},{"digest":"sha256:a2e21e7a1635c010652296cb6996df1d70833af4a9812c75f0c996b7e1eace5d","length":1657,"locations":["https://data.graphforge.sh/objects/sha256/a2e21e7a1635c010652296cb6996df1d70833af4a9812c75f0c996b7e1eace5d"],"media_type":"application/vnd.graphforge.project-summary+json"}],"ontology":{"bridge_sets":[],"composition_digest":"sha256:6cdf031b7bc21eea652e116ecede2e0922e90b50fb5e327e8ca5dbcbb66ab1e5","modules":[{"content_digest":"sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4","id":"https://openalex.org/ontology/works","package":{"format":"graphforge-project/2","object_digest":"sha256:94941cf9a402f2c662258fe66c2f1b170016b92ab3e495200284177103c70a9b","package_digest":"sha256:8528501d155c5e4212f930c3ae8d298d51fe3deff2298431cb8ecd5dc53b77b0"},"version":"2026.01"}]},"package":{"format":"graphforge-project/2","object_digest":"sha256:52cbffc43ab2bd081aefa297970a4f060fb52b8a8ab601b77e2bca9ca6dda54d","package_digest":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35"},"repository":{"owner":"openalex","repository":"openalex"},"requirements":[{"capability":"portable-v2","major":1}],"resolved_ref":"main","summary":{"format":"graphforge-project-summary/1","object_digest":"sha256:a2e21e7a1635c010652296cb6996df1d70833af4a9812c75f0c996b7e1eace5d","summary_digest":"sha256:a2e21e7a1635c010652296cb6996df1d70833af4a9812c75f0c996b7e1eace5d"},"version":{"major":1,"minor":1}} \ No newline at end of file diff --git a/tests/fixtures/hub/generated/v1/objects/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4.gfpb b/tests/fixtures/hub/generated/v1/objects/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4.gfpb new file mode 100644 index 000000000..8723c7d0b Binary files /dev/null and b/tests/fixtures/hub/generated/v1/objects/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4.gfpb differ diff --git a/tests/fixtures/hub/generated/v1/objects/openalex-openalex.gfpb b/tests/fixtures/hub/generated/v1/objects/openalex-openalex.gfpb index b55c5336e..911f522aa 100644 Binary files a/tests/fixtures/hub/generated/v1/objects/openalex-openalex.gfpb and b/tests/fixtures/hub/generated/v1/objects/openalex-openalex.gfpb differ diff --git a/tests/fixtures/hub/generated/v1/objects/openalex-openalex.summary.json b/tests/fixtures/hub/generated/v1/objects/openalex-openalex.summary.json new file mode 100644 index 000000000..1ea650fdf --- /dev/null +++ b/tests/fixtures/hub/generated/v1/objects/openalex-openalex.summary.json @@ -0,0 +1 @@ +{"capabilities":[],"facts":{"components":{"compatibility":2,"graph-data":1,"ontology":2,"settings":2},"evidence_present":false,"ontology_composition":{"bridge_sets":[],"composition_digest":"sha256:6cdf031b7bc21eea652e116ecede2e0922e90b50fb5e327e8ca5dbcbb66ab1e5","modules":[{"content_digest":"sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4","dialect":"graphforge-ontology","id":"https://openalex.org/ontology/works","profile":"advisory","version":"2026.01"}]},"ontology_mode":"advisory","payload_bytes":10235,"research_present":false},"format":"graphforge-project-summary/1","immutable_version":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35","metadata":{"access":{"access_policy":"open","visibility":"public"},"authors":["OurResearch"],"canonical_identifiers":[],"corpus_size":{"artifact_count":0,"node_count":1000,"relationship_count":2500,"source_count":1},"created_at":"2026-01-01T00:00:00.000000Z","description":null,"external_identifiers":[],"geographic_coverage":null,"languages":["en"],"license":"CC0-1.0","ontologies":["https://openalex.org/ontology/works"],"originating_projects":[],"related_projects":[],"source_types":["bibliographic-database"],"subjects":["scholarly-communication"],"tags":[],"temporal_coverage":null,"title":"OpenAlex","updated_at":"2026-01-01T00:00:00.000000Z"},"package":{"format":"graphforge-project/2","package_class":"complete","package_digest":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35"},"repository":{"owner":"openalex","repository":"openalex"},"requirements":[{"capability":"project-summary","major":1}],"version":{"major":1,"minor":1}} \ No newline at end of file diff --git a/tests/fixtures/hub/generated/v1/refs.json b/tests/fixtures/hub/generated/v1/refs.json index 02c1bc040..c93b8785c 100644 --- a/tests/fixtures/hub/generated/v1/refs.json +++ b/tests/fixtures/hub/generated/v1/refs.json @@ -1 +1 @@ -{"default_ref":"main","format":"graphforge-discovery/1","refs":[{"name":"main","target":"sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e","validator":"sha256:db5bd3e84171ca80ccca3f1e9e92d6acf76054238a5de6fd33c5f987fe2f1feb"}],"repository":{"owner":"openalex","repository":"openalex"},"version":{"major":1,"minor":1}} \ No newline at end of file +{"default_ref":"main","format":"graphforge-discovery/1","refs":[{"name":"main","target":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35","validator":"sha256:ca714a09673bb3b353dd903628816472ed58eedcd40c8b687401163977d71a36"}],"repository":{"owner":"openalex","repository":"openalex"},"version":{"major":1,"minor":1}} \ No newline at end of file diff --git a/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-ontology-composition/composition.json b/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-ontology-composition/composition.json new file mode 100644 index 000000000..390665ab9 --- /dev/null +++ b/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-ontology-composition/composition.json @@ -0,0 +1 @@ +{"activation_profile":{"overrides":[],"profile_default":"advisory"},"bridge_sets":[],"composition_digest":"sha256:6cdf031b7bc21eea652e116ecede2e0922e90b50fb5e327e8ca5dbcbb66ab1e5","contract":"graphforge-ontology-composition/1","modules":[{"content_digest":"sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4","dialect":"graphforge-ontology","ontology_id":"https://openalex.org/ontology/works","profile":"advisory","version":"2026.01"}],"optional_features":[],"required_features":["provenance-bridges@1","qualified-symbols@1"]} \ No newline at end of file diff --git a/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-runtime-map/runtime-generation.json b/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-runtime-map/runtime-generation.json index f610e321b..b0a8a4ca7 100644 --- a/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-runtime-map/runtime-generation.json +++ b/tests/fixtures/hub/openalex-source/data/components/compatibility/graphforge-runtime-map/runtime-generation.json @@ -1 +1 @@ -{"capabilities":[{"capability_id":"graph","capability_version":1},{"capability_id":"workspace","capability_version":1}],"contract":"graphforge-runtime-generation-map/1","graph_tree":null,"participants":[{"capability_id":"workspace","capability_version":1,"encoding":"json","participant_id":"workspace-configuration-0b4c69ad58c5","record_family_id":"configuration","record_version":1,"row_count":1,"schema_fingerprint":"359fa910781f485fbe4eefc5327e981540bfbfc169aed6e4f565636f04eb7d21"},{"capability_id":"workspace","capability_version":1,"encoding":"json","participant_id":"workspace-ontology-85202a777c5f","record_family_id":"ontology","record_version":1,"row_count":1,"schema_fingerprint":"732e262e0ecf22210bc3cab8e480fe0d718a9fc38b8dd5ad921f116106a13bcb"}]} \ No newline at end of file +{"capabilities":[{"capability_id":"graph","capability_version":1},{"capability_id":"workspace","capability_version":1}],"contract":"graphforge-runtime-generation-map/1","graph_tree":null,"participants":[{"capability_id":"graph","capability_version":1,"encoding":"json","participant_id":"graph-semantic_bindings-31f292cc38d4","record_family_id":"semantic_bindings","record_version":1,"row_count":4,"schema_fingerprint":"96790ff7d2250726e8bbb6a992cf5e77c907da2773cae542b188d9f810ab35dd"},{"capability_id":"workspace","capability_version":1,"encoding":"json","participant_id":"workspace-configuration-0b4c69ad58c5","record_family_id":"configuration","record_version":1,"row_count":1,"schema_fingerprint":"359fa910781f485fbe4eefc5327e981540bfbfc169aed6e4f565636f04eb7d21"},{"capability_id":"workspace","capability_version":1,"encoding":"json","participant_id":"workspace-ontology-85202a777c5f","record_family_id":"ontology","record_version":1,"row_count":1,"schema_fingerprint":"732e262e0ecf22210bc3cab8e480fe0d718a9fc38b8dd5ad921f116106a13bcb"},{"capability_id":"workspace","capability_version":1,"encoding":"json","participant_id":"workspace-research_metadata-e6819a3d5f99","record_family_id":"research_metadata","record_version":1,"row_count":1,"schema_fingerprint":"f8316568e127c2a94c17848b5782442bd465d263700b118720ff2d1a4ab4b7dd"}]} \ No newline at end of file diff --git a/tests/fixtures/hub/openalex-source/data/components/graph-data/graph-semantic_bindings-31f292cc38d4/participant.json b/tests/fixtures/hub/openalex-source/data/components/graph-data/graph-semantic_bindings-31f292cc38d4/participant.json new file mode 100644 index 000000000..a16c981cc --- /dev/null +++ b/tests/fixtures/hub/openalex-source/data/components/graph-data/graph-semantic_bindings-31f292cc38d4/participant.json @@ -0,0 +1 @@ +{"contract_version":1,"composition_fingerprint":"cfb81dcf8f8991bd76ba0382aaebda02d16ecb6f4e6d95a63dc4d587dc97dc72","bindings":[{"route_kind":"entity","storage_id":1,"route":"s-e59df37eec0b99d7d2a5eeb7c227aa59ebc82d31e6c33bc8ecd9e8b8512ece18","symbol":{"module":{"ontology_id":"https://openalex.org/ontology/works","authored_version":"2026.01","canonical_digest":"f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4"},"kind":"entity","local_id":"Author"},"owner":null},{"route_kind":"entity","storage_id":2,"route":"s-350f60d2d1c58b165f845c461020b829607280efa29651ce21e410b816894b0e","symbol":{"module":{"ontology_id":"https://openalex.org/ontology/works","authored_version":"2026.01","canonical_digest":"f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4"},"kind":"entity","local_id":"Institution"},"owner":null},{"route_kind":"entity","storage_id":3,"route":"s-864bcd53c1a7992bdcfcd1910e6ac27fa727809158ebdcf1c9dea04a7b36ccf7","symbol":{"module":{"ontology_id":"https://openalex.org/ontology/works","authored_version":"2026.01","canonical_digest":"f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4"},"kind":"entity","local_id":"Work"},"owner":null},{"route_kind":"relation","storage_id":1,"route":"s-6451e326adfe4ad0bc0fc1fb5af3a5af7df866df06993a5c9c6a3e2b2c36f7ae","symbol":{"module":{"ontology_id":"https://openalex.org/ontology/works","authored_version":"2026.01","canonical_digest":"f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4"},"kind":"relation","local_id":"AUTHORED"},"owner":null}]} diff --git a/tests/fixtures/hub/openalex-source/data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json b/tests/fixtures/hub/openalex-source/data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json new file mode 100644 index 000000000..26d5fd4d7 --- /dev/null +++ b/tests/fixtures/hub/openalex-source/data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json @@ -0,0 +1 @@ +{"constraints":[],"entity_types":[{"abstract":false,"name":"Work"},{"abstract":false,"name":"Author"},{"abstract":false,"name":"Institution"}],"migrations":[],"ontology_id":"https://openalex.org/ontology/works","properties":[],"relation_types":[{"dst":"Work","name":"AUTHORED","semantic":{"acyclic":false,"functional":false,"inverse_functional":false,"reflexive":false,"symmetric":false,"transitive":false},"src":"Author"}],"version":"2026.01"} \ No newline at end of file diff --git a/tests/fixtures/hub/openalex-source/data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json b/tests/fixtures/hub/openalex-source/data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json index 47c5cf7b1..a92ca0a88 100644 --- a/tests/fixtures/hub/openalex-source/data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json +++ b/tests/fixtures/hub/openalex-source/data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json @@ -1 +1 @@ -{"contract_version":1,"ontology_mode":"none","capability_configuration":{},"embedding_configuration":{}} +{"contract_version":1,"ontology_mode":"advisory","capability_configuration":{},"embedding_configuration":{}} diff --git a/tests/fixtures/hub/openalex-source/data/components/settings/workspace-research_metadata-e6819a3d5f99/participant.json b/tests/fixtures/hub/openalex-source/data/components/settings/workspace-research_metadata-e6819a3d5f99/participant.json new file mode 100644 index 000000000..61d40e043 --- /dev/null +++ b/tests/fixtures/hub/openalex-source/data/components/settings/workspace-research_metadata-e6819a3d5f99/participant.json @@ -0,0 +1 @@ +{"contract_version":1,"title":"OpenAlex","description":null,"authors":["OurResearch"],"subjects":["scholarly-communication"],"languages":["en"],"geographic_coverage":null,"temporal_coverage":null,"source_types":["bibliographic-database"],"corpus_size":{"node_count":1000,"relationship_count":2500,"source_count":1,"artifact_count":0},"ontologies":["https://openalex.org/ontology/works"],"license":"CC0-1.0","access":{"visibility":"public","access_policy":"open","collaborators":[]},"tags":[],"originating_projects":[],"related_projects":[],"canonical_identifiers":[],"external_identifiers":[],"created_at":"2026-01-01T00:00:00.000000Z","updated_at":"2026-01-01T00:00:00.000000Z","extensions":{},"discovery_facets":{"text_entry_points":0,"source_entry_points":0,"entity_entry_points":0,"relationship_entry_points":0,"story_document_entry_points":0,"event_location_entry_points":0,"ontology_type_entry_points":0,"linguistic_property_entry_points":0,"traversal_query_entry_points":0,"branch_entry_points":0}} diff --git a/tests/fixtures/hub/openalex-source/data/graphforge-project.json b/tests/fixtures/hub/openalex-source/data/graphforge-project.json index 8e31f4119..d82f1e850 100644 --- a/tests/fixtures/hub/openalex-source/data/graphforge-project.json +++ b/tests/fixtures/hub/openalex-source/data/graphforge-project.json @@ -1 +1 @@ -{"components":[{"files":[{"length":760,"media_type":"application/vnd.graphforge.runtime-generation+json","path":"data/components/compatibility/graphforge-runtime-map/runtime-generation.json","sha256":"84c1520e03239e647fa07ba10e6c7a879247cb8b1cd57a9bb9ab1fff2924b694"}],"kind":"compatibility","participant_id":"graphforge-runtime-map","required_dependencies":["workspace-configuration-0b4c69ad58c5","workspace-ontology-85202a777c5f"]},{"files":[{"length":117,"media_type":"application/json","path":"data/components/ontology/workspace-ontology-85202a777c5f/participant.json","sha256":"a21dd016f4ba02eca7cc43737d341d99fec00bcb5fb082e2a9877aaa4f27b824"}],"kind":"ontology","participant_id":"workspace-ontology-85202a777c5f","required_dependencies":[]},{"files":[{"length":105,"media_type":"application/json","path":"data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json","sha256":"5bf75875f9e3215c3f9036f0492c9c9a7209d3a62abf22d42136614f35137b34"}],"kind":"settings","participant_id":"workspace-configuration-0b4c69ad58c5","required_dependencies":[]}],"format":"graphforge-project/2","package_class":"complete","package_digest":"sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e","requirements":{"capabilities":["compatibility@1","ontology@1","settings@1"],"dependency_rule":"required-transitive-closure/1"},"selection":{"omissions":[],"redactions":[],"roots":["graphforge-runtime-map","workspace-configuration-0b4c69ad58c5","workspace-ontology-85202a777c5f"]},"source_generation":{"generation_uuid":"01a03068-48f4-7143-9329-945e6c74f844","manifest_sha256":"04a978bd8888cb942301c105f20103369abdc2b252adf41e7c8393213a02e3da"},"states":{"authenticity":"unsigned","compatibility":"supported","integrity":"verified"}} \ No newline at end of file +{"components":[{"files":[{"length":547,"media_type":"application/vnd.graphforge.ontology-composition+json","path":"data/components/compatibility/graphforge-ontology-composition/composition.json","sha256":"0c33290f8bbd99ca0a0c59f6646eee72be73a6ff6a174b0215878fca3762fe1e"}],"kind":"compatibility","participant_id":"graphforge-ontology-composition","required_dependencies":["ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4"]},{"files":[{"length":1334,"media_type":"application/vnd.graphforge.runtime-generation+json","path":"data/components/compatibility/graphforge-runtime-map/runtime-generation.json","sha256":"207fba41f40b6d2ac0c431678dc85614fa457bce710b9e119b58219bd42108c6"}],"kind":"compatibility","participant_id":"graphforge-runtime-map","required_dependencies":["graph-semantic_bindings-31f292cc38d4","workspace-configuration-0b4c69ad58c5","workspace-ontology-85202a777c5f","workspace-research_metadata-e6819a3d5f99"]},{"files":[{"length":1554,"media_type":"application/json","path":"data/components/graph-data/graph-semantic_bindings-31f292cc38d4/participant.json","sha256":"b0349dfc23b9a424357f91d1f00258439200d7c467d75d9a978402d5b6f2eceb"}],"kind":"graph-data","participant_id":"graph-semantic_bindings-31f292cc38d4","required_dependencies":[]},{"files":[{"length":444,"media_type":"application/vnd.graphforge.ontology+json","path":"data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json","sha256":"7d0c5018d4bd4abca09e268900412020317100af4ecdc11f81ad29b0296d6a94"}],"kind":"ontology","participant_id":"ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4","required_dependencies":[]},{"files":[{"length":117,"media_type":"application/json","path":"data/components/ontology/workspace-ontology-85202a777c5f/participant.json","sha256":"a21dd016f4ba02eca7cc43737d341d99fec00bcb5fb082e2a9877aaa4f27b824"}],"kind":"ontology","participant_id":"workspace-ontology-85202a777c5f","required_dependencies":[]},{"files":[{"length":109,"media_type":"application/json","path":"data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json","sha256":"1aa4258a2a4105ca290098f96aa45528a68ef360e3e3842a9bde01db1133f099"}],"kind":"settings","participant_id":"workspace-configuration-0b4c69ad58c5","required_dependencies":[]},{"files":[{"length":1006,"media_type":"application/json","path":"data/components/settings/workspace-research_metadata-e6819a3d5f99/participant.json","sha256":"a72fe593364ac30da927abf2fdb29f235c82ff9db22cba618b594b0e5aa8854a"}],"kind":"settings","participant_id":"workspace-research_metadata-e6819a3d5f99","required_dependencies":[]}],"format":"graphforge-project/2","package_class":"complete","package_digest":"sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35","requirements":{"capabilities":["compatibility@1","graph-data@1","ontology-composition@1","ontology@1","settings@1"],"dependency_rule":"required-transitive-closure/1"},"selection":{"omissions":[],"redactions":[],"roots":["graph-semantic_bindings-31f292cc38d4","graphforge-ontology-composition","graphforge-runtime-map","ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4","workspace-configuration-0b4c69ad58c5","workspace-ontology-85202a777c5f","workspace-research_metadata-e6819a3d5f99"]},"source_generation":{"generation_uuid":"61043aea-9d10-80d4-9ea0-26ddbc82df2e","manifest_sha256":"4d289d4d23c178bb12e44e50817fc801f8564c249b8f9064e2c03ad51bbdf586"},"states":{"authenticity":"unsigned","compatibility":"supported","integrity":"verified"}} \ No newline at end of file diff --git a/tests/fixtures/hub/openalex-source/manifest-sha256.txt b/tests/fixtures/hub/openalex-source/manifest-sha256.txt index 71d312c64..214fabff0 100644 --- a/tests/fixtures/hub/openalex-source/manifest-sha256.txt +++ b/tests/fixtures/hub/openalex-source/manifest-sha256.txt @@ -1,4 +1,8 @@ -84c1520e03239e647fa07ba10e6c7a879247cb8b1cd57a9bb9ab1fff2924b694 data/components/compatibility/graphforge-runtime-map/runtime-generation.json +0c33290f8bbd99ca0a0c59f6646eee72be73a6ff6a174b0215878fca3762fe1e data/components/compatibility/graphforge-ontology-composition/composition.json +207fba41f40b6d2ac0c431678dc85614fa457bce710b9e119b58219bd42108c6 data/components/compatibility/graphforge-runtime-map/runtime-generation.json +b0349dfc23b9a424357f91d1f00258439200d7c467d75d9a978402d5b6f2eceb data/components/graph-data/graph-semantic_bindings-31f292cc38d4/participant.json +7d0c5018d4bd4abca09e268900412020317100af4ecdc11f81ad29b0296d6a94 data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json a21dd016f4ba02eca7cc43737d341d99fec00bcb5fb082e2a9877aaa4f27b824 data/components/ontology/workspace-ontology-85202a777c5f/participant.json -5bf75875f9e3215c3f9036f0492c9c9a7209d3a62abf22d42136614f35137b34 data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json -37dc18685c6b4ed2c9a3c36b1a19c2716b0e24acce84b153c126496f42513405 data/graphforge-project.json +1aa4258a2a4105ca290098f96aa45528a68ef360e3e3842a9bde01db1133f099 data/components/settings/workspace-configuration-0b4c69ad58c5/participant.json +a72fe593364ac30da927abf2fdb29f235c82ff9db22cba618b594b0e5aa8854a data/components/settings/workspace-research_metadata-e6819a3d5f99/participant.json +b1637e8f6f1156a60a4914e7d84a06b77c915a8102581eaa08aea07b6e8eb7d9 data/graphforge-project.json diff --git a/tests/fixtures/hub/openalex-source/tagmanifest-sha256.txt b/tests/fixtures/hub/openalex-source/tagmanifest-sha256.txt index f42e71be1..0c5f07ebd 100644 --- a/tests/fixtures/hub/openalex-source/tagmanifest-sha256.txt +++ b/tests/fixtures/hub/openalex-source/tagmanifest-sha256.txt @@ -1,3 +1,3 @@ 87d06e3bb6ba6889185dfbc9d110af5614098e6b33fa787be2f514a01c949727 bag-info.txt 1712ecfb074bf29c4188ad3421032509159a09739fd604f8fe57038b4ddefcc9 bagit.txt -c2e99c9d9d6f99f8503717bab88a08773faca1a8ebeff360ea1cc3a2d6522f5c manifest-sha256.txt +efc6523edac4cd020a2f9d4ea713792fc8390b42f22221c9ec5154b53cb3ecc1 manifest-sha256.txt diff --git a/tests/fixtures/portable-v2/README.md b/tests/fixtures/portable-v2/README.md index 3a9c01e98..d7cdad55d 100644 --- a/tests/fixtures/portable-v2/README.md +++ b/tests/fixtures/portable-v2/README.md @@ -43,6 +43,19 @@ by the same-build `gf --json portable verify --mode full` and `--mode inspect` commands; `structure_only` is the binding/API spelling of inspect mode. The CLI integration test independently compares both actual facade and CLI results to this golden. Native Python and Node tests compare every field, using an explicit -Node naming/BigInt adapter with safe-integer roundtrip checks. Nonempty composition -and projected preview entries are separately exercised against actual exported -multi-ontology packages in the native lifecycle suites. +Node naming/BigInt adapter with safe-integer roundtrip checks. The bundle carries +a one-module ontology composition; bridge sets and projected preview entries are +separately exercised against actual exported multi-ontology packages in the +native lifecycle suites. + +Regenerate the receipts whenever the Hub fixture bundle changes. After +`generate_hub_fixture -- --update` (see `tests/fixtures/hub/README.md`), copy the +bundle to a private directory, run both commands against the copy, and store the +results under the keys `full` and `structure_only`. The Rust, Python, and Node +tests compare receipt values, not bytes, so pretty-printing the compact +`gf --json` output is not a hand edit: + +```bash +gf --json portable verify --mode full --input +gf --json portable verify --mode inspect --input +``` diff --git a/tests/fixtures/portable-v2/facade-verification-receipts.json b/tests/fixtures/portable-v2/facade-verification-receipts.json index 32738fa6a..7669cbf84 100644 --- a/tests/fixtures/portable-v2/facade-verification-receipts.json +++ b/tests/fixtures/portable-v2/facade-verification-receipts.json @@ -2,34 +2,106 @@ "full": { "contract": "graphforge-portable-verify/2", "representation": "bundle", - "package_digest": "sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e", + "package_digest": "sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35", "package_class": "complete", - "component_count": 3, - "entry_count": 8, - "payload_bytes": 3624, + "component_count": 7, + "entry_count": 12, + "payload_bytes": 10235, "integrity": "verified", "compatibility": "supported", "authenticity": "unsigned", - "transport_digest": "sha256:f11322419e4c10c33365f07b28f24d6a348a105e61a152f6e5fcd6750daaea35", - "ontology_composition": null, - "ontology_composition_entries": [], + "transport_digest": "sha256:52cbffc43ab2bd081aefa297970a4f060fb52b8a8ab601b77e2bca9ca6dda54d", + "ontology_composition": { + "contract": "graphforge-ontology-composition/1", + "activation_profile": { + "profile_default": "advisory", + "overrides": [] + }, + "modules": [ + { + "ontology_id": "https://openalex.org/ontology/works", + "version": "2026.01", + "content_digest": "sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4", + "dialect": "graphforge-ontology", + "profile": "advisory" + } + ], + "bridge_sets": [], + "required_features": [ + "provenance-bridges@1", + "qualified-symbols@1" + ], + "optional_features": [], + "composition_digest": "sha256:6cdf031b7bc21eea652e116ecede2e0922e90b50fb5e327e8ca5dbcbb66ab1e5" + }, + "ontology_composition_entries": [ + { + "kind": "ontology", + "identity": { + "id": "https://openalex.org/ontology/works", + "version": "2026.01", + "content_digest": "sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4" + }, + "path": "data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json", + "media_type": "application/vnd.graphforge.ontology+json", + "length": 444, + "sha256": "7d0c5018d4bd4abca09e268900412020317100af4ecdc11f81ad29b0296d6a94", + "required_dependencies": [] + } + ], "research_interchange": false, "research_entries": [] }, "structure_only": { "contract": "graphforge-portable-verify/2", "representation": "bundle", - "package_digest": "sha256:fbbf2d07c8b10790b2023cbfba6c6ca70ca58b8df68eceb9bdf3fafd8637e57e", + "package_digest": "sha256:492188fb9c3ac96b06b5373f571b47b6c6fc703c12800da1d02585001203fb35", "package_class": "complete", - "component_count": 3, - "entry_count": 8, - "payload_bytes": 3624, + "component_count": 7, + "entry_count": 12, + "payload_bytes": 10235, "integrity": "not_checked", "compatibility": "supported", "authenticity": "unsigned", - "transport_digest": "sha256:f11322419e4c10c33365f07b28f24d6a348a105e61a152f6e5fcd6750daaea35", - "ontology_composition": null, - "ontology_composition_entries": [], + "transport_digest": "sha256:52cbffc43ab2bd081aefa297970a4f060fb52b8a8ab601b77e2bca9ca6dda54d", + "ontology_composition": { + "contract": "graphforge-ontology-composition/1", + "activation_profile": { + "profile_default": "advisory", + "overrides": [] + }, + "modules": [ + { + "ontology_id": "https://openalex.org/ontology/works", + "version": "2026.01", + "content_digest": "sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4", + "dialect": "graphforge-ontology", + "profile": "advisory" + } + ], + "bridge_sets": [], + "required_features": [ + "provenance-bridges@1", + "qualified-symbols@1" + ], + "optional_features": [], + "composition_digest": "sha256:6cdf031b7bc21eea652e116ecede2e0922e90b50fb5e327e8ca5dbcbb66ab1e5" + }, + "ontology_composition_entries": [ + { + "kind": "ontology", + "identity": { + "id": "https://openalex.org/ontology/works", + "version": "2026.01", + "content_digest": "sha256:f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4" + }, + "path": "data/components/ontology/ontology-module-f1f475dfcf01bdfe7a578afb0203bac26831dc0b48f4cd009bdc418b506981b4/module.json", + "media_type": "application/vnd.graphforge.ontology+json", + "length": 444, + "sha256": "7d0c5018d4bd4abca09e268900412020317100af4ecdc11f81ad29b0296d6a94", + "required_dependencies": [] + } + ], "research_interchange": false, "research_entries": [] }