Skip to main content

graphforge_core/
portable.rs

1//! Neutral portable package contracts and sanitized lifecycle errors.
2#![allow(missing_docs)]
3
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// Writer and reader admission limits for portable-v2 packages.
8///
9/// Limits that grow with the entry count admit every package a project can
10/// produce (#900): the payload inventory streams, and the semantic manifest
11/// is bounded per package by the entries it actually lists.
12#[derive(Clone, Copy, Debug)]
13pub struct PortableV2Limits {
14    /// Components in the semantic manifest.
15    pub max_components: u64,
16    /// Regular entries in the package, tag files included.
17    pub max_entries: u64,
18    /// Bytes in one entry.
19    pub max_entry_bytes: u64,
20    /// Declared payload bytes across all entries.
21    pub max_total_bytes: u64,
22    /// Bytes in each retained JSON control whose size does not grow with the
23    /// entry count: the runtime map, the ontology composition, and ontology
24    /// and bridge documents in aggregate. It is also the semantic manifest's
25    /// fixed allowance; see [`Self::semantic_manifest_bound`].
26    pub max_manifest_bytes: u64,
27    /// Ceiling on the semantic manifest, `data/graphforge-project.json`,
28    /// whatever its entry count. It is parsed whole, so this bounds reader
29    /// memory under untrusted input.
30    pub max_semantic_manifest_bytes: u64,
31    /// Bytes in each retained tag file: `bagit.txt`, `bag-info.txt` and
32    /// `tagmanifest-sha256.txt`, which has three rows. The payload inventory,
33    /// `manifest-sha256.txt`, has one row per `data/` entry; writer and reader
34    /// stream it, and it is bounded by `max_entries` and `max_path_bytes`.
35    pub max_tag_manifest_bytes: u64,
36    /// UTF-8 bytes in one portable path.
37    pub max_path_bytes: usize,
38    /// Bytes in the streaming copy buffer.
39    pub copy_buffer_bytes: usize,
40}
41
42/// Semantic manifest budget per entry. A file record is
43/// `{"length":L,"media_type":"M","path":"P","sha256":"H"},`: 50 bytes of
44/// syntax, at most 20 length digits, a media type of at most 52 bytes and 64
45/// digest bytes, 186 bytes plus the path. The S26 Graph500 project's records
46/// average about 320 bytes (21,531 files, 6.9 MB), so 512 bytes admits paths
47/// that average up to 326 bytes, nearly twice S26's 169.
48pub const PORTABLE_V2_MANIFEST_BYTES_PER_ENTRY: u64 = 512;
49/// Most graph files one project holds: the storage graph-manifest default
50/// (`GraphManifestLimits::max_entries`), the largest graph a package can carry.
51const PRODUCER_MAX_GRAPH_FILES: u64 = 100_000;
52
53impl PortableV2Limits {
54    /// Largest semantic manifest admitted for a package of `entries` regular
55    /// entries, checked before it is parsed: the fixed `max_manifest_bytes`
56    /// allowance for components and selection, plus one file-record budget
57    /// per entry, never above `max_semantic_manifest_bytes`. A small package
58    /// therefore cannot present a large manifest.
59    #[must_use]
60    pub fn semantic_manifest_bound(&self, entries: u64) -> u64 {
61        self.max_manifest_bytes
62            .saturating_add(entries.saturating_mul(PORTABLE_V2_MANIFEST_BYTES_PER_ENTRY))
63            .min(self.max_semantic_manifest_bytes)
64    }
65}
66
67impl Default for PortableV2Limits {
68    fn default() -> Self {
69        Self {
70            max_components: 10_000,
71            max_entries: 1_000_000,
72            max_entry_bytes: 16 * 1024_u64.pow(4),
73            max_total_bytes: 1024 * 1024_u64.pow(4),
74            max_manifest_bytes: 16 * 1024 * 1024,
75            // 100,000 graph files x 512 bytes = 51,200,000, rounded up to
76            // 64 MiB: a manifest for the largest graph a project holds fits.
77            max_semantic_manifest_bytes: (PRODUCER_MAX_GRAPH_FILES
78                * PORTABLE_V2_MANIFEST_BYTES_PER_ENTRY)
79                .next_power_of_two(),
80            max_tag_manifest_bytes: 4 * 1024 * 1024,
81            max_path_bytes: 4096,
82            copy_buffer_bytes: 1024 * 1024,
83        }
84    }
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum PortableV2Mode {
89    StructureOnly,
90    Full,
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
94#[serde(rename_all = "snake_case")]
95pub enum PortableV2Representation {
96    Expanded,
97    Bundle,
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "kebab-case")]
102pub enum PortableV2PackageClass {
103    Complete,
104    OntologyOnly,
105    ComponentSelective,
106    GraphDataSubset,
107}
108
109#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
110#[serde(rename_all = "snake_case")]
111pub enum PortableV2Integrity {
112    NotChecked,
113    Verified,
114    Failed,
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
118#[serde(rename_all = "snake_case")]
119pub enum PortableV2Compatibility {
120    Supported,
121    UnsupportedFuture,
122    Failed,
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum PortableV2Authenticity {
128    NotEvaluated,
129    Unsigned,
130    Verified,
131    Failed,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
135pub struct PortableV2Report {
136    /// Whether full verification includes the registered research interchange component.
137    pub research_interchange: bool,
138    /// Manifest-authenticated research files for bounded native semantic verification.
139    pub research_entries: Vec<PortableV2ResearchEntry>,
140    pub contract: &'static str,
141    pub representation: PortableV2Representation,
142    pub package_digest: String,
143    pub package_class: PortableV2PackageClass,
144    pub component_count: u64,
145    pub entry_count: u64,
146    pub payload_bytes: u64,
147    pub integrity: PortableV2Integrity,
148    pub compatibility: PortableV2Compatibility,
149    pub authenticity: PortableV2Authenticity,
150    pub transport_digest: Option<String>,
151    /// Exact authenticated multi-ontology compatibility control, when present.
152    pub ontology_composition: Option<PortableV2OntologyComposition>,
153    /// Authenticated bounded payload entries available to explicit lifecycle consumers.
154    pub ontology_composition_entries: Vec<PortableV2CompositionEntry>,
155}
156
157/// An authenticated file in the versioned research component.
158#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
159pub struct PortableV2ResearchEntry {
160    pub component_id: String,
161    pub path: String,
162    pub length: u64,
163    pub sha256: String,
164}
165
166#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
167pub struct PortableV2CompositionEntry {
168    pub kind: String,
169    pub identity: PortableV2ExactIdentity,
170    pub path: String,
171    pub media_type: String,
172    pub length: u64,
173    pub sha256: String,
174    pub required_dependencies: Vec<String>,
175}
176
177#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct PortableV2ExactIdentity {
180    pub id: String,
181    pub version: String,
182    pub content_digest: String,
183}
184
185#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct PortableV2ActivationOverride {
188    pub scope: String,
189    pub subject: PortableV2ExactIdentity,
190    pub mode: String,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct PortableV2ActivationProfile {
196    pub profile_default: String,
197    pub overrides: Vec<PortableV2ActivationOverride>,
198}
199
200#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct PortableV2OntologyModule {
203    pub ontology_id: String,
204    pub version: String,
205    pub content_digest: String,
206    pub dialect: String,
207    pub profile: String,
208}
209
210#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct PortableV2BridgeSet {
213    pub bridge_id: String,
214    pub version: String,
215    pub content_digest: String,
216    pub source_modules: Vec<PortableV2ExactIdentity>,
217    pub target_modules: Vec<PortableV2ExactIdentity>,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct PortableV2OntologyComposition {
223    pub contract: String,
224    pub activation_profile: PortableV2ActivationProfile,
225    pub modules: Vec<PortableV2OntologyModule>,
226    pub bridge_sets: Vec<PortableV2BridgeSet>,
227    pub required_features: Vec<String>,
228    pub optional_features: Vec<String>,
229    pub composition_digest: String,
230}
231
232#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
233#[serde(rename_all = "snake_case")]
234pub enum PortableV2ErrorCode {
235    Cancelled,
236    LimitExceeded,
237    Io,
238    InvalidStructure,
239    InvalidPath,
240    DuplicateEntry,
241    UnsupportedFuture,
242    Incompatible,
243    DigestMismatch,
244    ConcurrentMutation,
245}
246
247#[derive(Debug)]
248pub struct PortableV2Error {
249    /// Authenticated committed import, even when acknowledgement or cleanup failed.
250    pub committed_import: Option<Box<PortableV2CommittedImport>>,
251    pub code: PortableV2ErrorCode,
252    pub entry: Option<String>,
253    detail: &'static str,
254    /// The sanitized underlying cause of an I/O failure: the platform error
255    /// text with every absolute host path reduced to its last two components.
256    /// Diagnostic only; `detail` stays the stable public text.
257    #[doc(hidden)]
258    pub cause: Option<String>,
259    /// Content-free native allocation evidence retained only for local
260    /// lifecycle qualification of an interrupted operation.
261    #[doc(hidden)]
262    pub allocation_identity_allocated_bytes: std::collections::BTreeMap<String, u64>,
263    /// Bytes actually read while reauthenticating an interrupted import.
264    #[doc(hidden)]
265    pub recovery_reauthentication_read_bytes: u64,
266    /// Calls actually completed while reauthenticating an interrupted import.
267    #[doc(hidden)]
268    pub recovery_reauthentication_read_calls: u64,
269}
270
271impl PortableV2Error {
272    /// Construct a sanitized package-level failure without a host path or payload value.
273    #[must_use]
274    pub fn new(code: PortableV2ErrorCode, detail: &'static str) -> Self {
275        Self {
276            committed_import: None,
277            code,
278            entry: None,
279            detail,
280            cause: None,
281            allocation_identity_allocated_bytes: std::collections::BTreeMap::new(),
282            recovery_reauthentication_read_bytes: 0,
283            recovery_reauthentication_read_calls: 0,
284        }
285    }
286    #[must_use]
287    pub fn at(code: PortableV2ErrorCode, entry: &str, detail: &'static str) -> Self {
288        Self {
289            committed_import: None,
290            code,
291            entry: Some(entry.chars().take(4096).collect()),
292            detail,
293            cause: None,
294            allocation_identity_allocated_bytes: std::collections::BTreeMap::new(),
295            recovery_reauthentication_read_bytes: 0,
296            recovery_reauthentication_read_calls: 0,
297        }
298    }
299
300    /// Record the sanitized underlying cause of an I/O failure.
301    #[must_use]
302    pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
303        self.cause = Some(cause.into());
304        self
305    }
306
307    #[must_use]
308    pub fn with_allocation_identities(
309        mut self,
310        identities: std::collections::BTreeMap<String, u64>,
311    ) -> Self {
312        self.allocation_identity_allocated_bytes = identities;
313        self
314    }
315
316    #[must_use]
317    pub fn with_recovery_reauthentication(mut self, read_bytes: u64, read_calls: u64) -> Self {
318        self.recovery_reauthentication_read_bytes = read_bytes;
319        self.recovery_reauthentication_read_calls = read_calls;
320        self
321    }
322
323    /// Attach exact publication evidence without claiming successful staging cleanup.
324    #[must_use]
325    pub fn with_committed_import(mut self, receipt: PortableV2CommittedImport) -> Self {
326        self.committed_import = Some(Box::new(receipt));
327        self
328    }
329}
330
331/// Payload-free receipt evidence for an import whose CURRENT replacement committed.
332#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
333pub struct PortableV2CommittedImport {
334    pub operation_uuid: uuid::Uuid,
335    pub generation_uuid: uuid::Uuid,
336    pub generation_manifest_sha256: [u8; 32],
337    pub package_digest: String,
338}
339impl fmt::Display for PortableV2Error {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        write!(f, "portable-v2 {:?}: {}", self.code, self.detail)?;
342        if let Some(receipt) = &self.committed_import {
343            write!(
344                f,
345                " committed=true operation_uuid={} generation_uuid={}",
346                receipt.operation_uuid, receipt.generation_uuid
347            )?;
348        }
349        Ok(())
350    }
351}
352impl std::error::Error for PortableV2Error {}
353impl From<crate::GfError> for PortableV2Error {
354    fn from(_: crate::GfError) -> Self {
355        Self::new(
356            PortableV2ErrorCode::Incompatible,
357            "pinned project generation is not exportable",
358        )
359    }
360}
361
362use std::path::PathBuf;
363/// Digest-pinned OCI reference returned after a successful publish.
364#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
365pub struct PortableV2OciReference {
366    /// Registry host (no credentials).
367    pub registry: String,
368    /// Repository path inside the registry.
369    pub repository: String,
370    /// OCI manifest digest (`sha256:…`).
371    pub oci_manifest_digest: String,
372    /// Authoritative GraphForge package digest (`sha256:…`).
373    pub package_digest: String,
374    /// Package class carried in the config blob.
375    pub package_class: PortableV2PackageClass,
376    /// Optional mutable tag that was also written; never used as identity.
377    pub tag: Option<String>,
378    /// Bytes uploaded across config + layer + manifest.
379    pub bytes_transferred: u64,
380    /// Blob count excluding the manifest itself.
381    pub blob_count: u64,
382}
383
384/// Sanitized receipt after a successful pull + local verification.
385#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
386pub struct PortableV2OciPullReceipt {
387    /// Digest-pinned reference that was resolved.
388    pub reference: PortableV2OciReference,
389    /// Destination path written after verification.
390    pub destination: PathBuf,
391    /// Local verifier report for the pulled package.
392    pub report: PortableV2Report,
393    /// Signature evaluation outcome (distinct from integrity).
394    pub signature_state: PortableV2OciSignatureState,
395}
396
397/// Optional authenticity policy for referrer/signature attachments.
398#[derive(Clone, Default, Eq, PartialEq)]
399pub struct PortableV2OciAuthenticityPolicy {
400    /// When set, unsigned content is integrity-valid but authenticity-absent.
401    pub require_named_signer: Option<String>,
402    /// Caller-owned verification key material. Never logged or persisted.
403    pub verification_key: Option<Vec<u8>>,
404}
405
406/// Explicit signature states. Never reused as integrity outcomes.
407#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
408#[serde(rename_all = "snake_case")]
409pub enum PortableV2OciSignatureState {
410    /// Signature present, signer matches policy, MAC verifies.
411    Valid,
412    /// Signature present but MAC verification failed.
413    Invalid,
414    /// No signature attachment was observed.
415    Absent,
416    /// Signature present for a different signer than the policy requires.
417    PolicyMismatched,
418}
419
420/// Progress phases for sanitized observability (no credentials or bodies).
421#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
422#[serde(rename_all = "snake_case")]
423pub enum PortableV2OciPhase {
424    VerifyLocal,
425    UploadBlob,
426    UploadManifest,
427    AttachSignature,
428    Observe,
429    DownloadManifest,
430    DownloadBlob,
431    VerifyPulled,
432    EvaluateAuthenticity,
433}
434
435/// Sanitized progress event.
436#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
437pub struct PortableV2OciProgress {
438    /// Current phase.
439    pub phase: PortableV2OciPhase,
440    /// Cumulative bytes moved in this operation.
441    pub bytes_transferred: u64,
442    /// Optional blob/manifest digest under consideration.
443    pub digest: Option<String>,
444}
445
446/// Caller-owned material used to attach an OCI signature referrer on publish.
447#[derive(Clone, Eq, PartialEq)]
448pub struct PortableV2OciSignatureMaterial {
449    /// Signer identity recorded in the attachment.
450    pub signer: String,
451    /// Key identifier (not secret material).
452    pub key_id: String,
453    /// Secret key bytes used to compute the MAC. Never logged.
454    pub secret: Vec<u8>,
455}
456
457impl std::fmt::Debug for PortableV2OciAuthenticityPolicy {
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        f.debug_struct("PortableV2OciAuthenticityPolicy")
460            .field("require_named_signer", &self.require_named_signer)
461            .field("verification_key", &"[REDACTED]")
462            .finish()
463    }
464}
465
466impl std::fmt::Debug for PortableV2OciSignatureMaterial {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        f.debug_struct("PortableV2OciSignatureMaterial")
469            .field("signer", &self.signer)
470            .field("key_id", &self.key_id)
471            .field("secret", &"[REDACTED]")
472            .finish()
473    }
474}
475
476/// Stable semantic participant identity. Runtime catalog IDs and host paths are never selectors.
477#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
478pub struct PortableV2ParticipantId {
479    /// Owning portable capability contract.
480    pub capability_id: String,
481    /// Stable record-family contract.
482    pub record_family_id: String,
483}
484
485/// Built-in deterministic selection profiles.
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub enum PortableV2SelectionProfile {
488    /// Every committed participant and graph-tree payload.
489    Complete,
490    /// Authored/adopted ontology plus required schema participants.
491    OntologyOnly,
492    /// Whole graph/data components. Row or subgraph selection belongs to #786.
493    DataComponents,
494    /// Derived and repository artifact participants.
495    Artifacts,
496    /// Closed-schema portable settings only.
497    Settings,
498    /// Explicit stable identities.
499    Custom(Vec<PortableV2ParticipantId>),
500    /// Exact projected ontology module or bridge identities. The immutable
501    /// preview exposes the complete emitted composition closure.
502    OntologyComposition(Vec<PortableV2ExactIdentity>),
503}
504
505/// Selection planning request.
506#[derive(Clone, Debug, Eq, PartialEq)]
507pub struct PortableV2SelectionRequest {
508    /// Requested built-in or custom profile.
509    pub profile: PortableV2SelectionProfile,
510    /// Refuse any automatically required dependency instead of widening visibly.
511    pub strict: bool,
512}
513
514/// Stable reason for inclusion/exclusion.
515#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
516#[serde(rename_all = "kebab-case")]
517pub enum PortableV2SelectionReason {
518    /// Directly requested by profile or exact identity.
519    Requested,
520    /// Required ontology/schema closure.
521    RequiredSchemaAuthority,
522    /// Required exact multi-ontology composition closure.
523    RequiredOntologyComposition,
524    /// Not part of the requested profile.
525    ProfileExcluded,
526}
527
528/// Content-free preview row.
529#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
530pub struct PortableV2SelectionEntry {
531    /// Stable semantic identity.
532    pub identity: PortableV2ParticipantId,
533    /// Canonical component kind.
534    pub kind: String,
535    /// Stable selection reason.
536    pub reason: PortableV2SelectionReason,
537    /// Exact committed payload bytes.
538    pub estimated_bytes: u64,
539    /// Manifest row count.
540    pub row_count: u64,
541}
542
543/// Exact ontology module or bridge emitted by composition projection.
544#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
545pub struct PortableV2ProjectedSelectionEntry {
546    /// Projected component kind (`ontology` or `schema`).
547    pub kind: String,
548    /// Exact semantic identity addressable by callers.
549    pub identity: PortableV2ExactIdentity,
550    /// Stable component identity used by the portable manifest.
551    pub participant_id: String,
552    /// Whether this exact identity was directly requested or closure-added.
553    pub reason: PortableV2SelectionReason,
554    /// Exact canonical projected payload bytes.
555    pub estimated_bytes: u64,
556}
557
558/// Immutable deterministic preview consumed by both export representations.
559#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
560pub struct PortableV2SelectionPlan {
561    /// Pinned source generation identity.
562    pub source_generation_uuid: String,
563    /// Pinned source generation manifest identity.
564    pub source_manifest_sha256: String,
565    /// Portable package class token.
566    pub package_class: String,
567    /// Included participants in canonical identity order.
568    pub included: Vec<PortableV2SelectionEntry>,
569    /// Excluded participants in canonical identity order.
570    pub excluded: Vec<PortableV2SelectionEntry>,
571    /// Exact projected ontology closure in canonical identity order.
572    pub projected: Vec<PortableV2ProjectedSelectionEntry>,
573    /// Explicit redaction reason codes; values are never retained.
574    pub redactions: Vec<String>,
575    /// Required portable capability contracts.
576    pub required_capabilities: Vec<String>,
577    /// Exact known participant bytes, excluding bounded control metadata.
578    pub estimated_payload_bytes: u64,
579    /// Stable digest over canonical content-free plan metadata.
580    pub selection_fingerprint: String,
581    include_graph_tree: bool,
582}
583
584impl PortableV2SelectionPlan {
585    /// Construct a passive preview; this does not authorize storage execution.
586    #[must_use]
587    #[allow(clippy::too_many_arguments)]
588    pub fn new(
589        source_generation_uuid: String,
590        source_manifest_sha256: String,
591        package_class: String,
592        included: Vec<PortableV2SelectionEntry>,
593        excluded: Vec<PortableV2SelectionEntry>,
594        projected: Vec<PortableV2ProjectedSelectionEntry>,
595        redactions: Vec<String>,
596        required_capabilities: Vec<String>,
597        estimated_payload_bytes: u64,
598        selection_fingerprint: String,
599        include_graph_tree: bool,
600    ) -> Self {
601        Self {
602            source_generation_uuid,
603            source_manifest_sha256,
604            package_class,
605            included,
606            excluded,
607            projected,
608            redactions,
609            required_capabilities,
610            estimated_payload_bytes,
611            selection_fingerprint,
612            include_graph_tree,
613        }
614    }
615    /// Whether the semantic selection includes the graph tree.
616    #[must_use]
617    pub fn includes_graph_tree(&self) -> bool {
618        self.include_graph_tree
619    }
620    /// Set the semantic graph-tree selection for a projected preview.
621    pub fn set_include_graph_tree(&mut self, include: bool) {
622        self.include_graph_tree = include;
623    }
624    /// Whether a semantic participant is included.
625    #[must_use]
626    pub fn includes(&self, capability: &str, family: &str) -> bool {
627        self.included.iter().any(|entry| {
628            entry.identity.capability_id == capability && entry.identity.record_family_id == family
629        })
630    }
631}
632
633/// Stable UUID selector for one pinned generation.
634#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
635pub struct PortableV2GraphSelector {
636    /// Ordered node UUIDs (hyphenated).
637    pub node_uuids: Vec<String>,
638    /// Ordered edge UUIDs (hyphenated).
639    pub edge_uuids: Vec<String>,
640}
641
642/// Portable-v2 on-wire closure tokens.
643#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
644#[serde(rename_all = "kebab-case")]
645pub enum PortableV2SubsetClosure {
646    /// Selected nodes plus edges whose endpoints are both selected.
647    InducedEdges,
648    /// Selected edges plus both endpoint nodes.
649    Referential,
650}
651
652/// Property projection/redaction for subset packages.
653#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
654pub struct PortableV2PropertyProjection {
655    /// Property field names excluded from payloads.
656    pub exclude: Vec<String>,
657}
658
659/// Subset planning request.
660#[derive(Clone, Debug, Eq, PartialEq)]
661pub struct PortableV2SubsetRequest {
662    /// Stable UUID selector.
663    pub selector: PortableV2GraphSelector,
664    /// Closure mode.
665    pub closure: PortableV2SubsetClosure,
666    /// Property projection.
667    pub projection: PortableV2PropertyProjection,
668}
669
670/// Content-free graph-subset receipt retained in the semantic manifest.
671#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
672pub struct PortableV2GraphSubsetMeta {
673    /// Canonical content-free selector digest token.
674    pub selector: String,
675    /// On-wire closure token.
676    pub closure: String,
677}
678
679/// Immutable subset preview consumed by planning and export.
680#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
681pub struct PortableV2SubsetPreview {
682    /// Component selection consumed by the exporter.
683    pub selection: PortableV2SelectionPlan,
684    /// Graph-subset metadata emitted into the semantic manifest.
685    pub graph_subset: PortableV2GraphSubsetMeta,
686    /// Resolved node count after closure.
687    pub selected_node_count: u64,
688    /// Resolved edge count after closure.
689    pub selected_edge_count: u64,
690    /// Endpoint nodes added beyond the caller's explicit node set.
691    pub endpoint_node_count: u64,
692    /// Domain-separated projected graph fingerprint.
693    pub result_fingerprint: String,
694    /// Stable digest over the full subset preview.
695    pub subset_fingerprint: String,
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
699/// Portable-v2 transport representation.
700pub enum PortableV2Output {
701    /// Closed BagIt-compatible directory.
702    Expanded,
703    /// Canonical uncompressed PAX/ustar stream.
704    Bundle,
705}
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707/// Aggregate content-free progress observation.
708pub struct PortableV2ExportProgress {
709    /// Fully emitted entries.
710    pub entries_completed: usize,
711    /// Emitted source payload bytes.
712    pub bytes_completed: u64,
713    /// Planned entry count.
714    pub entries_total: usize,
715    /// Planned source payload bytes.
716    pub bytes_total: u64,
717}
718
719#[cfg(test)]
720mod preview_wire_tests {
721    use super::*;
722
723    #[test]
724    fn moved_preview_contract_keeps_private_serialized_graph_decision() {
725        let plan = PortableV2SelectionPlan::new(
726            "generation".into(),
727            "manifest".into(),
728            "graph-data-subset".into(),
729            vec![PortableV2SelectionEntry {
730                identity: PortableV2ParticipantId {
731                    capability_id: "graph@1".into(),
732                    record_family_id: "files@1".into(),
733                },
734                kind: "graph-data".into(),
735                reason: PortableV2SelectionReason::Requested,
736                estimated_bytes: 7,
737                row_count: 2,
738            }],
739            Vec::new(),
740            Vec::new(),
741            vec!["redacted".into()],
742            vec!["graph@1".into()],
743            7,
744            "selection".into(),
745            false,
746        );
747        let expected = serde_json::json!({
748            "source_generation_uuid":"generation", "source_manifest_sha256":"manifest",
749            "package_class":"graph-data-subset", "included":[{"identity":{"capability_id":"graph@1","record_family_id":"files@1"},
750                "kind":"graph-data","reason":"requested","estimated_bytes":7,"row_count":2}],
751            "excluded":[],"projected":[],"redactions":["redacted"],"required_capabilities":["graph@1"],
752            "estimated_payload_bytes":7,"selection_fingerprint":"selection","include_graph_tree":false
753        });
754        assert_eq!(serde_json::to_value(&plan).unwrap(), expected);
755        let subset = PortableV2SubsetPreview {
756            selection: plan,
757            graph_subset: PortableV2GraphSubsetMeta {
758                selector: "selector".into(),
759                closure: "referential".into(),
760            },
761            selected_node_count: 2,
762            selected_edge_count: 1,
763            endpoint_node_count: 1,
764            result_fingerprint: "result".into(),
765            subset_fingerprint: "subset".into(),
766        };
767        assert_eq!(
768            serde_json::to_value(subset).unwrap(),
769            serde_json::json!({
770                "selection":expected,"graph_subset":{"selector":"selector","closure":"referential"},
771                "selected_node_count":2,"selected_edge_count":1,"endpoint_node_count":1,
772                "result_fingerprint":"result","subset_fingerprint":"subset"
773            })
774        );
775    }
776}
777
778#[cfg(test)]
779mod limit_tests {
780    use super::*;
781
782    /// The longest file record the writer emits, with an empty path.
783    fn longest_record_without_path() -> u64 {
784        let record = serde_json::json!({
785            "length": u64::MAX,
786            "media_type": "application/vnd.graphforge.ontology-composition+json",
787            "path": "",
788            "sha256": "0".repeat(64),
789        });
790        (serde_json::to_vec(&record).unwrap().len() + ",".len()) as u64
791    }
792
793    #[test]
794    fn default_limits_admit_every_package_a_project_can_produce() {
795        let limits = PortableV2Limits::default();
796        // The per-entry semantic manifest budget is one record plus its path.
797        assert_eq!(longest_record_without_path(), 186);
798        let path_budget = PORTABLE_V2_MANIFEST_BYTES_PER_ENTRY - longest_record_without_path();
799        assert_eq!(path_budget, 326);
800        // Controls that do not grow with the entry count keep 16 MiB; the
801        // semantic manifest ceiling holds the largest graph a project holds.
802        assert_eq!(limits.max_manifest_bytes, 16 * 1024 * 1024);
803        assert_eq!(limits.max_semantic_manifest_bytes, 64 * 1024 * 1024);
804        assert!(
805            PRODUCER_MAX_GRAPH_FILES * PORTABLE_V2_MANIFEST_BYTES_PER_ENTRY
806                <= limits.max_semantic_manifest_bytes
807        );
808        // S26 Graph500: 21,535 entries and a 6,874,538-byte manifest whose
809        // paths average 169 bytes. S28 has about four times as many entries.
810        assert!(6_874_538 <= limits.semantic_manifest_bound(21_535));
811        let s28_entries = 4 * 21_535;
812        let s28_manifest = s28_entries * (longest_record_without_path() + 169);
813        assert!(s28_manifest <= limits.semantic_manifest_bound(s28_entries));
814        assert!(169 < path_budget);
815        // The bound grows with the package and stops at the ceiling.
816        assert_eq!(limits.semantic_manifest_bound(0), limits.max_manifest_bytes);
817        assert_eq!(
818            limits.semantic_manifest_bound(1),
819            limits.max_manifest_bytes + PORTABLE_V2_MANIFEST_BYTES_PER_ENTRY
820        );
821        assert_eq!(
822            limits.semantic_manifest_bound(limits.max_entries),
823            limits.max_semantic_manifest_bytes
824        );
825        assert_eq!(
826            limits.semantic_manifest_bound(u64::MAX),
827            limits.max_semantic_manifest_bytes
828        );
829        // The tag files the tag limit bounds have a fixed size: the tag
830        // manifest has three rows whatever the entry count.
831        let tag_manifest = ["bag-info.txt", "bagit.txt", "manifest-sha256.txt"]
832            .iter()
833            .map(|path| 64 + 2 + path.len() as u64 + 1)
834            .sum::<u64>();
835        assert!(tag_manifest <= limits.max_tag_manifest_bytes);
836    }
837}