Skip to main content

safe_chains/engine/
archetype.rs

1//! Static capability archetypes (`docs/design/behavioral-taxonomy-archetypes.md`). The recurring
2//! facet profiles the Phase-1 subcommand surface classifies against. Each archetype is a fixed
3//! [`Capability`] declared in `archetypes.toml`; a subcommand references one by name (`profile =
4//! "remote-mutate"`) and the resolver emits that capability directly (a static profile — the
5//! sub's facets don't depend on its arguments, unlike the operand-role commands of Phase 0).
6//!
7//! This is the archetype as a *reusable audited bundle*, never a unit of analysis: `profile = …`
8//! expands to the explicit capability here, the researcher still verifies the sub genuinely is
9//! that archetype and cites it (the per-item provenance schema). Facet fields take an EXACT term,
10//! not a bound — these are points in facet-space, not the level predicates of `authoring`.
11
12use std::collections::BTreeMap;
13use std::sync::LazyLock;
14
15use serde::Deserialize;
16
17use super::facet::{Capability, FacetTerm, Operation};
18
19/// The capability an archetype expands to, or `None` if the name is unknown (fail-closed: an
20/// unknown `profile = …` must not silently resolve to nothing).
21pub fn archetype(name: &str) -> Option<&'static Capability> {
22    ARCHETYPES.get(name)
23}
24
25/// Every archetype name, for the `profile = …` closed-set check and the docs.
26pub fn names() -> impl Iterator<Item = &'static str> {
27    ARCHETYPES.keys().map(String::as_str)
28}
29
30static ARCHETYPES: LazyLock<BTreeMap<String, Capability>> = LazyLock::new(|| {
31    build_archetypes(include_str!("../../archetypes.toml")).expect("embedded archetypes.toml must compile")
32});
33
34/// How an archetype is told apart from a confusable neighbour.
35#[derive(Debug, Clone, Deserialize)]
36struct TomlDistinction {
37    #[allow(dead_code)] // authoring metadata, read by the near-neighbour guards
38    archetype: String,
39    /// The dotted facet name that differs, as `Capability::set_facets` spells it.
40    #[allow(dead_code)] // authoring metadata, read by the near-neighbour guards
41    by: String,
42}
43
44/// One archetype's authored disambiguation: its name, the `(other, axis)` pairs it declares itself
45/// distinguished from, and the archetype it declares itself facet-identical to.
46#[cfg(test)]
47type DeclaredDistinction = (String, Vec<(String, String)>, Option<String>);
48
49/// The authored disambiguation for every archetype — checked against the facets themselves by
50/// `near_neighbours_are_declared`.
51#[cfg(test)]
52fn declared_distinctions() -> Vec<DeclaredDistinction> {
53    let set: TomlArchetypeSet =
54        toml::from_str(include_str!("../../archetypes.toml")).expect("archetypes.toml parses");
55    set.archetype
56        .into_iter()
57        .map(|(name, tc)| {
58            let d = tc
59                .distinguished_from
60                .into_iter()
61                .map(|x| (x.archetype, x.by))
62                .collect();
63            (name, d, tc.same_point_as)
64        })
65        .collect()
66}
67
68fn build_archetypes(src: &str) -> Result<BTreeMap<String, Capability>, String> {
69    let set: TomlArchetypeSet = toml::from_str(src).map_err(|e| e.to_string())?;
70    set.archetype
71        .into_iter()
72        .map(|(name, tc)| build_capability(&name, tc).map(|c| (name, c)))
73        .collect()
74}
75
76fn build_capability(name: &str, tc: TomlCapability) -> Result<Capability, String> {
77    let operation = Operation::from_term(&tc.operation)
78        .ok_or_else(|| format!("archetype `{name}`: unknown operation `{}`", tc.operation))?;
79    let mut c = Capability::new(operation);
80
81    if let Some(l) = &tc.locus {
82        set_term(name, "locus.local", l.local.as_deref(), &mut c.locus.local)?;
83        set_term(name, "locus.remote", l.remote.as_deref(), &mut c.locus.remote)?;
84        set_term(name, "locus.binding", l.binding.as_deref(), &mut c.locus.binding)?;
85        set_term(name, "locus.provenance", l.provenance.as_deref(), &mut c.locus.provenance)?;
86    }
87    set_term(name, "scale", tc.scale.as_deref(), &mut c.scale)?;
88    set_term(name, "retrieval", tc.retrieval.as_deref(), &mut c.retrieval)?;
89    set_term(name, "authority", tc.authority.as_deref(), &mut c.authority)?;
90    set_term(name, "reversibility", tc.reversibility.as_deref(), &mut c.reversibility)?;
91    if let Some(p) = &tc.persistence {
92        set_term(name, "persistence.level", p.level.as_deref(), &mut c.persistence.level)?;
93    }
94    if let Some(d) = &tc.disclosure {
95        set_term(name, "disclosure.audience", d.audience.as_deref(), &mut c.disclosure.audience)?;
96    }
97    if let Some(s) = &tc.secret {
98        set_term(name, "secret.level", s.level.as_deref(), &mut c.secret.level)?;
99    }
100    if let Some(net) = &tc.network {
101        set_term(name, "network.direction", net.direction.as_deref(), &mut c.network.direction)?;
102        set_term(name, "network.destination", net.destination.as_deref(), &mut c.network.destination)?;
103        set_term(name, "network.payload", net.payload.as_deref(), &mut c.network.payload)?;
104    }
105    set_term(name, "execution", tc.execution.as_deref(), &mut c.execution.trust)?;
106    set_term(name, "cost", tc.cost.as_deref(), &mut c.cost)?;
107
108    if tc.because.trim().is_empty() {
109        return Err(format!("archetype `{name}`: `because` is required"));
110    }
111    c.because = tc.because;
112    Ok(c)
113}
114
115/// Parse an optional term into `slot`, leaving the zero-term default when absent. An unrecognized
116/// term is a compile error naming the archetype and facet (fail-closed, never a silent skip).
117fn set_term<T: FacetTerm>(name: &str, field: &str, s: Option<&str>, slot: &mut T) -> Result<(), String> {
118    if let Some(v) = s {
119        *slot = T::from_term(v).ok_or_else(|| format!("archetype `{name}`: unknown {field} term `{v}`"))?;
120    }
121    Ok(())
122}
123
124#[derive(Deserialize)]
125struct TomlArchetypeSet {
126    #[serde(default)]
127    archetype: BTreeMap<String, TomlCapability>,
128}
129
130#[derive(Deserialize)]
131#[serde(deny_unknown_fields)]
132struct TomlCapability {
133    operation: String,
134    because: String,
135    /// Archetypes this one is easily confused with, and the axis that separates them. Required
136    /// (both ways) for any pair differing on a single facet — see `near_neighbours_are_declared`.
137    #[serde(default)]
138    #[allow(dead_code)] // authoring metadata, read by the near-neighbour guards
139    distinguished_from: Vec<TomlDistinction>,
140    /// An archetype occupying the SAME point in facet space, declared deliberately. The two
141    /// classify identically and differ only in the prose `--explain` shows.
142    #[serde(default)]
143    #[allow(dead_code)] // authoring metadata, read by the near-neighbour guards
144    same_point_as: Option<String>,
145    #[serde(default)]
146    locus: Option<TomlLocus>,
147    #[serde(default)]
148    scale: Option<String>,
149    #[serde(default)]
150    retrieval: Option<String>,
151    #[serde(default)]
152    authority: Option<String>,
153    #[serde(default)]
154    reversibility: Option<String>,
155    #[serde(default)]
156    persistence: Option<TomlPersistence>,
157    #[serde(default)]
158    disclosure: Option<TomlDisclosure>,
159    #[serde(default)]
160    secret: Option<TomlSecret>,
161    #[serde(default)]
162    network: Option<TomlNetwork>,
163    #[serde(default)]
164    execution: Option<String>,
165    #[serde(default)]
166    cost: Option<String>,
167}
168
169#[derive(Deserialize)]
170#[serde(deny_unknown_fields)]
171struct TomlLocus {
172    local: Option<String>,
173    remote: Option<String>,
174    binding: Option<String>,
175    provenance: Option<String>,
176}
177
178#[derive(Deserialize)]
179#[serde(deny_unknown_fields)]
180struct TomlPersistence {
181    level: Option<String>,
182}
183
184#[derive(Deserialize)]
185#[serde(deny_unknown_fields)]
186struct TomlDisclosure {
187    audience: Option<String>,
188}
189
190#[derive(Deserialize)]
191#[serde(deny_unknown_fields)]
192struct TomlSecret {
193    level: Option<String>,
194}
195
196#[derive(Deserialize)]
197#[serde(deny_unknown_fields)]
198struct TomlNetwork {
199    direction: Option<String>,
200    destination: Option<String>,
201    payload: Option<String>,
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::engine::authoring::default_levels;
208    use crate::engine::bridge::project;
209    use crate::engine::facet::Profile;
210    use crate::engine::level::Level;
211    use crate::verdict::Verdict;
212
213    fn level(name: &str) -> &'static Level {
214        default_levels().iter().find(|l| l.name == name).expect("level exists")
215    }
216
217    /// The `adjacent` (sibling-workspace) locus lands where the design says, INDEPENDENT of the legacy
218    /// 3-band CLI projection (which collapses editor/developer to SafeWrite). A sibling READ auto-
219    /// approves from reader up; a sibling create/mutate (a "quick patch") is admitted at developer but
220    /// NOT editor (editor's writes stay `<= worktree`); a sibling DESTROY is withheld even at developer
221    /// (conservative — `rm -rf ../otherrepo` is not a patch).
222    #[test]
223    fn sibling_adjacent_locus_reads_at_reader_writes_at_developer_not_editor() {
224        use crate::engine::facet::{Capability, LocalLocus, Operation, PersistenceLevel, Reversibility};
225        let at_adjacent = |op: Operation| {
226            let mut c = Capability::new(op);
227            c.locus.local = LocalLocus::Adjacent;
228            c
229        };
230        let read = Profile::of(vec![at_adjacent(Operation::Observe)]);
231        let patch = Profile::of(vec![{
232            let mut c = at_adjacent(Operation::Mutate);
233            c.reversibility = Reversibility::Recoverable;
234            c.persistence.level = PersistenceLevel::Data;
235            c
236        }]);
237        let destroy = Profile::of(vec![at_adjacent(Operation::Destroy)]);
238
239        assert!(level("reader").admits(&read), "a sibling READ auto-approves from reader");
240        assert!(level("developer").admits(&read), "…and higher");
241        assert!(!level("editor").admits(&patch), "editor does NOT write a sibling (writes stay worktree)");
242        assert!(level("developer").admits(&patch), "developer patches a sibling (create/mutate)");
243        assert!(!level("editor").admits(&destroy), "editor does NOT destroy a sibling");
244        assert!(!level("developer").admits(&destroy), "developer does NOT destroy a sibling (conservative — its destroy clause stays `<= worktree`)");
245    }
246
247    #[test]
248    fn archetypes_toml_compiles_and_every_capability_is_justified() {
249        // LazyLock forces the parse; a bad term / missing `because` would have panicked.
250        let mut count = 0;
251        for n in names() {
252            let c = archetype(n).expect("listed archetype resolves");
253            assert!(!c.because.is_empty(), "archetype `{n}` has no because");
254            count += 1;
255        }
256        assert!(count >= 10, "expected the full catalog, got {count}");
257        assert!(archetype("does-not-exist").is_none(), "unknown profile fails closed");
258    }
259
260    /// The catalog's "Lands at" column, verified against the real algebra by loading the ACTUAL
261    /// archetype definitions (not hand-built copies): each is admitted by its claimed level and
262    /// refused by the level just below it. Ties archetypes.toml ↔ the catalog doc ↔ the levels.
263    #[test]
264    fn archetypes_land_where_the_catalog_says() {
265        // (archetype, admitted_by, refused_by)
266        let cases: &[(&str, &str, &str)] = &[
267            // A pure remote fetch is a READ — reader admits it; only paranoid (no network) refuses.
268            ("remote-read", "reader", "paranoid"),
269            // A BULK remote export (db dump to stdout) is still a read — reader admits it. `scale`
270            // records the volume but does not gate a read; the -f output file is a SEPARATE cap.
271            ("data-export", "reader", "paranoid"),
272            ("remote-mutate", "network-admin", "developer"),
273            ("remote-create", "network-admin", "developer"),
274            ("remote-destroy-recoverable", "network-admin", "developer"),
275            ("remote-destroy-irreversible", "yolo", "network-admin"),
276            ("remote-authorize", "network-admin", "developer"),
277            ("remote-control", "network-admin", "developer"),
278            ("vcs-sync", "network-admin", "developer"),
279            ("blockchain-txn", "yolo", "network-admin"),
280            ("local-privileged", "local-admin", "developer"),
281            // Transient service control (systemctl restart) — the mildest root-machine op, still local-admin.
282            ("privileged-control", "local-admin", "developer"),
283            // A pinned, scripts-off install runs no foreign code → developer (via the install clause).
284            ("local-install-pinned", "developer", "editor"),
285            // The scripts-on / unpinned install RUNS foreign code (network-sourced) → yolo only.
286            ("supply-chain-build", "yolo", "developer"),
287            // Arbitrary remote code execution (kubectl exec, ssh cmd) — execute op, no level below yolo.
288            ("remote-exec", "yolo", "network-admin"),
289            // Credential material read/mint → yolo (secret > uses-ambient everywhere below yolo).
290            ("credential-read", "yolo", "network-admin"),
291            ("credential-mint", "yolo", "network-admin"),
292            // Decrypt-to-screen (sops -d, age -d, ansible-vault view): a secret read that flows to the
293            // model — same `secret = reads` tier as a credential-store read → yolo, refused below.
294            ("decrypt-read", "yolo", "network-admin"),
295            // Arbitrary stored-object retrieval (s3 get-object): classified by `retrieval =
296            // bulk-content` (§5 #1), it lands at NETWORK-ADMIN — the proportionate bulk-egress tier —
297            // refused by developer. NOT yolo (it is not a credential read) and NOT reader (opaque bulk
298            // content is above the everyday read band).
299            ("bulk-object-read", "network-admin", "developer"),
300            // The LOCAL working-copy quartet — the mirror of the remote one, split on the same
301            // reversibility axis. Both mutates land at editor (differing only in how easily they
302            // undo, which the levels do not yet distinguish); the destroys split editor→developer
303            // →yolo exactly as reversibility worsens.
304            ("local-mutate-trivial", "editor", "reader"),
305            ("local-mutate-recoverable", "editor", "reader"),
306            ("local-destroy-recoverable", "developer", "editor"),
307            ("local-destroy-irreversible", "yolo", "developer"),
308        ];
309        for (name, admitted_by, refused_by) in cases {
310            let p = Profile::of(vec![archetype(name).expect("archetype exists").clone()]);
311            assert!(level(admitted_by).admits(&p), "{name} should be admitted by {admitted_by}");
312            assert!(!level(refused_by).admits(&p), "{name} should be refused by {refused_by}");
313        }
314
315        // COMPLETENESS. Without this, adding an archetype to archetypes.toml and forgetting the
316        // catalog row leaves it with NO level verification at all — it would ship classifying
317        // commands with nobody having checked where it lands. Enumerating the real catalog means a
318        // new archetype fails here until its landing is asserted above.
319        let uncovered: Vec<&str> =
320            names().filter(|n| !cases.iter().any(|(c, _, _)| c == n)).collect();
321        assert!(
322            uncovered.is_empty(),
323            "archetype(s) with no catalog row — add (name, admitted_by, refused_by) above: {uncovered:?}",
324        );
325    }
326
327    /// The whole point of Phase 1 for the WRITE side: every remote archetype that CHANGES remote
328    /// state (mutate/create/destroy/authorize/control), plus vcs-sync and blockchain-txn, is above
329    /// the auto-approve band — denied by CLASSIFICATION, not hand-marking. `remote-read` is the
330    /// deliberate exception: a pure fetch is a reader-level read and auto-approves (SafeRead).
331    #[test]
332    fn every_remote_write_archetype_is_not_auto_approved() {
333        let write_remotes = names()
334            .filter(|n| (n.starts_with("remote-") && *n != "remote-read") || *n == "vcs-sync" || *n == "blockchain-txn");
335        for name in write_remotes {
336            let p = Profile::of(vec![archetype(name).expect("archetype").clone()]);
337            assert_eq!(project(&p), Verdict::Denied, "{name} must not auto-approve in the 3-value projection");
338        }
339        // and the read DOES auto-approve — the read/write asymmetry, verified
340        assert_eq!(
341            project(&Profile::of(vec![archetype("remote-read").unwrap().clone()])),
342            Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
343            "a pure remote fetch is reader-level",
344        );
345    }
346
347    /// The exposure reframe (behavioral-taxonomy-exposure.md §3, §7): `disclosure.audience = public`
348    /// is a RECORD, not a gate. Publishing content you authored to a public destination (git push to
349    /// a public repo, `npm publish`) is a network-admin operation — NOT held back to yolo by its
350    /// publicness. What still gates to yolo is CONTENT: transmitting a secret off-box. Proves the
351    /// gate moved from "how public the destination is" to "is a secret leaving". Red on the old
352    /// `disclosure = { audience = "<= trusted-remote" }` ceiling (public publish refused everywhere
353    /// below yolo); green on `<= public`.
354    #[test]
355    fn public_disclosure_is_recorded_not_gated_secret_transmission_is() {
356        use crate::engine::facet::{
357            DisclosureAudience, NetDestination, NetDirection, NetPayload, Network, RemoteReach,
358            Reversibility, SecretLevel,
359        };
360
361        let publish_to_public = || {
362            let mut c = Capability::new(Operation::Communicate);
363            c.locus.remote = RemoteReach::Arbitrary;
364            c.reversibility = Reversibility::Effortful;
365            c.disclosure.audience = DisclosureAudience::Public;
366            c.network = Network {
367                direction: NetDirection::Outbound,
368                destination: NetDestination::Arbitrary,
369                payload: NetPayload::SendsHostData,
370            };
371            c
372        };
373
374        // Non-secret public publish → a network-admin op, still above the local developer band.
375        let mut publish = publish_to_public();
376        publish.because = "publish authored content to a public destination".into();
377        let publish = Profile::of(vec![publish]);
378        assert!(level("network-admin").admits(&publish), "public non-secret publish is network-admin");
379        assert!(!level("developer").admits(&publish), "outbound remote egress is above developer");
380
381        // Same shape, but it TRANSMITS A SECRET — now the CONTENT gates it up to yolo.
382        let mut exfil = publish_to_public();
383        exfil.secret.level = SecretLevel::Transmits;
384        exfil.because = "transmit a secret off-box".into();
385        let exfil = Profile::of(vec![exfil]);
386        assert!(!level("network-admin").admits(&exfil), "secret transmission is the gate, above network-admin");
387        assert!(level("yolo").admits(&exfil), "yolo admits secret exfil (non-destroy clause)");
388    }
389
390    /// The machine locus SUB-RUNG split (the `restart nginx` vs `/etc/passwd` distinction). ORDINARY
391    /// machine state — a service, an app config — is `machine` → local-admin. The identity/auth/boot/
392    /// loader TRUST substrate is `system-integrity` → ABOVE local-admin, yolo-only. Same operation +
393    /// authority; only the locus rung differs, and that difference is the whole gate: "run the machine
394    /// as admin" vs "own the machine's trust root".
395    #[test]
396    fn system_integrity_is_above_local_admin_ordinary_machine_is_not() {
397        use crate::engine::facet::{Authority, LocalLocus};
398        let (local, yolo) = (level("local-admin"), level("yolo"));
399
400        let root_write_at = |loc| {
401            let mut c = Capability::new(Operation::Mutate);
402            c.locus.local = loc;
403            c.authority = Authority::Root;
404            c.because = "root machine write".into();
405            Profile::of(vec![c])
406        };
407
408        // ordinary machine config (edit /etc/nginx.conf as root) — local-admin admits.
409        assert!(local.admits(&root_write_at(LocalLocus::Machine)), "ordinary machine write is local-admin");
410
411        // the trust substrate (rewrite /etc/passwd as root) — local-admin REFUSES; only yolo.
412        let integrity = root_write_at(LocalLocus::SystemIntegrity);
413        assert!(!local.admits(&integrity), "the system-integrity substrate is above local-admin");
414        assert!(yolo.admits(&integrity), "yolo owns the machine's trust root");
415    }
416
417    /// The developer supply-chain / install clause. A PINNED, SCRIPTS-OFF install (`npm ci
418    /// --ignore-scripts`) fetches packages and writes node_modules but runs NO foreign code —
419    /// `execution = self`, `persistence = installing` → a dev-loop staple, admitted at developer.
420    /// The scripts-ON or UNPINNED install is `execution = network-sourced` (the supply-chain-build
421    /// archetype) → no home below yolo. The resolver picks which shape a command emits; this pins the
422    /// LEVEL boundary. Modeling the safe install as `execution = self` (not a guardrail-gated
423    /// `network-sourced`) is what keeps the clause all-`<=` and facet-monotone.
424    #[test]
425    fn pinned_scripts_off_install_is_developer_the_supply_chain_surface_is_yolo() {
426        use crate::engine::facet::{
427            ExecutionTrust, LocalLocus, NetDirection, NetPayload, PersistenceLevel, Reversibility,
428        };
429        let (dev, yolo) = (level("developer"), level("yolo"));
430
431        // `npm ci --ignore-scripts`: install files, execute nothing foreign.
432        let safe_install = {
433            let mut c = Capability::new(Operation::Create);
434            c.locus.local = LocalLocus::Worktree;
435            c.persistence.level = PersistenceLevel::Installing;
436            c.reversibility = Reversibility::Effortful;
437            c.network.direction = NetDirection::Outbound;
438            c.network.payload = NetPayload::Fetches;
439            c.execution.trust = ExecutionTrust::SelfCode;
440            c.because = "pinned, scripts-off install".into();
441            Profile::of(vec![c])
442        };
443        assert!(dev.admits(&safe_install), "a pinned, scripts-off install is developer");
444        assert!(yolo.admits(&safe_install), "and of course yolo");
445
446        // scripts-ON / unpinned: the supply-chain surface (network-sourced execution).
447        let supply_chain = Profile::of(vec![archetype("supply-chain-build").unwrap().clone()]);
448        assert!(!dev.admits(&supply_chain), "network-sourced install (scripts on / unpinned) is above developer");
449        assert!(yolo.admits(&supply_chain), "the supply-chain surface lands at yolo");
450    }
451
452    /// Destination-trust (behavioral-taxonomy-exposure.md §4): the new `locus.provenance` facet.
453    /// A send to a target designated `literal` (a URL typed inline) is a network-admin op — the
454    /// human reviewing at that level SEES the URL; a send to an `opaque` target (from a variable,
455    /// unreviewable) is held to yolo. Proves network-admin's `provenance <= literal` ceiling. Red
456    /// if the ceiling is absent (opaque would leak into network-admin) or set to `established`
457    /// (literal URLs would be wrongly refused); green at `<= literal`.
458    #[test]
459    fn a_literal_send_target_is_network_admin_an_opaque_one_is_yolo() {
460        use crate::engine::facet::{NetDirection, NetPayload, Provenance, RemoteReach};
461
462        let send_to = |prov| {
463            let mut c = Capability::new(Operation::Communicate);
464            c.locus.remote = RemoteReach::Fixed;
465            c.locus.provenance = prov;
466            c.network.direction = NetDirection::Outbound;
467            c.network.payload = NetPayload::SendsHostData;
468            c.because = "send host data to a designated target".into();
469            c
470        };
471
472        let literal = Profile::of(vec![send_to(Provenance::Literal)]);
473        assert!(level("network-admin").admits(&literal), "a visible literal URL is a network-admin send");
474        assert!(!level("developer").admits(&literal), "sends-host-data is above the local developer band");
475
476        let opaque = Profile::of(vec![send_to(Provenance::Opaque)]);
477        assert!(!level("network-admin").admits(&opaque), "an opaque (variable) destination is held above network-admin");
478        assert!(level("yolo").admits(&opaque), "yolo leaves provenance unconstrained");
479    }
480}
481
482#[cfg(test)]
483mod neighbour_tests {
484    use super::*;
485    use std::collections::BTreeMap;
486
487    /// The facets on which two archetypes differ. `set_facets` omits terms sitting at their zero,
488    /// so a facet present in one map and absent from the other IS a difference (present vs default).
489    fn differing_facets(a: &Capability, b: &Capability) -> Vec<&'static str> {
490        let am: BTreeMap<_, _> = a.set_facets().into_iter().collect();
491        let bm: BTreeMap<_, _> = b.set_facets().into_iter().collect();
492        let mut keys: Vec<_> = am.keys().chain(bm.keys()).copied().collect();
493        keys.sort_unstable();
494        keys.dedup();
495        keys.into_iter().filter(|k| am.get(k) != bm.get(k)).collect()
496    }
497
498    /// Any two archetypes within ONE facet of each other must say so, both ways, naming the axis
499    /// that separates them.
500    ///
501    /// Choosing an archetype fixes 27 facets at once and is the most consequential authoring act in
502    /// the repo — yet it is done by picking a name from a flat list of 23, with the differences
503    /// buried in prose. That is not a theoretical hazard: `dynamodb scan` was classified
504    /// `bulk-object-read` when it is a `data-export`, because the two differ ONLY on `retrieval`
505    /// and the sentence saying so lived inside the OTHER archetype's `because`, invisible to
506    /// someone reading this one.
507    ///
508    /// Detection is mechanical rather than authored, so a confusable pair introduced later is
509    /// caught the moment it appears — nobody has to notice it first.
510    #[test]
511    fn near_neighbours_are_declared() {
512        let declared = declared_distinctions();
513        let dist_of = |n: &str| -> Vec<(String, String)> {
514            declared.iter().find(|(name, ..)| name == n).map(|(_, d, _)| d.clone()).unwrap_or_default()
515        };
516        let same_of = |n: &str| -> Option<String> {
517            declared.iter().find(|(name, ..)| name == n).and_then(|(_, _, s)| s.clone())
518        };
519
520        let names: Vec<&str> = names().collect();
521        let mut problems = Vec::new();
522        for (i, a) in names.iter().enumerate() {
523            for b in &names[i + 1..] {
524                let d = differing_facets(archetype(a).unwrap(), archetype(b).unwrap());
525                match d.len() {
526                    // Same point in facet space: they classify identically, so the choice is pure
527                    // prose. Legitimate, but it has to be deliberate — otherwise an author picks by
528                    // coin-flip and a later facet edit to one silently diverges them. A pair that
529                    // DOES declare it falls through to `_`, which is the no-op.
530                    0 if same_of(a).as_deref() != Some(*b) || same_of(b).as_deref() != Some(*a) => {
531                        problems.push(format!(
532                            "`{a}` and `{b}` are facet-IDENTICAL; both must declare \
533                             `same_point_as` naming the other, or be given a real difference",
534                        ));
535                    }
536                    1 => {
537                        let axis = d[0];
538                        for (x, y) in [(a, b), (b, a)] {
539                            if !dist_of(x).iter().any(|(n, by)| n == *y && by == axis) {
540                                problems.push(format!(
541                                    "`{x}` must declare `distinguished_from = [{{ archetype = \"{y}\", \
542                                     by = \"{axis}\" }}]` — they differ on that axis alone",
543                                ));
544                            }
545                        }
546                    }
547                    _ => {}
548                }
549            }
550        }
551        assert!(problems.is_empty(), "confusable archetypes:\n  {}", problems.join("\n  "));
552    }
553
554    /// A declared distinction must be TRUE: the named axis is really where the two differ. A stale
555    /// annotation is worse than none — it points an author at the wrong facet with authority.
556    #[test]
557    fn declared_distinctions_are_accurate() {
558        let mut problems = Vec::new();
559        for (name, dists, same) in declared_distinctions() {
560            let Some(a) = archetype(&name) else { continue };
561            for (other, by) in dists {
562                let Some(b) = archetype(&other) else {
563                    problems.push(format!("`{name}` names unknown archetype `{other}`"));
564                    continue;
565                };
566                let d = differing_facets(a, b);
567                if !d.contains(&by.as_str()) {
568                    problems.push(format!(
569                        "`{name}` claims it differs from `{other}` by `{by}`, but they differ on {d:?}",
570                    ));
571                }
572            }
573            if let Some(other) = same {
574                match archetype(&other) {
575                    None => problems.push(format!("`{name}` names unknown archetype `{other}`")),
576                    Some(b) => {
577                        let d = differing_facets(a, b);
578                        if !d.is_empty() {
579                            problems.push(format!(
580                                "`{name}` claims `same_point_as = \"{other}\"`, but they differ on {d:?}",
581                            ));
582                        }
583                    }
584                }
585            }
586        }
587        assert!(problems.is_empty(), "inaccurate distinctions:\n  {}", problems.join("\n  "));
588    }
589}