Skip to main content

graphforge_storage/
workspace_participants.rs

1//! Canonical generation-managed workspace ontology and configuration records.
2
3use std::collections::BTreeMap;
4
5use graphforge_core::{GfError, OntologyMode, ProjectErrorCode};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use uuid::Uuid;
10
11use crate::{ProjectParticipant, ProjectParticipantEncoding};
12
13/// Mandatory capability containing authoritative workspace control records.
14pub const WORKSPACE_CAPABILITY_ID: &str = "workspace";
15/// Frozen workspace capability contract version.
16pub const WORKSPACE_CAPABILITY_VERSION: u32 = 1;
17/// Canonical ontology record family.
18pub const WORKSPACE_ONTOLOGY_FAMILY: &str = "ontology";
19/// Canonical project-configuration record family.
20pub const WORKSPACE_CONFIGURATION_FAMILY: &str = "configuration";
21/// Canonical repository reconciliation snapshot record family.
22pub const WORKSPACE_REPOSITORY_SNAPSHOT_FAMILY: &str = "repository_snapshot";
23/// Frozen repository snapshot contract version.
24pub const WORKSPACE_REPOSITORY_SNAPSHOT_VERSION: u32 = 1;
25/// Maximum canonical repository snapshot participant size.
26pub const MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES: usize = 1024 * 1024;
27/// Maximum number of declared definitions or external sources in one snapshot.
28pub const MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ENTRIES: usize = 10_000;
29/// Maximum UTF-8 byte length of one stable definition or source identifier.
30pub const MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ID_BYTES: usize = 256;
31
32/// Persisted ontology mode. Absence is explicit rather than inferred from files.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum WorkspaceOntologyMode {
36    /// No adopted ontology; runtime behavior is exploratory.
37    None,
38    /// Adopted ontology emits advisory validation.
39    Advisory,
40    /// Adopted ontology is enforced strictly.
41    Strict,
42}
43
44impl WorkspaceOntologyMode {
45    /// Convert the persisted mode to the execution-layer mode.
46    #[must_use]
47    pub const fn execution_mode(self) -> OntologyMode {
48        match self {
49            Self::None => OntologyMode::Exploratory,
50            Self::Advisory => OntologyMode::Advisory,
51            Self::Strict => OntologyMode::Strict,
52        }
53    }
54}
55
56/// Original syntax accepted at ontology adoption.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum WorkspaceOntologySourceFormat {
60    /// YAML or YML input.
61    Yaml,
62    /// JSON input.
63    Json,
64}
65
66/// Canonical ontology participant.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct WorkspaceOntology {
70    /// Frozen record contract version.
71    pub contract_version: u32,
72    /// Explicit effective mode.
73    pub mode: WorkspaceOntologyMode,
74    /// Syntax used for adoption, absent when mode is `none`.
75    pub source_format: Option<WorkspaceOntologySourceFormat>,
76    /// SHA-256 over canonical ontology JSON, absent when mode is `none`.
77    pub canonical_ontology_sha256: Option<String>,
78    /// Validated canonical ontology document, absent when mode is `none`.
79    pub canonical_ontology: Option<Value>,
80}
81
82/// Canonical authoritative project configuration participant.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct WorkspaceConfiguration {
86    /// Frozen record contract version.
87    pub contract_version: u32,
88    /// Must match the ontology participant mode.
89    pub ontology_mode: WorkspaceOntologyMode,
90    /// Registered capability settings ordered by stable capability ID.
91    pub capability_configuration: BTreeMap<String, Value>,
92    /// Registered embedding settings ordered by stable setting ID.
93    pub embedding_configuration: BTreeMap<String, Value>,
94}
95
96/// One declared repository definition identified without retaining its path or contents.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct WorkspaceRepositoryDefinitionDigest {
100    /// Stable caller-defined definition identifier.
101    pub definition_id: String,
102    /// SHA-256 of the validated canonical definition tree.
103    pub sha256: String,
104}
105
106/// One declared external source reference; source data is never embedded.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct WorkspaceRepositorySourceDigest {
110    /// Stable caller-defined source identifier.
111    pub source_id: String,
112    /// Caller-declared SHA-256 of the external source.
113    pub sha256: String,
114}
115
116/// Bounded Git provenance recorded without paths, diffs, messages, or repository contents.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct WorkspaceRepositoryGitProvenance {
120    /// Exact hexadecimal Git object ID, absent for repositories without a commit.
121    pub commit_sha: Option<String>,
122    /// Whether tracked repository state differed from the selected commit.
123    pub dirty: bool,
124}
125
126/// Canonical secret-free desired-state evidence published by repository sync.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct WorkspaceRepositorySnapshot {
130    /// Frozen record contract version.
131    pub contract_version: u32,
132    /// SHA-256 of the canonical resolved, secret-free configuration.
133    pub resolved_config_sha256: String,
134    /// Strictly ordered declared-definition identifiers and digests.
135    pub definitions: Vec<WorkspaceRepositoryDefinitionDigest>,
136    /// Strictly ordered external source identifiers and digest references.
137    pub sources: Vec<WorkspaceRepositorySourceDigest>,
138    /// Bounded Git provenance.
139    pub git: WorkspaceRepositoryGitProvenance,
140    /// Caller-owned idempotency identity for the publishing operation.
141    pub operation_uuid: Uuid,
142    /// Optional caller-owned actor identity.
143    pub actor_uuid: Option<Uuid>,
144}
145
146impl WorkspaceOntology {
147    /// Construct explicit ontology absence for a new exploratory project.
148    #[must_use]
149    pub const fn none() -> Self {
150        Self {
151            contract_version: 1,
152            mode: WorkspaceOntologyMode::None,
153            source_format: None,
154            canonical_ontology_sha256: None,
155            canonical_ontology: None,
156        }
157    }
158
159    /// Validate invariants and return canonical JSON plus LF.
160    ///
161    /// # Errors
162    /// Returns `GF_PROJECT_CORRUPT` for an inconsistent persisted record.
163    pub fn to_canonical_json(&self) -> Result<Vec<u8>, GfError> {
164        validate_ontology(self)?;
165        canonical_json(self, "workspace ontology")
166    }
167
168    /// Parse exact canonical JSON plus LF.
169    ///
170    /// # Errors
171    /// Returns `GF_PROJECT_CORRUPT` for future, malformed, or noncanonical data.
172    pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, GfError> {
173        parse_canonical_json(bytes, "workspace ontology", validate_ontology)
174    }
175}
176
177impl WorkspaceConfiguration {
178    /// Construct the empty authoritative configuration for a new project.
179    #[must_use]
180    pub fn empty() -> Self {
181        Self {
182            contract_version: 1,
183            ontology_mode: WorkspaceOntologyMode::None,
184            capability_configuration: BTreeMap::new(),
185            embedding_configuration: BTreeMap::new(),
186        }
187    }
188
189    /// Validate and return canonical JSON plus LF.
190    ///
191    /// # Errors
192    /// Returns `GF_PROJECT_CORRUPT` for a future contract.
193    pub fn to_canonical_json(&self) -> Result<Vec<u8>, GfError> {
194        validate_configuration(self)?;
195        canonical_json(self, "workspace configuration")
196    }
197
198    /// Parse exact canonical JSON plus LF.
199    ///
200    /// # Errors
201    /// Returns `GF_PROJECT_CORRUPT` for future, malformed, or noncanonical data.
202    pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, GfError> {
203        parse_canonical_json(bytes, "workspace configuration", validate_configuration)
204    }
205}
206
207impl WorkspaceRepositorySnapshot {
208    /// Validate and return canonical JSON plus LF.
209    ///
210    /// # Errors
211    /// Returns `GF_PROJECT_CORRUPT` for future, malformed, noncanonical, or
212    /// out-of-bounds snapshots.
213    pub fn to_canonical_json(&self) -> Result<Vec<u8>, GfError> {
214        validate_repository_snapshot(self)?;
215        let bytes = canonical_json(self, "workspace repository snapshot")?;
216        if bytes.len() > MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES {
217            return Err(corrupt("workspace repository snapshot exceeds size limit"));
218        }
219        Ok(bytes)
220    }
221
222    /// Parse exact canonical JSON plus LF and enforce every contract bound.
223    ///
224    /// # Errors
225    /// Returns `GF_PROJECT_CORRUPT` for future, malformed, noncanonical, or
226    /// out-of-bounds snapshots.
227    pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, GfError> {
228        if bytes.len() > MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES {
229            return Err(corrupt("workspace repository snapshot exceeds size limit"));
230        }
231        parse_canonical_json(
232            bytes,
233            "workspace repository snapshot",
234            validate_repository_snapshot,
235        )
236    }
237
238    /// Encode this record as the registered workspace generation participant.
239    ///
240    /// # Errors
241    /// Returns a structured error when the snapshot violates its contract.
242    pub fn to_project_participant(&self) -> Result<ProjectParticipant, GfError> {
243        Ok(participant(
244            WORKSPACE_REPOSITORY_SNAPSHOT_FAMILY,
245            self.to_canonical_json()?,
246        ))
247    }
248}
249
250/// Build both mandatory participants for a new project.
251///
252/// # Errors
253/// Returns a structured error if canonical encoding fails.
254pub fn empty_workspace_participants() -> Result<Vec<ProjectParticipant>, GfError> {
255    Ok(vec![
256        participant(
257            WORKSPACE_CONFIGURATION_FAMILY,
258            WorkspaceConfiguration::empty().to_canonical_json()?,
259        ),
260        participant(
261            WORKSPACE_ONTOLOGY_FAMILY,
262            WorkspaceOntology::none().to_canonical_json()?,
263        ),
264    ])
265}
266
267fn participant(family: &str, bytes: Vec<u8>) -> ProjectParticipant {
268    ProjectParticipant {
269        capability_id: WORKSPACE_CAPABILITY_ID.into(),
270        capability_version: WORKSPACE_CAPABILITY_VERSION,
271        record_family_id: family.into(),
272        record_version: 1,
273        encoding: ProjectParticipantEncoding::Json,
274        schema_fingerprint: Sha256::digest(format!("workspace/{family}@1")).into(),
275        row_count: 1,
276        bytes,
277    }
278}
279
280fn validate_ontology(record: &WorkspaceOntology) -> Result<(), GfError> {
281    if record.contract_version != 1 {
282        return Err(corrupt("unsupported workspace ontology contract"));
283    }
284    match record.mode {
285        WorkspaceOntologyMode::None
286            if record.source_format.is_none()
287                && record.canonical_ontology_sha256.is_none()
288                && record.canonical_ontology.is_none() =>
289        {
290            Ok(())
291        }
292        WorkspaceOntologyMode::Advisory | WorkspaceOntologyMode::Strict => {
293            let document = record
294                .canonical_ontology
295                .as_ref()
296                .ok_or_else(|| corrupt("adopted ontology document is missing"))?;
297            if record.source_format.is_none() {
298                return Err(corrupt("adopted ontology source format is missing"));
299            }
300            let canonical = serde_json::to_vec(document)
301                .map_err(|_| corrupt("ontology document cannot be encoded"))?;
302            let digest = encode_hex(&Sha256::digest(canonical));
303            if record.canonical_ontology_sha256.as_deref() != Some(digest.as_str()) {
304                return Err(corrupt("canonical ontology digest does not match"));
305            }
306            Ok(())
307        }
308        WorkspaceOntologyMode::None => Err(corrupt("workspace ontology absence is inconsistent")),
309    }
310}
311
312fn validate_configuration(record: &WorkspaceConfiguration) -> Result<(), GfError> {
313    if record.contract_version != 1 {
314        return Err(corrupt("unsupported workspace configuration contract"));
315    }
316    Ok(())
317}
318
319fn validate_repository_snapshot(record: &WorkspaceRepositorySnapshot) -> Result<(), GfError> {
320    if record.contract_version != WORKSPACE_REPOSITORY_SNAPSHOT_VERSION {
321        return Err(corrupt(
322            "unsupported workspace repository snapshot contract",
323        ));
324    }
325    validate_sha256(&record.resolved_config_sha256, "resolved config")?;
326    validate_digest_entries(
327        &record.definitions,
328        |entry| (&entry.definition_id, &entry.sha256),
329        "definition",
330    )?;
331    validate_digest_entries(
332        &record.sources,
333        |entry| (&entry.source_id, &entry.sha256),
334        "source",
335    )?;
336    if let Some(commit_sha) = &record.git.commit_sha
337        && (!matches!(commit_sha.len(), 40 | 64)
338            || !commit_sha.bytes().all(|byte| byte.is_ascii_hexdigit())
339            || commit_sha.bytes().any(|byte| byte.is_ascii_uppercase()))
340    {
341        return Err(corrupt("Git commit SHA is not canonical hexadecimal"));
342    }
343    if record.operation_uuid.is_nil() {
344        return Err(corrupt("repository snapshot operation UUID is nil"));
345    }
346    if record.actor_uuid.is_some_and(|actor| actor.is_nil()) {
347        return Err(corrupt("repository snapshot actor UUID is nil"));
348    }
349    Ok(())
350}
351
352fn validate_digest_entries<T>(
353    entries: &[T],
354    fields: impl for<'a> Fn(&'a T) -> (&'a String, &'a String),
355    name: &str,
356) -> Result<(), GfError> {
357    if entries.len() > MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ENTRIES {
358        return Err(corrupt(format!(
359            "workspace repository {name} count exceeds limit"
360        )));
361    }
362    let mut prior: Option<&str> = None;
363    for entry in entries {
364        let (id, digest) = fields(entry);
365        let canonical_id = !id.is_empty()
366            && id.len() <= MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ID_BYTES
367            && id.bytes().enumerate().all(|(index, byte)| {
368                byte.is_ascii_lowercase()
369                    || index > 0 && byte.is_ascii_digit()
370                    || index > 0 && matches!(byte, b'-' | b'_')
371            });
372        if !canonical_id {
373            return Err(corrupt(format!(
374                "workspace repository {name} identifier is invalid"
375            )));
376        }
377        if prior.is_some_and(|prior| prior >= id.as_str()) {
378            return Err(corrupt(format!(
379                "workspace repository {name} identifiers are not canonical"
380            )));
381        }
382        validate_sha256(digest, name)?;
383        prior = Some(id);
384    }
385    Ok(())
386}
387
388fn validate_sha256(value: &str, name: &str) -> Result<(), GfError> {
389    if value.len() != 64
390        || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
391        || value.bytes().any(|byte| byte.is_ascii_uppercase())
392    {
393        return Err(corrupt(format!("{name} SHA-256 is not canonical")));
394    }
395    Ok(())
396}
397
398fn canonical_json(value: &impl Serialize, name: &str) -> Result<Vec<u8>, GfError> {
399    let mut bytes =
400        serde_json::to_vec(value).map_err(|_| corrupt(format!("{name} cannot be encoded")))?;
401    bytes.push(b'\n');
402    Ok(bytes)
403}
404
405fn parse_canonical_json<T>(
406    bytes: &[u8],
407    name: &str,
408    validate: impl FnOnce(&T) -> Result<(), GfError>,
409) -> Result<T, GfError>
410where
411    T: for<'de> Deserialize<'de> + Serialize,
412{
413    if !bytes.ends_with(b"\n") || bytes[..bytes.len().saturating_sub(1)].contains(&b'\n') {
414        return Err(corrupt(format!("{name} is not canonical JSON plus LF")));
415    }
416    let parsed =
417        serde_json::from_slice(bytes).map_err(|_| corrupt(format!("{name} is malformed")))?;
418    validate(&parsed)?;
419    if canonical_json(&parsed, name)? != bytes {
420        return Err(corrupt(format!("{name} is not canonically encoded")));
421    }
422    Ok(parsed)
423}
424
425fn corrupt(message: impl Into<String>) -> GfError {
426    GfError::Project {
427        code: ProjectErrorCode::ProjectCorrupt,
428        message: message.into(),
429    }
430}
431
432fn encode_hex(bytes: &[u8]) -> String {
433    const HEX: &[u8; 16] = b"0123456789abcdef";
434    let mut output = String::with_capacity(bytes.len() * 2);
435    for byte in bytes {
436        output.push(char::from(HEX[usize::from(byte >> 4)]));
437        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
438    }
439    output
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn repository_snapshot() -> WorkspaceRepositorySnapshot {
447        WorkspaceRepositorySnapshot {
448            contract_version: WORKSPACE_REPOSITORY_SNAPSHOT_VERSION,
449            resolved_config_sha256: "11".repeat(32),
450            definitions: vec![
451                WorkspaceRepositoryDefinitionDigest {
452                    definition_id: "migrations".into(),
453                    sha256: "22".repeat(32),
454                },
455                WorkspaceRepositoryDefinitionDigest {
456                    definition_id: "ontology".into(),
457                    sha256: "33".repeat(32),
458                },
459            ],
460            sources: vec![WorkspaceRepositorySourceDigest {
461                source_id: "customers".into(),
462                sha256: "44".repeat(32),
463            }],
464            git: WorkspaceRepositoryGitProvenance {
465                commit_sha: Some("a1".repeat(20)),
466                dirty: true,
467            },
468            operation_uuid: Uuid::from_bytes([5; 16]),
469            actor_uuid: Some(Uuid::from_bytes([6; 16])),
470        }
471    }
472
473    #[test]
474    fn empty_records_round_trip_canonically() {
475        let ontology = WorkspaceOntology::none();
476        let ontology_bytes = ontology.to_canonical_json().unwrap();
477        assert_eq!(
478            WorkspaceOntology::from_canonical_json(&ontology_bytes).unwrap(),
479            ontology
480        );
481
482        let configuration = WorkspaceConfiguration::empty();
483        let configuration_bytes = configuration.to_canonical_json().unwrap();
484        assert_eq!(
485            WorkspaceConfiguration::from_canonical_json(&configuration_bytes).unwrap(),
486            configuration
487        );
488    }
489
490    #[test]
491    fn future_and_noncanonical_records_fail_closed() {
492        let mut future = WorkspaceOntology::none();
493        future.contract_version = 2;
494        assert_eq!(
495            future.to_canonical_json().unwrap_err().code(),
496            "GF_PROJECT_CORRUPT"
497        );
498
499        let bytes = br#"{"mode":"none","contract_version":1,"source_format":null,"canonical_ontology_sha256":null,"canonical_ontology":null}
500"#;
501        assert_eq!(
502            WorkspaceOntology::from_canonical_json(bytes)
503                .unwrap_err()
504                .code(),
505            "GF_PROJECT_CORRUPT"
506        );
507    }
508
509    #[test]
510    fn adopted_ontology_requires_matching_digest() {
511        let record = WorkspaceOntology {
512            contract_version: 1,
513            mode: WorkspaceOntologyMode::Strict,
514            source_format: Some(WorkspaceOntologySourceFormat::Json),
515            canonical_ontology_sha256: Some("0".repeat(64)),
516            canonical_ontology: Some(serde_json::json!({"ontology_id": "x"})),
517        };
518        assert_eq!(
519            record.to_canonical_json().unwrap_err().code(),
520            "GF_PROJECT_CORRUPT"
521        );
522    }
523
524    #[test]
525    fn repository_snapshot_round_trips_canonically_as_registered_participant() {
526        let snapshot = repository_snapshot();
527        let bytes = snapshot.to_canonical_json().unwrap();
528        assert_eq!(
529            WorkspaceRepositorySnapshot::from_canonical_json(&bytes).unwrap(),
530            snapshot
531        );
532        assert_eq!(bytes.last(), Some(&b'\n'));
533        assert!(!bytes.windows(2).any(|window| window == b"\n\n"));
534        assert!(!String::from_utf8_lossy(&bytes).contains("/Users/"));
535
536        let participant = snapshot.to_project_participant().unwrap();
537        assert_eq!(participant.capability_id, WORKSPACE_CAPABILITY_ID);
538        assert_eq!(
539            participant.record_family_id,
540            WORKSPACE_REPOSITORY_SNAPSHOT_FAMILY
541        );
542        let expected_fingerprint: [u8; 32] =
543            Sha256::digest("workspace/repository_snapshot@1").into();
544        assert_eq!(participant.schema_fingerprint, expected_fingerprint);
545        assert_eq!(participant.bytes, bytes);
546    }
547
548    #[test]
549    fn repository_snapshot_rejects_future_noncanonical_and_unbounded_records() {
550        let mut future = repository_snapshot();
551        future.contract_version += 1;
552        assert_eq!(
553            future.to_canonical_json().unwrap_err().code(),
554            "GF_PROJECT_CORRUPT"
555        );
556
557        let mut unordered = repository_snapshot();
558        unordered.definitions.reverse();
559        assert_eq!(
560            unordered.to_canonical_json().unwrap_err().code(),
561            "GF_PROJECT_CORRUPT"
562        );
563
564        let mut duplicate = repository_snapshot();
565        duplicate.sources.push(duplicate.sources[0].clone());
566        assert_eq!(
567            duplicate.to_canonical_json().unwrap_err().code(),
568            "GF_PROJECT_CORRUPT"
569        );
570
571        let mut oversized_id = repository_snapshot();
572        oversized_id.sources[0].source_id =
573            "x".repeat(MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ID_BYTES + 1);
574        assert_eq!(
575            oversized_id.to_canonical_json().unwrap_err().code(),
576            "GF_PROJECT_CORRUPT"
577        );
578
579        let mut too_many = repository_snapshot();
580        too_many.definitions = (0..=MAX_WORKSPACE_REPOSITORY_SNAPSHOT_ENTRIES)
581            .map(|index| WorkspaceRepositoryDefinitionDigest {
582                definition_id: format!("{index:05}"),
583                sha256: "55".repeat(32),
584            })
585            .collect();
586        assert_eq!(
587            too_many.to_canonical_json().unwrap_err().code(),
588            "GF_PROJECT_CORRUPT"
589        );
590
591        let mut absolute_id = repository_snapshot();
592        absolute_id.sources[0].source_id = "/private/data/customers".into();
593        assert_eq!(
594            absolute_id.to_canonical_json().unwrap_err().code(),
595            "GF_PROJECT_CORRUPT"
596        );
597        for hostile in [
598            "../customers",
599            "file:///private/customers",
600            "customers/data",
601            "customers\\data",
602            ".hidden",
603            "Customers",
604            "9customers",
605        ] {
606            let mut hostile_id = repository_snapshot();
607            hostile_id.sources[0].source_id = hostile.into();
608            assert_eq!(
609                hostile_id.to_canonical_json().unwrap_err().code(),
610                "GF_PROJECT_CORRUPT",
611                "{hostile}"
612            );
613        }
614
615        let oversized = vec![b' '; MAX_WORKSPACE_REPOSITORY_SNAPSHOT_BYTES + 1];
616        assert_eq!(
617            WorkspaceRepositorySnapshot::from_canonical_json(&oversized)
618                .unwrap_err()
619                .code(),
620            "GF_PROJECT_CORRUPT"
621        );
622
623        let with_unrestricted_field = String::from_utf8(
624            repository_snapshot()
625                .to_canonical_json()
626                .unwrap()
627                .into_iter()
628                .collect(),
629        )
630        .unwrap()
631        .replacen(
632            "\"actor_uuid\"",
633            "\"absolute_path\":\"/secret/data\",\"actor_uuid\"",
634            1,
635        );
636        assert_eq!(
637            WorkspaceRepositorySnapshot::from_canonical_json(with_unrestricted_field.as_bytes())
638                .unwrap_err()
639                .code(),
640            "GF_PROJECT_CORRUPT"
641        );
642    }
643}