Skip to main content

rto_render/okf/
read.rs

1//! Read an Open Knowledge Format bundle back into graph facts (issue #706).
2//!
3//! # Why the reader lives beside the writer
4//!
5//! `rto-render` is the renderer, and a parser is the other direction — so this
6//! module is here on purpose rather than in `rto-spec`, which already hosts
7//! `import_graphify` and `import_lat` and would be the obvious home.
8//!
9//! The reason is the naming rule. A concept's *identity in a bundle is its
10//! path*, and [`super::slug`], [`super::section_for`] and the collision digest
11//! are what turn a graph key into one. A reader in another crate would need all
12//! three, so either they become public API or the rule is written down twice —
13//! and [`super::assemble`]'s own documentation already says why a second copy is
14//! wrong: "any rule that turns a key into a path on its own is guessing". The
15//! writer is the specification of what this reads, and a specification and its
16//! parser drift the moment they are in different crates.
17//!
18//! So: OKF read and OKF write move together. If `rto-render` is ever split, they
19//! go to the same place.
20//!
21//! # What this reads, and what it refuses
22//!
23//! A Roteiro bundle round-trips, and that is the floor rather than the goal —
24//! ADR-0021 adopted OKF because it is **vendor-neutral**, so a bundle written by
25//! something else has to be readable too. OKF v0.2's only hard requirement is a
26//! non-empty `type`, and §11 tells consumers not to reject a document for a
27//! missing or unrecognised optional field. The rules that follow are chosen
28//! against that, one at a time:
29//!
30//! | situation | what happens | why |
31//! | --- | --- | --- |
32//! | unrecognised `type` | **imported**, as [`NodeKind::Other`] | the spec leaves `type` open; refusing would reject conformant bundles |
33//! | missing or empty `type` | file **skipped**, reason reported | the one thing the spec does require |
34//! | no frontmatter, or an unterminated block | file **skipped**, reason reported | a document with no frontmatter is not a concept |
35//! | no `verified` key | imported as `external-inferred` | absence of `verified` **is** the unverified tier (§5.3), not missing data |
36//! | link to a concept the bundle does not contain | edge dropped, counted | the store requires both endpoints; a dangling edge would be pruned anyway |
37//! | *every* concept file skipped | the whole read **fails** | a directory in which nothing parsed is not a bundle we read badly, it is not a bundle |
38//!
39//! Nothing is dropped silently. Every skip carries a path and a reason into
40//! [`OkfReport`], and the CLI prints them: a bundle that is *partly* readable
41//! has to say what it left behind, because the alternative is a graph quietly
42//! missing concepts nobody knows to look for.
43//!
44//! # This reader was checked against an independent implementation
45//!
46//! Reading back one's own output proves a round trip, not interoperability, so
47//! the trust tiers this module derives (§5.3) were compared against a second,
48//! unrelated OKF v0.2 implementation over inputs neither project wrote.
49//!
50//! **What was compared, so the claim can be re-tested rather than believed:**
51//!
52//! - **Oracle:** [`W4G1/okf`](https://github.com/W4G1/okf) `okf-core` /
53//!   `okf-validator` **0.2.6** (2026-08-27), Apache-2.0 — a pure-Rust v0.2
54//!   toolkit. Its `okf trust <bundle>` prints a tier per concept and
55//!   `okf validate <bundle>` reports conformance.
56//! - **Inputs:** all four bundles published in the specification's own
57//!   repository at commit `ad30107` — `acme_retail`, `ga4`, `stackoverflow`,
58//!   `crypto_bitcoin` — plus Roteiro's own `render okf` output for this
59//!   repository.
60//! - **Result, 2026-09-01:** exact agreement on every bundle. Concept counts
61//!   9 / 9 / 26 / 9, and tiers matching one-for-one — `acme_retail` as 8
62//!   human-reviewed + 1 unverified (our `external-authored` / `external-inferred`),
63//!   the other three entirely unverified. Our rendered bundle validated with
64//!   **0 conformance errors across 9,029 concepts**.
65//!
66//! The oracle is **not** a dependency, of this crate or of the test suite: it
67//! was run as a separate binary and the agreement was then frozen into
68//! `tests/okf_interop.rs`, which pins the same expectations against vendored
69//! copies of two of those bundles. That is what survives the oracle's absence —
70//! a foreign bundle in the test suite, which is the thing phase 1 never had.
71//!
72//! To re-run the comparison: `cargo install okf`, then `okf trust <bundle>`
73//! against `crates/rto-render/tests/fixtures/okf-upstream/*` and
74//! `roteiro import --from okf <bundle> --trust --json`.
75//!
76//! Worth knowing if adopting it is ever considered: `okf-core` has **zero
77//! dependencies** — no `serde`, no `serde_yaml`, no `regex`, no `chrono` — and
78//! carries its own YAML-subset parser. `okf-validator` is the heavy one, adding
79//! 94 transitive crates (a JavaScript, Python and SQL parser, plus `syn`) to
80//! syntax-check fenced code blocks.
81//!
82//! # Relationships come from the `## Relationships` section, and nowhere else
83//!
84//! §6 says a plain markdown link asserts a relationship. Read at its widest that
85//! would make every link in every sentence an edge, so a paragraph citing a
86//! neighbouring concept would manufacture one. Roteiro's own writer puts
87//! relationships under a `## Relationships` heading and prose everywhere else,
88//! and that is the line taken here: links under that heading are edges, links
89//! outside it are citations and are counted rather than imported
90//! ([`OkfReport::links_outside_relationships`]).
91//!
92//! A `←` link is the *same* edge seen from its other end — [`super::render_concept`]
93//! writes both directions into both documents — so only `→` (and unmarked) links
94//! become edges. Taking both would not duplicate anything (edges are a set), but
95//! it would reverse half of them.
96
97use std::collections::BTreeMap;
98
99use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
100use yaml_rust2::Yaml;
101
102use super::{Actor, INDEX_FILE, LOG_FILE, Origin, section_for, short_digest, slug};
103
104/// The `src_ref` prefix every OKF import layer is persisted under.
105///
106/// One ref **per bundle**, not one for all of them: `apply_import_layer` is
107/// authoritative per ref, so a single shared ref would make importing a second
108/// peer's bundle delete the first peer's concepts. The same reasoning that gave
109/// `import:links` and `import:links/authored` separate refs.
110///
111/// # It sorts after `import:links`, and that is load-bearing
112///
113/// `Store::reapply_imports` re-upserts every layer's nodes in **`src_ref`
114/// order**, so when two layers name one node the last one wins. A filled
115/// `extref:` placeholder is exactly that case: `import:links` contributes the
116/// bare stub, and this layer contributes the same key with the peer's content.
117/// `"import:okf/…"` sorting after `"import:links…"` is what stops a rebuild from
118/// resetting the fill back to an empty placeholder.
119///
120/// **Every rebuild**, not only an explicit `sync`: a read command refreshes the
121/// graph before answering, so renaming this prefix to anything sorting earlier
122/// would undo the fill before the very next `roteiro query` — verified by
123/// injection, which is how the reach of it was established rather than guessed.
124/// A real dependency on the string, then, and not a coincidence worth leaving
125/// unstated. `an_imported_concept_fills_a_cross_repo_placeholder` is the guard.
126pub const OKF_REF_PREFIX: &str = "import:okf/";
127
128/// The node-key namespace for an imported concept that does not fill an
129/// [`rto_graph::external_ref_key`] stub.
130pub const OKF_KEY_PREFIX: &str = "okf:";
131
132/// The `src_ref` an import from `peer` is persisted under.
133#[must_use]
134pub fn import_ref(peer: &str) -> String {
135    format!("{OKF_REF_PREFIX}{peer}")
136}
137
138/// How much of a peer's claim is adopted on import.
139///
140/// The set is closed by the decision in issue #706 rather than by us, and is
141/// deliberately **not `#[non_exhaustive]`**: it enumerates the answers to a
142/// consent question — adopt their confirmations, or take their information
143/// without them — and *ignore*, the third answer, is not a mode of importing but
144/// the decision not to. A fourth would be a new answer to that question, and a
145/// caller matching on this enum should stop compiling until someone has looked
146/// at what it means, rather than absorbing it into a wildcard arm.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum Trust {
149    /// Import at `external-<the peer's tier>`, preserving what they claimed.
150    Trust,
151    /// Import at `external-inferred` **regardless** of the peer's claimed tier:
152    /// their information without their confirmation.
153    Acknowledge,
154}
155
156impl Trust {
157    /// The stable CLI/report token.
158    #[must_use]
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::Trust => "trust",
162            Self::Acknowledge => "acknowledge",
163        }
164    }
165}
166
167/// Why a file in the bundle directory did not become a concept.
168///
169/// `#[non_exhaustive]` because this names ways a *document* can be malformed,
170/// and unlike [`Trust`] that set is open: it grows with every real bundle that
171/// arrives shaped in a way nobody predicted. A caller should be able to report a
172/// new one without this becoming a breaking change.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174#[non_exhaustive]
175pub enum SkipReason {
176    /// The file does not open with a `---` frontmatter fence.
177    NoFrontmatter,
178    /// It opens one and never closes it.
179    UnterminatedFrontmatter,
180    /// The block is delimited correctly but is not parseable YAML.
181    ///
182    /// Distinct from [`Self::MissingType`] on purpose. Both end with no `type`,
183    /// but they send a producer to different places: one means *add a key*, the
184    /// other means *the block does not parse at all* — and reporting broken YAML
185    /// as a missing field is how someone spends an afternoon staring at a `type`
186    /// that was there all along.
187    UnparsableFrontmatter,
188    /// The frontmatter carries no `type`, or an empty one — OKF's only hard
189    /// requirement (§4).
190    MissingType,
191}
192
193impl SkipReason {
194    /// A one-line explanation, for the report and the CLI.
195    #[must_use]
196    pub fn as_str(self) -> &'static str {
197        match self {
198            Self::NoFrontmatter => "no YAML frontmatter block",
199            Self::UnterminatedFrontmatter => "frontmatter block is never closed",
200            Self::UnparsableFrontmatter => "frontmatter block is not parseable YAML",
201            Self::MissingType => "no non-empty `type` (OKF's one required key)",
202        }
203    }
204}
205
206/// A file the reader declined, and why.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct Skipped {
209    /// The bundle-relative path, as given.
210    pub path: String,
211    /// Why it was not imported.
212    pub reason: SkipReason,
213}
214
215/// An auditable summary of reading a bundle.
216#[derive(Debug, Clone, Default, serde::Serialize)]
217pub struct OkfReport {
218    /// The bundle's declared `okf_version`, when its root index carried one.
219    pub okf_version: Option<String>,
220    /// Markdown files offered to the reader.
221    pub files_total: usize,
222    /// Reserved files (`index.md`, `log.md`) passed over, per §8/§9.
223    pub reserved_skipped: usize,
224    /// Concepts imported.
225    pub concepts_read: usize,
226    /// Imported concepts by their declared `type`.
227    pub concepts_by_type: BTreeMap<String, usize>,
228    /// Imported concepts by the provenance they landed at.
229    pub concepts_by_provenance: BTreeMap<String, usize>,
230    /// Files that were not concepts, each with its reason.
231    pub skipped: Vec<SkippedRow>,
232    /// Links found under a `## Relationships` heading.
233    pub links_total: usize,
234    /// Relationship links that became edges.
235    pub edges_read: usize,
236    /// `←` links: the same edge seen from its other end, captured there.
237    pub links_reciprocal: usize,
238    /// Relationship links whose target is not a concept in this bundle.
239    pub links_unresolved: usize,
240    /// Markdown links outside the relationships section — citations, not
241    /// asserted relationships. Counted so the choice is visible rather than
242    /// silent.
243    pub links_outside_relationships: usize,
244    /// `extref:` placeholders this import filled, as `(stub key, bundle path)`.
245    pub extrefs_filled: Vec<(String, String)>,
246    /// Placeholders left alone because the correspondence was not one-to-one.
247    /// A wrong fill attaches a peer's content to the wrong node, which is worse
248    /// than an unfilled stub, so an ambiguous match fills nothing.
249    pub extrefs_ambiguous: Vec<String>,
250}
251
252/// A [`Skipped`] flattened for JSON output.
253#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
254pub struct SkippedRow {
255    /// The bundle-relative path.
256    pub path: String,
257    /// The reason, as its stable token.
258    pub reason: String,
259}
260
261/// The facts to apply, and what was read to produce them.
262#[derive(Debug, Clone)]
263pub struct OkfImport {
264    /// Nodes and `external-*` edges to apply to the store.
265    pub facts: FactSet,
266    /// A summary of what was imported and what was not.
267    pub report: OkfReport,
268}
269
270/// Errors raised while reading a bundle.
271///
272/// `#[non_exhaustive]`, on [`SkipReason`]'s reasoning and for the same subject:
273/// these name ways a *directory somebody else produced* fails to be a bundle,
274/// and that set is open by construction — OKF is a vendor-neutral format, so the
275/// producers are not ours to enumerate. A caller wants the message, not an
276/// exhaustive match; adding a way to fail should not be a breaking change.
277#[derive(Debug, thiserror::Error)]
278#[non_exhaustive]
279pub enum OkfError {
280    /// The directory holds no markdown at all.
281    #[error("no markdown files under {0}: an OKF bundle is a directory of concept documents")]
282    Empty(String),
283    /// Markdown was found, and none of it was a concept.
284    ///
285    /// Deliberately fatal where a *single* bad document is only skipped: one
286    /// unreadable file in a readable bundle is the case §11 asks consumers to
287    /// tolerate, but a directory in which **nothing** parsed is not a bundle
288    /// read badly — it is not a bundle, and importing zero concepts while
289    /// exiting zero would report success for having done nothing.
290    #[error(
291        "{path} holds {files} markdown file(s) and no readable concept among them, so it is \
292         not an OKF bundle. First failures: {detail}"
293    )]
294    NoConcepts {
295        /// The bundle root, as given.
296        path: String,
297        /// How many markdown files were considered.
298        files: usize,
299        /// Up to three `path: reason` pairs.
300        detail: String,
301    },
302}
303
304/// A concept document's parsed frontmatter, in the subset this reads.
305///
306/// Separate from [`super::Frontmatter`], which is a *render* input: that one
307/// holds an [`Origin`] the renderer will split into `generated`/`verified`, and
308/// this one holds what those two keys actually said, which is not the same
309/// question. Notably a document may carry `verified` and no `generated`.
310#[derive(Debug, Clone, Default, PartialEq, Eq)]
311struct ParsedFrontmatter {
312    type_: String,
313    title: Option<String>,
314    description: Option<String>,
315    resource: Option<String>,
316    status: Option<String>,
317    tags: Vec<String>,
318    sources: Vec<String>,
319    generated: Option<(String, String)>,
320    verified: Vec<(String, String)>,
321}
322
323impl ParsedFrontmatter {
324    /// The trust tier this document claims, as the *local* provenance that
325    /// would have produced it — the exact inverse of ADR-0021's mapping table.
326    ///
327    /// **The absence of `verified` is a claim, not a gap.** §5.3 derives the
328    /// unverified tier from exactly that absence, and ADR-0021's table renders
329    /// `Inferred` as "`generated:` alone" for the same reason: a producer that
330    /// confirmed something says so. So a concept with no `verified` key is
331    /// `Inferred`, not "unknown, assume the best".
332    ///
333    /// §7 makes the `human:` prefix the only thing separating human-reviewed
334    /// from machine-confirmed, so it is the only thing consulted here.
335    fn claimed_tier(&self) -> Provenance {
336        match self.verified.first() {
337            None => Provenance::Inferred,
338            Some((by, _)) if by.starts_with("human:") => Provenance::Authored,
339            Some(_) => Provenance::Derived,
340        }
341    }
342
343    /// The origin to re-emit for this concept: whoever the bundle named, with
344    /// the timestamp it gave. `confirms` is the *effective* confirmation, which
345    /// [`Trust::Acknowledge`] clears — under acknowledge we deliberately did not
346    /// adopt the peer's confirmation, so re-emitting it would put it back.
347    fn effective_origin(&self, trust: Trust) -> Option<Origin> {
348        let confirmed = self.verified.first();
349        let (by, at) = confirmed.or(self.generated.as_ref())?;
350        Some(Origin {
351            by: parse_actor(by),
352            at: at.clone(),
353            confirms: confirmed.is_some() && trust == Trust::Trust,
354        })
355    }
356}
357
358/// An OKF actor token (§7) as an [`Actor`].
359///
360/// The inverse of [`Actor::as_token`], and lossy in one direction on purpose: a
361/// token that is none of the three forms becomes [`Actor::Process`], because §7
362/// names exactly three and an unrecognised one is a producer this graph has not
363/// met rather than a reason to drop the attribution. Losing *who* confirmed
364/// something is the one thing a trust model must not do.
365fn parse_actor(token: &str) -> Actor {
366    if let Some(id) = token.strip_prefix("human:") {
367        return Actor::Human(id.to_owned());
368    }
369    if let Some(id) = token.strip_prefix("process:") {
370        return Actor::Process(id.to_owned());
371    }
372    match token.split_once('/') {
373        Some((producer, version)) => Actor::Tool(producer.to_owned(), version.to_owned()),
374        None => Actor::Process(token.to_owned()),
375    }
376}
377
378/// The [`Origin`] recorded on an imported node by [`read_bundle`], or `None` for
379/// a node this graph produced itself.
380///
381/// `render okf` prefers this over [`super::origin_for`] so an imported concept
382/// leaves carrying the attribution it arrived with. Without it the round trip
383/// re-tiers every external fact to *unverified* on the way out — the laundering
384/// the tier-carrying provenance exists to prevent, arriving one step later.
385#[must_use]
386pub fn peer_origin(meta: &serde_json::Value) -> Option<Origin> {
387    let origin = meta.get("okf")?.get("origin")?;
388    Some(Origin {
389        by: parse_actor(origin.get("by")?.as_str()?),
390        at: origin.get("at")?.as_str()?.to_owned(),
391        confirms: origin
392            .get("confirms")
393            .and_then(serde_json::Value::as_bool)
394            .unwrap_or(false),
395    })
396}
397
398/// Split a document into its frontmatter block and its body.
399///
400/// The opening fence must be the **first** bytes of the file, per §4. A `---`
401/// further down is a horizontal rule, and a reader that went looking for one
402/// would turn an ordinary markdown document into a concept whose "frontmatter"
403/// is its opening prose.
404fn split_frontmatter(text: &str) -> Result<(&str, &str), SkipReason> {
405    let rest = text
406        .strip_prefix("---\n")
407        .or_else(|| text.strip_prefix("---\r\n"))
408        .ok_or(SkipReason::NoFrontmatter)?;
409    let mut offset = 0usize;
410    for line in rest.split_inclusive('\n') {
411        if line.trim_end_matches(['\r', '\n']) == "---" {
412            let body = rest[offset + line.len()..].trim_start_matches(['\r', '\n']);
413            return Ok((&rest[..offset], body));
414        }
415        offset += line.len();
416    }
417    Err(SkipReason::UnterminatedFrontmatter)
418}
419
420/// Parse a frontmatter block with a real YAML parser.
421///
422/// # Why not a line scanner
423///
424/// This reader originally hand-parsed a line-oriented subset shaped like the
425/// bundles Roteiro itself writes. That is enough for a round trip and wrong for
426/// everybody else's bundles, which is the opposite of what an interchange format
427/// is for. Measured against Google's own published bundles (`bundles/ga4`,
428/// `bundles/acme_retail` in the specification's repository), the subset silently
429/// lost:
430///
431/// - **flow mappings** — `generated: { by: agent/1.0, at: … }`, the form the
432///   specification's own examples use throughout, so `generated` and `verified`
433///   both vanished and every concept read as *unverified*;
434/// - **flow sequences** — `tags: [finance, revenue]`;
435/// - **block sequences whose items sit at the key's own indentation**, which is
436///   what `PyYAML` emits by default, so `tags` and `sources` vanished;
437/// - **multi-line scalars**, where a folded `description:` was silently
438///   *truncated* at its first line rather than dropped.
439///
440/// All four are ordinary YAML, and all four were silent: nothing was skipped and
441/// nothing was reported. The trust loss is the serious one — a concept a human
442/// signed off read as unverified, so `import --from okf --trust` adopted nothing
443/// while reporting success. `a_google_bundle_keeps_its_human_verifiers` is the
444/// guard.
445///
446/// `yaml-rust2` is already a non-optional dependency of `rto-graph`, so this
447/// costs a declared edge and no new crate in the lockfile.
448///
449/// Unknown top-level keys are ignored rather than rejected: §11 tells a consumer
450/// not to reject a document for a field it does not know, and a producer with
451/// its own extensions is the case a vendor-neutral format exists to allow.
452fn parse_frontmatter(block: &str) -> Result<ParsedFrontmatter, SkipReason> {
453    let mut fm = ParsedFrontmatter::default();
454    let docs = yaml_rust2::YamlLoader::load_from_str(block)
455        .map_err(|_| SkipReason::UnparsableFrontmatter)?;
456    let Some(first) = docs.first() else {
457        // An empty block parses to no documents. That is well-formed YAML
458        // carrying no keys, so it is a missing `type`, not a parse failure.
459        return Ok(fm);
460    };
461    let Some(map) = first.as_hash() else {
462        // A block that parses to a scalar or a sequence is legal YAML and has
463        // no keys to read, so again: no `type`, rather than unparsable.
464        return Ok(fm);
465    };
466    let get = |key: &str| map.get(&Yaml::String(key.to_owned()));
467
468    if let Some(v) = get("type").and_then(scalar_text) {
469        fm.type_ = v;
470    }
471    fm.title = get("title").and_then(scalar_text);
472    fm.description = get("description").and_then(scalar_text);
473    fm.resource = get("resource").and_then(scalar_text);
474    fm.status = get("status").and_then(scalar_text);
475
476    if let Some(tags) = get("tags") {
477        match tags {
478            Yaml::Array(items) => fm.tags.extend(items.iter().filter_map(scalar_text)),
479            // §4.1 asks for a list, and a bare string is not one — but it is a
480            // shape that really occurs: Google's published `stackoverflow`
481            // bundle writes `tags: stackoverflow, posts, deprecated` in seven
482            // documents.
483            //
484            // Kept **whole**, not split on commas. Splitting would recover the
485            // intent in this bundle and invent a convention the specification
486            // does not have, which is how a reader starts disagreeing with
487            // every other reader about what a document says. Keeping the string
488            // loses nothing and lets a consumer see exactly what was written —
489            // the alternative, dropping it, is the silent loss this whole
490            // parser was rewritten to stop.
491            other => fm.tags.extend(scalar_text(other)),
492        }
493    }
494
495    // §5.1 shapes `sources` as a list of entries. A producer who wrote a single
496    // entry without the list dash is tolerated, mirroring the shorthand §5.2
497    // *does* sanction for `verified` — the shapes are analogous and the slip is
498    // the same one.
499    //
500    // A bare scalar is deliberately **not** tolerated here, unlike for `tags`
501    // above. `tags: a, b` is attested — Google's own `stackoverflow` bundle
502    // writes it in seven documents — whereas no published bundle writes a
503    // scalar `sources`, and there would be no way to tell `sources: foo` from a
504    // typo that happened to land on a key. Accepting it would invent a
505    // provenance record rather than read one, and provenance is the one field
506    // where guessing is worse than reporting nothing.
507    match get("sources") {
508        Some(Yaml::Array(items)) => {
509            for item in items {
510                fm.sources.extend(source_resource(item));
511            }
512        }
513        Some(single @ Yaml::Hash(_)) => fm.sources.extend(source_resource(single)),
514        _ => {}
515    }
516
517    fm.generated = get("generated").and_then(by_at);
518    fm.verified = get("verified").map(verified_entries).unwrap_or_default();
519    Ok(fm)
520}
521
522/// One `sources` entry's `resource` (§5.1), which is REQUIRED within an entry.
523///
524/// An entry carrying no `resource` names nothing a consumer could follow, so it
525/// yields `None` rather than an empty string: a source that resolves to `""` is
526/// worse than one that is absent, because it looks like a record.
527fn source_resource(entry: &Yaml) -> Option<String> {
528    entry
529        .as_hash()?
530        .get(&Yaml::String("resource".to_owned()))
531        .and_then(scalar_text)
532        .filter(|r| !r.trim().is_empty())
533}
534
535/// A YAML scalar as a plain string; containers yield `None`.
536///
537/// `Real` keeps its own source text, so a timestamp survives unretyped rather
538/// than being reformatted through a float.
539fn scalar_text(v: &Yaml) -> Option<String> {
540    match v {
541        Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
542        Yaml::Integer(i) => Some(i.to_string()),
543        Yaml::Boolean(b) => Some(b.to_string()),
544        _ => None,
545    }
546}
547
548/// One `{ by, at }` mapping (§5.2).
549///
550/// A pair with no `at` keeps an empty timestamp rather than being dropped:
551/// **who** confirmed something is the load-bearing half, and §7 is about the
552/// actor. A mapping with no `by` names nobody, and is dropped.
553fn by_at(node: &Yaml) -> Option<(String, String)> {
554    let map = node.as_hash()?;
555    let by = map
556        .get(&Yaml::String("by".to_owned()))
557        .and_then(scalar_text)?;
558    if by.trim().is_empty() {
559        return None;
560    }
561    let at = map
562        .get(&Yaml::String("at".to_owned()))
563        .and_then(scalar_text)
564        .unwrap_or_default();
565    Some((by, at))
566}
567
568/// The `verified` field as a list of verification events (§5.2).
569///
570/// §5.2 is explicit that *"a single verifier MAY be written as one `{ by, at }`
571/// mapping without the list dash"* and that consumers **MUST** treat a bare
572/// mapping as a one-element list. That MUST is discharged here, in the one place
573/// that can tell the two shapes apart.
574fn verified_entries(node: &Yaml) -> Vec<(String, String)> {
575    match node {
576        Yaml::Array(items) => items.iter().filter_map(by_at).collect(),
577        other => by_at(other).into_iter().collect(),
578    }
579}
580
581/// One link found in a concept's relationships section.
582struct RelLink {
583    kind: String,
584    target: String,
585    reciprocal: bool,
586}
587
588/// The relationship links in a concept body, plus how many markdown links sat
589/// outside the relationships section.
590fn parse_relationships(body: &str) -> (Vec<RelLink>, usize) {
591    let mut links = Vec::new();
592    let mut outside = 0usize;
593    let mut in_section = false;
594    let mut kind = EdgeKind::Related.as_str().to_owned();
595    for line in body.lines() {
596        let trimmed = line.trim();
597        if let Some(heading) = trimmed.strip_prefix("## ") {
598            in_section = heading.trim().eq_ignore_ascii_case("relationships");
599            EdgeKind::Related.as_str().clone_into(&mut kind);
600            continue;
601        }
602        if trimmed.starts_with("# ") {
603            in_section = false;
604            continue;
605        }
606        if let Some(heading) = trimmed.strip_prefix("### ")
607            && in_section
608        {
609            heading.trim().clone_into(&mut kind);
610            continue;
611        }
612        for target in markdown_link_targets(trimmed) {
613            if in_section {
614                links.push(RelLink {
615                    kind: kind.clone(),
616                    // `→` and `←` are what `render_concept` writes; an unmarked
617                    // link (another producer's) reads as outgoing.
618                    reciprocal: trimmed.contains('\u{2190}'),
619                    target,
620                });
621            } else {
622                outside += 1;
623            }
624        }
625    }
626    (links, outside)
627}
628
629/// Every `[text](target)` target on one line.
630///
631/// Hand-rolled rather than run through the markdown parser this crate already
632/// has: `pulldown-cmark` would give the same answer for a well-formed line and a
633/// *different* one for a malformed bundle, because it recovers. Here a link that
634/// does not close is not a link, which is the reading that cannot invent an edge
635/// out of stray punctuation.
636fn markdown_link_targets(line: &str) -> Vec<String> {
637    let mut out = Vec::new();
638    let bytes = line.as_bytes();
639    let mut i = 0;
640    while i < bytes.len() {
641        if bytes[i] != b'[' {
642            i += 1;
643            continue;
644        }
645        let Some(close) = line[i..].find("](") else {
646            break;
647        };
648        let after = i + close + 2;
649        let Some(end) = line[after..].find(')') else {
650            break;
651        };
652        let target = line[after..after + end].trim();
653        if !target.is_empty() {
654            out.push(target.to_owned());
655        }
656        i = after + end + 1;
657    }
658    out
659}
660
661/// Resolve a link target to a bundle-relative path, as §6's absolute form or as
662/// a path relative to `from`'s own directory.
663fn resolve_target(from: &str, target: &str) -> Option<String> {
664    // A URL or an anchor is not a concept in this bundle.
665    if target.contains("://") || target.starts_with('#') {
666        return None;
667    }
668    let target = target.split('#').next().unwrap_or(target);
669    if target.is_empty() {
670        return None;
671    }
672    if target.starts_with('/') {
673        return Some(normalise(target));
674    }
675    let dir = from.rsplit_once('/').map_or("", |(d, _)| d);
676    Some(normalise(&format!("{dir}/{target}")))
677}
678
679/// Collapse `.`/`..` segments and guarantee a single leading `/`.
680fn normalise(path: &str) -> String {
681    let mut parts: Vec<&str> = Vec::new();
682    for seg in path.split('/') {
683        match seg {
684            "" | "." => {}
685            ".." => {
686                parts.pop();
687            }
688            other => parts.push(other),
689        }
690    }
691    format!("/{}", parts.join("/"))
692}
693
694/// The bundle-relative path of a file, always `/`-separated and leading-slashed.
695fn bundle_path(raw: &str) -> String {
696    normalise(&raw.replace('\\', "/"))
697}
698
699/// Whether a bundle path is one of the reserved index/log files (§8, §9).
700fn is_reserved(path: &str) -> bool {
701    let name = path.rsplit('/').next().unwrap_or(path);
702    name == INDEX_FILE || name == LOG_FILE
703}
704
705/// A concept read out of the bundle, before keys are assigned.
706struct Concept {
707    path: String,
708    fm: ParsedFrontmatter,
709    body: String,
710    links: Vec<RelLink>,
711}
712
713/// Options for [`read_bundle`].
714pub struct ReadOptions<'a> {
715    /// How much of the peer's claim to adopt.
716    pub trust: Trust,
717    /// The peer's name, used for the node-key namespace and the `src_ref`.
718    pub peer: &'a str,
719    /// Keys of `extref:` placeholders already in this graph, which an imported
720    /// concept may fill (ADR-0009). Pass an empty slice to fill none.
721    pub extref_keys: &'a [String],
722}
723
724/// Read an OKF bundle from `(path, content)` pairs into graph facts.
725///
726/// `files` is every markdown file under the bundle root, each keyed by its
727/// bundle-relative path. Taking the file set rather than a directory keeps the
728/// whole rule testable without a filesystem, on `import_lat`'s precedent.
729///
730/// # Errors
731/// Returns [`OkfError::Empty`] when there is no markdown at all, and
732/// [`OkfError::NoConcepts`] when there is markdown and none of it parsed — see
733/// that variant for why one bad document is tolerated and a bundle of them is
734/// not.
735pub fn read_bundle(
736    root: &str,
737    files: &[(String, String)],
738    opts: &ReadOptions<'_>,
739) -> Result<OkfImport, OkfError> {
740    let mut report = OkfReport {
741        files_total: files.len(),
742        ..OkfReport::default()
743    };
744    if files.is_empty() {
745        return Err(OkfError::Empty(root.to_owned()));
746    }
747
748    let (concepts, skipped) = collect_concepts(files, &mut report);
749
750    if concepts.is_empty() {
751        let considered = files.len() - report.reserved_skipped;
752        if considered == 0 {
753            return Err(OkfError::Empty(root.to_owned()));
754        }
755        let detail = skipped
756            .iter()
757            .take(3)
758            .map(|s| format!("{} ({})", s.path, s.reason.as_str()))
759            .collect::<Vec<_>>()
760            .join("; ");
761        return Err(OkfError::NoConcepts {
762            path: root.to_owned(),
763            files: considered,
764            detail,
765        });
766    }
767
768    report.skipped = skipped
769        .into_iter()
770        .map(|s| SkippedRow {
771            path: s.path,
772            reason: s.reason.as_str().to_owned(),
773        })
774        .collect();
775
776    // Assign a key to every concept, filling an `extref:` stub where exactly one
777    // corresponds. `stub_for` is `bundle path -> stub key`.
778    let (stub_for, ambiguous) = extref_fills(&concepts, opts.extref_keys);
779    report.extrefs_ambiguous = ambiguous;
780    let keys: BTreeMap<&str, String> = concepts
781        .iter()
782        .map(|c| {
783            let key = stub_for.get(c.path.as_str()).cloned().unwrap_or_else(|| {
784                format!(
785                    "{OKF_KEY_PREFIX}{peer}{path}",
786                    peer = opts.peer,
787                    path = c.path
788                )
789            });
790            (c.path.as_str(), key)
791        })
792        .collect();
793    for (path, key) in &stub_for {
794        report
795            .extrefs_filled
796            .push((key.clone(), (*path).to_owned()));
797    }
798    report.extrefs_filled.sort();
799
800    let src_ref = import_ref(opts.peer);
801    let mut facts = FactSet::new();
802    for c in &concepts {
803        push_concept(c, opts, &src_ref, &keys, &stub_for, &mut facts, &mut report);
804    }
805
806    Ok(OkfImport { facts, report })
807}
808
809/// Read every markdown file into a concept, or into a reason it is not one.
810///
811/// Both results come back sorted by bundle path, **at this boundary rather than
812/// at the caller's**. [`read_bundle`] is public and takes a slice, so the order
813/// is whatever a caller happened to build; the CLI's directory walk sorts, but
814/// that is one caller's habit and not a property of the reader.
815///
816/// Three things depend on it, and only one of them is cosmetic:
817/// [`OkfReport::skipped`]'s order, the first-three failures named in
818/// [`OkfError::NoConcepts`], and — the one that matters — the order of
819/// `facts.nodes`, which is serialized verbatim into the persisted import layer.
820/// Without this, one unchanged bundle read twice could store two different
821/// layer blobs. [`super::assemble`] sorts on the write side for the same reason.
822fn collect_concepts(
823    files: &[(String, String)],
824    report: &mut OkfReport,
825) -> (Vec<Concept>, Vec<Skipped>) {
826    let mut concepts: Vec<Concept> = Vec::new();
827    let mut skipped: Vec<Skipped> = Vec::new();
828    for (raw_path, content) in files {
829        let path = bundle_path(raw_path);
830        if is_reserved(&path) {
831            report.reserved_skipped += 1;
832            if path == format!("/{INDEX_FILE}") {
833                report.okf_version = root_okf_version(content);
834            }
835            continue;
836        }
837        match split_frontmatter(content) {
838            Err(reason) => skipped.push(Skipped { path, reason }),
839            Ok((block, body)) => {
840                let fm = match parse_frontmatter(block) {
841                    Ok(fm) => fm,
842                    Err(reason) => {
843                        skipped.push(Skipped { path, reason });
844                        continue;
845                    }
846                };
847                if fm.type_.trim().is_empty() {
848                    skipped.push(Skipped {
849                        path,
850                        reason: SkipReason::MissingType,
851                    });
852                    continue;
853                }
854                let (links, outside) = parse_relationships(body);
855                report.links_outside_relationships += outside;
856                concepts.push(Concept {
857                    path,
858                    fm,
859                    body: body.to_owned(),
860                    links,
861                });
862            }
863        }
864    }
865    concepts.sort_by(|a, b| a.path.cmp(&b.path));
866    skipped.sort_by(|a, b| a.path.cmp(&b.path));
867    (concepts, skipped)
868}
869
870/// Turn one concept into a node and its outgoing edges.
871fn push_concept(
872    c: &Concept,
873    opts: &ReadOptions<'_>,
874    src_ref: &str,
875    keys: &BTreeMap<&str, String>,
876    stub_for: &BTreeMap<&str, String>,
877    facts: &mut FactSet,
878    report: &mut OkfReport,
879) {
880    let key = &keys[c.path.as_str()];
881    let provenance = match opts.trust {
882        Trust::Trust => c.fm.claimed_tier().externalise(),
883        // Their information without their confirmation. `externalise` is
884        // deliberately **not** used here: the tier is *replaced*, not carried.
885        Trust::Acknowledge => Provenance::ExternalInferred,
886    };
887    let name =
888        c.fm.title
889            .clone()
890            .filter(|t| !t.trim().is_empty())
891            .unwrap_or_else(|| {
892                c.path
893                    .rsplit('/')
894                    .next()
895                    .unwrap_or(&c.path)
896                    .trim_end_matches(".md")
897                    .to_owned()
898            });
899    let mut node =
900        Node::new(key.clone(), NodeKind::from_token(&c.fm.type_), name).with_provenance(provenance);
901    node.meta = concept_meta(
902        c,
903        opts,
904        src_ref,
905        stub_for.get(c.path.as_str()).map(String::as_str),
906    );
907    facts.nodes.push(node);
908    *report
909        .concepts_by_type
910        .entry(c.fm.type_.clone())
911        .or_default() += 1;
912    *report
913        .concepts_by_provenance
914        .entry(provenance.as_str().to_owned())
915        .or_default() += 1;
916    report.concepts_read += 1;
917
918    for link in &c.links {
919        report.links_total += 1;
920        if link.reciprocal {
921            report.links_reciprocal += 1;
922            continue;
923        }
924        let target =
925            resolve_target(&c.path, &link.target).and_then(|t| keys.get(t.as_str()).cloned());
926        let Some(dst) = target else {
927            report.links_unresolved += 1;
928            continue;
929        };
930        let mut edge = Edge::derived(key.clone(), dst, EdgeKind::from_token(&link.kind));
931        // No confidence, ever: OKF carries none for a relationship, so there is
932        // no number to adopt and inventing one would fabricate precision. The
933        // store's `CHECK` and `Edge::is_valid` both say the same thing.
934        edge.provenance = provenance;
935        edge.src_ref = Some(src_ref.to_owned());
936        facts.edges.push(edge);
937        report.edges_read += 1;
938    }
939}
940
941/// The `okf_version` a bundle root's `index.md` declares (§10).
942fn root_okf_version(content: &str) -> Option<String> {
943    let (block, _) = split_frontmatter(content).ok()?;
944    yaml_rust2::YamlLoader::load_from_str(block)
945        .ok()?
946        .first()?
947        .as_hash()?
948        .get(&Yaml::String("okf_version".to_owned()))
949        .and_then(scalar_text)
950        .map(|v| v.trim().to_owned())
951}
952
953/// The `meta` an imported concept carries.
954///
955/// `okf.origin` is what `render okf` re-emits (see [`peer_origin`]);
956/// `okf.claimed` records what the bundle actually said, so an *acknowledge*
957/// import still knows what it declined to adopt and can be re-run as *trust*
958/// without re-reading the bundle. Keeping the peer's claim as data while the
959/// provenance carries only what we accepted is the whole distinction between
960/// the two modes.
961fn concept_meta(
962    c: &Concept,
963    opts: &ReadOptions<'_>,
964    src_ref: &str,
965    fills: Option<&str>,
966) -> serde_json::Value {
967    let mut meta = serde_json::json!({
968        "okf": {
969            "source": src_ref,
970            "peer": opts.peer,
971            "path": c.path,
972            "type": c.fm.type_,
973            "trust": opts.trust.as_str(),
974            "claimed": {
975                "tier": c.fm.claimed_tier().as_str(),
976                "verified": !c.fm.verified.is_empty(),
977            },
978            "resource": c.fm.resource,
979            "status": c.fm.status,
980            "tags": c.fm.tags,
981            "sources": c.fm.sources,
982        },
983    });
984    if let Some(origin) = c.fm.effective_origin(opts.trust) {
985        meta["okf"]["origin"] = serde_json::json!({
986            "by": origin.by.as_token(),
987            "at": origin.at,
988            "confirms": origin.confirms,
989        });
990    }
991    if let Some(desc) = &c.fm.description {
992        meta["okf"]["description"] = serde_json::Value::from(desc.clone());
993    }
994    // The prose, on the same budget the derived and authored layers use — a
995    // second cap here would let the store grow by whichever number was written
996    // down last.
997    let content = rto_graph::cap_content(&c.body);
998    if !content.is_empty() {
999        meta["content"] = serde_json::Value::from(content);
1000    }
1001    // A filled placeholder keeps `qualified` at the top level, because that is
1002    // where `rto_graph::external_ref_target` reads it and the workspace resolver
1003    // follows it across repos (ADR-0009). Filling a stub adds content to it; it
1004    // must not stop it being a stub, or the cross-repo link this whole import
1005    // exists to improve stops resolving at all.
1006    if let Some(qualified) = fills.and_then(|stub| stub.strip_prefix("extref:")) {
1007        meta["qualified"] = serde_json::Value::from(qualified);
1008    }
1009    meta
1010}
1011
1012/// Which `extref:` placeholder each imported concept fills, and which
1013/// placeholders were left alone because the correspondence was ambiguous.
1014///
1015/// # The correspondence is computed forwards, because it cannot be inverted
1016///
1017/// A bundle does **not** carry the producer's node key. The only trace of it is
1018/// the filename, and [`super::slug`] is lossy: it lowercases, collapses every
1019/// run of non-alphanumerics to one `-`, and truncates past 200 characters. So
1020/// `file:src/a.rs` and `file:src-a.rs` both slug to `file-src-a-rs`, and no
1021/// inverse exists. Inverting it is the natural-looking route and it is wrong.
1022///
1023/// What *is* sound is the forward direction: this graph knows its own
1024/// placeholder keys, so it can compute the filename each one **would** have had
1025/// in the peer's bundle — `slug(bare)`, or `slug(bare)-<digest>` when
1026/// [`super::assemble`] had to disambiguate — and compare. That is the writer's
1027/// own rule applied to our keys, not a guess about theirs.
1028///
1029/// It can still be ambiguous, because two of *our* placeholder keys can slug
1030/// alike even though the peer's bundle contained no collision. A concept
1031/// matching more than one placeholder, or a placeholder matching more than one
1032/// concept, fills **nothing** and is reported: a wrong fill attaches a peer's
1033/// content to the wrong node, which is strictly worse than a stub that stayed a
1034/// stub.
1035///
1036/// A bundle from another producer simply does not match, because its filenames
1037/// were not produced by this rule. That is the honest outcome — the concepts are
1038/// still imported, they just do not resolve a placeholder — and it is why this
1039/// is an enhancement rather than the import's purpose.
1040fn extref_fills<'a>(
1041    concepts: &'a [Concept],
1042    extref_keys: &[String],
1043) -> (BTreeMap<&'a str, String>, Vec<String>) {
1044    // stub key -> the concept paths it could name.
1045    let mut by_stub: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1046    // concept path -> the stub keys that could name it.
1047    let mut by_path: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1048
1049    for stub in extref_keys {
1050        let Some(qualified) = stub.strip_prefix("extref:") else {
1051            continue;
1052        };
1053        let Some((_project, bare)) = rto_graph::parse_qualified(qualified) else {
1054            continue;
1055        };
1056        let bare_slug = slug(bare);
1057        let with_digest = format!("{bare_slug}-{}", short_digest(bare));
1058        for c in concepts {
1059            let (dir, file) = c.path.rsplit_once('/').unwrap_or(("", &c.path));
1060            let name = file.trim_end_matches(".md");
1061            let section = dir.rsplit('/').next().unwrap_or("");
1062            if section != section_for(&c.fm.type_) {
1063                continue;
1064            }
1065            if name == bare_slug || name == with_digest {
1066                by_stub.entry(stub).or_default().push(&c.path);
1067                by_path.entry(&c.path).or_default().push(stub);
1068            }
1069        }
1070    }
1071
1072    let mut fills = BTreeMap::new();
1073    let mut ambiguous: Vec<String> = Vec::new();
1074    for (stub, paths) in &by_stub {
1075        match paths.as_slice() {
1076            [only] if by_path.get(*only).is_some_and(|s| s.len() == 1) => {
1077                fills.insert(*only, (*stub).to_owned());
1078            }
1079            _ => ambiguous.push((*stub).to_owned()),
1080        }
1081    }
1082    ambiguous.sort();
1083    ambiguous.dedup();
1084    (fills, ambiguous)
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use crate::okf::{Concept as RenderConcept, Frontmatter, OKF_VERSION, assemble, origin_for};
1091    use rto_graph::{EdgeRef, Explanation, NodeSummary};
1092
1093    fn opts(trust: Trust, extref_keys: &[String]) -> ReadOptions<'_> {
1094        ReadOptions {
1095            trust,
1096            peer: "acme",
1097            extref_keys,
1098        }
1099    }
1100
1101    fn read(files: &[(&str, &str)], trust: Trust) -> OkfImport {
1102        let owned: Vec<(String, String)> = files
1103            .iter()
1104            .map(|(p, c)| ((*p).to_owned(), (*c).to_owned()))
1105            .collect();
1106        read_bundle("okf/", &owned, &opts(trust, &[])).expect("read")
1107    }
1108
1109    fn node_named<'a>(import: &'a OkfImport, key: &str) -> &'a rto_graph::Node {
1110        import
1111            .facts
1112            .nodes
1113            .iter()
1114            .find(|n| n.key == key)
1115            .unwrap_or_else(|| panic!("no node {key} in {:?}", keys(import)))
1116    }
1117
1118    fn keys(import: &OkfImport) -> Vec<&str> {
1119        import.facts.nodes.iter().map(|n| n.key.as_str()).collect()
1120    }
1121
1122    fn summary(key: &str, kind: &str, name: &str) -> NodeSummary {
1123        NodeSummary {
1124            key: key.to_owned(),
1125            kind: kind.to_owned(),
1126            name: name.to_owned(),
1127            path: None,
1128            lang: None,
1129        }
1130    }
1131
1132    fn explanation(
1133        key: &str,
1134        kind: &str,
1135        name: &str,
1136        out: Vec<EdgeRef>,
1137        inc: Vec<EdgeRef>,
1138    ) -> Explanation {
1139        Explanation {
1140            schema: rto_graph::SCHEMA,
1141            node: summary(key, kind, name),
1142            meta: serde_json::Value::Null,
1143            outgoing: out,
1144            incoming: inc,
1145        }
1146    }
1147
1148    fn edge_ref(to: &str) -> EdgeRef {
1149        EdgeRef {
1150            kind: "references".to_owned(),
1151            provenance: "authored",
1152            confidence: None,
1153            node: to.to_owned(),
1154        }
1155    }
1156
1157    /// A Roteiro bundle round-trips: what [`assemble`] wrote, this reads, and
1158    /// each concept comes back at the **external** tier matching the one it went
1159    /// out at.
1160    ///
1161    /// The write side is the specification, so the fixture is produced by
1162    /// rendering rather than written by hand: a hand-written fixture keeps
1163    /// passing after the renderer changes shape, which is the one failure a
1164    /// round-trip test exists to catch.
1165    #[test]
1166    fn a_roteiro_bundle_round_trips_at_the_external_tier() {
1167        let at = "2026-09-01T10:00:00Z";
1168        let tool = Actor::Tool("roteiro".to_owned(), "5.0.0".to_owned());
1169        let alice = Actor::Human("alice".to_owned());
1170
1171        // One relationship, written into **both** documents by the renderer:
1172        // outgoing from the ADR, incoming on the file. Reading both would
1173        // reverse half the graph, so the fixture has to contain both halves.
1174        let adr = explanation(
1175            "adr:0021",
1176            "adr",
1177            "OKF bundle",
1178            vec![edge_ref("file:src/lib.rs")],
1179            Vec::new(),
1180        );
1181        let file = explanation(
1182            "file:src/lib.rs",
1183            "file",
1184            "lib.rs",
1185            Vec::new(),
1186            vec![edge_ref("adr:0021")],
1187        );
1188        let guess = explanation("sym:rust:src/lib.rs#f", "fn", "f", Vec::new(), Vec::new());
1189
1190        let rendered = assemble(
1191            vec![
1192                RenderConcept {
1193                    explanation: &adr,
1194                    frontmatter: Frontmatter {
1195                        type_: "adr".to_owned(),
1196                        title: Some("OKF bundle".to_owned()),
1197                        origin: Some(origin_for(Provenance::Authored, at, &tool, Some(&alice))),
1198                        ..Frontmatter::default()
1199                    },
1200                    body: Some("The decision text.".to_owned()),
1201                    member: None,
1202                },
1203                RenderConcept {
1204                    explanation: &file,
1205                    frontmatter: Frontmatter {
1206                        type_: "file".to_owned(),
1207                        title: Some("lib.rs".to_owned()),
1208                        origin: Some(origin_for(Provenance::Derived, at, &tool, None)),
1209                        ..Frontmatter::default()
1210                    },
1211                    body: None,
1212                    member: None,
1213                },
1214                RenderConcept {
1215                    explanation: &guess,
1216                    frontmatter: Frontmatter {
1217                        type_: "fn".to_owned(),
1218                        title: Some("f".to_owned()),
1219                        origin: Some(origin_for(Provenance::Inferred, at, &tool, None)),
1220                        ..Frontmatter::default()
1221                    },
1222                    body: None,
1223                    member: None,
1224                },
1225            ],
1226            "acme",
1227            &[],
1228        );
1229
1230        let files: Vec<(String, String)> = rendered
1231            .iter()
1232            .map(|f| (f.path.clone(), f.content.clone()))
1233            .collect();
1234        let import = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect("read");
1235        assert_round_trip(&import, at);
1236    }
1237
1238    /// The assertions of [`a_roteiro_bundle_round_trips_at_the_external_tier`],
1239    /// split out so the fixture that renders the bundle and the claims made
1240    /// about reading it back stay separately readable.
1241    fn assert_round_trip(import: &OkfImport, at: &str) {
1242        assert_eq!(import.report.concepts_read, 3, "{:?}", keys(import));
1243        assert_eq!(import.report.okf_version.as_deref(), Some(OKF_VERSION));
1244
1245        // Each tier survived the round trip, carried rather than flattened.
1246        let by_prov: BTreeMap<&str, &str> = import
1247            .facts
1248            .nodes
1249            .iter()
1250            .map(|n| (n.key.as_str(), n.provenance.as_str()))
1251            .collect();
1252        assert_eq!(
1253            by_prov,
1254            BTreeMap::from([
1255                ("okf:acme/decisions/adr-0021.md", "external-authored"),
1256                ("okf:acme/files/file-src-lib-rs.md", "external-derived"),
1257                (
1258                    "okf:acme/symbols/sym-rust-src-lib-rs-f.md",
1259                    "external-inferred"
1260                ),
1261            ]),
1262            "a flat `External` would collapse these three into one"
1263        );
1264
1265        // The relationship came back, once, pointing the same way.
1266        assert_eq!(import.facts.edges.len(), 1);
1267        let e = &import.facts.edges[0];
1268        assert_eq!(e.src, "okf:acme/decisions/adr-0021.md");
1269        assert_eq!(e.dst, "okf:acme/files/file-src-lib-rs.md");
1270        assert_eq!(e.kind.as_str(), "references");
1271        assert_eq!(e.provenance, Provenance::ExternalAuthored);
1272        assert_eq!(
1273            e.confidence, None,
1274            "an imported edge carries no confidence this graph never computed"
1275        );
1276        assert!(e.is_valid(), "and must still satisfy the store's invariant");
1277        assert_eq!(
1278            import.report.links_reciprocal, 1,
1279            "the `left-arrow` half of the same edge is skipped, not reversed"
1280        );
1281
1282        // Title, body and the peer's own attribution came with it.
1283        let adr_node = node_named(import, "okf:acme/decisions/adr-0021.md");
1284        assert_eq!(adr_node.name, "OKF bundle");
1285        assert_eq!(adr_node.kind.as_str(), "adr");
1286        assert!(
1287            adr_node.meta["content"]
1288                .as_str()
1289                .expect("content")
1290                .contains("The decision text."),
1291            "{:?}",
1292            adr_node.meta["content"]
1293        );
1294        assert_eq!(
1295            peer_origin(&adr_node.meta),
1296            Some(Origin {
1297                by: Actor::Human("alice".to_owned()),
1298                at: at.to_owned(),
1299                confirms: true,
1300            }),
1301            "Alice's confirmation is re-emitted naming Alice, not re-tiered"
1302        );
1303    }
1304
1305    const AUTHORED: &str = "---\ntype: \"adr\"\ntitle: \"A decision\"\ngenerated:\n  by: \"human:alice\"\n  at: \"2026-09-01T10:00:00Z\"\nverified:\n  - by: \"human:alice\"\n    at: \"2026-09-01T10:00:00Z\"\n---\n\n# A decision\n\nBody.\n";
1306
1307    #[test]
1308    fn trust_preserves_the_peers_tier_and_acknowledge_replaces_it() {
1309        let trusted = read(&[("/decisions/a.md", AUTHORED)], Trust::Trust);
1310        let node = &trusted.facts.nodes[0];
1311        assert_eq!(node.provenance, Provenance::ExternalAuthored);
1312        assert!(peer_origin(&node.meta).expect("origin").confirms);
1313
1314        let acked = read(&[("/decisions/a.md", AUTHORED)], Trust::Acknowledge);
1315        let node = &acked.facts.nodes[0];
1316        assert_eq!(
1317            node.provenance,
1318            Provenance::ExternalInferred,
1319            "acknowledge takes their information without their confirmation"
1320        );
1321        // What they claimed is still recorded — as data, not as provenance — so
1322        // the import can be re-run as `trust` without re-reading the bundle.
1323        assert_eq!(node.meta["okf"]["claimed"]["tier"], "authored");
1324        assert_eq!(node.meta["okf"]["trust"], "acknowledge");
1325        assert!(
1326            !peer_origin(&node.meta).expect("origin").confirms,
1327            "and re-rendering must not put the confirmation back"
1328        );
1329    }
1330
1331    /// Section 5.3 derives *unverified* from the absence of `verified`. So the
1332    /// absence is a claim, not missing data, and a concept without one is
1333    /// `external-inferred` even under **trust** — the mode that preserves what
1334    /// the peer said.
1335    #[test]
1336    fn a_concept_with_no_verified_key_is_unverified_not_unknown() {
1337        let doc = "---\ntype: \"doc\"\ngenerated:\n  by: \"roteiro/5.0.0\"\n  at: \"2026-09-01T00:00:00Z\"\n---\n\n# D\n";
1338        let import = read(&[("/docs/d.md", doc)], Trust::Trust);
1339        assert_eq!(
1340            import.facts.nodes[0].provenance,
1341            Provenance::ExternalInferred
1342        );
1343    }
1344
1345    /// A non-`human:` verifier is machine-confirmed, per section 7 — the
1346    /// `human:` prefix is the only thing separating the two tiers.
1347    #[test]
1348    fn a_tool_verifier_is_machine_confirmed() {
1349        let doc = "---\ntype: \"file\"\nverified:\n  - by: \"roteiro/5.0.0\"\n    at: \"2026-09-01T00:00:00Z\"\n---\n\n# F\n";
1350        let import = read(&[("/files/f.md", doc)], Trust::Trust);
1351        assert_eq!(
1352            import.facts.nodes[0].provenance,
1353            Provenance::ExternalDerived
1354        );
1355    }
1356
1357    /// The spec's only hard requirement is a non-empty `type`, and it leaves the
1358    /// *value* open. So an unknown one is imported rather than refused —
1359    /// refusing would reject conformant bundles from the very producers a
1360    /// vendor-neutral format exists to interoperate with.
1361    #[test]
1362    fn an_unrecognised_type_is_imported_as_an_other_kind() {
1363        let doc = "---\ntype: \"dataset\"\ntitle: \"Sales\"\n---\n\n# Sales\n";
1364        let import = read(&[("/things/s.md", doc)], Trust::Trust);
1365        assert_eq!(
1366            import.facts.nodes[0].kind,
1367            NodeKind::Other("dataset".to_owned())
1368        );
1369        assert_eq!(import.report.concepts_by_type["dataset"], 1);
1370    }
1371
1372    /// A bad document is skipped **with a reason**, and the readable ones still
1373    /// arrive: section 11 asks a consumer to be liberal, and a silent drop would
1374    /// leave a graph missing concepts nobody knows to look for.
1375    #[test]
1376    fn a_partly_readable_bundle_reports_what_it_skipped() {
1377        let import = read(
1378            &[
1379                ("/decisions/good.md", AUTHORED),
1380                ("/decisions/plain.md", "# Just markdown\n"),
1381                ("/decisions/open.md", "---\ntype: \"adr\"\nnever closed\n"),
1382                ("/decisions/typeless.md", "---\ntitle: \"x\"\n---\n\nBody\n"),
1383                // Delimited correctly, `type` plainly present, and still not
1384                // YAML: the flow sequence is never closed.
1385                ("/decisions/broken.md", "---\ntype: [adr\n---\n\nBody\n"),
1386            ],
1387            Trust::Trust,
1388        );
1389        assert_eq!(import.report.concepts_read, 1);
1390        let rows: Vec<(&str, &str)> = import
1391            .report
1392            .skipped
1393            .iter()
1394            .map(|s| (s.path.as_str(), s.reason.as_str()))
1395            .collect();
1396        // Sorted by path, not by the order the files were handed over — see
1397        // `collect_concepts`. Asserting the *whole* list in a fixed order is the
1398        // point: a report that named the same three skips in a different order
1399        // each run would be a report nobody could diff.
1400        assert_eq!(
1401            rows,
1402            vec![
1403                (
1404                    "/decisions/broken.md",
1405                    "frontmatter block is not parseable YAML"
1406                ),
1407                ("/decisions/open.md", "frontmatter block is never closed"),
1408                ("/decisions/plain.md", "no YAML frontmatter block"),
1409                (
1410                    "/decisions/typeless.md",
1411                    "no non-empty `type` (OKF's one required key)"
1412                ),
1413            ],
1414            "unparseable YAML and a missing `type` are separate reasons: both end \
1415             with no type, but one means *add a key* and the other means *the \
1416             block does not parse*"
1417        );
1418    }
1419
1420    /// The shapes a real producer writes that §4.1 and §5.1 do not describe.
1421    ///
1422    /// Each choice here is a judgement about *liberality*, and they deliberately
1423    /// do not all go the same way — so they are asserted together, where the
1424    /// asymmetry is visible and has to be defended rather than drifted into.
1425    #[test]
1426    fn an_off_spec_shape_is_read_where_a_real_producer_writes_one() {
1427        // Attested: Google's `stackoverflow` bundle writes exactly this in seven
1428        // documents. Kept whole rather than split on commas, because splitting
1429        // invents a convention no other reader would share.
1430        let bare_tags = "---\ntype: \"adr\"\ntags: stackoverflow, posts, deprecated\n---\n\nB\n";
1431        // Not attested anywhere, but analogous to the single-mapping shorthand
1432        // §5.2 explicitly sanctions for `verified`.
1433        let one_source =
1434            "---\ntype: \"adr\"\nsources:\n  resource: \"/tables/orders.md\"\n---\n\nB\n";
1435        // Refused: a scalar `sources` is indistinguishable from a typo, and a
1436        // guessed provenance record is worse than none.
1437        let scalar_source = "---\ntype: \"adr\"\nsources: \"/tables/orders.md\"\n---\n\nB\n";
1438        // Refused: §5.1 makes `resource` REQUIRED within an entry, so an entry
1439        // without one names nothing to follow.
1440        let no_resource =
1441            "---\ntype: \"adr\"\nsources:\n  - id: \"x\"\n    title: \"T\"\n---\n\nB\n";
1442
1443        let tags_of = |doc: &str| {
1444            let (block, _) = split_frontmatter(doc).expect("split");
1445            parse_frontmatter(block).expect("parse").tags
1446        };
1447        let sources_of = |doc: &str| {
1448            let (block, _) = split_frontmatter(doc).expect("split");
1449            parse_frontmatter(block).expect("parse").sources
1450        };
1451
1452        assert_eq!(
1453            tags_of(bare_tags),
1454            vec!["stackoverflow, posts, deprecated".to_owned()],
1455            "a bare `tags` string is kept verbatim as one tag: nothing is lost, \
1456             and no comma convention is invented"
1457        );
1458        assert_eq!(
1459            sources_of(one_source),
1460            vec!["/tables/orders.md".to_owned()],
1461            "a single `sources` entry written without the list dash is read, \
1462             mirroring the shorthand §5.2 sanctions for `verified`"
1463        );
1464        assert_eq!(
1465            sources_of(scalar_source),
1466            Vec::<String>::new(),
1467            "a scalar `sources` is not read: it cannot be told from a typo, and \
1468             provenance is the one field where a guess is worse than silence"
1469        );
1470        assert_eq!(
1471            sources_of(no_resource),
1472            Vec::<String>::new(),
1473            "§5.1 makes `resource` REQUIRED within an entry; an entry without \
1474             one names nothing a consumer could follow"
1475        );
1476    }
1477
1478    /// One unreadable document is tolerated; a directory of them is not a bundle
1479    /// read badly, it is not a bundle — and importing zero concepts while
1480    /// exiting zero would report success for having done nothing.
1481    #[test]
1482    fn a_directory_with_no_readable_concept_is_refused_whole() {
1483        let files = vec![
1484            ("okf/a.md".to_owned(), "# no frontmatter\n".to_owned()),
1485            ("okf/b.md".to_owned(), "plain text\n".to_owned()),
1486        ];
1487        let err = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect_err("refuse");
1488        assert_eq!(
1489            err.to_string(),
1490            "okf/ holds 2 markdown file(s) and no readable concept among them, so it is not \
1491             an OKF bundle. First failures: /okf/a.md (no YAML frontmatter block); \
1492             /okf/b.md (no YAML frontmatter block)",
1493        );
1494    }
1495
1496    #[test]
1497    fn an_empty_directory_is_refused_by_name() {
1498        let err = read_bundle("okf/", &[], &opts(Trust::Trust, &[])).expect_err("refuse");
1499        assert_eq!(
1500            err.to_string(),
1501            "no markdown files under okf/: an OKF bundle is a directory of concept documents",
1502        );
1503    }
1504
1505    /// A bundle of nothing but reserved files is *empty*, not unreadable: there
1506    /// were no concept documents to fail on, so the message must not accuse the
1507    /// index of being malformed.
1508    #[test]
1509    fn a_bundle_of_only_reserved_files_is_empty_rather_than_unreadable() {
1510        let files = vec![
1511            (
1512                format!("/{INDEX_FILE}"),
1513                format!("---\nokf_version: \"{OKF_VERSION}\"\n---\n\n# Index\n"),
1514            ),
1515            (format!("/{LOG_FILE}"), "# Log\n".to_owned()),
1516        ];
1517        let err = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect_err("refuse");
1518        assert_eq!(
1519            err.to_string(),
1520            "no markdown files under okf/: an OKF bundle is a directory of concept documents",
1521        );
1522    }
1523
1524    const A_DOC: &str = "---\ntype: \"doc\"\n---\n\n# A\n\nSee [B](/docs/b.md) in prose.\n\n## Relationships\n\n### references\n\n* \u{2192} [b](/docs/b.md)\n* \u{2192} [gone](/docs/gone.md)\n* \u{2190} [c](/docs/c.md)\n";
1525
1526    #[test]
1527    fn only_links_under_relationships_become_edges() {
1528        let import = read(
1529            &[
1530                ("/docs/a.md", A_DOC),
1531                ("/docs/b.md", "---\ntype: \"doc\"\n---\n\n# B\n"),
1532                ("/docs/c.md", "---\ntype: \"doc\"\n---\n\n# C\n"),
1533            ],
1534            Trust::Trust,
1535        );
1536        assert_eq!(import.facts.edges.len(), 1, "{:?}", import.facts.edges);
1537        assert_eq!(import.facts.edges[0].dst, "okf:acme/docs/b.md");
1538        assert_eq!(
1539            import.report.links_outside_relationships, 1,
1540            "the prose citation is counted, not imported as a relationship"
1541        );
1542        assert_eq!(
1543            import.report.links_unresolved, 1,
1544            "an edge to a concept the bundle does not contain is dropped and said so"
1545        );
1546        assert_eq!(import.report.links_reciprocal, 1);
1547    }
1548
1549    /// `yaml_scalar` escapes a newline, a quote and every control character, and
1550    /// a reader that did not undo exactly that would hand back a different
1551    /// string while looking fine. The fixture is produced by the writer, so the
1552    /// two cannot drift apart.
1553    #[test]
1554    fn a_scalar_round_trips_through_the_writers_escaper() {
1555        let hostile = "line one\nkey: forged\t\"quoted\" \\ back \u{1}";
1556        let fm = Frontmatter {
1557            type_: "doc".to_owned(),
1558            title: Some(hostile.to_owned()),
1559            ..Frontmatter::default()
1560        };
1561        let doc = format!("{}\n# x\n", fm.render());
1562        let (block, _) = split_frontmatter(&doc).expect("split");
1563        let fm = parse_frontmatter(block).expect("the writer emits parseable YAML");
1564        assert_eq!(fm.title.as_deref(), Some(hostile));
1565    }
1566
1567    #[test]
1568    fn an_imported_concept_fills_the_matching_placeholder() {
1569        let stub = rto_graph::external_ref_key("acme::adr:0021");
1570        let stubs = vec![stub.clone()];
1571        let files = vec![("/decisions/adr-0021.md".to_owned(), AUTHORED.to_owned())];
1572        let import = read_bundle("okf/", &files, &opts(Trust::Trust, &stubs)).expect("read");
1573
1574        assert_eq!(keys(&import), vec![stub.as_str()]);
1575        let node = node_named(&import, &stub);
1576        assert_eq!(node.name, "A decision");
1577        assert_eq!(node.provenance, Provenance::ExternalAuthored);
1578        assert!(node.meta.get("content").is_some(), "a stub gained content");
1579        // Filling it must not stop it being a placeholder: the workspace
1580        // resolver follows `meta.qualified` across repos (ADR-0009).
1581        assert_eq!(node.meta["qualified"], "acme::adr:0021");
1582        assert_eq!(
1583            import.report.extrefs_filled,
1584            vec![(stub, "/decisions/adr-0021.md".to_owned())]
1585        );
1586    }
1587
1588    /// `slug` is **not invertible**: it lowercases and collapses every run of
1589    /// non-alphanumerics, so two different keys can produce one filename. When
1590    /// they do, nothing is filled — a wrong fill attaches a peer's content to
1591    /// the wrong node, which is worse than a stub that stayed a stub.
1592    #[test]
1593    fn an_ambiguous_correspondence_fills_nothing_and_says_so() {
1594        // Both slug to `adr-0021`, which is the whole point of the fixture.
1595        assert_eq!(slug("adr:0021"), slug("adr/0021"));
1596        let a = rto_graph::external_ref_key("acme::adr:0021");
1597        let b = rto_graph::external_ref_key("acme::adr/0021");
1598        let stubs = vec![a.clone(), b.clone()];
1599        let files = vec![("/decisions/adr-0021.md".to_owned(), AUTHORED.to_owned())];
1600        let import = read_bundle("okf/", &files, &opts(Trust::Trust, &stubs)).expect("read");
1601
1602        assert_eq!(
1603            keys(&import),
1604            vec!["okf:acme/decisions/adr-0021.md"],
1605            "the concept is still imported, just not attached to a placeholder"
1606        );
1607        assert!(import.report.extrefs_filled.is_empty());
1608        assert_eq!(import.report.extrefs_ambiguous, vec![b, a]);
1609    }
1610
1611    /// The section check is what establishes that the **bundle was written by
1612    /// the placement rule the filename comparison assumes**, and it is not
1613    /// decoration: a concept sitting somewhere `section_for` would never have
1614    /// put it came from a producer with its own layout, so its filename was not
1615    /// produced by [`slug`] either and a name that happens to match means
1616    /// nothing.
1617    ///
1618    /// Here the filename is exactly right and the directory is not, which is
1619    /// precisely the case a filename-only comparison would fill wrongly.
1620    #[test]
1621    fn a_concept_outside_the_layout_the_naming_rule_assumes_is_not_a_match() {
1622        let stubs = vec![rto_graph::external_ref_key("acme::adr:0021")];
1623        assert_eq!(section_for("adr"), "decisions");
1624        let files = vec![(
1625            "/notes/adr-0021.md".to_owned(),
1626            "---\ntype: \"adr\"\n---\n\n# x\n".to_owned(),
1627        )];
1628        let import = read_bundle("okf/", &files, &opts(Trust::Trust, &stubs)).expect("read");
1629        assert!(import.report.extrefs_filled.is_empty());
1630        assert!(import.report.extrefs_ambiguous.is_empty());
1631        assert_eq!(keys(&import), vec!["okf:acme/notes/adr-0021.md"]);
1632    }
1633
1634    /// Reading one bundle twice gives the same answer, whatever order the files
1635    /// arrive in.
1636    ///
1637    /// `read_bundle` takes a slice, so the order is the caller's — and the CLI's
1638    /// directory walk sorting is that caller's habit, not the reader's contract.
1639    /// The stake is not tidiness: `facts.nodes` is serialized verbatim into the
1640    /// persisted import layer, so an unsorted caller would make one unchanged
1641    /// bundle store a different blob on each read.
1642    #[test]
1643    fn the_answer_does_not_depend_on_the_order_the_files_arrive_in() {
1644        let files: Vec<(String, String)> = vec![
1645            ("/decisions/a.md", AUTHORED),
1646            ("/docs/b.md", "---\ntype: \"doc\"\n---\n\n# B\n"),
1647            ("/docs/plain.md", "# no frontmatter\n"),
1648            ("/docs/typeless.md", "---\ntitle: \"x\"\n---\n\nB\n"),
1649            ("/symbols/c.md", "---\ntype: \"fn\"\n---\n\n# C\n"),
1650        ]
1651        .into_iter()
1652        .map(|(p, c)| (p.to_owned(), c.to_owned()))
1653        .collect();
1654
1655        let forwards = read_bundle("okf/", &files, &opts(Trust::Trust, &[])).expect("read");
1656        let mut backwards_input = files;
1657        backwards_input.reverse();
1658        let backwards =
1659            read_bundle("okf/", &backwards_input, &opts(Trust::Trust, &[])).expect("read");
1660
1661        assert_eq!(
1662            keys(&forwards),
1663            keys(&backwards),
1664            "node order is what reaches the persisted import layer"
1665        );
1666        assert_eq!(
1667            forwards
1668                .report
1669                .skipped
1670                .iter()
1671                .map(|s| s.path.as_str())
1672                .collect::<Vec<_>>(),
1673            backwards
1674                .report
1675                .skipped
1676                .iter()
1677                .map(|s| s.path.as_str())
1678                .collect::<Vec<_>>(),
1679        );
1680        // And the whole fact set, byte for byte, which is the property the store
1681        // actually depends on.
1682        assert_eq!(
1683            serde_json::to_string(&forwards.facts).expect("json"),
1684            serde_json::to_string(&backwards.facts).expect("json"),
1685        );
1686    }
1687
1688    /// Every field `concept_meta` writes, asserted once.
1689    ///
1690    /// The fields are the peer's own record of what they published, and most of
1691    /// them had no test at all: `tags`, `sources`, `resource`, `status`, `peer`
1692    /// and `path` were constructed and never read back, so any of them could
1693    /// have been dropped, renamed or crossed with its neighbour and every other
1694    /// test would still have passed.
1695    ///
1696    /// Written as **one whole-value comparison** rather than a field at a time,
1697    /// so a field added to `concept_meta` without a decision about it fails here
1698    /// instead of arriving unnoticed. The two halves that vary per import —
1699    /// `origin` and `content` — are checked separately below.
1700    #[test]
1701    fn the_peers_own_record_survives_the_import_intact() {
1702        let doc = "---\ntype: \"adr\"\ntitle: \"A decision\"\ndescription: \"One sentence.\"\nresource: \"https://example.test/blob/abc/docs/adr/0001.md\"\nstatus: \"Accepted\"\ntags:\n  - \"architecture\"\n  - \"storage\"\nverified:\n  - by: \"human:alice\"\n    at: \"2026-09-01T10:00:00Z\"\nsources:\n  - resource: \"/docs/adr/0001.md\"\n---\n\n# A decision\n\nThe prose.\n";
1703        let import = read(&[("/decisions/a.md", doc)], Trust::Trust);
1704        let meta = &import.facts.nodes[0].meta;
1705
1706        let mut okf = meta["okf"].clone();
1707        // Checked on their own terms just below; removed so the comparison
1708        // covers everything else exhaustively.
1709        let origin = okf["origin"].take();
1710        assert_eq!(
1711            okf,
1712            serde_json::json!({
1713                "source": "import:okf/acme",
1714                "peer": "acme",
1715                "path": "/decisions/a.md",
1716                "type": "adr",
1717                "trust": "trust",
1718                "claimed": { "tier": "authored", "verified": true },
1719                "resource": "https://example.test/blob/abc/docs/adr/0001.md",
1720                "status": "Accepted",
1721                "tags": ["architecture", "storage"],
1722                "sources": ["/docs/adr/0001.md"],
1723                "description": "One sentence.",
1724                "origin": serde_json::Value::Null,
1725            }),
1726        );
1727        assert_eq!(
1728            origin,
1729            serde_json::json!({
1730                "by": "human:alice",
1731                "at": "2026-09-01T10:00:00Z",
1732                "confirms": true,
1733            }),
1734        );
1735        assert_eq!(meta["content"], "# A decision The prose.");
1736        // Not a placeholder, so no `qualified` — that key is what
1737        // `external_ref_target` reads, and writing it on a node that stands in
1738        // for nothing would make the workspace resolver chase an empty target.
1739        assert_eq!(meta.get("qualified"), None);
1740    }
1741
1742    #[test]
1743    fn a_relative_link_resolves_against_its_own_directory() {
1744        assert_eq!(
1745            resolve_target("/a/b/c.md", "../d/e.md").as_deref(),
1746            Some("/a/d/e.md")
1747        );
1748        assert_eq!(
1749            resolve_target("/a/b/c.md", "/x/y.md").as_deref(),
1750            Some("/x/y.md")
1751        );
1752        assert_eq!(resolve_target("/a/b/c.md", "https://x/y").as_deref(), None);
1753        assert_eq!(resolve_target("/a/b/c.md", "#anchor").as_deref(), None);
1754    }
1755
1756    #[test]
1757    fn an_actor_token_round_trips_and_never_loses_the_attribution() {
1758        for token in ["human:alice", "roteiro/5.0.0", "process:sync"] {
1759            assert_eq!(parse_actor(token).as_token(), token);
1760        }
1761        // An unrecognised form keeps the attribution rather than dropping it.
1762        assert_eq!(parse_actor("mystery").as_token(), "process:mystery");
1763    }
1764
1765    #[test]
1766    fn an_unknown_frontmatter_key_takes_its_children_with_it() {
1767        let block = "type: \"doc\"\nvendor_thing:\n  by: \"not-an-actor\"\n  nested:\n    - x\ntitle: \"kept\"\n";
1768        let fm = parse_frontmatter(block).expect("parseable YAML");
1769        assert_eq!(fm.type_, "doc");
1770        assert_eq!(fm.title.as_deref(), Some("kept"));
1771        assert_eq!(
1772            fm.generated, None,
1773            "a `by:` nested under an unknown key is not the document's origin"
1774        );
1775    }
1776}