Skip to main content

scc_graph/
state.rs

1//! STATE & DATA AUTHORITY (Ontology phase): deterministic attribution of
2//! state ownership per component from the fact layer.
3//!
4//! Six subsections:
5//! - `persistent`: stores/data entities via WRITES edges (the existing
6//!   component `owns` claims).
7//! - `runtime`: FIELD facts with `mutable=true`, STATE entities, REGISTRY
8//!   entities.
9//! - `reactive`: REACTIVE entities (svelte/vue/react/mobx/signals state)
10//!   via OWNS edges.
11//! - `configuration`: CONFIGURED_BY relationships (Configuration facts).
12//! - `caches`: WRITES/READS to cache-technology stores.
13//! - `derived`: PUBLISHES/SUBSCRIBES/CONSUMES topics + middleware/registry
14//!   registrations.
15//!
16//! Every line is `COMPONENT <verb> TARGET (PROV)`-style, deterministically
17//! sorted. Provenance is preserved verbatim from the underlying
18//! relationship — nothing is promoted.
19
20use crate::RealityGraph;
21use scc_core::{kinds, predicates, Entity, Provenance};
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23
24pub const S_PERSISTENT: &str = "persistent";
25pub const S_RUNTIME: &str = "runtime";
26pub const S_REACTIVE: &str = "reactive";
27pub const S_CONFIGURATION: &str = "configuration";
28pub const S_CACHES: &str = "caches";
29pub const S_DERIVED: &str = "derived";
30
31/// Deterministic render order of the STATE & DATA AUTHORITY subsections.
32pub const STATE_SECTIONS: [&str; 6] = [
33    S_PERSISTENT,
34    S_RUNTIME,
35    S_REACTIVE,
36    S_CONFIGURATION,
37    S_CACHES,
38    S_DERIVED,
39];
40
41/// Human-readable subsection header for a section key.
42// # trace:exempt — subsection header label map, no behavior of its own
43pub fn section_label(section: &str) -> &'static str {
44    match section {
45        S_PERSISTENT => "DATA OWNERSHIP",
46        S_RUNTIME => "RUNTIME STATE",
47        S_REACTIVE => "REACTIVE STATE",
48        S_CONFIGURATION => "CONFIGURATION",
49        S_CACHES => "CACHES",
50        S_DERIVED => "DERIVED / REGISTRIES",
51        _ => "STATE",
52    }
53}
54
55/// Live OCCURRENCE entities attached to a concept (OCCURS edges), sorted
56/// by id for determinism. Derived counts and provenance always come from
57/// these — concept `count` attributes are never stored or mutated at write
58/// time (Wave 13).
59// trace:v1 id=impl.scc.state.occurrences work=WORK-SCC-005 satisfies=REQ-SCC-IR
60pub fn concept_occurrences<'g>(graph: &'g RealityGraph, concept_id: &str) -> Vec<&'g Entity> {
61    let mut occs: Vec<&Entity> = graph
62        .in_pred(concept_id, predicates::OCCURS)
63        .into_iter()
64        .filter_map(|r| graph.entities.get(&r.subject))
65        .collect();
66    occs.sort_by(|a, b| a.id.cmp(&b.id));
67    occs
68}
69
70/// Derived occurrence count of a concept — the count the schema/reactive
71/// compilers use (never a stored counter). Purge naturally lowers it.
72// trace:v1 id=impl.scc.state.occurrence_count work=WORK-SCC-005 satisfies=REQ-SCC-IR
73pub fn occurrence_count(graph: &RealityGraph, concept_id: &str) -> usize {
74    concept_occurrences(graph, concept_id).len()
75}
76
77/// Most frequent occurrence owner name of a concept (deterministic
78/// tie-break: lexicographic smallest), or `None` when the concept has no
79/// occurrences. The atlas schema producer — an owner symbol, never the
80/// concept/expr itself.
81// trace:v1 id=impl.scc.state.occurrence_producer work=WORK-SCC-005 satisfies=REQ-SCC-IR
82pub fn occurrence_producer(graph: &RealityGraph, concept_id: &str) -> Option<String> {
83    let mut freq: BTreeMap<String, usize> = BTreeMap::new();
84    for occ in concept_occurrences(graph, concept_id) {
85        if let Some(o) = occ.attributes.get("owner").and_then(|v| v.as_str()) {
86            *freq.entry(o.to_string()).or_default() += 1;
87        }
88    }
89    freq.into_iter()
90        .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
91        .map(|(name, _)| name)
92}
93
94/// Cache-technology store detection (store_refs with cache tech): an
95/// explicit technology hint or a cache-ish store name.
96pub fn is_cache_store(name: &str, technology: Option<&str>) -> bool {
97    if let Some(t) = technology {
98        let t = t.to_ascii_lowercase();
99        if matches!(t.as_str(), "redis" | "memcached" | "valkey") {
100            return true;
101        }
102    }
103    let n = name.to_ascii_lowercase();
104    n.contains("cache") || n.contains("redis") || n.contains("memcache")
105}
106
107/// Attribute state ownership per component from the fact layer.
108///
109/// `symbol_comp` maps symbol entity id -> component name (the component
110/// compiler builds it from the *current* candidate boundaries, so the
111/// result never depends on stale stored components).
112///
113/// Returns `section -> sorted "COMP verb TARGET (PROV)" lines`. Function-level
114/// access is `{comp}::{symbol} {reads|writes|…} {target} ({PROV})` and never
115/// replaces component ownership. Section keys are [`STATE_SECTIONS`]; the
116/// map is a `BTreeMap` and every line set is sorted — output is
117/// deterministic for identical input.
118// trace:v1 id=impl.scc.state.authority work=WORK-SCC-005 satisfies=REQ-state-function-access,REQ-SCC-IR
119pub fn compile_state_authority(
120    graph: &RealityGraph,
121    symbol_comp: &HashMap<String, String>,
122) -> BTreeMap<String, Vec<String>> {
123    let mut sections: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
124    // every section key exists (empty sections stay empty) so callers can
125    // iterate STATE_SECTIONS unconditionally
126    for k in STATE_SECTIONS {
127        sections.entry(k.to_string()).or_default();
128    }
129    let mut push = |section: &str, line: String| {
130        sections
131            .entry(section.to_string())
132            .or_default()
133            .insert(line);
134    };
135
136    let prov_str = |p: &Provenance| p.as_str().to_string();
137
138    // symbol id -> entity (for target resolution)
139    let comp_of = |sym_id: &str| symbol_comp.get(sym_id).cloned();
140
141    // ---- per-symbol edges (persistent / caches / derived) ----
142    let mut symbol_ids: Vec<&String> = symbol_comp.keys().collect();
143    symbol_ids.sort();
144    for sym_id in symbol_ids {
145        let Some(comp) = comp_of(sym_id) else {
146            continue;
147        };
148        let sym_name = graph
149            .entities
150            .get(sym_id)
151            .map(|e| e.name.clone())
152            .unwrap_or_else(|| {
153                sym_id
154                    .rsplit('/')
155                    .next()
156                    .unwrap_or(sym_id.as_str())
157                    .to_string()
158            });
159        let mut rels: Vec<&scc_core::Relationship> = graph.out_edges(sym_id);
160        rels.sort_by(|a, b| {
161            a.predicate
162                .cmp(&b.predicate)
163                .then_with(|| a.object.cmp(&b.object))
164                .then_with(|| a.id.cmp(&b.id))
165        });
166        for r in rels {
167            let target = match graph.entities.get(&r.object) {
168                Some(e) => e,
169                None => continue,
170            };
171            match r.predicate.as_str() {
172                predicates::WRITES => {
173                    if is_cache_store(&target.name, tech(graph, &r.object)) {
174                        push(
175                            S_CACHES,
176                            format!(
177                                "{} writes {} ({})",
178                                comp,
179                                target.name,
180                                prov_str(&r.provenance)
181                            ),
182                        );
183                        push(
184                            S_CACHES,
185                            format!(
186                                "{}::{} writes {} ({})",
187                                comp,
188                                sym_name,
189                                target.name,
190                                prov_str(&r.provenance)
191                            ),
192                        );
193                    } else if target.kind == kinds::DATA_STORE || target.kind == kinds::DATA_ENTITY
194                    {
195                        let tgt = store_ref(graph, &r.object);
196                        push(
197                            S_PERSISTENT,
198                            format!("{} owns {} ({})", comp, tgt, prov_str(&r.provenance)),
199                        );
200                        push(
201                            S_PERSISTENT,
202                            format!(
203                                "{}::{} writes {} ({})",
204                                comp,
205                                sym_name,
206                                tgt,
207                                prov_str(&r.provenance)
208                            ),
209                        );
210                    }
211                }
212                predicates::READS | predicates::QUERIES => {
213                    let verb = if r.predicate == predicates::QUERIES {
214                        "queries"
215                    } else {
216                        "reads"
217                    };
218                    let cache = target.kind == kinds::DATA_STORE
219                        && is_cache_store(&target.name, tech(graph, &r.object));
220                    if cache {
221                        if r.predicate == predicates::READS {
222                            push(
223                                S_CACHES,
224                                format!(
225                                    "{} reads {} ({})",
226                                    comp,
227                                    target.name,
228                                    prov_str(&r.provenance)
229                                ),
230                            );
231                        }
232                        push(
233                            S_CACHES,
234                            format!(
235                                "{}::{} {verb} {} ({})",
236                                comp,
237                                sym_name,
238                                target.name,
239                                prov_str(&r.provenance)
240                            ),
241                        );
242                    } else if target.kind == kinds::DATA_STORE || target.kind == kinds::DATA_ENTITY
243                    {
244                        // Readers are not owners: function-level access only.
245                        push(
246                            S_PERSISTENT,
247                            format!(
248                                "{}::{} {verb} {} ({})",
249                                comp,
250                                sym_name,
251                                store_ref(graph, &r.object),
252                                prov_str(&r.provenance)
253                            ),
254                        );
255                    }
256                }
257                predicates::PUBLISHES => {
258                    push(
259                        S_DERIVED,
260                        format!(
261                            "{} publishes {} ({})",
262                            comp,
263                            target.name,
264                            prov_str(&r.provenance)
265                        ),
266                    );
267                    push(
268                        S_DERIVED,
269                        format!(
270                            "{}::{} publishes {} ({})",
271                            comp,
272                            sym_name,
273                            target.name,
274                            prov_str(&r.provenance)
275                        ),
276                    );
277                }
278                predicates::SUBSCRIBES => {
279                    push(
280                        S_DERIVED,
281                        format!(
282                            "{} subscribes {} ({})",
283                            comp,
284                            target.name,
285                            prov_str(&r.provenance)
286                        ),
287                    );
288                    push(
289                        S_DERIVED,
290                        format!(
291                            "{}::{} subscribes {} ({})",
292                            comp,
293                            sym_name,
294                            target.name,
295                            prov_str(&r.provenance)
296                        ),
297                    );
298                }
299                predicates::CONSUMES => {
300                    push(
301                        S_DERIVED,
302                        format!(
303                            "{} consumes {} ({})",
304                            comp,
305                            target.name,
306                            prov_str(&r.provenance)
307                        ),
308                    );
309                    push(
310                        S_DERIVED,
311                        format!(
312                            "{}::{} consumes {} ({})",
313                            comp,
314                            sym_name,
315                            target.name,
316                            prov_str(&r.provenance)
317                        ),
318                    );
319                }
320                predicates::REGISTERS => {
321                    let is_mw_registry = target.kind == kinds::MIDDLEWARE
322                        || target.kind == kinds::REGISTRY
323                        || (target.kind == kinds::CONTRACT
324                            && target
325                                .attributes
326                                .get("kind")
327                                .and_then(|v| v.as_str())
328                                .map(|k| matches!(k, "middleware" | "registry"))
329                                .unwrap_or(false));
330                    if is_mw_registry {
331                        push(
332                            S_DERIVED,
333                            format!(
334                                "{} registers {} ({})",
335                                comp,
336                                target.name,
337                                prov_str(&r.provenance)
338                            ),
339                        );
340                    }
341                }
342                _ => {}
343            }
344        }
345    }
346
347    // ---- runtime state: mutable FIELD facts + STATE/REGISTRY entities ----
348    let mut runtime_entities: Vec<&scc_core::Entity> = graph
349        .entities_of_kind(kinds::FIELD)
350        .into_iter()
351        .filter(|f| f.attributes.get("mutable").and_then(|v| v.as_bool()) == Some(true))
352        .collect();
353    runtime_entities.extend(graph.entities_of_kind(kinds::STATE));
354    runtime_entities.extend(graph.entities_of_kind(kinds::REGISTRY));
355    runtime_entities.sort_by(|a, b| a.id.cmp(&b.id));
356    for e in runtime_entities {
357        // FIELD facts are CONTAINS-ed by their owning symbol; STATE/REGISTRY
358        // entities too — resolve the owner symbol, then the component.
359        let mut owner: Option<String> = None;
360        let mut prov: Option<Provenance> = None;
361        let mut rels = graph.in_pred(&e.id, predicates::CONTAINS);
362        rels.sort_by(|a, b| a.id.cmp(&b.id));
363        for r in rels {
364            if let Some(c) = comp_of(&r.subject) {
365                owner = Some(c);
366                prov = Some(r.provenance);
367                break;
368            }
369        }
370        let Some(comp) = owner else { continue };
371        let tag = match e.kind.as_str() {
372            kinds::FIELD => "mutable",
373            kinds::STATE => "state",
374            _ => "registry",
375        };
376        push(
377            S_RUNTIME,
378            format!(
379                "{} owns {} ({tag}) ({})",
380                comp,
381                e.name,
382                prov.map(|p| prov_str(&p))
383                    .unwrap_or_else(|| "EXTRACTED".to_string())
384            ),
385        );
386    }
387
388    // ---- configuration: CONFIGURED_BY (config -> owner symbol) ----
389    for cfg in graph.entities_of_kind(kinds::CONFIGURATION) {
390        let mut rels = graph.out_pred(&cfg.id, predicates::CONFIGURED_BY);
391        rels.sort_by(|a, b| a.id.cmp(&b.id));
392        for r in rels {
393            if let Some(comp) = comp_of(&r.object) {
394                push(
395                    S_CONFIGURATION,
396                    format!(
397                        "{} configured_by {} ({})",
398                        comp,
399                        cfg.name,
400                        prov_str(&r.provenance)
401                    ),
402                );
403            }
404        }
405    }
406
407    // ---- reactive state: REACTIVE concepts rendered per occurrence (Wave
408    // 13). Each (path, owner, line) occurrence keeps its own access/expr
409    // variant, so auth.ts and editor.ts both declaring `const [state,
410    // setState]` render both — never collapse. The derived occurrence
411    // count (occurrence_count) gates nothing here: every concept renders.
412    for e in graph.entities_of_kind(kinds::REACTIVE) {
413        let mut occs = concept_occurrences(graph, &e.id);
414        occs.sort_by(|a, b| a.id.cmp(&b.id));
415        for occ in occs {
416            let access = occ
417                .attributes
418                .get("access")
419                .and_then(|v| v.as_str())
420                .unwrap_or("state");
421            let expr = occ
422                .attributes
423                .get("expr")
424                .and_then(|v| v.as_str())
425                .map(|s| s.to_string());
426            // the owner symbol OWNS its occurrence
427            let mut rels = graph.in_pred(&occ.id, predicates::OWNS);
428            rels.sort_by(|a, b| a.id.cmp(&b.id));
429            for r in rels {
430                if let Some(comp) = comp_of(&r.subject) {
431                    let line = match &expr {
432                        Some(exp) if !exp.is_empty() => format!(
433                            "{} owns reactive: {} [{}] = {} ({})",
434                            comp,
435                            e.name,
436                            access,
437                            exp,
438                            prov_str(&r.provenance)
439                        ),
440                        _ => format!(
441                            "{} owns reactive: {} [{}] ({})",
442                            comp,
443                            e.name,
444                            access,
445                            prov_str(&r.provenance)
446                        ),
447                    };
448                    push(S_REACTIVE, line);
449                }
450            }
451        }
452    }
453
454    sections
455        .into_iter()
456        .map(|(k, v)| (k, v.into_iter().collect()))
457        .collect()
458}
459
460/// Groups of symbol ids that SHARE state authority: distinct symbols
461/// writing the same store (data entities resolve to their owning store, so
462/// `db.users` and `db.orders` count as one target), read by the same
463/// CONFIGURED_BY configuration target, or owning the same REACTIVE state
464/// entity (Wave 11 — symbols mutating the same reactive state cohere).
465/// This is the shared-state-authority signal for the semantic clustering
466/// graph (+4 per pair inside a group).
467///
468/// Deterministic: groups are built over sorted symbol ids and each group is
469/// a sorted `BTreeSet`; groups with fewer than 2 symbols (no pair) are
470/// omitted. Pure function of the graph — nothing is stored or promoted.
471// trace:v1 id=impl.scc.state.groups work=WORK-SCC-005 satisfies=REQ-SCC-IR
472pub fn state_authority_groups(graph: &RealityGraph) -> Vec<BTreeSet<String>> {
473    // store target -> symbols writing it (data entities resolve to their
474    // owning store so writes to db.users and db.orders share authority)
475    let mut store_syms: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
476    let mut sym_ids: Vec<&String> = graph
477        .entities_of_kind(kinds::SYMBOL)
478        .into_iter()
479        .map(|e| &e.id)
480        .collect();
481    sym_ids.sort();
482    for sym in sym_ids {
483        for r in graph.out_pred(sym, predicates::WRITES) {
484            let target = if r.object.contains("/data/") {
485                graph
486                    .entities
487                    .get(&r.object)
488                    .and_then(|e| e.attributes.get("store"))
489                    .and_then(|v| v.as_str())
490                    .map(|s| scc_core::entity_id(&graph.repo_id, kinds::DATA_STORE, s))
491                    .unwrap_or_else(|| r.object.clone())
492            } else {
493                r.object.clone()
494            };
495            store_syms.entry(target).or_default().insert(sym.clone());
496        }
497    }
498    // configuration target -> symbols it configures (CONFIGURED_BY)
499    let mut cfg_syms: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
500    for cfg in graph.entities_of_kind(kinds::CONFIGURATION) {
501        let mut rels = graph.out_pred(&cfg.id, predicates::CONFIGURED_BY);
502        rels.sort_by(|a, b| a.id.cmp(&b.id));
503        for r in rels {
504            cfg_syms
505                .entry(cfg.id.clone())
506                .or_default()
507                .insert(r.object.clone());
508        }
509    }
510    // reactive concept -> symbols owning its occurrences (OWNS edges on
511    // the OCCURRENCE entities, keyed by concept): symbols in different
512    // files declaring the same reactive state name cohere (Wave 13 — the
513    // owners come from the per-site occurrences, never a collapsed global
514    // entity).
515    let mut reactive_syms: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
516    for rs in graph.entities_of_kind(kinds::REACTIVE) {
517        for r in graph.in_pred(&rs.id, predicates::OCCURS) {
518            let mut rels = graph.in_pred(&r.subject, predicates::OWNS);
519            rels.sort_by(|a, b| a.id.cmp(&b.id));
520            for or in rels {
521                reactive_syms
522                    .entry(rs.id.clone())
523                    .or_default()
524                    .insert(or.subject.clone());
525            }
526        }
527    }
528    let mut out: Vec<BTreeSet<String>> = Vec::new();
529    for group in store_syms
530        .values()
531        .chain(cfg_syms.values())
532        .chain(reactive_syms.values())
533    {
534        if group.len() >= 2 {
535            out.push(group.clone());
536        }
537    }
538    out.sort();
539    out
540}
541
542/// One structured state-ownership claim: `component` owns/reads/registers
543/// `target` (evidence `provenance`). `verb` is `owns` for write-derived
544/// ownership and `reads` for readers — atlas `owns` claims ignore reads so
545/// a reader is never promoted to owner. The structured bridge from the
546/// STATE & DATA AUTHORITY compiler into the atlas component `owns` claims.
547#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
548// trace:exempt reason=internal-detail
549pub struct StateClaim {
550    pub component: String,
551    pub verb: String,
552    pub target: String,
553    pub provenance: String,
554}
555
556/// Emit per-component state claims over the same fact sets as
557/// [`compile_state_authority`]: WRITES to stores/caches, mutable FIELD /
558/// STATE / REGISTRY owners, CONFIGURED_BY configuration targets, topics
559/// (PUBLISHES/SUBSCRIBES/CONSUMES), and middleware/registry REGISTERS.
560/// Deterministic: sorted by (component, verb, target, provenance).
561// trace:v1 id=impl.scc.state work=WORK-SCC-005 satisfies=REQ-state-function-access,REQ-SCC-IR
562pub fn compile_state_claims(
563    graph: &RealityGraph,
564    symbol_comp: &HashMap<String, String>,
565) -> Vec<StateClaim> {
566    let mut claims: BTreeSet<StateClaim> = BTreeSet::new();
567    let comp_of = |sym_id: &str| symbol_comp.get(sym_id).cloned();
568    let prov_str = |p: &Provenance| p.as_str().to_string();
569
570    // per-symbol edges (writes/reads/publishes/subscribes/consumes/registers)
571    let mut symbol_ids: Vec<&String> = symbol_comp.keys().collect();
572    symbol_ids.sort();
573    for sym_id in symbol_ids {
574        let Some(comp) = comp_of(sym_id) else {
575            continue;
576        };
577        let mut rels: Vec<&scc_core::Relationship> = graph.out_edges(sym_id);
578        rels.sort_by(|a, b| {
579            a.predicate
580                .cmp(&b.predicate)
581                .then_with(|| a.object.cmp(&b.object))
582                .then_with(|| a.id.cmp(&b.id))
583        });
584        for r in rels {
585            let Some(target) = graph.entities.get(&r.object) else {
586                continue;
587            };
588            let tgt = match r.predicate.as_str() {
589                predicates::WRITES => {
590                    if target.kind == kinds::DATA_STORE || target.kind == kinds::DATA_ENTITY {
591                        Some(("owns", store_ref(graph, &r.object)))
592                    } else {
593                        Some(("owns", target.name.clone()))
594                    }
595                }
596                predicates::READS | predicates::QUERIES => Some(("reads", target.name.clone())),
597                predicates::PUBLISHES | predicates::SUBSCRIBES | predicates::CONSUMES => {
598                    Some(("owns", target.name.clone()))
599                }
600                predicates::REGISTERS => {
601                    let is_mw_registry = target.kind == kinds::MIDDLEWARE
602                        || target.kind == kinds::REGISTRY
603                        || (target.kind == kinds::CONTRACT
604                            && target
605                                .attributes
606                                .get("kind")
607                                .and_then(|v| v.as_str())
608                                .map(|k| matches!(k, "middleware" | "registry"))
609                                .unwrap_or(false));
610                    if is_mw_registry {
611                        Some(("owns", target.name.clone()))
612                    } else {
613                        None
614                    }
615                }
616                _ => None,
617            };
618            if let Some((verb, t)) = tgt {
619                claims.insert(StateClaim {
620                    component: comp.clone(),
621                    verb: verb.to_string(),
622                    target: t,
623                    provenance: prov_str(&r.provenance),
624                });
625            }
626        }
627    }
628
629    // runtime state: mutable FIELD facts + STATE/REGISTRY entities
630    let mut runtime_entities: Vec<&scc_core::Entity> = graph
631        .entities_of_kind(kinds::FIELD)
632        .into_iter()
633        .filter(|f| f.attributes.get("mutable").and_then(|v| v.as_bool()) == Some(true))
634        .collect();
635    runtime_entities.extend(graph.entities_of_kind(kinds::STATE));
636    runtime_entities.extend(graph.entities_of_kind(kinds::REGISTRY));
637    runtime_entities.sort_by(|a, b| a.id.cmp(&b.id));
638    for e in runtime_entities {
639        let mut rels = graph.in_pred(&e.id, predicates::CONTAINS);
640        rels.sort_by(|a, b| a.id.cmp(&b.id));
641        for r in rels {
642            if let Some(comp) = comp_of(&r.subject) {
643                claims.insert(StateClaim {
644                    component: comp,
645                    verb: "owns".into(),
646                    target: e.name.clone(),
647                    provenance: prov_str(&r.provenance),
648                });
649                break;
650            }
651        }
652    }
653
654    // configuration: CONFIGURED_BY (config -> owner symbol)
655    for cfg in graph.entities_of_kind(kinds::CONFIGURATION) {
656        let mut rels = graph.out_pred(&cfg.id, predicates::CONFIGURED_BY);
657        rels.sort_by(|a, b| a.id.cmp(&b.id));
658        for r in rels {
659            if let Some(comp) = comp_of(&r.object) {
660                claims.insert(StateClaim {
661                    component: comp,
662                    verb: "owns".into(),
663                    target: cfg.name.clone(),
664                    provenance: prov_str(&r.provenance),
665                });
666            }
667        }
668    }
669
670    // reactive state: REACTIVE concepts rendered per occurrence (the owner
671    // symbol's component carries the `reactive: name [access]` claim,
672    // access coming from the occurrence — each file keeps its own variant)
673    for e in graph.entities_of_kind(kinds::REACTIVE) {
674        let mut occs = concept_occurrences(graph, &e.id);
675        occs.sort_by(|a, b| a.id.cmp(&b.id));
676        for occ in occs {
677            let access = occ
678                .attributes
679                .get("access")
680                .and_then(|v| v.as_str())
681                .unwrap_or("state");
682            let mut rels = graph.in_pred(&occ.id, predicates::OWNS);
683            rels.sort_by(|a, b| a.id.cmp(&b.id));
684            for r in rels {
685                if let Some(comp) = comp_of(&r.subject) {
686                    claims.insert(StateClaim {
687                        component: comp,
688                        verb: "owns".into(),
689                        target: format!("reactive: {} [{}]", e.name, access),
690                        provenance: prov_str(&r.provenance),
691                    });
692                }
693            }
694        }
695    }
696
697    let mut out: Vec<StateClaim> = claims.into_iter().collect();
698    out.sort();
699    out
700}
701
702/// Resolve a write target to a human store reference: data entities render
703/// as `store.entity`, stores as their name.
704// trace:exempt reason=internal-detail
705fn store_ref(graph: &RealityGraph, id: &str) -> String {
706    match graph.entities.get(id) {
707        Some(e) if e.kind == kinds::DATA_ENTITY => e
708            .attributes
709            .get("store")
710            .and_then(|v| v.as_str())
711            .map(|s| format!("{s}.{}", e.name))
712            .unwrap_or_else(|| e.name.clone()),
713        Some(e) => e.name.clone(),
714        None => id.to_string(),
715    }
716}
717
718fn tech<'a>(graph: &'a RealityGraph, id: &str) -> Option<&'a str> {
719    graph
720        .entities
721        .get(id)
722        .and_then(|e| e.attributes.get("technology"))
723        .and_then(|v| v.as_str())
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use scc_core::{entity_id, symbol_id, Entity, Relationship};
730    use scc_store::Store;
731
732    fn open() -> (tempfile::TempDir, Store) {
733        let dir = tempfile::TempDir::new().unwrap();
734        let root = dir.path().join("repo");
735        std::fs::create_dir_all(&root).unwrap();
736        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
737        (dir, store)
738    }
739
740    // trace:exempt reason=internal-detail
741    fn sym(store: &Store, path: &str, name: &str) -> String {
742        let id = symbol_id(&store.repo_id, path, name);
743        store
744            .insert_entity(
745                &Entity::new(id.clone(), kinds::SYMBOL, name),
746                &[path.into()],
747            )
748            .unwrap();
749        id
750    }
751
752    fn component(store: &Store, name: &str, files: &[&str]) {
753        // components live in the `components` table (RealityGraph::load
754        // reads store.components()), so insert them through
755        // replace_components like the real pipeline does.
756        let id = entity_id(&store.repo_id, kinds::COMPONENT, name);
757        let mut existing: Vec<scc_core::Entity> = store.components().unwrap();
758        if let Some(c) = existing.iter_mut().find(|c| c.name == name) {
759            // keep prior CONTAINS edges; just ensure presence
760            c.id = id.clone();
761        } else {
762            existing.push(scc_core::Entity::new(id.clone(), kinds::COMPONENT, name));
763        }
764        store.replace_components(&existing).unwrap();
765        for f in files {
766            let fid = entity_id(&store.repo_id, kinds::FILE, f);
767            store
768                .insert_relationship(
769                    &Relationship::new(
770                        format!("rel:c:{name}:{f}"),
771                        id.clone(),
772                        predicates::CONTAINS,
773                        fid,
774                        Provenance::Extracted,
775                    ),
776                    f,
777                )
778                .unwrap();
779        }
780    }
781
782    fn attach(store: &Store, comp: &str, sym_id: &str, path: &str) {
783        // symbol lives in a file of the component: file CONTAINS symbol
784        let fid = entity_id(&store.repo_id, kinds::FILE, path);
785        store
786            .insert_relationship(
787                &Relationship::new(
788                    format!("rel:fc:{comp}:{sym_id}"),
789                    fid,
790                    predicates::CONTAINS,
791                    sym_id.to_string(),
792                    Provenance::Extracted,
793                ),
794                path,
795            )
796            .unwrap();
797    }
798
799    #[test]
800    // trace:exempt reason=internal-detail
801    fn attributes_state_ownership_per_component() {
802        let (_dir, store) = open();
803        let repo = store.repo_id.clone();
804
805        component(&store, "api", &["api/app.py"]);
806        component(&store, "web", &["web/app.py"]);
807
808        // api writes db.users (persistent) and a cache, owns mutable field,
809        // reads config, publishes a topic, registers middleware
810        let api_writer = sym(&store, "api/app.py", "create_user");
811        attach(&store, "api", &api_writer, "api/app.py");
812        let store_ent = entity_id(&repo, kinds::DATA_STORE, "db");
813        store
814            .insert_entity(
815                &Entity::new(store_ent.clone(), kinds::DATA_STORE, "db"),
816                &["api/app.py".into()],
817            )
818            .unwrap();
819        let user = entity_id(&repo, kinds::DATA_ENTITY, "db.users");
820        store
821            .insert_entity(
822                Entity::new(user.clone(), kinds::DATA_ENTITY, "users")
823                    .attr("store", serde_json::json!("db")),
824                &["api/app.py".into()],
825            )
826            .unwrap();
827        store
828            .insert_relationship(
829                &Relationship::new(
830                    "rel:w:users",
831                    api_writer.clone(),
832                    predicates::WRITES,
833                    user,
834                    Provenance::Extracted,
835                )
836                .with_confidence(1.0),
837                "api/app.py",
838            )
839            .unwrap();
840        let cache = entity_id(&repo, kinds::DATA_STORE, "redis");
841        store
842            .insert_entity(
843                Entity::new(cache.clone(), kinds::DATA_STORE, "redis")
844                    .attr("technology", serde_json::json!("redis")),
845                &["api/app.py".into()],
846            )
847            .unwrap();
848        store
849            .insert_relationship(
850                &Relationship::new(
851                    "rel:r:cache",
852                    api_writer.clone(),
853                    predicates::READS,
854                    cache,
855                    Provenance::Extracted,
856                ),
857                "api/app.py",
858            )
859            .unwrap();
860        // mutable field on class Cart inside api/app.py
861        let cart = sym(&store, "api/app.py", "Cart");
862        attach(&store, "api", &cart, "api/app.py");
863        let field = entity_id(&repo, kinds::FIELD, "Cart.items");
864        store
865            .insert_entity(
866                Entity::new(field.clone(), kinds::FIELD, "Cart.items")
867                    .attr("mutable", serde_json::json!(true))
868                    .attr("owner", serde_json::json!("Cart")),
869                &["api/app.py".into()],
870            )
871            .unwrap();
872        store
873            .insert_relationship(
874                &Relationship::new(
875                    "rel:f:items",
876                    cart,
877                    predicates::CONTAINS,
878                    field,
879                    Provenance::Extracted,
880                ),
881                "api/app.py",
882            )
883            .unwrap();
884        // configuration
885        let cfg = entity_id(&repo, kinds::CONFIGURATION, "DEBUG");
886        store
887            .insert_entity(
888                &Entity::new(cfg.clone(), kinds::CONFIGURATION, "DEBUG"),
889                &["api/app.py".into()],
890            )
891            .unwrap();
892        store
893            .insert_relationship(
894                &Relationship::new(
895                    "rel:cfg",
896                    cfg,
897                    predicates::CONFIGURED_BY,
898                    api_writer.clone(),
899                    Provenance::Extracted,
900                ),
901                "api/app.py",
902            )
903            .unwrap();
904        // topic publish
905        let topic = entity_id(&repo, kinds::TOPIC, "user.created");
906        store
907            .insert_entity(
908                Entity::new(topic.clone(), kinds::TOPIC, "user.created")
909                    .attr("store", serde_json::json!("kafka")),
910                &["api/app.py".into()],
911            )
912            .unwrap();
913        store
914            .insert_relationship(
915                &Relationship::new(
916                    "rel:p:topic",
917                    api_writer.clone(),
918                    predicates::PUBLISHES,
919                    topic,
920                    Provenance::Extracted,
921                ),
922                "api/app.py",
923            )
924            .unwrap();
925        // middleware registration
926        let mw = entity_id(&repo, kinds::MIDDLEWARE, "RequestLogger");
927        store
928            .insert_entity(
929                &Entity::new(mw.clone(), kinds::MIDDLEWARE, "RequestLogger"),
930                &["api/app.py".into()],
931            )
932            .unwrap();
933        store
934            .insert_relationship(
935                &Relationship::new(
936                    "rel:reg:mw",
937                    api_writer,
938                    predicates::REGISTERS,
939                    mw,
940                    Provenance::Extracted,
941                ),
942                "api/app.py",
943            )
944            .unwrap();
945
946        // web only reads a plain store (not cache) -> no state claims
947        let web_reader = sym(&store, "web/app.py", "list_items");
948        attach(&store, "web", &web_reader, "web/app.py");
949        store
950            .insert_relationship(
951                &Relationship::new(
952                    "rel:r:web",
953                    web_reader,
954                    predicates::READS,
955                    store_ent,
956                    Provenance::Extracted,
957                ),
958                "web/app.py",
959            )
960            .unwrap();
961
962        let graph = RealityGraph::load(&store).unwrap();
963        let mut symbol_comp: HashMap<String, String> = HashMap::new();
964        for c in &graph.components {
965            for r in graph.out_pred(&c.id, predicates::CONTAINS) {
966                for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
967                    symbol_comp.insert(sr.object.clone(), c.name.clone());
968                }
969            }
970        }
971
972        let state = compile_state_authority(&graph, &symbol_comp);
973        // every section key present (empty sections stay empty)
974        for k in STATE_SECTIONS {
975            assert!(state.contains_key(k), "missing section {k}: {state:?}");
976        }
977        let persistent = &state[S_PERSISTENT];
978        assert!(
979            persistent
980                .iter()
981                .any(|l| l == "api owns db.users (EXTRACTED)"),
982            "component owns missing: {persistent:?}"
983        );
984        assert!(
985            persistent
986                .iter()
987                .any(|l| l == "api::create_user writes db.users (EXTRACTED)"),
988            "function writes missing: {persistent:?}"
989        );
990        assert!(
991            persistent
992                .iter()
993                .any(|l| l == "web::list_items reads db (EXTRACTED)"),
994            "function reads missing: {persistent:?}"
995        );
996        assert!(
997            !persistent.iter().any(|l| l.contains("web owns")),
998            "readers must not own: {persistent:?}"
999        );
1000        let runtime = &state[S_RUNTIME];
1001        assert_eq!(runtime.len(), 1, "{runtime:?}");
1002        assert_eq!(runtime[0], "api owns Cart.items (mutable) (EXTRACTED)");
1003        let config = &state[S_CONFIGURATION];
1004        assert_eq!(config[0], "api configured_by DEBUG (EXTRACTED)");
1005        let caches = &state[S_CACHES];
1006        assert_eq!(caches[0], "api reads redis (EXTRACTED)");
1007        let derived = &state[S_DERIVED];
1008        assert!(
1009            derived
1010                .iter()
1011                .any(|l| l.starts_with("api publishes user.created")),
1012            "{derived:?}"
1013        );
1014        assert!(
1015            derived
1016                .iter()
1017                .any(|l| l == "api registers RequestLogger (EXTRACTED)"),
1018            "{derived:?}"
1019        );
1020
1021        // web has NO ownership claims (reads are not owns)
1022        for k in STATE_SECTIONS {
1023            for line in &state[k] {
1024                assert!(
1025                    !line.contains("web owns") && !line.starts_with("web owns"),
1026                    "web must not own state: {line}"
1027                );
1028            }
1029        }
1030
1031        // determinism: identical graph -> identical output
1032        let graph2 = RealityGraph::load(&store).unwrap();
1033        let state2 = compile_state_authority(&graph2, &symbol_comp);
1034        assert_eq!(state, state2);
1035    }
1036
1037    #[test]
1038    // trace:v1 id=test.scc.state.function-access verifies=REQ-state-function-access exercises=impl.scc.state.authority
1039    fn function_access_is_not_ownership() {
1040        let (_dir, store) = open();
1041        let repo = store.repo_id.clone();
1042        component(&store, "api", &["api/app.py"]);
1043        component(&store, "web", &["web/app.py"]);
1044        let writer = sym(&store, "api/app.py", "save");
1045        attach(&store, "api", &writer, "api/app.py");
1046        let reader = sym(&store, "web/app.py", "load");
1047        attach(&store, "web", &reader, "web/app.py");
1048        let db = entity_id(&repo, kinds::DATA_STORE, "orders");
1049        store
1050            .insert_entity(
1051                &Entity::new(db.clone(), kinds::DATA_STORE, "orders"),
1052                &["api/app.py".into()],
1053            )
1054            .unwrap();
1055        store
1056            .insert_relationship(
1057                &Relationship::new(
1058                    "rel:w",
1059                    writer,
1060                    predicates::WRITES,
1061                    db.clone(),
1062                    Provenance::Extracted,
1063                ),
1064                "api/app.py",
1065            )
1066            .unwrap();
1067        store
1068            .insert_relationship(
1069                &Relationship::new(
1070                    "rel:r",
1071                    reader,
1072                    predicates::READS,
1073                    db,
1074                    Provenance::Extracted,
1075                ),
1076                "web/app.py",
1077            )
1078            .unwrap();
1079        let graph = RealityGraph::load(&store).unwrap();
1080        let mut symbol_comp: HashMap<String, String> = HashMap::new();
1081        for c in &graph.components {
1082            for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1083                for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1084                    symbol_comp.insert(sr.object.clone(), c.name.clone());
1085                }
1086            }
1087        }
1088        let state = compile_state_authority(&graph, &symbol_comp);
1089        let persistent = &state[S_PERSISTENT];
1090        assert!(persistent
1091            .iter()
1092            .any(|l| l == "api owns orders (EXTRACTED)"));
1093        assert!(persistent
1094            .iter()
1095            .any(|l| l == "api::save writes orders (EXTRACTED)"));
1096        assert!(persistent
1097            .iter()
1098            .any(|l| l == "web::load reads orders (EXTRACTED)"));
1099        assert!(!persistent.iter().any(|l| l.contains("web owns")));
1100        let claims = compile_state_claims(&graph, &symbol_comp);
1101        assert!(claims
1102            .iter()
1103            .any(|c| c.component == "api" && c.verb == "owns" && c.target == "orders"));
1104        assert!(claims
1105            .iter()
1106            .any(|c| c.component == "web" && c.verb == "reads" && c.target == "orders"));
1107        assert!(!claims
1108            .iter()
1109            .any(|c| c.component == "web" && c.verb == "owns"));
1110    }
1111
1112    #[test]
1113    fn cache_store_heuristics() {
1114        assert!(is_cache_store("redis", Some("redis")));
1115        assert!(is_cache_store("cache", None));
1116        assert!(is_cache_store("user-cache", None));
1117        assert!(is_cache_store("kv", Some("valkey")));
1118        assert!(!is_cache_store("db", Some("postgres")));
1119        assert!(!is_cache_store("orders", None));
1120    }
1121
1122    // trace:exempt reason=internal-detail
1123
1124    /// Wave 11/13: symbols OWNS-ing occurrences of the same REACTIVE
1125    /// concept form a shared-state authority group (the +4 clustering
1126    /// signal), and the REACTIVE STATE section attributes each occurrence
1127    /// to its owner symbol's component.
1128    #[test]
1129
1130    // trace:exempt reason=internal-detail
1131    fn reactive_state_owners_group_and_attribute() {
1132        let (_dir, store) = open();
1133        let repo = store.repo_id.clone();
1134        component(&store, "api", &["api/app.py"]);
1135        let a = sym(&store, "api/app.py", "store_a");
1136        attach(&store, "api", &a, "api/app.py");
1137        let b = sym(&store, "api/app.py", "store_b");
1138        attach(&store, "api", &b, "api/app.py");
1139
1140        // one concept, two per-site occurrences (Wave 13: identity is per
1141        // concept/path/owner/line — the global REACTIVE entity no longer
1142        // collapses owners)
1143        let rs = entity_id(&repo, kinds::REACTIVE, "count");
1144        store
1145            .insert_entity(
1146                &Entity::new(rs.clone(), kinds::REACTIVE, "count"),
1147                &["api/app.py".into()],
1148            )
1149            .unwrap();
1150        for (i, (occ, owner_id, owner_name)) in [
1151            (
1152                scc_core::occurrence_id(&repo, "count", "api/app.py", "store_a", 1),
1153                a.clone(),
1154                "store_a",
1155            ),
1156            (
1157                scc_core::occurrence_id(&repo, "count", "api/app.py", "store_b", 2),
1158                b.clone(),
1159                "store_b",
1160            ),
1161        ]
1162        .iter()
1163        .enumerate()
1164        {
1165            store
1166                .insert_entity(
1167                    Entity::new(
1168                        occ.clone(),
1169                        kinds::OCCURRENCE,
1170                        format!("count@api/app.py@{}@{}", owner_name, i + 1),
1171                    )
1172                    .attr("concept", serde_json::json!(rs))
1173                    .attr("path", serde_json::json!("api/app.py"))
1174                    .attr("owner", serde_json::json!(owner_name))
1175                    .attr("line", serde_json::json!(i + 1))
1176                    .attr("access", serde_json::json!("state")),
1177                    &["api/app.py".into()],
1178                )
1179                .unwrap();
1180            store
1181                .insert_relationship(
1182                    &Relationship::new(
1183                        format!("rel:owns:{i}"),
1184                        owner_id.clone(),
1185                        predicates::OWNS,
1186                        occ.clone(),
1187                        Provenance::Extracted,
1188                    ),
1189                    "api/app.py",
1190                )
1191                .unwrap();
1192            store
1193                .insert_relationship(
1194                    &Relationship::new(
1195                        format!("rel:occ:{i}"),
1196                        occ.clone(),
1197                        predicates::OCCURS,
1198                        rs.clone(),
1199                        Provenance::Extracted,
1200                    ),
1201                    "api/app.py",
1202                )
1203                .unwrap();
1204        }
1205
1206        // shared-reactive-ownership group: both owners in one group
1207        let groups = state_authority_groups(&RealityGraph::load(&store).unwrap());
1208        assert!(
1209            groups
1210                .iter()
1211                .any(|g| g.contains(&a) && g.contains(&b) && g.len() == 2),
1212            "reactive owners must group: {groups:?}"
1213        );
1214
1215        // section attribution: both owners render under REACTIVE STATE
1216        // (identical occurrence variants dedupe to one line)
1217        let graph = RealityGraph::load(&store).unwrap();
1218        let mut symbol_comp: HashMap<String, String> = HashMap::new();
1219        for c in &graph.components {
1220            for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1221                for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1222                    symbol_comp.insert(sr.object.clone(), c.name.clone());
1223                }
1224            }
1225        }
1226        let state = compile_state_authority(&graph, &symbol_comp);
1227        assert_eq!(
1228            state[S_REACTIVE],
1229            vec!["api owns reactive: count [state] (EXTRACTED)".to_string()],
1230            "{:?}",
1231            state[S_REACTIVE]
1232        );
1233
1234        // derived count: two live occurrences
1235        assert_eq!(occurrence_count(&graph, &rs), 2);
1236        assert_eq!(occurrence_producer(&graph, &rs).as_deref(), Some("store_a"));
1237
1238        // structured claims bridge carries the same attribution
1239        let claims = compile_state_claims(&graph, &symbol_comp);
1240        assert!(
1241            claims.iter().any(|c| c.target == "reactive: count [state]"),
1242            "claims missing reactive state: {claims:?}"
1243        );
1244    }
1245
1246    #[test]
1247    // trace:exempt reason=internal-detail
1248    fn non_mutable_fields_are_not_runtime_state() {
1249        let (_dir, store) = open();
1250        let repo = store.repo_id.clone();
1251        component(&store, "api", &["api/app.py"]);
1252        let cart = sym(&store, "api/app.py", "Cart");
1253        attach(&store, "api", &cart, "api/app.py");
1254        let field = entity_id(&repo, kinds::FIELD, "Cart.name");
1255        store
1256            .insert_entity(
1257                Entity::new(field.clone(), kinds::FIELD, "Cart.name")
1258                    .attr("mutable", serde_json::json!(false))
1259                    .attr("owner", serde_json::json!("Cart")),
1260                &["api/app.py".into()],
1261            )
1262            .unwrap();
1263        store
1264            .insert_relationship(
1265                &Relationship::new(
1266                    "rel:f:name",
1267                    cart,
1268                    predicates::CONTAINS,
1269                    field,
1270                    Provenance::Extracted,
1271                ),
1272                "api/app.py",
1273            )
1274            .unwrap();
1275        let graph = RealityGraph::load(&store).unwrap();
1276        let mut symbol_comp = HashMap::new();
1277        for c in &graph.components {
1278            for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1279                for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1280                    symbol_comp.insert(sr.object.clone(), c.name.clone());
1281                }
1282            }
1283        }
1284        let state = compile_state_authority(&graph, &symbol_comp);
1285        let runtime = state.get(S_RUNTIME).map(|v| v.as_slice()).unwrap_or(&[]);
1286        assert!(
1287            runtime.is_empty(),
1288            "immutable field is not runtime state: {runtime:?}"
1289        );
1290    }
1291
1292    /// Wave 9 symbol→state authority: module-level globals (owned by the
1293    /// module symbol) and class statics are mutable FIELD facts attributed
1294    /// to their component in the RUNTIME STATE section + claims bridge.
1295    #[test]
1296    fn module_global_and_static_state_attribute_to_component() {
1297        let (_dir, store) = open();
1298        let repo = store.repo_id.clone();
1299        component(&store, "api", &["api/app.py"]);
1300
1301        // module symbol (file stem) owns a module-level mutable global
1302        let module = sym(&store, "api/app.py", "app");
1303        attach(&store, "api", &module, "api/app.py");
1304        let field = entity_id(&repo, kinds::FIELD, "app.DEFAULT_TIMEOUT");
1305        store
1306            .insert_entity(
1307                Entity::new(field.clone(), kinds::FIELD, "app.DEFAULT_TIMEOUT")
1308                    .attr("mutable", serde_json::json!(true))
1309                    .attr("owner", serde_json::json!("app")),
1310                &["api/app.py".into()],
1311            )
1312            .unwrap();
1313        store
1314            .insert_relationship(
1315                &Relationship::new(
1316                    "rel:f:dt",
1317                    module,
1318                    predicates::CONTAINS,
1319                    field,
1320                    Provenance::Extracted,
1321                ),
1322                "api/app.py",
1323            )
1324            .unwrap();
1325
1326        // static field on a class in the same file
1327        let cfg = sym(&store, "api/app.py", "Config");
1328        attach(&store, "api", &cfg, "api/app.py");
1329        let stat = entity_id(&repo, kinds::FIELD, "Config.retries");
1330        store
1331            .insert_entity(
1332                Entity::new(stat.clone(), kinds::FIELD, "Config.retries")
1333                    .attr("mutable", serde_json::json!(true))
1334                    .attr("owner", serde_json::json!("Config")),
1335                &["api/app.py".into()],
1336            )
1337            .unwrap();
1338        store
1339            .insert_relationship(
1340                &Relationship::new(
1341                    "rel:f:r",
1342                    cfg,
1343                    predicates::CONTAINS,
1344                    stat,
1345                    Provenance::Extracted,
1346                ),
1347                "api/app.py",
1348            )
1349            .unwrap();
1350
1351        let graph = RealityGraph::load(&store).unwrap();
1352        let mut symbol_comp: HashMap<String, String> = HashMap::new();
1353        for c in &graph.components {
1354            for r in graph.out_pred(&c.id, predicates::CONTAINS) {
1355                for sr in graph.out_pred(&r.object, predicates::CONTAINS) {
1356                    symbol_comp.insert(sr.object.clone(), c.name.clone());
1357                }
1358            }
1359        }
1360
1361        let state = compile_state_authority(&graph, &symbol_comp);
1362        let runtime = &state[S_RUNTIME];
1363        assert!(
1364            runtime
1365                .iter()
1366                .any(|l| l == "api owns app.DEFAULT_TIMEOUT (mutable) (EXTRACTED)"),
1367            "module global missing from runtime state: {runtime:?}"
1368        );
1369        assert!(
1370            runtime
1371                .iter()
1372                .any(|l| l == "api owns Config.retries (mutable) (EXTRACTED)"),
1373            "class static missing from runtime state: {runtime:?}"
1374        );
1375
1376        // structured claims bridge carries the same attributions
1377        let claims = compile_state_claims(&graph, &symbol_comp);
1378        assert!(
1379            claims
1380                .iter()
1381                .any(|c| c.component == "api" && c.target == "app.DEFAULT_TIMEOUT"),
1382            "claims missing module global: {claims:?}"
1383        );
1384        assert!(
1385            claims
1386                .iter()
1387                .any(|c| c.component == "api" && c.target == "Config.retries"),
1388            "claims missing class static: {claims:?}"
1389        );
1390    }
1391}