prov 0.6.1

A self-describing plaintext workspace: structure lives in documents' own embedded metadata.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
//! Attachments — giving an *arbitrary* file its own workspace-linked metadata.
//!
//! A prov document carries its structure in embedded frontmatter, but a
//! binary — an image, a PDF, a font — cannot. The fix reuses the **separated**
//! document shape (`EmbedStyle::Separate`): a whole-file metadata *sidecar*
//! joined to a body file by a `content` attribute. An attachment is that same
//! pattern with the body relaxed from prose to bytes — the sidecar
//! `photo.jpg.yaml` holds `title`/`id`/relations and points `content` at
//! `photo.jpg`, which prov links, moves, and validates but never *reads*.
//!
//! This is the sidecar prov's philosophy welcomes, not the one it rejects:
//! a co-located, visible, self-describing document any tool can open — the exact
//! opposite of an app-private `.obsidian/`-style folder (`lib.rs`).
//!
//! Three operations:
//! - [`attach`](Workspace::attach) — mint a sidecar for a loose file and link it
//!   under a parent (the attachment analogue of [`create`](Workspace::create)).
//! - [`attachment_for`](Workspace::attachment_for) — the reverse lookup: given a
//!   payload, find its sidecar by the `<file>.<ext>` convention, confirmed by the
//!   authoritative `content` pointer.
//! - [`loose_attachments`](Workspace::loose_attachments) — every opaque file with
//!   no sidecar yet, the work-list an importer walks.
//!
//! Opacity is a *role*, not a format. A file prov can read is refused by
//! `attach` — it can carry its own metadata, so it should — but
//! [`attach_opaque`](Workspace::attach_opaque) waives that for a specimen: an
//! example document whose metadata block is an exhibit rather than a claim about
//! this workspace. The sidecar's `attachment: true` marker is what shadows it,
//! and [`is_shadowed_payload`](Workspace::is_shadowed_payload) is what holds prov
//! to it in the scans that would otherwise read the file.
//!
//! Move and delete need no new code: a sidecar is a separated node, so
//! [`rename`](Workspace::rename) already relocates the payload beside it (keeping
//! `content` correct) and [`delete`](Workspace::delete) removes the pair.

use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;

use fig::Segment;

use crate::identity::{IdentityPolicy, Trigger};
use crate::workspace::Workspace;
use prov_graph::document::{is_opaque_payload, whole_file_extension};
use prov_graph::error::{Error, Result};
use prov_graph::fs::ReadStorage;
use prov_graph::graph::sidecar_candidates;
use prov_graph::index::IdIndex;
use prov_graph::link;
use prov_graph::meta::{Mapping, Value};
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;

/// The sidecar path for `payload` in metadata `format`: the payload's full name
/// plus the format's whole-file extension, as a sibling (`sub/a.pdf` →
/// `sub/a.pdf.yaml`), so the sidecar's `content` pointer is just the basename.
pub(crate) fn sidecar_path(payload: &Path, format: fig::Format) -> PathBuf {
    let ext = whole_file_extension(format);
    let name = payload
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or_default();
    payload.with_file_name(format!("{name}.{ext}"))
}

impl<FS: ReadStorage, Id, Ix: IdIndex> Workspace<FS, Id, Ix> {
    /// The metadata sidecar for the attachment `payload`, or `None` when it has
    /// none. Probes the `<payload>.<ext>` convention for each whole-file metadata
    /// extension and confirms the candidate's `content` actually resolves back to
    /// `payload` — the convention is the fast path, the `content` pointer is
    /// authoritative, so a sidecar under a non-conventional name is still found by
    /// [`loose_attachments`] treating the payload as unattached only when no
    /// pointer claims it. (Here we accept the convention's hits; a bespoke layout
    /// is the caller's to track.)
    pub async fn attachment_for(&self, payload: &Path) -> Result<Option<PathBuf>> {
        let payload = link::normalize(payload);
        for candidate in sidecar_candidates(&payload) {
            if !self.exists(&candidate).await? {
                continue;
            }
            if self.graph().sidecar_claims(&candidate, &payload).await {
                return Ok(Some(candidate));
            }
        }
        Ok(None)
    }

    /// Every opaque file under the root that has no sidecar yet — the *recursive*
    /// population, the whole tree. A flat filesystem scan (hidden entries
    /// skipped), independent of link resolution, like the title/id/content scans
    /// beside it. Sidecars and prose documents are text prov reads, so they
    /// are not payloads and never appear here.
    ///
    /// This is the `--recursive` escape hatch for `attach --all`; the bounded
    /// [`loose_attachments_in`](Self::loose_attachments_in) is the safer default.
    ///
    /// **Prov's own byte-parking stores are excluded even here.** The bounded
    /// scan never reaches them because nothing links into them, and that is not
    /// an accident of the layout — a history blob and a binned file are
    /// bookkeeping the workspace is meant to be blind to
    /// ([`parked_dirs`](Self::parked_dirs)). `--recursive` lifts the
    /// reachability bound, which is a statement about the *documents* a
    /// workspace has not linked yet, not a licence to mint a sidecar beside
    /// every blob — those sidecars are orphans the moment they exist, and
    /// `history-prune` would collect the payloads out from under them. The root
    /// is located here rather than passed in because it is wanted only to
    /// resolve the store pointers: this scan is deliberately *not* bounded by
    /// what the root reaches, and taking a start document would imply it was.
    pub async fn loose_attachments(&self) -> Result<Vec<PathBuf>> {
        // No discoverable root means no pointers to follow and so no known
        // stores — the scan is then exactly what it was before, over a
        // workspace that has no history or bin to walk into anyway.
        let parked = match self.root_document().await? {
            Some(root_doc) => self.parked_dirs(&root_doc).await?,
            None => Vec::new(),
        };
        let mut found = Vec::new();
        self.scan_loose(PathBuf::new(), &parked, &mut found).await?;
        found.sort();
        Ok(found)
    }

    /// Loose opaque files (no sidecar yet) in the directories the workspace
    /// already reaches from `start` — **reachability-bounded** discovery, the
    /// default for `attach --all`. Unreached directories are never scanned, so
    /// `attach --all` in a project root sweeps only the folders the workspace
    /// occupies, not a vendored subtree or a nested prov workspace. The
    /// counterpart to the bounded orphan check (DESIGN §8).
    pub async fn loose_attachments_in(&self, start: &Path) -> Result<Vec<PathBuf>> {
        // Two passes over the same documents: the census that establishes what is
        // reachable, and `manifest_roots`, which loads each reachable document
        // again to ask whether it declares a manifest.
        let _scope = self.read_scope();
        // The reachable set: `start` plus every path a census link resolves to.
        let mut reachable: BTreeSet<PathBuf> = BTreeSet::new();
        reachable.insert(link::normalize(start));
        for entry in self.census(start).await? {
            if let Some(p) = entry.resolution.resolved_path() {
                reachable.insert(p.clone());
            }
        }
        let reached_dirs = Self::reached_dirs(&reachable);
        // Directories already covered by a manifest, computed once from the
        // census rather than probed per file: a covered file is accounted for in
        // bulk, and offering it here is offering to mint the ten thousand
        // sidecars a manifest exists to avoid.
        let covered = self.graph().manifest_roots(&reachable).await;
        let mut found = Vec::new();
        for file in self.direct_child_files(&reached_dirs).await? {
            if is_opaque_payload(&file)
                && !covered.iter().any(|root| file.starts_with(root))
                && self.attachment_for(&file).await?.is_none()
            {
                found.push(file);
            }
        }
        found.sort();
        Ok(found)
    }

    /// Recursively collect opaque files lacking a sidecar under `rel_dir`. Same
    /// walk shape as the content/id scans; unreadable, hidden, `parked` and
    /// manifest-covered entries skipped.
    fn scan_loose<'a>(
        &'a self,
        rel_dir: PathBuf,
        parked: &'a [PathBuf],
        out: &'a mut Vec<PathBuf>,
    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
        Box::pin(async move {
            let Ok(entries) = self.listing(&rel_dir).await else {
                return Ok(());
            };
            for entry in entries {
                let Some(name) = entry
                    .file_name()
                    .and_then(|n| n.to_str())
                    .map(str::to_owned)
                else {
                    continue;
                };
                if name.starts_with('.') {
                    continue;
                }
                let rel = if rel_dir.as_os_str().is_empty() {
                    PathBuf::from(&name)
                } else {
                    rel_dir.join(&name)
                };
                if entry.file_type().is_dir() {
                    // A store's interior is prov's own bookkeeping, never the
                    // workspace's files — the one place this sweep stops short
                    // of the whole tree. Refused by *not descending*, so a
                    // thousand blobs are never listed only to be discarded.
                    if parked.iter().any(|dir| rel.starts_with(dir)) {
                        continue;
                    }
                    // A covered directory is accounted for in bulk. Probed
                    // directory-locally (the `<dir>.<ext>` convention, confirmed
                    // by the manifest's own `root`) rather than from a census,
                    // because this walk deliberately has none: it is the
                    // `--recursive` sweep, which goes where reachability does
                    // not.
                    if self.manifest_node_for(&rel).await?.is_some() {
                        continue;
                    }
                    self.scan_loose(rel, parked, out).await?;
                } else if entry.file_type().is_file()
                    && is_opaque_payload(&rel)
                    && self.attachment_for(&rel).await?.is_none()
                {
                    out.push(rel);
                }
            }
            Ok(())
        })
    }
}

impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
    /// Attach the opaque file `payload` as a spanning child of `parent`: mint a
    /// whole-file metadata **sidecar** beside it (`photo.jpg` → `photo.jpg.yaml`)
    /// carrying `title`, the inverse link back to `parent`, a `content` pointer at
    /// the payload, and an `attachment: true` marker; and add the sidecar (never
    /// the payload) to `parent`'s spanning field. If the identity policy registers
    /// on create, the sidecar is assigned a stable ID. Returns the sidecar's path.
    ///
    /// The payload is the structural analogue of a separated document's prose
    /// body, so it is *not* rewritten, read, or required to be text — only its
    /// existence is checked. Refuses a payload prov can read as a document
    /// (that is [`adopt`](Workspace::adopt)'s job: it can hold its own
    /// frontmatter; [`attach_opaque`](Self::attach_opaque) overrides) and refuses
    /// when a sidecar already exists (query
    /// [`attachment_for`](Workspace::attachment_for) first for idempotency).
    pub async fn attach(&mut self, payload: &Path, parent: &Path) -> Result<PathBuf> {
        self.attach_titled(payload, parent, None, false).await
    }

    /// [`attach`](Self::attach) for a payload prov *can* read — a file shadowed
    /// on purpose rather than by its extension.
    ///
    /// Ordinarily a readable file is a document that should carry its own
    /// metadata, so `attach` refuses it. But opacity is a statement about a
    /// file's *role*, not its format: a specimen prov document — an example in a
    /// spec, a fixture, a captured export — is a text file whose metadata block
    /// is the exhibit, not a claim about this workspace. [`adopt`](Workspace::adopt)
    /// would write an inverse link into that block and so edit the thing being
    /// demonstrated; its example links would then be censused as real. Shadowing
    /// it keeps the bytes exact (and, under `fixity: attachments`, pinned by a
    /// `content_hash` that `check` verifies).
    ///
    /// The sidecar carries `attachment: true`, which the reader already honors
    /// over the payload's extension ([`Document::is_attachment`](crate::Document::is_attachment)),
    /// and which keeps the payload out of the title and id scans
    /// ([`is_shadowed_payload`](Self::is_shadowed_payload)). An already-opaque
    /// payload is accepted too — the flag is then simply redundant.
    pub async fn attach_opaque(&mut self, payload: &Path, parent: &Path) -> Result<PathBuf> {
        self.attach_titled(payload, parent, None, true).await
    }

    /// [`attach`](Self::attach) with an explicit sidecar title (else the payload's
    /// titleized stem). Authoring the title here keeps the parent's spanning-entry
    /// *label* in step with it, exactly as [`create_titled`](Self::create_titled).
    /// `opaque` waives the readable-payload refusal (see
    /// [`attach_opaque`](Self::attach_opaque)).
    pub(crate) async fn attach_titled(
        &mut self,
        payload: &Path,
        parent: &Path,
        title_override: Option<&str>,
        opaque: bool,
    ) -> Result<PathBuf> {
        let payload = link::normalize(payload);
        let parent = link::normalize(parent);

        if !self.exists(&payload).await? {
            return Err(Error::NotFound(payload.to_path_buf()));
        }
        // An attachment shadows *external* content. A file prov can read is a
        // document that should carry its own metadata — adopt it, don't sidecar it.
        // Unless the caller says otherwise: opacity is a role, and a specimen
        // document is text prov must agree not to interpret (`attach_opaque`).
        if !opaque && !is_opaque_payload(&payload) {
            return Err(Error::Structure(format!(
                "{} is a prov document, not an opaque attachment — use `adopt`, \
                 or `--opaque` to shadow it unread",
                payload.display()
            )));
        }
        // A file a manifest already covers has metadata — its parent directory's
        // node — and a sidecar beside it would be a second claim on the same
        // bytes, with two fixity records to keep in step and no rule about which
        // wins.
        if self.graph().under_manifest(&payload).await? {
            return Err(Error::Structure(format!(
                "{} is already covered by a manifest — the directory's node is \
                 its metadata; `manifest --update` records a change to it",
                payload.display()
            )));
        }

        let (spanning, inverse) = self.spanning_pair()?;
        let format = self.default_embed_format();
        let node = sidecar_path(&payload, format);
        if self.exists(&node).await? {
            return Err(Error::AlreadyExists(node.to_path_buf()));
        }

        let (parent_text, parent_doc) = self.load(&parent).await?;
        let title = title_override
            .map(str::to_owned)
            .unwrap_or_else(|| link::path_to_title(&payload));
        let parent_title = parent_doc
            .meta
            .get("title")
            .and_then(Value::as_str)
            .map(str::to_owned)
            .unwrap_or_else(|| link::path_to_title(&parent));

        // Opens before the first id-authoring call below, so the index
        // checkpoint covers the registrations those make (see `mutate::create`).
        let mut cs = self.change();

        // The sidecar's inverse link up (the parent exists → an id link registers
        // it by path) and the parent's spanning entry down (the sidecar is not on
        // disk yet → mint its id directly rather than register-by-path).
        let up = self
            .authored_target(&inverse, &node, &parent, &parent_title, true)
            .await?;
        let down = self
            .authored_target(&spanning, &parent, &node, &title, false)
            .await?;

        // The sidecar: a whole-file mapping pointing `content` at the payload
        // (a sibling, so just its name) and flagged as an attachment.
        let payload_ref = payload
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or_default()
            .to_string();
        let mut map = Mapping::new();
        map.insert("title".into(), Value::String(title));
        map.insert(inverse.clone(), Value::String(up));
        map.insert("content".into(), Value::String(payload_ref));
        map.insert("attachment".into(), Value::Bool(true));
        // Fixity: record a checksum of the payload's bytes, so `check` can later
        // detect bit-rot. Unambiguous for an attachment — its bytes are never
        // edited — so it is recorded whenever the workspace covers payloads, with
        // no per-file opt-in. The payload is read once here, at attach time.
        if self.fixity().covers_payloads() {
            let bytes = self.read_bytes(&payload).await?;
            map.insert(
                "content_hash".into(),
                Value::String(crate::fixity::digest(&bytes)),
            );
        }
        let node_text = prov_graph::meta::serialize_mapping(&map, format)?;

        // The parent: append the sidecar to its spanning field (creating it if
        // absent — `append` needs an existing sequence).
        let mut parent_editor = MetaEditor::open_or_init(&parent_text, parent_doc.carrier)?;
        let span_path = [Segment::Key(&spanning)];
        if parent_editor
            .append_value(&span_path, fig::Value::Str(down.clone()))
            .is_err()
        {
            parent_editor.set_value(&span_path, fig::Value::Seq(vec![fig::Value::Str(down)]))?;
        }
        let parent_out = parent_editor.render()?;

        cs.write(&node, node_text);
        cs.write(&parent, parent_out);

        // Identity hook — eager policies assign an ID from birth (idempotent: an
        // id-linked sidecar was already registered above).
        if self.identity().registration().fires_on(Trigger::Create)
            && self.index().id_for_path(&node).is_none()
        {
            let id = self.mint_unique(&node);
            self.index_mut().register(&id, &node);
        }
        self.commit(cs).await?;
        Ok(node)
    }
}

#[cfg(all(test, feature = "yaml"))]
mod tests {
    use super::*;
    use crate::validate::Finding;
    use prov_graph::exec::block_on;
    use prov_graph::fs::StdFs;
    use prov_graph::title::TitleMatch;

    fn write(dir: &Path, rel: &str, bytes: &[u8]) {
        let p = dir.join(rel);
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
        std::fs::write(p, bytes).unwrap();
    }

    fn read(dir: &Path, rel: &str) -> String {
        std::fs::read_to_string(dir.join(rel)).unwrap()
    }

    fn tempdir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("prov-attach-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn ws(dir: &Path) -> Workspace<StdFs> {
        Workspace::builder(StdFs).root(dir).build()
    }

    #[test]
    fn attach_gives_a_binary_a_linked_metadata_sidecar() {
        let dir = tempdir("basic");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        // A binary payload: bytes prov must never try to read as text.
        write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x00]);

        let node =
            block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
        // The sidecar keeps the full payload name (no `a.jpg`/`a.png` collision).
        assert_eq!(node, PathBuf::from("photo.jpg.yaml"));

        let sidecar = read(&dir, "photo.jpg.yaml");
        assert!(sidecar.contains("title: Photo"), "{sidecar}");
        assert!(
            sidecar.contains("content: photo.jpg"),
            "points at the payload: {sidecar}"
        );
        assert!(
            sidecar.contains("attachment: true"),
            "flagged as an attachment: {sidecar}"
        );
        assert!(
            sidecar.contains("index.md"),
            "inverse link up to the parent: {sidecar}"
        );

        // The parent links the sidecar (the node), never the raw payload.
        let index = read(&dir, "index.md");
        assert!(index.contains("photo.jpg.yaml"), "{index}");
        assert!(
            !index.contains("[photo.jpg]") && !index.contains("(photo.jpg)"),
            "{index}"
        );

        // The payload is untouched, and the whole workspace validates — the
        // `content` pointer resolves, and the opaque payload is neither read nor
        // treated as an orphan.
        assert_eq!(
            std::fs::read(dir.join("photo.jpg")).unwrap(),
            [0xff, 0xd8, 0xff, 0xe0, 0x00]
        );
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn attach_records_a_payload_checksum_that_check_verifies() {
        // Fixity default (payloads): the sidecar carries a sha256 of the bytes,
        // and a clean workspace verifies without a finding.
        let dir = tempdir("fixity-record");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        let payload: &[u8] = &[0xff, 0xd8, 0xff, 0xe0, 0x01, 0x02, 0x03];
        write(&dir, "photo.jpg", payload);

        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        let sidecar = read(&dir, "photo.jpg.yaml");
        let expected = crate::fixity::digest(payload);
        assert!(
            sidecar.contains(&format!("content_hash: {expected}")),
            "{sidecar}"
        );
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn check_catches_a_corrupted_payload() {
        // The archival payoff: bit-rot no link check would ever see.
        let dir = tempdir("fixity-rot");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x01]);
        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        // A bit rots — the payload's bytes change out from under its checksum.
        write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x99]);

        let findings = block_on(ws(&dir).check("index.md")).unwrap();
        assert!(
            findings.iter().any(|f| matches!(
                f,
                Finding::FixityMismatch { doc, .. } if doc == Path::new("photo.jpg.yaml")
            )),
            "expected a fixity mismatch, got: {findings:?}"
        );
    }

    #[test]
    fn restamping_accepts_a_changed_payload_and_clears_the_finding() {
        // The pressure-release valve: an intended change is re-blessed by
        // re-stamping, and the workspace validates again.
        let dir = tempdir("fixity-restamp");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0x01, 0x02, 0x03]);
        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        write(&dir, "photo.jpg", &[0x04, 0x05, 0x06]); // an intended re-export

        let mut w = ws(&dir);
        let finding = block_on(w.check("index.md"))
            .unwrap()
            .into_iter()
            .find(|f| matches!(f, Finding::FixityMismatch { .. }))
            .expect("a mismatch to re-stamp");
        let fix = block_on(w.suggest_fix(&finding))
            .unwrap()
            .expect("a re-stamp fix");
        block_on(w.apply_fix(&fix)).unwrap();

        // Re-blessed: the recorded hash now matches the new bytes.
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
        assert!(read(&dir, "photo.jpg.yaml").contains(&crate::fixity::digest(&[0x04, 0x05, 0x06])));
    }

    #[test]
    fn fixity_off_records_no_checksum() {
        let dir = tempdir("fixity-off");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0x01, 0x02, 0x03]);

        let w = || {
            Workspace::builder(StdFs)
                .root(&dir)
                .fixity(crate::config::Fixity::Off)
                .build()
        };
        block_on(w().attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        assert!(
            !read(&dir, "photo.jpg.yaml").contains("content_hash"),
            "off records nothing"
        );
        assert_eq!(block_on(w().check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn attachment_for_finds_the_sidecar_and_refuses_a_document_payload() {
        let dir = tempdir("lookup");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "assets/logo.png", &[0x89, 0x50, 0x4e, 0x47]);

        assert!(
            block_on(ws(&dir).attachment_for(Path::new("assets/logo.png")))
                .unwrap()
                .is_none()
        );
        block_on(ws(&dir).attach(Path::new("assets/logo.png"), Path::new("index.md"))).unwrap();
        assert_eq!(
            block_on(ws(&dir).attachment_for(Path::new("assets/logo.png"))).unwrap(),
            Some(PathBuf::from("assets/logo.png.yaml"))
        );

        // A readable document is not an attachment — adopt it instead.
        write(&dir, "note.md", b"---\ntitle: Note\n---\nbody\n");
        let err =
            block_on(ws(&dir).attach(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
        assert!(
            err.to_string().contains("not an opaque attachment"),
            "{err}"
        );
    }

    /// A specimen: a *readable* prov document whose metadata block is an
    /// exhibit — an example title, an example id, an example link to a file that
    /// was never meant to exist here.
    const SPECIMEN: &[u8] = b"---\ntitle: How this workspace is organized\nid: fpk38j\n\
                              contents:\n- some-other-workspace.md\n---\n# Example\n";

    #[test]
    fn attach_opaque_shadows_a_readable_document_without_touching_it() {
        let dir = tempdir("opaque");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "examples/sample.md", SPECIMEN);

        let node = block_on(
            ws(&dir).attach_opaque(Path::new("examples/sample.md"), Path::new("index.md")),
        )
        .unwrap();
        assert_eq!(node, PathBuf::from("examples/sample.md.yaml"));

        // The marker the reader honors over the payload's extension, and the
        // pointer that names what is shadowed.
        let sidecar = read(&dir, "examples/sample.md.yaml");
        assert!(sidecar.contains("attachment: true"), "{sidecar}");
        assert!(sidecar.contains("content: sample.md"), "{sidecar}");
        assert!(
            sidecar.contains(&crate::fixity::digest(SPECIMEN)),
            "{sidecar}"
        );

        // The exhibit is byte-exact: `adopt` would have written a link into it.
        assert_eq!(
            std::fs::read(dir.join("examples/sample.md")).unwrap(),
            SPECIMEN
        );

        // And prov keeps out of it: the specimen's example link is not censused,
        // so it is neither a broken link nor an orphan.
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn a_shadowed_payloads_title_and_id_stay_out_of_the_scans() {
        let dir = tempdir("opaque-scans");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "examples/sample.md", SPECIMEN);

        // Before shadowing, the specimen is an ordinary document: prov reads it,
        // and its exhibit competes for both the name and the id.
        assert_eq!(
            block_on(ws(&dir).title_index())
                .unwrap()
                .resolve("How this workspace is organized"),
            TitleMatch::Unique(PathBuf::from("examples/sample.md"))
        );
        assert_eq!(block_on(ws(&dir).scan_ids()).unwrap().len(), 1);

        block_on(ws(&dir).attach_opaque(Path::new("examples/sample.md"), Path::new("index.md")))
            .unwrap();

        // After: bytes prov agreed not to read as a document.
        for index in [
            block_on(ws(&dir).title_index()).unwrap(),
            block_on(ws(&dir).title_index_scoped(Path::new("index.md"))).unwrap(),
        ] {
            assert_eq!(
                index.resolve("How this workspace is organized"),
                TitleMatch::Unknown,
                "a specimen's title must not answer an alias"
            );
            assert_eq!(index.resolve("sample"), TitleMatch::Unknown, "nor its stem");
        }
        assert_eq!(
            block_on(ws(&dir).scan_ids()).unwrap(),
            vec![],
            "an example id is not a claim on the registry"
        );
    }

    /// A specimen carrying two hazards of its own: a `fields` value no
    /// vocabulary recognizes, and a `content_hash` that does not match its own
    /// bytes. Both read as live frontmatter to an ordinary document — which is
    /// exactly why a *shadowed* one must never let either surface: they are the
    /// exhibit's claims, not this workspace's.
    const HAZARDOUS_SPECIMEN: &[u8] = b"---\ntitle: A Captured Export\naudience: someone-else\n\
        content_hash: sha256:0000000000000000000000000000000000000000000000000000000000000000\n\
        ---\n# Example\n";

    #[test]
    fn attach_opaque_shadows_a_payloads_own_vocabulary_and_fixity_findings() {
        let dir = tempdir("opaque-hazards");
        write(
            &dir,
            "index.md",
            b"---\ntitle: Home\n\
              prov:\n  fields:\n    audience:\n      values: closed\n      vocabulary: vocab/audiences.yaml\n\
              ---\n",
        );
        write(
            &dir,
            "vocab/audiences.yaml",
            b"title: Audiences\npart_of: /index.md\nvocabulary:\n  field: audience\n  values: closed\n\
              terms:\n  public: {}\n  friends: {}\n",
        );
        write(&dir, "examples/sample.md", HAZARDOUS_SPECIMEN);

        let mut w = ws(&dir);
        block_on(w.attach_opaque(Path::new("examples/sample.md"), Path::new("index.md"))).unwrap();

        // Neither hazard is surfaced: the exhibit's `audience` never meets the
        // vocabulary, and its `content_hash` is never checked against its own
        // bytes — both belong to the specimen, not this workspace.
        let findings = block_on(w.check("index.md")).unwrap();
        assert_eq!(findings, vec![], "{findings:?}");

        // With no finding raised, nothing `suggest_fix` could offer would ever
        // touch the payload — its bytes stay exactly as attached.
        assert_eq!(
            std::fs::read(dir.join("examples/sample.md")).unwrap(),
            HAZARDOUS_SPECIMEN,
            "no fix may have rewritten the exhibit"
        );
    }

    #[test]
    fn the_same_hazards_adopted_readably_are_still_caught() {
        // Control: the non-opaque contract. `adopt` (not `attach --opaque`)
        // links the very same file as an ordinary readable document, and its
        // frontmatter hazards are then real claims `check` must catch — proof
        // the opaque test above is suppressing genuine findings, not vacuous
        // ones which would pass no matter what the code did.
        let dir = tempdir("opaque-hazards-control");
        write(
            &dir,
            "index.md",
            b"---\ntitle: Home\n\
              prov:\n  fields:\n    audience:\n      values: closed\n      vocabulary: vocab/audiences.yaml\n\
              ---\n",
        );
        write(
            &dir,
            "vocab/audiences.yaml",
            b"title: Audiences\npart_of: /index.md\nvocabulary:\n  field: audience\n  values: closed\n\
              terms:\n  public: {}\n  friends: {}\n",
        );
        write(&dir, "examples/sample.md", HAZARDOUS_SPECIMEN);

        let mut w = ws(&dir);
        block_on(w.adopt(Path::new("examples/sample.md"), Path::new("index.md"))).unwrap();

        let findings = block_on(w.check("index.md")).unwrap();
        assert!(
            findings.iter().any(|f| matches!(
                f,
                Finding::UnknownTerm { doc, field, value, .. }
                    if doc == Path::new("examples/sample.md")
                        && field == "audience"
                        && value == "someone-else"
            )),
            "{findings:?}"
        );
        assert!(
            findings.iter().any(|f| matches!(
                f,
                Finding::FixityMismatch { doc, .. } if doc == Path::new("examples/sample.md")
            )),
            "{findings:?}"
        );
    }

    #[test]
    fn a_separated_prose_body_is_not_shadowed() {
        // The neighbouring shape: `note.yaml` + `note.md` is a *separated*
        // document, and its body is a document in its own right — prov must keep
        // reading it. (The conventions cannot collide: a sidecar appends the
        // extension, `note.md.yaml`, rather than replacing it.)
        let dir = tempdir("opaque-separated");
        write(
            &dir,
            "index.md",
            b"---\ntitle: Home\ncontents:\n- note.yaml\n---\n",
        );
        write(
            &dir,
            "note.yaml",
            b"title: Split Note\npart_of: index.md\ncontent: note.md\n",
        );
        write(&dir, "note.md", b"# Split Note\n");

        // The body keeps its place in the index (sharing the stem with its own
        // metadata half, as a separated node always has).
        assert!(
            matches!(
                block_on(ws(&dir).title_index()).unwrap().resolve("note"),
                TitleMatch::Ambiguous(paths) if paths.contains(&PathBuf::from("note.md"))
            ),
            "a separated body is still a document the scans read"
        );
    }

    #[test]
    fn the_parked_stores_are_invisible_to_the_recursive_sweep() {
        // `--recursive` lifts the *reachability* bound, and a byte-parking store
        // is unreached on purpose rather than by oversight (spec §4, and
        // `parked_dirs`). Sweeping into one mints a `.yaml` beside every history
        // blob and every binned file — sidecars that are orphans the instant
        // they exist, and whose payloads `history-prune` then collects out from
        // under them.
        let dir = tempdir("parked");
        write(
            &dir,
            "index.md",
            b"---\ntitle: Home\nhistory: history/index.md\nrecycle_bin: recyclebin/index.md\n---\n",
        );
        write(&dir, "history/index.md", b"---\ntitle: History\n---\n");
        write(&dir, "recyclebin/index.md", b"---\ntitle: Bin\n---\n");
        // A blob (content-addressed, so extensionless and opaque), a binned
        // attachment, and one genuinely loose file to prove the sweep still runs.
        write(&dir, "history/blobs/ab/cdef", b"parked bytes");
        write(&dir, "recyclebin/items/binned.jpg", &[0xff, 0xd8]);
        write(&dir, "loose.pdf", b"%PDF-1.7\n");

        assert_eq!(
            block_on(ws(&dir).loose_attachments()).unwrap(),
            vec![PathBuf::from("loose.pdf")],
            "the recursive sweep walks into no store prov parks bytes in"
        );
    }

    #[test]
    fn loose_attachments_lists_only_unsidecarred_binaries() {
        let dir = tempdir("loose");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "a.pdf", b"%PDF-1.7\n");
        write(&dir, "sub/b.png", &[0x89, 0x50]);
        // A prose document is not a payload; it should never appear.
        write(&dir, "sub/note.md", b"---\ntitle: Note\n---\n");

        let mut loose = block_on(ws(&dir).loose_attachments()).unwrap();
        loose.sort();
        assert_eq!(
            loose,
            vec![PathBuf::from("a.pdf"), PathBuf::from("sub/b.png")]
        );

        // Attaching one drops it from the loose set (its sidecar now claims it).
        block_on(ws(&dir).attach(Path::new("a.pdf"), Path::new("index.md"))).unwrap();
        assert_eq!(
            block_on(ws(&dir).loose_attachments()).unwrap(),
            vec![PathBuf::from("sub/b.png")]
        );
    }

    #[test]
    fn renaming_a_sidecar_moves_its_payload_and_keeps_content_correct() {
        // A sidecar is a separated node, so the existing move machinery relocates
        // the payload beside it and repoints `content` — no attachment-specific code.
        let dir = tempdir("rename");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0xff, 0xd8]);
        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        block_on(ws(&dir).rename(
            Path::new("photo.jpg.yaml"),
            Path::new("media/hero.jpg.yaml"),
        ))
        .unwrap();

        assert!(
            dir.join("media/hero.jpg").exists(),
            "payload moved beside the sidecar"
        );
        assert!(!dir.join("photo.jpg").exists(), "old payload gone");
        assert!(
            read(&dir, "media/hero.jpg.yaml").contains("content: hero.jpg"),
            "content repointed"
        );
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }
}