From 2f4507c195c15bddb2a064e6973df77238b8c632 Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:24:34 +0000 Subject: [PATCH 01/10] fix: correct fabric entity taxonomy and Smithville coverage summary MAN LAN entity taxonomy correction: Ixia is test/measurement equipment (not a fabric attachment); WIX interconnect is interconnect context; NEAAR is a service reference; TWAREN and OMAN are unresolved source mentions. Fabric diagram and attachment count (10 -> 5) now contain only reviewed attached networks, with a bounded entity taxonomy in the reviewed interconnection context. Smithville event page: observation-coverage summary (collector, site, family, target visibility, relationship visibility, qualification) derived from canonical preflight evidence; demo import now prefers the latest reviewed immutable snapshot (INC0301970.source.json + reviewed meta) over the older fixture; source snapshot time, lifecycle at snapshot, analysis cutoff, and cutoff provenance are distinct fields; assessment leads with the operator-readable explanation; status labels separate analysis status from source lifecycle. Zero external evaluation sessions; canonical BGP evidence unchanged; no analysis rerun. --- .../INC0301970.source.json.meta.json | 9 + .../indiana-gigapop-smithville-2026/README.md | 11 +- .../observation-coverage.json | 66 ++ case-studies/manlan-2019/README.md | 15 +- case-studies/manlan-2019/case-study.json | 67 +- docs/DOMAIN.md | 13 + docs/GLOSSARY.md | 17 + docs/OBSERVABILITY.md | 18 + docs/UX.md | 8 + ...6-08-entity-taxonomy-smithville-summary.md | 104 +++ .../NOC-ALPHA-FACILITATOR-GUIDE.md | 21 + scripts/build-evaluation-answer-key.py | 27 +- src/catalog/case_study_import.rs | 24 +- src/catalog/import.rs | 76 +- src/catalog/web/api.rs | 2 +- src/catalog/web/fabric_path_tests.rs | 8 +- src/catalog/web/handlers.rs | 2 +- src/catalog/web/mod.rs | 2 + src/catalog/web/taxonomy_tests.rs | 881 ++++++++++++++++++ src/catalog/web/templates/case_study.html | 54 +- src/catalog/web/templates/event_detail.html | 41 +- src/catalog/web/view.rs | 318 ++++++- src/catalog/web/workbench_fix_tests.rs | 8 +- 23 files changed, 1714 insertions(+), 78 deletions(-) create mode 100644 case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json create mode 100644 case-studies/indiana-gigapop-smithville-2026/observation-coverage.json create mode 100644 docs/audits/2026-08-entity-taxonomy-smithville-summary.md create mode 100644 src/catalog/web/taxonomy_tests.rs diff --git a/case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json b/case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json new file mode 100644 index 0000000..efeb7f2 --- /dev/null +++ b/case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "fetched_at_utc": "2026-08-04T00:01:37Z", + "lifecycle_at_snapshot": "Open", + "lifecycle_evidence": "source state In Progress (state code 2) with no published end at the refresh retrieval; the event remains open", + "snapshot_role": "latest reviewed immutable snapshot (exact source refresh retrieval)", + "cutoff_provenance": "Analysis cutoff is the reviewed snapshot cutoff: the exact source refresh retrieval 2026-08-04T00:01:37Z (state In Progress, no end). It is the reviewed end of the provisional analysis window (manifest analysis_end_utc), not the fetch time of the older tracked offline fixture.", + "provenance": "docs/audits/2026-08-smithville-source-refresh.md (Exact source refresh); manifests/INC0301970.json analyst note 3" +} diff --git a/case-studies/indiana-gigapop-smithville-2026/README.md b/case-studies/indiana-gigapop-smithville-2026/README.md index 156161a..0d61a96 100644 --- a/case-studies/indiana-gigapop-smithville-2026/README.md +++ b/case-studies/indiana-gigapop-smithville-2026/README.md @@ -15,11 +15,20 @@ labeled provisional. reported unavailable. - **Ticket horizon:** work_start 2026-07-28T04:35:26Z; source state In Progress; no published end (open event). +- **Source snapshot:** fetched at 2026-08-04T00:01:37Z (the exact + source refresh retrieval, reviewed); source lifecycle at that + snapshot: Open (state In Progress, no end). The tracked immutable + snapshot `INC0301970.source.json` is the refreshed snapshot; the + older tracked offline fixture is not the reviewed snapshot. - **Analysis horizon:** the reviewed event window from 2026-07-28T04:35:00Z (reviewed window start; the source work_start is 04:35:26Z) through the reviewed snapshot cutoff 2026-08-04T00:01:37Z (the exact source refresh retrieval, reviewed). Result is - Provisional. + Provisional. The analysis cutoff is the reviewed snapshot cutoff — + the reviewed end of the provisional analysis window, not a fixture + fetch time; its provenance is recorded in + `INC0301970.source.json.meta.json` and + `docs/audits/2026-08-smithville-source-refresh.md`. ## Reviewed identities diff --git a/case-studies/indiana-gigapop-smithville-2026/observation-coverage.json b/case-studies/indiana-gigapop-smithville-2026/observation-coverage.json new file mode 100644 index 0000000..fecec5b --- /dev/null +++ b/case-studies/indiana-gigapop-smithville-2026/observation-coverage.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "event_id": "INC0301970", + "reviewed_at": "2026-08-05", + "provenance": "Reviewed observer-coverage summary for the INC0301970 open-event run. Counts are the canonical event-date baseline preflight facts recorded in docs/audits/2026-08-smithville-source-refresh.md (Event-date baseline preflight table) and the reviewed manifest analyst notes (manifests/INC0301970.json, analyst note 4). Collector sites come from the reviewed collector-location metadata (case-studies/manlan-2019/pilot/collector-locations.json; same collectors, stable locations). No new acquisition; no analysis rerun.", + "summary": "Smithville-origin routes were visible at selected public collectors, but none exposed the reviewed Indiana GigaPOP\u2013Smithville AS-path adjacency and no direct AS19782 observer session was available. No qualifying baseline cohort was formed, so UPDATE archives were not acquired.", + "updates_acquired": false, + "updates_explanation": "No qualifying baseline cohort was formed: zero AS11550 routes traversed AS19782 and zero direct AS19782 observer sessions existed at the selected collectors, so no UPDATE acquisition was justified. The zero-baseline stop is by design.", + "rows": [ + { + "collector": "route-views2", + "family": "RouteViews", + "target_visible": true, + "target_prefixes": 13, + "relationship_visible": false, + "direct_observer_session": false, + "human_label": "Target routes visible; reviewed relationship not visible", + "blocker_classification": "TargetPresentRelationshipAbsent + RequiredSessionAbsent", + "note": "221 AS11550 routes; 0 of 221 traverse AS19782" + }, + { + "collector": "rrc00", + "family": "RIPE RIS", + "target_visible": true, + "target_prefixes": 13, + "relationship_visible": false, + "direct_observer_session": false, + "human_label": "Target routes visible; reviewed relationship not visible", + "blocker_classification": "TargetPresentRelationshipAbsent + RequiredSessionAbsent", + "note": "546 AS11550 routes; 0 of 546 traverse AS19782" + }, + { + "collector": "rrc06", + "family": "RIPE RIS", + "target_visible": true, + "target_prefixes": 13, + "relationship_visible": false, + "direct_observer_session": false, + "human_label": "Target routes visible; reviewed relationship not visible", + "blocker_classification": "TargetPresentRelationshipAbsent + RequiredSessionAbsent", + "note": "65 AS11550 routes; 0 of 65 traverse AS19782" + }, + { + "collector": "rrc11", + "family": "RIPE RIS", + "target_visible": true, + "target_prefixes": 13, + "relationship_visible": false, + "direct_observer_session": false, + "human_label": "Target routes visible; reviewed relationship not visible", + "blocker_classification": "TargetPresentRelationshipAbsent + RequiredSessionAbsent", + "note": "91 AS11550 routes; 0 of 91 traverse AS19782" + }, + { + "collector": "route-views6", + "family": "RouteViews", + "target_visible": false, + "target_prefixes": 0, + "relationship_visible": false, + "direct_observer_session": false, + "human_label": "Target origin not visible", + "blocker_classification": "TargetOriginNotVisible (IPv6-only collector)", + "note": "0 AS11550 announces of 4,854,128 parsed" + } + ] +} diff --git a/case-studies/manlan-2019/README.md b/case-studies/manlan-2019/README.md index 3ec6a30..1e80c9e 100644 --- a/case-studies/manlan-2019/README.md +++ b/case-studies/manlan-2019/README.md @@ -26,11 +26,20 @@ study: - MAN LAN does **not** speak BGP, does not originate routes, and does **not** appear as an AS-path hop; - MAN LAN facilitates Layer-2 connectivity among attached networks - (reviewed attachments are listed in `case-study.json` → - `interconnection_context`, with ASN labels only where the reviewed - target research establishes them for 2019-08-21); + (reviewed attached networks are listed in `case-study.json` → + `interconnection_context` → `attachments`, with ASN labels only + where the reviewed target research establishes them for 2019-08-21; + all other source-mentioned entities are classified separately as + test equipment, interconnect context, service references, or + unresolved mentions and are **not** fabric attachments — source + mention is not proof of attachment, a reviewed ASN is not proof of + attachment, and a familiar organization name is not proof of entity + class); - **Layer-2 attachment is not BGP adjacency**: an attached network may or may not have exchanged routes directly with other attachments; +- **test/measurement equipment (Ixia) is not a peer**: it has no ASN, + no BGP relationship, and no network-participant role; it appears + only as operational/test-equipment context; - public BGP observes **exported route consequences** at public collectors, never switch-fabric state. diff --git a/case-studies/manlan-2019/case-study.json b/case-studies/manlan-2019/case-study.json index b0d4b32..4a15cfa 100644 --- a/case-studies/manlan-2019/case-study.json +++ b/case-studies/manlan-2019/case-study.json @@ -395,70 +395,85 @@ "interconnection_context": { "kind": "Layer2Fabric", "label": "MAN LAN", - "provenance": "Reviewed 2026-08-04 (session-54 semantic correction): MAN LAN is a Layer-2 exchange/fabric operated by Internet2 for research-and-education interconnection (per the operator after-action report and the reviewed target research, case-studies/manlan-2019/target-research.json). Attachments and their reviewed ASN labels where established come only from the reviewed target research (historical_asns, validity date 2019-08-21); no ASN is guessed. Attachment describes reviewed physical/Layer-2 participation context; it does not prove BGP adjacency, exported route visibility, a commercial relationship, traffic flow, or active state during the event.", + "provenance": "Reviewed 2026-08-04 (session-54 semantic correction) and 2026-08-05 (session-55 entity taxonomy correction): MAN LAN is a Layer-2 exchange/fabric operated by Internet2 for research-and-education interconnection (per the operator after-action report and the reviewed target research, case-studies/manlan-2019/target-research.json). Reviewed attached networks and their ASN labels where established come only from the reviewed target research (historical_asns, validity date 2019-08-21) and the reviewed pilot selection; no ASN is guessed. Entities whose attachment is not established are classified separately (test equipment, interconnect context, service references, unresolved mentions) and are not fabric attachments. Attachment describes reviewed physical/Layer-2 participation context; it does not prove BGP adjacency, exported route visibility, a commercial relationship, traffic flow, or active state during the event.", "limitations": [ "MAN LAN is a Layer-2 fabric: it has no ASN for the purposes of this case study, does not speak BGP, does not originate routes, and does not appear as an AS-path hop.", "Layer-2 attachment is not BGP adjacency: an attached network may or may not have exchanged routes directly with other attachments.", + "A source mention (AAR) is not proof of attachment; a reviewed ASN is not by itself proof of MAN LAN attachment; a familiar organization name is not proof of entity class.", + "Test equipment (Ixia) is not a peer, an AS node, or a network participant; it appears only as operational/test-equipment context.", "The reviewed ASN labels are 2019-08-21 historical identities; current registry metadata must not be used as 2019 truth.", "The NORDUnet pilot observes one attached network (AS2603) at selected public collectors; it is not a complete analysis of the fabric or of all connectors." ], "attachments": [ { "label": "NORDUnet", - "note": "research/education network operator; analyzed target of the completed historical pilot", + "note": "research/education network operator; analyzed target of the completed historical pilot (AAR-documented MAN LAN interface actions; reviewed pilot selection)", "asn": 2603, "asn_validity_date": "2019-08-21" }, { "label": "ESnet", - "note": "research/education network operator", + "note": "research/education network operator; AAR-documented MAN LAN interface actions (interface shut ~16:39, disabled 17:30, re-enabled 20:44)", "asn": 293, "asn_validity_date": "2019-08-21" }, { "label": "GÉANT", - "note": "research/education network operator", + "note": "research/education network operator; AAR-documented MAN LAN interface state (still not up 13:13). Attachment identity AS21320 (2019 PeeringDB capture) is distinct from AS20965 observed in actual NORDUnet route paths (current-identity-only review); the differing ASNs are not treated as contradictory", "asn": 21320, "asn_validity_date": "2019-08-21" }, { "label": "CANARIE", - "note": "research/education network operator", + "note": "research/education network operator; MAN LAN presence explicitly documented (2019 Wikipedia: external segments extend to Manhattan Landing; AAR-documented optic swap/dropped interface 12:03-12:34)", "asn": 6509, "asn_validity_date": "2019-08-21" }, - { - "label": "TWAREN", - "note": "research/education network operator", - "asn": 7539, - "asn_validity_date": "2019-08-21" - }, { "label": "SINET", - "note": "research/education network operator", + "note": "research/education network operator; AAR-documented MAN LAN interface action (swapped with NORDUnet 15:48; BGP not re-establishing)", "asn": 2907, "asn_validity_date": "2019-08-21" - }, + } + ], + "test_equipment": [ { - "label": "Ixia", - "note": "connector/test context; no reviewed ASN label", + "label": "Ixia test equipment", + "note": "network test/measurement hardware (traffic generation and analysis); not an autonomous system, not a BGP peer, not a participant network, and not a reviewed fabric attachment. AAR documents an outage at 16:54. No reviewed ASN; no PeeringDB network entry.", "asn": null - }, + } + ], + "interconnect_context": [ + { + "label": "WIX interconnect", + "note": "exchange fabric (Washington International Exchange; Internet2 + MAX partnership), not an origin network and not established as an attached network on MAN LAN. AAR documents a brief outage 13:26-13:27, resolved 14:40." + } + ], + "operational_services": [ { "label": "NEAAR", - "note": "ambiguous service identity; no reviewed ASN label", - "asn": null - }, + "note": "research/education connectivity consortium and circuit/connectivity service (NEA3R: Networks for European, American, African and Arctic Research); listed as a MAN LAN peer in Internet2's international peers table, but not established as an attached autonomous network. AAR documents VLAN no packets 13:04, up 13:32. No origin ASN." + } + ], + "unresolved_mentions": [ { - "label": "OMAN", - "note": "unresolved identity; no reviewed ASN label", - "asn": null + "label": "TWAREN", + "note": "identity historically reviewed (AS7539, 2019-08-21) and AAR documents an interface not receiving light at 13:45, but the reviewed pilot selection flags MAN LAN attachment as less certain (2019 US presence primarily via PacificWave, West Coast). Not rendered as an attached network; the reviewed ASN is retained here as identity detail only.", + "asn": 7539, + "asn_validity_date": "2019-08-21" }, { - "label": "WIX interconnect", - "note": "interconnect context; no reviewed ASN label", + "label": "OMAN", + "note": "unresolved source mention: no reviewed source connects OMAN/OMREN to MAN LAN or CANARIE; MAN LAN peer lists contain no Oman entry. AAR notes it rides the CANARIE interface. No reviewed ASN, no organization or country-network assumption.", "asn": null } - ] + ], + "entity_review": { + "date": "2026-08-05", + "reason": "Internal walkthrough correction: test equipment and entities without established attachment were rendered as reviewed Layer-2 fabric attachments. Reviewed classification now follows the reviewed target research and pilot selection: only entities whose reviewed evidence supports fabric attachment are AttachedNetwork; all others are classified TestEquipment / InterconnectContext / OperationalService / UnresolvedMention.", + "prior_classification": "10 reviewed attachments (NORDUnet, ESnet, GÉANT, CANARIE, TWAREN, SINET, Ixia, NEAAR, OMAN, WIX interconnect), all rendered as Layer-2 attachments.", + "corrected_classification": "5 reviewed attached networks (NORDUnet, ESnet, GÉANT, CANARIE, SINET); Ixia = test equipment; WIX interconnect = interconnect context; NEAAR = operational/service reference; TWAREN and OMAN = unresolved source mentions (TWAREN AS7539 retained as identity detail).", + "evidence": "case-studies/manlan-2019/target-research.json; case-studies/manlan-2019/pilot/PILOT-SELECTION.md; AAR-derived target roles in case-study.json" + } } -} +} \ No newline at end of file diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index 205fc1d..c313e2e 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -427,6 +427,19 @@ the reviewed records establish them, their ASN labels with a validity date. It is stored as reviewed interpretation in the case-study layer (`interconnection_context`) and rendered as presentation. +Entities mentioned by sources are classified with a **bounded entity +taxonomy**: `AttachedNetwork` (reviewed evidence supports a Layer-2 +attachment), `TestEquipment` (measurement/test hardware — never a +peer, an AS node, or an attached network), `InterconnectContext` +(interconnection or external-fabric reference), `OperationalService` +(service/facility context), and `UnresolvedMention` (role not +sufficiently established). Only reviewed attached networks are fabric +diagram participants; every other class is listed separately as +context. Source mention is not proof of attachment; a reviewed ASN is +not by itself proof of attachment; a Layer-2 attachment is not proof +of BGP adjacency; a familiar organization name is not proof of entity +class. + The context is deliberately **not protocol evidence**: production analysis never uses fabric attachment metadata in route predicates, cohort selection, or findings. Layer-2 attachment is not BGP diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 3697e68..660321e 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -228,6 +228,23 @@ provisional run is never mutated. adjacency, exported route visibility, a commercial relationship, traffic flow, or active state during the event. Attachment is never rendered as a directional BGP edge. +- **Source-mentioned entity** — an entity that appears in reviewed + source material (for example an after-action report) without its + role being established. A source mention is **not** proof of fabric + attachment; a reviewed ASN is not by itself proof of attachment; a + familiar organization name is not proof of entity class. Entities + whose role is not established are classified separately (for + example as unresolved mentions) and are not rendered as fabric + attachments. +- **Test equipment** — network test/measurement hardware (for example + a traffic generator or analyzer) mentioned by sources. Test + equipment is not an autonomous system, not a BGP peer, not an AS + node, and not an attached network; it may be described in prose as + operational/test-equipment context only. +- **Interconnect context** — an interconnection or external-fabric + reference (for example another exchange) that is not established as + a participant AS on the reviewed fabric. It is listed as context, + never as an attached network. - **Observed AS-path diagram** — a presentation of an observed AS path at one public observer, rendered from canonical route evidence. A solid arrow is observed AS-path order; arrow direction never labels diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 8e178c8..7736005 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -100,6 +100,24 @@ NotDirectlyVisible condition stays NotDirectlyObservable even when a pilot run exists; a narrow pilot's absence of observations never refutes non-BGP-visible conditions, and never extends beyond its own window. +## Observer-coverage summaries (2026-08) + +Event pages may render a reviewed **observation-coverage summary** +(collector-by-collector: collector, collector site, source family, +target-origin visibility, reviewed-relationship visibility, and the +qualification reason). Coverage summaries are derived from canonical +preflight/run evidence (reviewed per-collector counts, the reviewed +archive manifest, and the reviewed manifest analyst notes) and are +reviewed interpretation, not new evidence. Target-origin visibility and +reviewed-relationship visibility are distinct facts and are always +presented separately: target routes may be visible at a collector while +the reviewed relationship is not exposed by its baselines. Collector +site describes where the collector's route reflector is hosted; it is +not the observer peer's location. A coverage summary never claims a +relationship did not exist, that routing was stable, or that no outage +occurred — it states what the selected public observers could and could +not see. + ## Pilot timing interpretation Temporal relations preserve event order: for point action anchors the diff --git a/docs/UX.md b/docs/UX.md index 6b0c202..f435206 100644 --- a/docs/UX.md +++ b/docs/UX.md @@ -208,6 +208,14 @@ Evidence semantics are encoded visually and repeated in text: - **grey undirected line** — reviewed Layer-2 attachment context (never a BGP adjacency claim). +Case-study pages separate **reviewed attached networks** (the only +entities drawn in the fabric diagram) from **other incident context** +(test equipment, interconnect/service references, unresolved source +mentions), which are listed in tables with their reviewed notes and +never given attachment edges. The fabric attachment count counts only +reviewed attached networks; other source-mentioned entities are +counted separately. + Rules: arrow direction is never described as provider/customer/peer without separate reviewed evidence; a Layer-2 fabric is never drawn as an ASN node; a withdrawn route is an absence block, never an arrow to diff --git a/docs/audits/2026-08-entity-taxonomy-smithville-summary.md b/docs/audits/2026-08-entity-taxonomy-smithville-summary.md new file mode 100644 index 0000000..68c6b60 --- /dev/null +++ b/docs/audits/2026-08-entity-taxonomy-smithville-summary.md @@ -0,0 +1,104 @@ +# MAN LAN entity taxonomy and Smithville event-summary review — 2026-08-05 + +Internal project-owner walkthrough. This is **not** an external evaluation +session; the pilot registry remains at **zero external sessions**. + +## Scope + +Two evaluator-facing defects found during the internal walkthrough: + +1. **MAN LAN entity taxonomy** — test/measurement equipment (Ixia) and + entities whose attachment is not established (NEAAR, OMAN, WIX + interconnect, TWAREN) are rendered as reviewed Layer-2 fabric + attachments, implying they are attached networks or connectors. +2. **Smithville event summary** — the event page does not clearly + explain the selected observer coverage, and it does not distinguish + the imported source-snapshot fetch time from the reviewed analysis + cutoff or show the cutoff's provenance. + +## Observed facts (fresh read-only evaluator demo, 2026-08-05) + +Reproduced from a clean clone at `bb894fc`, `inim demo init` + `inim +demo verify` (ok), then served read-only. + +### MAN LAN case-study page (`/case-studies/manlan-2019`) + +- The fabric SVG renders ten attachment nodes, each labeled + "reviewed Layer-2 attachment (not BGP adjacency)": + NORDUnet, ESnet, GÉANT, CANARIE, TWAREN, SINET, Ixia, NEAAR, OMAN, + WIX interconnect. +- The attachment table (header "Attached network/connector") lists the + same ten entries; Ixia/NEAAR/OMAN/WIX interconnect carry "no + reviewed ASN". +- Ixia (a network test-equipment vendor, per the reviewed target + research) is presented with the same node type as attached networks. + +### Smithville event page (`/events/INC0301970`) + +- Workflow line: "Provisional analysis completed — observed through + 2026-08-04T00:01:37Z"; Latest result "Insufficient qualifying + visibility". +- Labels: "Status Complete" and "Lifecycle Open" (adjacent rows). +- Event window block: Start, "Source lifecycle: Open", "Analysis + cutoff: 2026-08-04T00:01:37Z". +- Source snapshot history shows the imported snapshot fetched at + **2026-07-31T00:00:00Z** (tracked offline fixture), with no statement + of when the source lifecycle was verified relative to the cutoff. +- No observation-coverage summary: the page does not state how many + collectors were checked, that AS11550-origin routes were visible, or + why no UPDATE archives were acquired. + +## Canonical evidence consulted (tracked, no new acquisition) + +- `case-studies/manlan-2019/case-study.json` — reviewed interconnection + context (10 attachments) and AAR-derived target roles. +- `case-studies/manlan-2019/target-research.json` — reviewed entity + identities (2026-08-04 review): ASN labels and per-entity notes. +- `case-studies/manlan-2019/pilot/PILOT-SELECTION.md` — reviewed + per-entity determination table (AAR-documented actions, origin + mapping, pilot-suitability verdicts). +- `docs/audits/2026-08-smithville-source-refresh.md` — exact source + refresh record (retrieval timestamp 2026-08-04T00:01:37Z, raw + snapshot SHA-256, lifecycle In Progress / open, event-date baseline + preflight table with per-collector counts). +- `manifests/INC0301970.json` — reviewed manifest: `analysis_end_utc` + 2026-08-04T00:01:37Z, `open: true`, analyst notes recording the exact + source refresh and the preflight determination. +- `case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json` + — the immutable refreshed source snapshot (state In Progress, no + end), committed 2026-08-04. + +## Findings + +1. Ixia is classified in the reviewed research as a network + test-equipment vendor, not a network operator; it has no ASN and no + PeeringDB network entry. Its current presentation as a Layer-2 + fabric attachment is incorrect. +2. NEAAR, OMAN, and WIX interconnect have no reviewed attachment + evidence; TWAREN's attachment is explicitly flagged "less certain" + in the reviewed pilot selection. None meet the reviewed + "AttachedNetwork" bar (source mention alone is insufficient). +3. The demo imports the 2026-07-31 tracked fixture as the source + snapshot, while the reviewed manifest's snapshot cutoff is the + 2026-08-04T00:01:37Z exact source refresh (tracked immutable + snapshot `INC0301970.source.json`). The later reviewed snapshot is + not imported into the demo. +4. The source lifecycle "Open" was verified at the 2026-08-04T00:01:37Z + refresh (state In Progress, no published end), but the event page + does not state that the lifecycle claim is anchored to that snapshot. +5. The analysis cutoff equals the reviewed snapshot cutoff (the exact + source refresh retrieval) — it is not a fixture fetch time — and its + provenance is recorded in the manifest analyst notes and the source + refresh audit, but is not visible on the event page. +6. No canonical BGP evidence changed during this review; no analysis + was rerun; no source was contacted. + +## Corrections applied + +See the session-55 change set: entity taxonomy in the reviewed +interconnection context (Ixia → test equipment; WIX → interconnect +context; NEAAR → service reference; OMAN and TWAREN → unresolved +mentions), fabric diagram restricted to reviewed attached networks, +corrected attachment count, Smithville observation-coverage summary, +snapshot/cutoff provenance presentation, and the demo import preferring +the latest reviewed immutable snapshot. diff --git a/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md b/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md index 6c098bd..8d263b4 100644 --- a/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md +++ b/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md @@ -150,3 +150,24 @@ these reviewed truths and may clarify them without leading answers: - **Layer-2 attachment and AS-path adjacency are different evidence classes**: attachment does not prove BGP adjacency, route export, a commercial relationship, traffic flow, or active state. + +## Entity taxonomy and coverage truths (2026-08) + +- **Ixia is network test/measurement equipment** — not a participating + network, not a BGP peer, not an AS node, and not a reviewed fabric + attachment. It may appear as operational/test-equipment context only. +- **Not every source-mentioned entity is a reviewed fabric + attachment**: source mention is not proof of attachment, a reviewed + ASN is not proof of attachment, and a familiar organization name is + not proof of entity class. The fabric diagram shows only reviewed + attached networks; other entities are listed separately. +- **Smithville target-origin visibility and named-relationship + visibility are separate**: AS11550-origin routes were visible at + selected public collectors, while the reviewed Indiana + GigaPOP–Smithville AS-path adjacency was not exposed and no direct + AS19782 observer session was available. +- **Source snapshot time and analysis cutoff are separate + timestamps**: the event page shows the source snapshot fetch time, + the source lifecycle at that snapshot, and the reviewed analysis + cutoff (the reviewed snapshot cutoff) with its provenance as + distinct fields. diff --git a/scripts/build-evaluation-answer-key.py b/scripts/build-evaluation-answer-key.py index a63442f..c4bea49 100644 --- a/scripts/build-evaluation-answer-key.py +++ b/scripts/build-evaluation-answer-key.py @@ -176,6 +176,7 @@ def iso_parse(s: str): "target": "NORDUnet AS2603 is the analyzed BGP target (one attached network); the completed pilot is NORDUnet-target-scoped, not MAN LAN BGP analysis.", "path_evidence": "observed AS paths are public-collector evidence (route-views2 peer 64.57.28.241 and RIS observers); they show what the collector received, never switch-fabric state.", "attachment_vs_adjacency": "Layer-2 attachment and AS-path adjacency are different evidence classes: attachment does not prove BGP adjacency, route export, a commercial relationship, traffic flow, or active state during the event.", + "entity_taxonomy": "Ixia is network test/measurement equipment, not a participating network and not a BGP peer. Not every source-mentioned entity is a reviewed fabric attachment: source mention is not proof of attachment, a reviewed ASN is not proof of attachment, and a familiar organization name is not proof of entity class.", "reference": path_as_ref("case-studies/manlan-2019/case-study.json"), }, "analysis_window_utc": pilot["window_start_utc"] + " .. " + pilot["window_end_utc"], @@ -473,6 +474,21 @@ def i2px_section(root: Path) -> dict: } +def smithville_snapshot_facts(root: Path) -> dict: + meta = checked_json( + root, "case-studies/indiana-gigapop-smithville-2026", "INC0301970.source.json.meta.json" + ) + manifest = checked_json(root, "manifests", "INC0301970.json") + return { + "source_snapshot_fetched_at": meta.get("fetched_at_utc", ""), + "source_lifecycle_at_snapshot": meta.get("lifecycle_at_snapshot", ""), + "lifecycle_evidence": meta.get("lifecycle_evidence", ""), + "analysis_cutoff": manifest.get("analysis_end_utc") or "", + "cutoff_provenance": meta.get("cutoff_provenance", ""), + "note": "source snapshot fetch time, source lifecycle at that snapshot, and the analysis cutoff are separate timestamps presented separately; the cutoff is the reviewed snapshot cutoff, not a fixture fetch time.", + "reference": path_as_ref("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json"), + } + def smithville_section(root: Path) -> dict: manifest = checked_json(root, "manifests", "INC0301970.json") report = checked_json(root, "case-studies/indiana-gigapop-smithville-2026/out/INC0301970", "report.json") @@ -517,11 +533,14 @@ def smithville_section(root: Path) -> dict: "reference": path_as_ref("case-studies/indiana-gigapop-smithville-2026/out/INC0301970/report.json"), }, "why_insufficient_visibility": ( - "no selected RouteViews observer had a pre-event route matching the reviewed " - "path predicate, so no qualifying baseline exists; the run records " - "InsufficientVisibility with no UPDATE acquisition. This is distinct from " - "observing no route-state change: there was no qualifying observation at all." + "no selected observer had an event-baseline route exposing the reviewed " + "AS19782-AS11550 adjacency, so no qualifying baseline cohort exists; the run " + "records InsufficientVisibility with no UPDATE acquisition. This is distinct " + "from observing no route-state change: there was no qualifying observation " + "at all. Target-origin visibility (AS11550 routes were visible) and " + "reviewed-relationship visibility (none exposed the adjacency) are separate." ), + "snapshot_vs_cutoff": smithville_snapshot_facts(root), "non_conclusions": [ "no qualifying relationship evidence was observed through the reviewed cutoff", "not claimed: no relationship existed", diff --git a/src/catalog/case_study_import.rs b/src/catalog/case_study_import.rs index 776b8c0..7e4f122 100644 --- a/src/catalog/case_study_import.rs +++ b/src/catalog/case_study_import.rs @@ -69,6 +69,25 @@ pub struct DataInterconnectionContext { /// Reviewed attachments (physical / Layer-2 participation context). #[serde(default)] pub attachments: Vec, + /// Network test/measurement hardware mentioned by sources. Never a + /// peer, an AS node, or an attached network. + #[serde(default)] + pub test_equipment: Vec, + /// Interconnection or external-fabric references that are not + /// established as attached networks on this fabric. + #[serde(default)] + pub interconnect_context: Vec, + /// Service or facility context not established as an attached + /// autonomous network. + #[serde(default)] + pub operational_services: Vec, + /// Source-mentioned entities whose role is not sufficiently + /// established to classify as attachments. + #[serde(default)] + pub unresolved_mentions: Vec, + /// Record of reviewed classification corrections. + #[serde(default)] + pub entity_review: Option, /// Where this reviewed context comes from. pub provenance: String, /// Explicit limitations of the attachment evidence class. @@ -76,7 +95,10 @@ pub struct DataInterconnectionContext { pub limitations: Vec, } -/// One reviewed attachment to a Layer-2 fabric. +/// One reviewed entity reference in a Layer-2 fabric context. The +/// semantic class is conveyed by which list the entry appears in +/// (attachments = attached networks; test_equipment; interconnect +/// context; operational services; unresolved mentions). #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] #[serde(deny_unknown_fields)] pub struct DataAttachment { diff --git a/src/catalog/import.rs b/src/catalog/import.rs index ba7ae49..9628493 100644 --- a/src/catalog/import.rs +++ b/src/catalog/import.rs @@ -143,9 +143,54 @@ pub(crate) fn import_one( std::fs::read_to_string(manifest_path).map_err(|e| format!("cannot read manifest: {e}"))?; let manifest_sha = hex_sha256(&manifest_payload); - // ── Source snapshot: prefer the ticket fixture; else derive. ── + // ── Source snapshot: prefer the latest reviewed case-study + // snapshot (/.source.json, with optional + // .source.json.meta.json carrying the reviewed fetch time + // and lifecycle-at-snapshot), then the tracked offline fixture, + // else derive from the manifest. ── + let case_study_snapshot = case_study_snapshot_for(out_dir, &event_id_str); let fixture_path = ticket_fixture_for(&event_id_str); - let (snapshot, snapshot_sha) = if let Some(fixture) = fixture_path { + let (snapshot, snapshot_sha) = if let Some((snapshot_path, meta)) = case_study_snapshot { + let raw = std::fs::read_to_string(&snapshot_path) + .map_err(|e| format!("cannot read snapshot {}: {e}", snapshot_path.display()))?; + let sha = hex_sha256(&raw); + let fetched_at = meta + .get("fetched_at_utc") + .and_then(|v| v.as_str()) + .unwrap_or("2026-07-31T00:00:00Z") + .to_string(); + // The tracked snapshot is the raw source record; the normalized + // event is derived from it (title + window), mirroring the + // fixture normalization. + let normalized = serde_json::json!({ + "id": event_id_str, + "title": serde_json::from_str::(&raw) + .ok() + .and_then(|v| { + v.get("short_description") + .or_else(|| v.get("title")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| manifest.target.label.clone()), + "source": "tracked-snapshot", + "start": manifest.event_window_utc.start, + "end": manifest.event_window_utc.end, + }); + ( + EventSnapshot { + id: 0, + event_id: 0, + fetched_at, + source_url: format!("file://{}", snapshot_path.display()), + content_sha256: sha.clone(), + raw_payload: raw, + normalized_json: normalized.to_string(), + parser_version: "tracked-snapshot-1".to_string(), + }, + sha, + ) + } else if let Some(fixture) = fixture_path { let raw = std::fs::read_to_string(&fixture) .map_err(|e| format!("cannot read fixture {}: {e}", fixture.display()))?; let sha = hex_sha256(&raw); @@ -751,6 +796,33 @@ fn ticket_fixture_for(event_id: &str) -> Option { None } +/// The latest reviewed immutable source snapshot for an event: +/// `/.source.json` next to the run output root, +/// with its optional `.source.json.meta.json` sidecar +/// (reviewed fetch time, lifecycle at snapshot, cutoff provenance). +/// Returns `None` when no tracked snapshot exists for the event. +fn case_study_snapshot_for( + out_dir: &Path, + event_id: &str, +) -> Option<(PathBuf, serde_json::Value)> { + let snapshot_path = out_dir.parent()?.join(format!("{event_id}.source.json")); + if !snapshot_path.is_file() { + return None; + } + let meta_path = out_dir + .parent()? + .join(format!("{event_id}.source.json.meta.json")); + let meta: serde_json::Value = if meta_path.is_file() { + std::fs::read_to_string(&meta_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + Some((snapshot_path, meta)) +} + pub(crate) fn artifact_kind(rel: &str) -> &'static str { if rel.ends_with("report.json") || rel.ends_with("report.txt") { "report" diff --git a/src/catalog/web/api.rs b/src/catalog/web/api.rs index 78f877b..7660541 100644 --- a/src/catalog/web/api.rs +++ b/src/catalog/web/api.rs @@ -67,7 +67,7 @@ pub async fn api_event_detail( AxumPath(event_id): AxumPath, ) -> Response { let db = state.db.lock().unwrap(); - match super::view::load_event_detail_json(&db, &event_id) { + match super::view::load_event_detail_json(&db, &event_id, &state.catalog_root) { Ok(Some(v)) => { // Direct access to an excluded event is consistently 404. if let Ok(Some(event)) = crate::catalog::db::get_event_by_external_any(&db, &event_id) { diff --git a/src/catalog/web/fabric_path_tests.rs b/src/catalog/web/fabric_path_tests.rs index 50767e6..736a695 100644 --- a/src/catalog/web/fabric_path_tests.rs +++ b/src/catalog/web/fabric_path_tests.rs @@ -152,7 +152,7 @@ async fn nordunet_identified_as_target_not_fabric() { "analyzed target named in the completed-analysis panel: {seg}" ); // The analyzed target is an attachment in the fabric table, not the fabric itself. - let context = body.find("Reviewed attachment context").unwrap(); + let context = body.find("Reviewed attached networks").unwrap(); let seg = &body[context..context + 1200]; assert!( seg.to_lowercase().contains("nordunet") && seg.contains("AS2603"), @@ -283,7 +283,7 @@ async fn diagram_uses_only_reviewed_attachments() { .iter() .map(|a| a["label"].as_str().unwrap().to_string()) .collect(); - let fabric = body.find("Reviewed attachment context").unwrap(); + let fabric = body.find("Reviewed attached networks").unwrap(); let seg = &body[fabric..fabric + 6000]; for label in &reviewed { assert!( @@ -313,10 +313,10 @@ async fn diagram_has_text_equivalent() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let fabric = body.find("Reviewed attachment context").unwrap(); + let fabric = body.find("Reviewed attached networks").unwrap(); let seg = &body[fabric..fabric + 6000]; assert!(seg.contains(""), "text table equivalent: {seg}"); - assert!(seg.contains("Attached network/connector"), "{seg}"); + assert!(seg.contains("Attached network"), "{seg}"); assert!(seg.contains("Reviewed ASN"), "{seg}"); } diff --git a/src/catalog/web/handlers.rs b/src/catalog/web/handlers.rs index 157b2f9..d409aa3 100644 --- a/src/catalog/web/handlers.rs +++ b/src/catalog/web/handlers.rs @@ -112,7 +112,7 @@ pub async fn event_detail( AxumPath(event_id): AxumPath, ) -> Response { let db = state.db.lock().unwrap(); - match super::view::load_event_detail(&db, &event_id) { + match super::view::load_event_detail(&db, &event_id, &state.catalog_root) { Ok(Some(view)) => { // Direct access to an excluded event is consistently 404: // it is not an active project result. diff --git a/src/catalog/web/mod.rs b/src/catalog/web/mod.rs index dbc5c5e..819ff19 100644 --- a/src/catalog/web/mod.rs +++ b/src/catalog/web/mod.rs @@ -20,6 +20,8 @@ pub mod tests; pub mod view; #[cfg(test)] pub mod workbench_fix_tests; +#[cfg(test)] +pub mod taxonomy_tests; use std::path::PathBuf; use std::sync::{Arc, Mutex}; diff --git a/src/catalog/web/taxonomy_tests.rs b/src/catalog/web/taxonomy_tests.rs new file mode 100644 index 0000000..bc46415 --- /dev/null +++ b/src/catalog/web/taxonomy_tests.rs @@ -0,0 +1,881 @@ +//! Regression tests for the MAN LAN entity taxonomy correction and the +//! Smithville event-page coverage/provenance repair. +//! +//! Case-study integration tests load the tracked cases through the same +//! repository import the demo uses; no live network, no analysis. +//! Generic component tests live in `src/catalog/web/path_diagram.rs`. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::catalog::web::server::{build_app, build_state}; +use crate::catalog::web::AppState; + +fn repo_artifacts_available() -> bool { + std::path::Path::new("case-studies/manlan-2019/pilot/out").is_dir() + && std::path::Path::new( + "case-studies/indiana-gigapop-smithville-2026/out/INC0301970/report.json", + ) + .is_file() +} + +fn setup_demo_catalog() -> (tempfile::TempDir, std::path::PathBuf) { + let dbdir = tempfile::tempdir().unwrap(); + let path = dbdir.path().join("catalog.sqlite"); + crate::catalog::demo::demo_init(&path, std::path::Path::new("."), false) + .expect("demo import succeeds"); + (dbdir, std::path::PathBuf::from(".")) +} + +fn state_from(dbdir: &tempfile::TempDir, rootdir: &std::path::Path) -> Arc { + build_state( + &dbdir.path().join("catalog.sqlite"), + rootdir, + "0.1.0", + false, + ) + .unwrap() +} + +async fn get(app: &axum::Router, uri: &str) -> (StatusCode, String) { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024 * 1024) + .await + .unwrap(); + (status, String::from_utf8_lossy(&bytes).to_string()) +} + +/// Case-insensitive run lookup (SQLite LIKE is ASCII case-insensitive). +fn run_id_for_like(dbdir: &tempfile::TempDir, _pattern: &str) -> i64 { + let conn = crate::catalog::db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + let rows: Vec = conn + .prepare("SELECT id FROM analysis_runs WHERE status = 'Complete' ORDER BY id") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .map(|x| x.unwrap()) + .collect(); + // The first complete run is the Smithville run in the demo. + assert!(!rows.is_empty(), "demo has a complete run"); + rows[0] +} + +fn db_conn(dbdir: &tempfile::TempDir) -> rusqlite::Connection { + crate::catalog::db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap() +} + +fn strip_html(body: &str) -> String { + let re = regex::Regex::new(r"<[^>]+>").unwrap(); + let text = re.replace_all(body, " "); + let text = text.replace(" ", " "); + text.split_whitespace().collect::>().join(" ") +} + +// ── Ixia classification (Part 2) ────────────────────────────────── + +#[tokio::test] +async fn ixia_is_not_layer2_fabric_attachment() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + // Ixia appears only inside the "Test equipment" contextual table, + // never in the reviewed attached-networks table. + let attached_region = text + .find("Reviewed attached networks (Layer-2 fabric participants)") + .map(|i| &text[i..]) + .unwrap_or(""); + let other_region = text + .find("Other incident context") + .map(|i| &text[i..]) + .unwrap_or(""); + assert!( + !attached_region.contains("Ixia"), + "Ixia must not appear in the reviewed attached-networks section" + ); + assert!( + other_region.contains("Ixia test equipment"), + "Ixia renders as test equipment context" + ); +} + +#[tokio::test] +async fn ixia_is_not_rendered_as_network_node() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + // The fabric SVG (the network diagram) contains no Ixia node. + let svg_start = body.find("").unwrap_or(body.len()); + let fabric_svg = &body[svg_start..svg_end]; + assert!( + !fabric_svg.contains("Ixia"), + "Ixia must not be a node in the fabric diagram" + ); +} + +#[tokio::test] +async fn ixia_is_not_counted_as_attached_network() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + // The count line states the reviewed attached-network count; Ixia + // is not among the counted labels (verified by the table test). + let count_line = text + .find("reviewed attached") + .map(|i| text[i..i + 80].to_string()) + .unwrap_or_default(); + assert!( + count_line.starts_with("5 reviewed attached networks"), + "attachment count is 5: {count_line}" + ); +} + +#[tokio::test] +async fn ixia_has_no_asn() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + let ixia_region = text + .find("Ixia test equipment") + .map(|i| &text[i..i + 120]) + .unwrap_or(""); + assert!( + ixia_region.contains("no reviewed ASN"), + "Ixia carries no ASN: {ixia_region}" + ); +} + +#[tokio::test] +async fn ixia_has_no_bgp_relationship() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + // No edge (solid or dashed) references Ixia, and no "peer" wording + // is attached to the Ixia row. + let ixia_region = text + .find("Ixia test equipment") + .map(|i| &text[i..i + 200]) + .unwrap_or(""); + assert!( + !ixia_region.contains("peer") && !ixia_region.contains("Adjacent"), + "Ixia has no BGP relationship wording: {ixia_region}" + ); + assert!( + !body.contains("Ixia") && !body.contains(">Ixia<"), + "no Ixia node markup" + ); +} + +#[tokio::test] +async fn ixia_may_render_as_test_equipment_context() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + assert!( + body.contains("Test equipment") && body.contains("Ixia test equipment"), + "Ixia renders in the test-equipment contextual section" + ); + assert!( + body.contains("network test/measurement hardware"), + "Ixia described as measurement hardware" + ); +} + +// ── Taxonomy rules (Part 3) ─────────────────────────────────────── + +#[tokio::test] +async fn aar_mention_does_not_imply_attachment() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + let attached_region = text + .find("Reviewed attached networks (Layer-2 fabric participants)") + .map(|i| &text[i..]) + .unwrap_or(""); + let other_region = text + .find("Other incident context") + .map(|i| &text[i..]) + .unwrap_or(""); + for aar_mentioned in ["WIX interconnect", "NEAAR", "OMAN"] { + assert!( + !attached_region.contains(aar_mentioned), + "{aar_mentioned} must not be an attached network (AAR mention only)" + ); + assert!( + other_region.contains(aar_mentioned), + "{aar_mentioned} must appear in other incident context" + ); + } +} + +#[tokio::test] +async fn reviewed_asn_does_not_imply_attachment() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + let attached_region = text + .find("Reviewed attached networks (Layer-2 fabric participants)") + .map(|i| &text[i..]) + .unwrap_or(""); + assert!( + !attached_region.contains("TWAREN"), + "TWAREN has a reviewed ASN but not established MAN LAN attachment" + ); + // The reviewed ASN is retained as identity detail in the + // unresolved list. + let unresolved_region = text + .find("Unresolved source mentions") + .map(|i| &text[i..]) + .unwrap_or(""); + assert!( + unresolved_region.contains("TWAREN") && unresolved_region.contains("AS7539"), + "TWAREN AS7539 retained as identity detail: {unresolved_region}" + ); +} + +#[tokio::test] +async fn equipment_cannot_enter_as_path() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + let story_region = text + .find("Representative observer route story") + .map(|i| &text[i..]) + .unwrap_or(""); + assert!( + !story_region.contains("Ixia"), + "test equipment never enters an observed AS path" + ); +} + +#[tokio::test] +async fn unresolved_entity_not_rendered_as_network() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let svg_start = body.find("").unwrap_or(body.len()); + let fabric_svg = &body[svg_start..svg_end]; + for unresolved in ["OMAN", "NEAAR", "TWAREN"] { + assert!( + !fabric_svg.contains(unresolved), + "{unresolved} must not be a fabric network node" + ); + } +} + +// ── Fabric diagram entity audit (Part 4) ────────────────────────── + +#[tokio::test] +async fn diagram_entities_equal_reviewed_attached_networks() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + // Reviewed attachment labels from the tracked case-study data. + let cs: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string("case-studies/manlan-2019/case-study.json").unwrap(), + ) + .unwrap(); + let mut expected: Vec = cs["interconnection_context"]["attachments"] + .as_array() + .unwrap() + .iter() + .map(|a| a["label"].as_str().unwrap().to_string()) + .collect(); + expected.sort(); + // Node labels present in the fabric SVG. + let svg_start = body.find("").unwrap_or(body.len()); + let svg = &body[svg_start..svg_end]; + let mut found: Vec = expected + .iter() + .filter(|label| svg.contains(label.as_str())) + .cloned() + .collect(); + found.sort(); + assert_eq!( + found, expected, + "fabric diagram entities must equal the reviewed attached networks" + ); + // No reviewed attachment is missing from the diagram. + assert_eq!(found.len(), expected.len()); +} + +#[tokio::test] +async fn contextual_entities_not_given_attachment_edges() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let svg_start = body.find("").unwrap_or(body.len()); + let svg = &body[svg_start..svg_end]; + for contextual in ["Ixia", "NEAAR", "OMAN", "WIX", "TWAREN"] { + assert!( + !svg.contains(contextual), + "{contextual} must not have a diagram attachment edge" + ); + } +} + +#[tokio::test] +async fn unresolved_entities_preserve_uncertainty() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + let unresolved_region = text + .find("Unresolved source mentions") + .map(|i| &text[i..]) + .unwrap_or(""); + assert!( + unresolved_region.contains("less certain") || unresolved_region.contains("unresolved"), + "uncertainty is preserved for unresolved entities: {unresolved_region}" + ); + assert!( + unresolved_region.contains("OMAN"), + "OMAN listed as unresolved source mention" + ); +} + +// ── Smithville observation coverage (Parts 7-8) ─────────────────── + +#[tokio::test] +async fn smithville_event_page_has_observation_coverage() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Observation coverage"), + "event page has an observation-coverage section" + ); + assert!( + text.contains("Smithville-origin routes were visible at selected public collectors"), + "operator-readable coverage summary present: {text}" + ); + assert!( + text.contains("UPDATE archives were not acquired"), + "UPDATE acquisition outcome explained" + ); + assert!( + text.contains("No qualifying baseline cohort was formed"), + "why no UPDATE archives were acquired" + ); +} + +#[tokio::test] +async fn target_visibility_and_relationship_visibility_distinct() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Target visible?") && text.contains("Reviewed relationship visible?"), + "target visibility and relationship visibility are separate columns" + ); + assert!( + text.contains("Yes — 13 prefixes"), + "target prefixes visible at IPv4 collectors" + ); + assert!( + text.contains("Target routes visible; reviewed relationship not visible"), + "primary human label for present-target absent-relationship collectors" + ); + assert!( + text.contains("Target origin not visible"), + "primary human label for the IPv6-only collector" + ); +} + +#[tokio::test] +async fn collector_site_not_peer_location() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + assert!( + body.contains("Collector site"), + "collector-site column present" + ); + assert!( + body.contains("not the observer peer's location"), + "collector site is not labeled as peer location" + ); +} + +#[tokio::test] +async fn event_summary_links_to_full_evidence() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let run_id = run_id_for_like(&dbdir, "%"); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + assert!( + body.contains("Full observation coverage and evidence"), + "link to full evidence present" + ); + assert!( + body.contains(&format!("/analyses/{run_id}")), + "full-evidence link targets the run workbench" + ); +} + +#[tokio::test] +async fn insufficient_visibility_not_no_change() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Insufficient qualifying visibility"), + "observed result label present" + ); + assert!( + !text.contains("routing was stable") && !text.contains("no route-state change"), + "insufficient visibility is not presented as no-change" + ); +} + +// ── Snapshot / cutoff provenance (Part 9) ───────────────────────── + +#[tokio::test] +async fn source_snapshot_time_and_analysis_cutoff_are_distinct() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Source snapshot fetched at: 2026-08-04T00:01:37Z"), + "source snapshot fetch time shown: {text}" + ); + assert!( + text.contains("Analysis cutoff: 2026-08-04T00:01:37Z"), + "analysis cutoff shown" + ); + let snapshot_pos = text.find("Source snapshot fetched at"); + let cutoff_pos = text.find("Analysis cutoff"); + assert!( + snapshot_pos.is_some() && cutoff_pos.is_some() && snapshot_pos < cutoff_pos, + "snapshot time and cutoff are distinct labeled fields" + ); +} + +#[tokio::test] +async fn open_lifecycle_claim_has_snapshot_provenance() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Source lifecycle at snapshot: Open"), + "lifecycle anchored to the snapshot" + ); + assert!( + text.contains("state In Progress"), + "lifecycle evidence shown" + ); +} + +#[tokio::test] +async fn cutoff_provenance_visible() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Cutoff provenance"), + "cutoff provenance field present" + ); + assert!( + text.contains("reviewed snapshot cutoff"), + "cutoff identified as the reviewed snapshot cutoff" + ); +} + +#[tokio::test] +async fn demo_imports_latest_reviewed_snapshot_when_present() { + if !repo_artifacts_available() { + return; + } + let (dbdir, _rootdir) = setup_demo_catalog(); + let conn = db_conn(&dbdir); + let snapshot: Option<(String, String)> = conn + .query_row( + "SELECT s.fetched_at, s.content_sha256 + FROM event_snapshots s + JOIN catalog_events e ON e.id = s.event_id + WHERE e.external_id = 'INC0301970' + ORDER BY s.id DESC LIMIT 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .ok(); + let (fetched_at, sha) = snapshot.expect("INC0301970 snapshot imported"); + assert_eq!(fetched_at, "2026-08-04T00:01:37Z", "reviewed fetch time"); + // The imported snapshot is the tracked immutable source snapshot. + let tracked = std::fs::read("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json") + .unwrap(); + let tracked_sha = { + crate::catalog::document::hex_sha256(&tracked) + }; + assert_eq!(sha, tracked_sha, "imported snapshot is the tracked source.json"); +} + +#[tokio::test] +async fn no_immutable_snapshot_mutation() { + if !repo_artifacts_available() { + return; + } + // The tracked immutable snapshot still hashes to the SHA-256 + // recorded in the case-study README. + let tracked = std::fs::read("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json") + .unwrap(); + let sha = crate::catalog::document::hex_sha256(&tracked); + assert_eq!( + sha, + "d911687c634a5efa7eafbea5816c4aa376f61c7c8fc14bd3611042873696de77", + "immutable snapshot content must be unchanged" + ); +} + +// ── Status labels (Part 10) ─────────────────────────────────────── + +#[tokio::test] +async fn analysis_status_and_source_lifecycle_distinct() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Analysis status") && text.contains("Source lifecycle"), + "analysis status and source lifecycle are distinct labeled fields" + ); + let status_pos = text.find("Analysis status"); + let lifecycle_pos = text.find("Source lifecycle"); + assert!( + status_pos.is_some() && lifecycle_pos.is_some() && status_pos < lifecycle_pos, + "analysis status precedes source lifecycle" + ); +} + +#[tokio::test] +async fn complete_does_not_imply_event_closed() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Analysis status") && text.contains("Complete"), + "analysis status Complete shown" + ); + assert!( + text.contains("Source lifecycle") && text.contains("Open"), + "source lifecycle Open shown alongside" + ); + assert!( + text.contains("Analysis cutoff"), + "cutoff retained" + ); + assert!( + !text.contains("event is closed") && !text.contains("Lifecycle Closed"), + "Complete must not be presented as event closure" + ); +} + +// ── Assessment wording (Part 11) ────────────────────────────────── + +#[tokio::test] +async fn smithville_human_explanation_mentions_target_visibility() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("Target routes were visible at selected public collectors"), + "operator-readable explanation mentions target visibility" + ); +} + +#[tokio::test] +async fn smithville_human_explanation_mentions_relationship_visibility() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let text = strip_html(&body); + assert!( + text.contains("reviewed relationship was not exposed by the selected baselines"), + "operator-readable explanation mentions relationship visibility" + ); +} + +#[tokio::test] +async fn technical_blockers_preserved() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + assert!( + body.contains("TargetPresentRelationshipAbsent"), + "exact machine classification preserved" + ); + assert!( + body.contains("RequiredSessionAbsent"), + "required-session blocker preserved" + ); +} + +// ── Neutrality / evidence safety (Part 14) ──────────────────────── + +#[tokio::test] +async fn equipment_never_rendered_as_as_node() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let svg_start = body.find("").unwrap_or(body.len()); + let svg = &body[svg_start..svg_end]; + assert!( + !svg.contains("Ixia"), + "equipment is never an AS node in any diagram" + ); +} + +#[tokio::test] +async fn equipment_never_rendered_as_peer() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + let ixia_region = text + .find("Ixia test equipment") + .map(|i| &text[i..i + 300]) + .unwrap_or(""); + assert!( + !ixia_region.contains("peer") && !ixia_region.contains("BGP peer"), + "equipment is never described as a peer: {ixia_region}" + ); +} + +#[tokio::test] +async fn equipment_never_enters_attachment_count() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let text = strip_html(&body); + assert!( + text.contains("5 reviewed attached networks"), + "attachment count excludes equipment" + ); +} + +#[tokio::test] +async fn only_reviewed_attached_networks_enter_fabric_diagram() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let cs: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string("case-studies/manlan-2019/case-study.json").unwrap(), + ) + .unwrap(); + let expected: Vec = cs["interconnection_context"]["attachments"] + .as_array() + .unwrap() + .iter() + .map(|a| a["label"].as_str().unwrap().to_string()) + .collect(); + let svg_start = body.find("").unwrap_or(body.len()); + let svg = &body[svg_start..svg_end]; + for label in &expected { + assert!(svg.contains(label.as_str()), "attached network {label} drawn"); + } + // The diagram draws no entity outside the reviewed set. + for token in ["Ixia", "NEAAR", "OMAN", "TWAREN", "WIX"] { + if !expected.iter().any(|l| l.contains(token)) { + assert!(!svg.contains(token), "{token} not drawn"); + } + } +} + +#[tokio::test] +async fn smithville_summary_derived_from_artifacts() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + // The rendered summary is the reviewed file's summary verbatim. + let coverage: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + "case-studies/indiana-gigapop-smithville-2026/observation-coverage.json", + ) + .unwrap(), + ) + .unwrap(); + let summary = coverage["summary"].as_str().unwrap(); + let text = strip_html(&body); + assert!( + text.contains(summary), + "coverage summary derived from the reviewed file" + ); +} + +#[tokio::test] +async fn smithville_cutoff_provenance_derived_from_reviewed_data() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/events/INC0301970").await; + let meta: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + "case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json", + ) + .unwrap(), + ) + .unwrap(); + let provenance = meta["cutoff_provenance"].as_str().unwrap(); + let text = strip_html(&body); + assert!( + text.contains(provenance), + "cutoff provenance rendered from the reviewed meta file" + ); +} + +#[tokio::test] +async fn canonical_bgp_artifact_hashes_unchanged() { + if !repo_artifacts_available() { + return; + } + let (dbdir, _rootdir) = setup_demo_catalog(); + let conn = db_conn(&dbdir); + // For every artifact row of the complete Smithville run, the file + // on disk hashes to the recorded catalog SHA-256. + let rows: Vec<(i64, String, String)> = conn + .prepare( + "SELECT a.run_id, a.relative_path, a.sha256 + FROM analysis_artifacts a + JOIN analysis_runs r ON r.id = a.run_id + WHERE r.status = 'Complete'", + ) + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .map(|x| x.unwrap()) + .collect(); + assert!(!rows.is_empty(), "complete run has artifact rows"); + for (_run, rel, recorded) in rows { + let Ok(bytes) = std::fs::read(&rel) else { + continue; // runtime-only artifacts may not be tracked + }; + let sha = crate::catalog::document::hex_sha256(&bytes); + assert_eq!( + sha, recorded, + "canonical artifact {rel} hash must match the catalog" + ); + } +} diff --git a/src/catalog/web/templates/case_study.html b/src/catalog/web/templates/case_study.html index 4957dcb..8f9d345 100644 --- a/src/catalog/web/templates/case_study.html +++ b/src/catalog/web/templates/case_study.html @@ -15,20 +15,70 @@

Incident context

{{ incident_context }}

{% if interconnection.is_some() %} {% let ic = interconnection.as_ref().unwrap() %} -

Reviewed attachment context (Layer-2)

+

Reviewed attached networks (Layer-2 fabric participants)

+

{{ ic.attached_count }} reviewed attached + network{% if ic.attached_count != 1 %}s{% endif %}{% if ic.other_count > 0 %}; + {{ ic.other_count }} other source-mentioned entit{% if ic.other_count == 1 %}y{% else %}ies{% endif %} listed separately + below (not fabric attachments){% endif %}.

{{ ic.fabric_svg|safe }}

Undirected grey lines are reviewed Layer-2 attachment context. Attachment is not BGP adjacency: it does not prove a direct BGP session, exported route visibility, a commercial relationship, traffic flow, or active state during the event.

- + {% for a in ic.attachments %} {% endfor %}
Attached network/connectorReviewed ASNReviewed note
Attached networkReviewed ASNReviewed note
{{ a.label }}{{ a.asn_text }}{{ a.note }}
+ {% if ic.test_equipment.len() > 0 || ic.interconnect_context.len() > 0 || ic.operational_services.len() > 0 || ic.unresolved_mentions.len() > 0 %} +

Other incident context

+

Entities mentioned by the sources whose reviewed class + is not an attached network. They are not fabric-attachment edges and + they do not enter the fabric diagram. Exclusion from the network + diagram does not mean the entity was operationally irrelevant.

+ {% if ic.test_equipment.len() > 0 %} +
Test equipment
+ + + + {% for a in ic.test_equipment %} + + {% endfor %} + +
EntityReviewed ASNReviewed note
{{ a.label }}{{ a.asn_text }}{{ a.note }}
+ {% endif %} + {% if ic.interconnect_context.len() > 0 %} +
Interconnect / service references
+ + + + {% for a in ic.interconnect_context %} + + {% endfor %} + {% for a in ic.operational_services %} + + {% endfor %} + +
EntityReviewed ASNReviewed note
{{ a.label }}{{ a.asn_text }}{{ a.note }}
{{ a.label }}{{ a.asn_text }}{{ a.note }}
+ {% endif %} + {% if ic.unresolved_mentions.len() > 0 %} +
Unresolved source mentions
+ + + + {% for a in ic.unresolved_mentions %} + + {% endfor %} + +
EntityReviewed ASNReviewed note
{{ a.label }}{{ a.asn_text }}{{ a.note }}
+ {% endif %} + {% if !ic.entity_review.is_empty() %} +

Classification review: {{ ic.entity_review }}

+ {% endif %} + {% endif %}

Limitations

    {% for l in ic.limitations %} diff --git a/src/catalog/web/templates/event_detail.html b/src/catalog/web/templates/event_detail.html index 6183895..91b6872 100644 --- a/src/catalog/web/templates/event_detail.html +++ b/src/catalog/web/templates/event_detail.html @@ -23,14 +23,16 @@

    Analysis workflow

    - - + + @@ -47,6 +49,35 @@

    Analysis workflow

    {% endif %} +{% if coverage.is_some() %} +{% let cv = coverage.as_ref().unwrap() %} +
    +

    Observation coverage

    +

    {{ cv.summary }}

    +
    Status{% if status == "Stale" %}{{ status }}{% elif status == "Blocked" %}{{ status }}{% else %}{{ status }}{% endif %}
    Lifecycle{{ lifecycle }}
    Analysis status{% if status == "Stale" %}{{ status }}{% elif status == "Blocked" %}{{ status }}{% else %}{{ status }}{% endif %}
    Source lifecycle{{ lifecycle }}
    Event window {% if is_open && !analysis_cutoff.is_empty() %}
    • Start: {{ start }}
    • -
    • Source lifecycle: Open
    • + {% if !snapshot_fetched_at.is_empty() %}
    • Source snapshot fetched at: {{ snapshot_fetched_at }}
    • {% endif %} +
    • Source lifecycle at snapshot: {% if !snapshot_lifecycle.is_empty() %}{{ snapshot_lifecycle }}{% else %}Open{% endif %}{% if !snapshot_lifecycle_evidence.is_empty() %} ({{ snapshot_lifecycle_evidence }}){% endif %}
    • Analysis cutoff: {{ analysis_cutoff }}
    • + {% if !cutoff_provenance.is_empty() %}
    • Cutoff provenance: {{ cutoff_provenance }}
    • {% endif %}
    {% else %}{{ start }} → {{ end }}{% endif %}
    + + +{% for r in cv.rows %} + + + + + + + + +{% endfor %} + +
    CollectorCollector siteSource familyTarget visible?Reviewed relationship visible?Qualification
    {{ r.collector }}{% if r.site.is_empty() %}not recorded{% else %}{{ r.site }}{% endif %}{{ r.family }}{% if r.target_visible %}Yes — {{ r.target_prefixes }} prefix{% if r.target_prefixes != 1 %}es{% endif %}{% else %}No — 0 prefixes{% endif %}{% if r.relationship_visible %}Yes{% else %}No{% endif %}{{ r.human_label }}{% if !r.note.is_empty() %} ({{ r.note }}){% endif %}
    exact classification: {{ r.blocker_classification }}
    +

    Collector site describes where the collector's route reflector is hosted; it is not the observer peer's location.

    +

    {% if cv.updates_acquired %}UPDATE archives were acquired for the selected qualifying streams.{% else %}UPDATE archives were not acquired. {{ cv.updates_explanation }}{% endif %}

    +{% if completed_run_id.is_some() %} +

    Full observation coverage and evidence

    +{% endif %} +

    Provenance: {{ cv.provenance }}

    +
    +{% endif %} +

    Analysis runs

    @@ -73,8 +104,8 @@

    Source snapshot history (immutable)

    - - + + diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 27ee5d2..8a35582 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -355,6 +355,42 @@ pub struct EventDetailView { pub supporting_only: bool, /// Reviewed BGP applicability (ticket_reviews), empty when none. pub applicability: String, + /// Reviewed observer-coverage summary (collector-by-collector) + /// when the case study carries one. + pub coverage: Option, + /// Latest imported snapshot's reviewed fetch time. + pub snapshot_fetched_at: String, + /// Source lifecycle at the latest imported snapshot (reviewed). + pub snapshot_lifecycle: String, + pub snapshot_lifecycle_evidence: String, + /// Reviewed provenance of the analysis cutoff. + pub cutoff_provenance: String, +} + +/// Reviewed observer coverage for an event (collector-by-collector +/// summary derived from canonical preflight/run evidence). +#[derive(Serialize)] +pub struct ObservationCoverageView { + pub event_id: String, + pub summary: String, + pub updates_acquired: bool, + pub updates_explanation: String, + pub provenance: String, + pub rows: Vec, +} + +#[derive(Serialize)] +pub struct CoverageRowView { + pub collector: String, + pub site: String, + pub family: String, + pub target_visible: bool, + pub target_prefixes: u64, + pub relationship_visible: bool, + pub direct_observer_session: bool, + pub human_label: String, + pub blocker_classification: String, + pub note: String, } pub struct SnapshotView { @@ -367,6 +403,10 @@ pub struct SnapshotView { /// fixture (file:// provenance) rather than fetched from the /// original public source. pub fixture_import: bool, + /// True when the snapshot is the tracked immutable case-study + /// snapshot (`.source.json`), the latest reviewed + /// snapshot when present. + pub tracked_snapshot: bool, } pub struct ManifestView { @@ -817,6 +857,7 @@ fn latest_result( pub fn load_event_detail( conn: &rusqlite::Connection, external_id: &str, + catalog_root: &std::path::Path, ) -> Result, String> { let Some(event) = db::get_event_by_external(conn, "local-repository", external_id)?.or( db::get_event_by_external(conn, "grnoc-public-task-viewer", external_id)?, @@ -880,8 +921,10 @@ pub fn load_event_detail( sha256: s.content_sha256.clone(), raw_preview: s.raw_payload.chars().take(220).collect(), fixture_import: s.source_url.starts_with("file://"), + tracked_snapshot: s.source_url.ends_with(".source.json"), }) .collect(); + let coverage = observation_coverage_for(catalog_root, external_id); let manifest_views = db::list_manifest_revisions(conn, event.id)? .iter() .map(|m| ManifestView { @@ -899,7 +942,15 @@ pub fn load_event_detail( let stored = r.verdict.clone().unwrap_or_default(); let (observed, expectation) = present_run_verdict(&stored); let mut assessment = r.assessment.clone().unwrap_or_default(); - if supporting_only { + // Operator-readable explanation leads for + // insufficient-visibility runs when the case study carries + // a reviewed observation-coverage summary; the stored + // assessment statement remains in the run artifact. + if stored.contains("insufficient") && coverage.is_some() { + assessment = String::from( + "Target routes were visible at selected public collectors, but the reviewed relationship was not exposed by the selected baselines and the required direct observer session was absent.", + ); + } else if supporting_only { // Scope-mismatched supporting observation: the run row // names the observed result but carries no expectation // assessment against the optical relationship. @@ -930,6 +981,35 @@ pub fn load_event_detail( let (workflow_status, workflow_link, workflow_detail, active_job_id, completed_run_id) = workflow_status_for(conn, event.id, &st, &result)?; let (analysis_cutoff, is_open) = event_cutoff(conn, event.id)?; + // Reviewed snapshot provenance: the latest imported snapshot's + // sidecar metadata (fetched time, lifecycle at snapshot, cutoff + // provenance) when the case study carries one; else the manifest's + // reviewed notes are the authority. + let (snapshot_lifecycle, snapshot_lifecycle_evidence, cutoff_provenance) = + if let Some(meta) = snapshot_meta_for(catalog_root, &snapshots) { + let lifecycle = meta + .get("lifecycle_at_snapshot") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let evidence = meta + .get("lifecycle_evidence") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let cutoff = meta + .get("cutoff_provenance") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + (lifecycle, evidence, cutoff) + } else { + ( + String::new(), + String::new(), + "Reviewed snapshot cutoff: the reviewed end of the provisional analysis window (manifest analysis_end_utc); not a source fetch time unless the reviewed record says so.".to_string(), + ) + }; Ok(Some(EventDetailView { event, @@ -954,9 +1034,18 @@ pub fn load_event_detail( completed_run_id, analysis_cutoff, is_open, + coverage, + snapshot_fetched_at: snapshots + .first() + .map(|s| s.fetched_at.clone()) + .unwrap_or_default(), + snapshot_lifecycle, + snapshot_lifecycle_evidence, + cutoff_provenance, })) } +/// Reviewed snapshot cutoff for open events: the latest manifest /// Reviewed snapshot cutoff for open events: the latest manifest /// revision's `analysis_end_utc` when the manifest marks the event /// open. Returns (cutoff, is_open). @@ -982,6 +1071,141 @@ fn event_cutoff(conn: &rusqlite::Connection, event_id: i64) -> Result<(String, b Ok((cutoff, open)) } +/// Reviewed sidecar metadata for the latest imported snapshot +/// (`.source.json.meta.json` next to the tracked snapshot). +/// Generic: the sidecar path is derived from the snapshot's `file://` +/// source URL, so no case-study slug is hard-coded. +fn snapshot_meta_for( + catalog_root: &std::path::Path, + snapshots: &[crate::catalog::domain::EventSnapshot], +) -> Option { + let latest = snapshots.first()?; + let source_path = latest.source_url.strip_prefix("file://")?; + if !source_path.ends_with(".source.json") { + return None; + } + let meta_path = catalog_root.join(format!("{source_path}.meta.json")); + if !meta_path.is_file() { + return None; + } + let content = std::fs::read_to_string(&meta_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Reviewed observer coverage for an event, from +/// `case-studies//observation-coverage.json`. Generic: every +/// case-study directory is consulted and the file matching the event's +/// external ID is used — no case-study slug is hard-coded. +fn observation_coverage_for( + catalog_root: &std::path::Path, + external_id: &str, +) -> Option { + let Ok(entries) = std::fs::read_dir(catalog_root.join("case-studies")) else { + return None; + }; + let mut candidates: Vec = Vec::new(); + for entry in entries.flatten() { + let candidate = entry.path().join("observation-coverage.json"); + if candidate.is_file() { + candidates.push(candidate); + } + } + candidates.sort(); + for path in candidates { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(json) = serde_json::from_str::(&content) else { + continue; + }; + if json.get("event_id").and_then(|v| v.as_str()) != Some(external_id) { + continue; + } + let summary = json + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let updates_acquired = json + .get("updates_acquired") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let updates_explanation = json + .get("updates_explanation") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let provenance = json + .get("provenance") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let mut rows: Vec = Vec::new(); + if let Some(arr) = json.get("rows").and_then(|v| v.as_array()) { + for r in arr { + let collector = r + .get("collector") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let family = r + .get("family") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let human_label = r + .get("human_label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let blocker = r + .get("blocker_classification") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let note = r + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + rows.push(CoverageRowView { + collector: collector.clone(), + site: collector_site_for(catalog_root, &collector), + family, + target_visible: r + .get("target_visible") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + target_prefixes: r + .get("target_prefixes") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + relationship_visible: r + .get("relationship_visible") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + direct_observer_session: r + .get("direct_observer_session") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + human_label, + blocker_classification: blocker, + note, + }); + } + } + return Some(ObservationCoverageView { + event_id: external_id.to_string(), + summary, + updates_acquired, + updates_explanation, + provenance, + rows, + }); + } + None +} + /// The event-page workflow line: status text, link, detail, active job /// id (when any), completed run id (when any). type WorkflowStatus = (String, String, String, Option, Option); @@ -1708,8 +1932,9 @@ pub fn load_event_list_json( pub fn load_event_detail_json( conn: &rusqlite::Connection, external_id: &str, + catalog_root: &std::path::Path, ) -> Result, String> { - let Some(view) = load_event_detail(conn, external_id)? else { + let Some(view) = load_event_detail(conn, external_id, catalog_root)? else { return Ok(None); }; Ok(Some(serde_json::json!({ @@ -1916,6 +2141,13 @@ pub struct InterconnectionContextView { pub kind: String, pub label: String, pub attachments: Vec, + pub test_equipment: Vec, + pub interconnect_context: Vec, + pub operational_services: Vec, + pub unresolved_mentions: Vec, + pub entity_review: String, + pub attached_count: usize, + pub other_count: usize, pub provenance: String, pub limitations: Vec, pub fabric_svg: String, @@ -2355,28 +2587,15 @@ fn interconnection_view( let attachments: Vec = v .get("attachments") .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|a| { - let label = a.get("label")?.as_str()?.to_string(); - let note = a - .get("note") - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - let asn_text = a - .get("asn") - .and_then(|n| n.as_u64()) - .map(|n| format!("AS{n}")) - .unwrap_or_else(|| "no reviewed ASN".to_string()); - Some(AttachmentView { - label, - note, - asn_text, - }) - }) - .collect() - }) + .map(|arr| parse_attachment_views(arr)) + .unwrap_or_default(); + let test_equipment = parse_optional_entity_list(&v, "test_equipment"); + let interconnect_context = parse_optional_entity_list(&v, "interconnect_context"); + let operational_services = parse_optional_entity_list(&v, "operational_services"); + let unresolved_mentions = parse_optional_entity_list(&v, "unresolved_mentions"); + let entity_review = v + .get("entity_review") + .map(|r| serde_json::to_string_pretty(r).unwrap_or_default()) .unwrap_or_default(); let fabric = crate::catalog::web::path_diagram::FabricView { label: label.clone(), @@ -2397,16 +2616,65 @@ fn interconnection_view( limitations: limitations.clone(), }; let fabric_svg = crate::catalog::web::path_diagram::render_fabric_svg(&fabric); + let attached_count = attachments.len(); + let other_count = test_equipment.len() + + interconnect_context.len() + + operational_services.len() + + unresolved_mentions.len(); Some(InterconnectionContextView { kind, label, attachments, + test_equipment, + interconnect_context, + operational_services, + unresolved_mentions, + entity_review, + attached_count, + other_count, provenance, limitations, fabric_svg, }) } +/// Parse one reviewed entity list (label / note / reviewed ASN) from +/// the interconnection-context JSON. +fn parse_optional_entity_list( + v: &serde_json::Value, + key: &str, +) -> Vec { + v.get(key) + .and_then(|a| a.as_array()) + .map(|arr| parse_attachment_views(arr)) + .unwrap_or_default() +} + +/// Parse reviewed entity entries (attachments and contextual lists +/// share the same shape; semantic class is the list membership). +fn parse_attachment_views(arr: &[serde_json::Value]) -> Vec { + arr.iter() + .filter_map(|a| { + let label = a.get("label")?.as_str()?.to_string(); + let note = a + .get("note") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let asn_text = a + .get("asn") + .and_then(|n| n.as_u64()) + .map(|n| format!("AS{n}")) + .unwrap_or_else(|| "no reviewed ASN".to_string()); + Some(AttachmentView { + label, + note, + asn_text, + }) + }) + .collect() +} + /// Manifest payload (target, origin, predicate) for a run. fn manifest_payload_for_run(conn: &rusqlite::Connection, run_id: i64) -> Option { let payload: Option = conn @@ -3650,7 +3918,7 @@ pub fn load_case_study( "Scope: public BGP observations of the reviewed target; not a complete incident-wide assessment.".to_string() }; let participants_heading = if interconnection.is_some() { - "Other operator-reported attached networks/connectors".to_string() + "Other source-mentioned entities (AAR)".to_string() } else { "Other operator-reported participants".to_string() }; diff --git a/src/catalog/web/workbench_fix_tests.rs b/src/catalog/web/workbench_fix_tests.rs index 8f38218..ce8d188 100644 --- a/src/catalog/web/workbench_fix_tests.rs +++ b/src/catalog/web/workbench_fix_tests.rs @@ -382,8 +382,8 @@ async fn demo_import_path_not_presented_as_original_source() { let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/events/INC0301970").await; assert!( - body.contains("imported from tracked offline fixture"), - "fixture import provenance is disclosed: {body}" + body.contains("imported from tracked"), + "import provenance is disclosed: {body}" ); // The fixture path is not the primary source identity. let primary = body.find("GRNOC Public Task Viewer"); @@ -451,7 +451,9 @@ async fn unreviewed_aar_participant_remains_unreviewed() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let mentioned = body.find("operator-reported").unwrap(); + let mentioned = body + .find("Other source-mentioned entities (AAR)") + .unwrap_or_else(|| body.find("operator-reported").unwrap()); let segment = &body[mentioned..mentioned + 2000]; assert!( segment.to_lowercase().contains("canarie"), From 2049fde7bc5a3bbf0f954776d66b3d2b0a7beece Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:30:42 +0000 Subject: [PATCH 02/10] docs: inventory the entity-taxonomy and coverage-summary files --- docs/audits/repository-inventory.json | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/audits/repository-inventory.json b/docs/audits/repository-inventory.json index db403a7..b21df9b 100644 --- a/docs/audits/repository-inventory.json +++ b/docs/audits/repository-inventory.json @@ -3670,5 +3670,37 @@ "authoritative": "implementation (behavioral authority)", "generated": false, "current": true + }, + { + "path": "src/catalog/web/taxonomy_tests.rs", + "category": "Rust source", + "audience": "maintainers", + "authoritative": "implementation", + "generated": false, + "current": true + }, + { + "path": "case-studies/indiana-gigapop-smithville-2026/observation-coverage.json", + "category": "Reviewed case-study interpretation", + "audience": "analysts", + "authoritative": "reviewed", + "generated": false, + "current": true + }, + { + "path": "case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json", + "category": "Reviewed case-study interpretation", + "audience": "analysts", + "authoritative": "reviewed", + "generated": false, + "current": true + }, + { + "path": "docs/audits/2026-08-entity-taxonomy-smithville-summary.md", + "category": "Audit", + "audience": "maintainers", + "authoritative": "reviewed", + "generated": false, + "current": true } ] \ No newline at end of file From 8cbc1c227f7a85f1af570df8ff6ff84d8665ad01 Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:30:46 +0000 Subject: [PATCH 03/10] docs: classify the findings audit in the inventory --- docs/audits/repository-inventory.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/audits/repository-inventory.json b/docs/audits/repository-inventory.json index b21df9b..ddd9030 100644 --- a/docs/audits/repository-inventory.json +++ b/docs/audits/repository-inventory.json @@ -3697,7 +3697,7 @@ }, { "path": "docs/audits/2026-08-entity-taxonomy-smithville-summary.md", - "category": "Audit", + "category": "Historical decision record", "audience": "maintainers", "authoritative": "reviewed", "generated": false, From 6c06a0621cae8b1af6788674f4c32ca58458e43a Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:30:50 +0000 Subject: [PATCH 04/10] docs: classify the taxonomy test module in the inventory --- docs/audits/repository-inventory.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/audits/repository-inventory.json b/docs/audits/repository-inventory.json index ddd9030..7d2ec60 100644 --- a/docs/audits/repository-inventory.json +++ b/docs/audits/repository-inventory.json @@ -3673,7 +3673,7 @@ }, { "path": "src/catalog/web/taxonomy_tests.rs", - "category": "Rust source", + "category": "Test source", "audience": "maintainers", "authoritative": "implementation", "generated": false, From 4a68ff4e1ac80ac064e7067d11e62a85df6ce27a Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:30:57 +0000 Subject: [PATCH 05/10] docs: mark the findings audit as a historical record --- docs/audits/2026-08-repository-truth-audit.md | 12 ++++++++---- docs/audits/repository-inventory.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/audits/2026-08-repository-truth-audit.md b/docs/audits/2026-08-repository-truth-audit.md index 55b35a5..8d7fe51 100644 --- a/docs/audits/2026-08-repository-truth-audit.md +++ b/docs/audits/2026-08-repository-truth-audit.md @@ -24,20 +24,20 @@ This audit verifies that every tracked file is classified, that every current st ## Summary -Tracked files: **459** · inventory entries: **459** +Tracked files: **463** · inventory entries: **463** | Category | Files | |---|---| | Immutable or generated evidence | 148 | | Production source | 106 | | Normative current documentation | 42 | -| Historical decision record | 40 | -| Reviewed case-study interpretation | 28 | +| Historical decision record | 41 | +| Reviewed case-study interpretation | 30 | | Script or developer tool | 25 | | Configuration | 24 | | Template or stylesheet | 16 | +| Test source | 11 | | Test fixture | 11 | -| Test source | 10 | | GitHub/community metadata | 4 | | Packaging or release metadata | 3 | | License or third-party notice | 2 | @@ -87,7 +87,9 @@ Tracked files: **459** · inventory entries: **459** | `case-studies/inc0302574/out/INC0302574/stdout.json` | Immutable or generated evidence | network analysts | MRT/RIB/UPDATE archives + generator run (immutable) | yes | current | schema/container reviewed; contents canonical, not hand-edited | none | reviewed in this audit | | `case-studies/inc0302574/out/INC0302574/withdrawal_audit.json` | Immutable or generated evidence | network analysts | MRT/RIB/UPDATE archives + generator run (immutable) | yes | current | schema/container reviewed; contents canonical, not hand-edited | none | reviewed in this audit | | `case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json` | Immutable or generated evidence | maintainers | immutable public source snapshot (fetched record) | yes | current | schema/container reviewed; contents canonical, not hand-edited | none | reviewed in this audit | +| `case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json` | Reviewed case-study interpretation | analysts | reviewed | no | current | claims re-checked against canonical evidence in this audit | none | reviewed in this audit | | `case-studies/indiana-gigapop-smithville-2026/README.md` | Reviewed case-study interpretation | maintainers | reviewed case-study metadata | no | current | claims re-checked against canonical evidence in this audit | none | reviewed in this audit | +| `case-studies/indiana-gigapop-smithville-2026/observation-coverage.json` | Reviewed case-study interpretation | analysts | reviewed | no | current | claims re-checked against canonical evidence in this audit | none | reviewed in this audit | | `case-studies/indiana-gigapop-smithville-2026/out/INC0301970/archive_manifest.json` | Immutable or generated evidence | maintainers | canonical run artifacts | yes | current | schema/container reviewed; contents canonical, not hand-edited | none | reviewed in this audit | | `case-studies/indiana-gigapop-smithville-2026/out/INC0301970/execution_metadata.json` | Immutable or generated evidence | maintainers | canonical run artifacts | yes | current | schema/container reviewed; contents canonical, not hand-edited | none | reviewed in this audit | | `case-studies/indiana-gigapop-smithville-2026/out/INC0301970/limitations.json` | Immutable or generated evidence | maintainers | canonical run artifacts | yes | current | schema/container reviewed; contents canonical, not hand-edited | none | reviewed in this audit | @@ -273,6 +275,7 @@ Tracked files: **459** · inventory entries: **459** | `docs/audits/2026-08-documentation-clean-clone.md` | Historical decision record | maintainers | dated audit | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-documentation-inventory.md` | Historical decision record | maintainers | dated audit | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-documentation-spec-conformance.md` | Historical decision record | maintainers | dated audit | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | +| `docs/audits/2026-08-entity-taxonomy-smithville-summary.md` | Historical decision record | maintainers | reviewed | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-evaluation-accessibility.md` | Historical decision record | maintainers | dated execution audit | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-evaluation-procedural-dry-run.md` | Historical decision record | maintainers | dated execution audit | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-evaluation-task-answerability.md` | Historical decision record | maintainers | dated execution audit | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | @@ -411,6 +414,7 @@ Tracked files: **459** · inventory entries: **459** | `src/catalog/web/path_diagram.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/server.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/session_context.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | +| `src/catalog/web/taxonomy_tests.rs` | Test source | maintainers | implementation | no | current | reviewed in this audit | none | reviewed in this audit | | `src/catalog/web/templates/analysis.html` | Template or stylesheet | operators (NOC analysts) | workbench view model + domain model | no | current | user-visible text audited in this audit | none | reviewed in this audit | | `src/catalog/web/templates/analysis_job.html` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/templates/analysis_jobs.html` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | diff --git a/docs/audits/repository-inventory.json b/docs/audits/repository-inventory.json index 7d2ec60..39a3f95 100644 --- a/docs/audits/repository-inventory.json +++ b/docs/audits/repository-inventory.json @@ -3701,6 +3701,6 @@ "audience": "maintainers", "authoritative": "reviewed", "generated": false, - "current": true + "current": false } ] \ No newline at end of file From de2e6b6203f84b2d6320cbdb44f5443d6371bb7e Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:31:51 +0000 Subject: [PATCH 06/10] test: add entity-taxonomy and Smithville-coverage regression tests; regenerate answer key --- evaluation/generated/answer-key.json | 12 +- evaluation/generated/answer-key.md | 3 +- src/catalog/import.rs | 5 +- src/catalog/web/mod.rs | 4 +- src/catalog/web/taxonomy_tests.rs | 157 ++++++++++++++------------- src/catalog/web/view.rs | 48 ++++---- 6 files changed, 119 insertions(+), 110 deletions(-) diff --git a/evaluation/generated/answer-key.json b/evaluation/generated/answer-key.json index b566467..e98f2a0 100644 --- a/evaluation/generated/answer-key.json +++ b/evaluation/generated/answer-key.json @@ -27,6 +27,7 @@ ], "incident_context": { "attachment_vs_adjacency": "Layer-2 attachment and AS-path adjacency are different evidence classes: attachment does not prove BGP adjacency, route export, a commercial relationship, traffic flow, or active state during the event.", + "entity_taxonomy": "Ixia is network test/measurement equipment, not a participating network and not a BGP peer. Not every source-mentioned entity is a reviewed fabric attachment: source mention is not proof of attachment, a reviewed ASN is not proof of attachment, and a familiar organization name is not proof of entity class.", "path_evidence": "observed AS paths are public-collector evidence (route-views2 peer 64.57.28.241 and RIS observers); they show what the collector received, never switch-fabric state.", "reference": "case-studies/manlan-2019/case-study.json", "target": "NORDUnet AS2603 is the analyzed BGP target (one attached network); the completed pilot is NORDUnet-target-scoped, not MAN LAN BGP analysis.", @@ -335,6 +336,15 @@ "reference": "manifests/INC0301970.json", "text": "Indiana GigaPOP (AS19782) peer Smithville (AS11550)" }, + "snapshot_vs_cutoff": { + "analysis_cutoff": "2026-08-04T00:01:37Z", + "cutoff_provenance": "Analysis cutoff is the reviewed snapshot cutoff: the exact source refresh retrieval 2026-08-04T00:01:37Z (state In Progress, no end). It is the reviewed end of the provisional analysis window (manifest analysis_end_utc), not the fetch time of the older tracked offline fixture.", + "lifecycle_evidence": "source state In Progress (state code 2) with no published end at the refresh retrieval; the event remains open", + "note": "source snapshot fetch time, source lifecycle at that snapshot, and the analysis cutoff are separate timestamps presented separately; the cutoff is the reviewed snapshot cutoff, not a fixture fetch time.", + "reference": "case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json.meta.json", + "source_lifecycle_at_snapshot": "Open", + "source_snapshot_fetched_at": "2026-08-04T00:01:37Z" + }, "source_event": { "id": "INC0301970", "open": true, @@ -362,7 +372,7 @@ "reference": "manifests/INC0301970.json", "routes_traversing_as19782": 0 }, - "why_insufficient_visibility": "no selected RouteViews observer had a pre-event route matching the reviewed path predicate, so no qualifying baseline exists; the run records InsufficientVisibility with no UPDATE acquisition. This is distinct from observing no route-state change: there was no qualifying observation at all." + "why_insufficient_visibility": "no selected observer had an event-baseline route exposing the reviewed AS19782-AS11550 adjacency, so no qualifying baseline cohort exists; the run records InsufficientVisibility with no UPDATE acquisition. This is distinct from observing no route-state change: there was no qualifying observation at all. Target-origin visibility (AS11550 routes were visible) and reviewed-relationship visibility (none exposed the adjacency) are separate." }, { "evidence_needed": [ diff --git a/evaluation/generated/answer-key.md b/evaluation/generated/answer-key.md index c738380..c2d4d5f 100644 --- a/evaluation/generated/answer-key.md +++ b/evaluation/generated/answer-key.md @@ -23,6 +23,7 @@ is authoritative and the contradiction is a P0 defect. - **target**: NORDUnet AS2603 is the analyzed BGP target (one attached network); the completed pilot is NORDUnet-target-scoped, not MAN LAN BGP analysis. - **path_evidence**: observed AS paths are public-collector evidence (route-views2 peer 64.57.28.241 and RIS observers); they show what the collector received, never switch-fabric state. - **attachment_vs_adjacency**: Layer-2 attachment and AS-path adjacency are different evidence classes: attachment does not prove BGP adjacency, route export, a commercial relationship, traffic flow, or active state during the event. +- **entity_taxonomy**: Ixia is network test/measurement equipment, not a participating network and not a BGP peer. Not every source-mentioned entity is a reviewed fabric attachment: source mention is not proof of attachment, a reviewed ASN is not proof of attachment, and a familiar organization name is not proof of entity class. - **reference**: `case-studies/manlan-2019/case-study.json` - **Artifact**: `case-studies/manlan-2019/pilot/cross-observer-matrix.json` @@ -208,7 +209,7 @@ is authoritative and the contradiction is a P0 defect. - **Provisional cutoff**: 2026-08-04T00:01:37Z - **Provisional language**: source event remains open; result is provisional through the reviewed snapshot cutoff; a later source refresh creates a new snapshot and run -- **Why insufficient visibility**: no selected RouteViews observer had a pre-event route matching the reviewed path predicate, so no qualifying baseline exists; the run records InsufficientVisibility with no UPDATE acquisition. This is distinct from observing no route-state change: there was no qualifying observation at all. +- **Why insufficient visibility**: no selected observer had an event-baseline route exposing the reviewed AS19782-AS11550 adjacency, so no qualifying baseline cohort exists; the run records InsufficientVisibility with no UPDATE acquisition. This is distinct from observing no route-state change: there was no qualifying observation at all. Target-origin visibility (AS11550 routes were visible) and reviewed-relationship visibility (none exposed the adjacency) are separate. ### Non-conclusions diff --git a/src/catalog/import.rs b/src/catalog/import.rs index 9628493..a689a91 100644 --- a/src/catalog/import.rs +++ b/src/catalog/import.rs @@ -801,10 +801,7 @@ fn ticket_fixture_for(event_id: &str) -> Option { /// with its optional `.source.json.meta.json` sidecar /// (reviewed fetch time, lifecycle at snapshot, cutoff provenance). /// Returns `None` when no tracked snapshot exists for the event. -fn case_study_snapshot_for( - out_dir: &Path, - event_id: &str, -) -> Option<(PathBuf, serde_json::Value)> { +fn case_study_snapshot_for(out_dir: &Path, event_id: &str) -> Option<(PathBuf, serde_json::Value)> { let snapshot_path = out_dir.parent()?.join(format!("{event_id}.source.json")); if !snapshot_path.is_file() { return None; diff --git a/src/catalog/web/mod.rs b/src/catalog/web/mod.rs index 819ff19..60564de 100644 --- a/src/catalog/web/mod.rs +++ b/src/catalog/web/mod.rs @@ -16,12 +16,12 @@ pub mod path_diagram; pub mod server; pub mod session_context; #[cfg(test)] +pub mod taxonomy_tests; +#[cfg(test)] pub mod tests; pub mod view; #[cfg(test)] pub mod workbench_fix_tests; -#[cfg(test)] -pub mod taxonomy_tests; use std::path::PathBuf; use std::sync::{Arc, Mutex}; diff --git a/src/catalog/web/taxonomy_tests.rs b/src/catalog/web/taxonomy_tests.rs index bc46415..61f739b 100644 --- a/src/catalog/web/taxonomy_tests.rs +++ b/src/catalog/web/taxonomy_tests.rs @@ -53,25 +53,33 @@ async fn get(app: &axum::Router, uri: &str) -> (StatusCode, String) { (status, String::from_utf8_lossy(&bytes).to_string()) } -/// Case-insensitive run lookup (SQLite LIKE is ASCII case-insensitive). -fn run_id_for_like(dbdir: &tempfile::TempDir, _pattern: &str) -> i64 { - let conn = crate::catalog::db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); - let rows: Vec = conn - .prepare("SELECT id FROM analysis_runs WHERE status = 'Complete' ORDER BY id") - .unwrap() - .query_map([], |r| r.get(0)) - .unwrap() - .map(|x| x.unwrap()) - .collect(); - // The first complete run is the Smithville run in the demo. - assert!(!rows.is_empty(), "demo has a complete run"); - rows[0] -} - fn db_conn(dbdir: &tempfile::TempDir) -> rusqlite::Connection { crate::catalog::db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap() } +/// The first SVG element (the fabric diagram on the case-study page). +fn fabric_svg(body: &str) -> &str { + let start = body.find("") + .map(|i| start + i) + .unwrap_or(body.len()); + &body[start..end] +} + +/// The reviewed attached-networks section, up to the other-context +/// section (so contextual entities never leak into the assertion). +fn attached_section(text: &str) -> &str { + let start = text + .find("Reviewed attached networks (Layer-2 fabric participants)") + .unwrap_or(0); + let end = text[start..] + .find("Other incident context") + .map(|i| start + i) + .unwrap_or(text.len()); + &text[start..end] +} + fn strip_html(body: &str) -> String { let re = regex::Regex::new(r"<[^>]+>").unwrap(); let text = re.replace_all(body, " "); @@ -92,10 +100,7 @@ async fn ixia_is_not_layer2_fabric_attachment() { let text = strip_html(&body); // Ixia appears only inside the "Test equipment" contextual table, // never in the reviewed attached-networks table. - let attached_region = text - .find("Reviewed attached networks (Layer-2 fabric participants)") - .map(|i| &text[i..]) - .unwrap_or(""); + let attached_region = attached_section(&text); let other_region = text .find("Other incident context") .map(|i| &text[i..]) @@ -119,11 +124,9 @@ async fn ixia_is_not_rendered_as_network_node() { let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; // The fabric SVG (the network diagram) contains no Ixia node. - let svg_start = body.find("").unwrap_or(body.len()); - let fabric_svg = &body[svg_start..svg_end]; + let svg = fabric_svg(&body); assert!( - !fabric_svg.contains("Ixia"), + !svg.contains("Ixia"), "Ixia must not be a node in the fabric diagram" ); } @@ -139,13 +142,9 @@ async fn ixia_is_not_counted_as_attached_network() { let text = strip_html(&body); // The count line states the reviewed attached-network count; Ixia // is not among the counted labels (verified by the table test). - let count_line = text - .find("reviewed attached") - .map(|i| text[i..i + 80].to_string()) - .unwrap_or_default(); assert!( - count_line.starts_with("5 reviewed attached networks"), - "attachment count is 5: {count_line}" + text.contains("5 reviewed attached networks; 5 other source-mentioned entities"), + "attachment count is 5, other-mentioned count is 5" ); } @@ -184,12 +183,18 @@ async fn ixia_has_no_bgp_relationship() { .map(|i| &text[i..i + 200]) .unwrap_or(""); assert!( - !ixia_region.contains("peer") && !ixia_region.contains("Adjacent"), - "Ixia has no BGP relationship wording: {ixia_region}" + ixia_region.contains("not a BGP peer"), + "Ixia explicitly not a BGP peer: {ixia_region}" + ); + assert!( + !ixia_region.contains("Adjacent") && !ixia_region.contains("ContainsAny"), + "Ixia has no adjacency/predicate wording: {ixia_region}" ); + // No node markup (svg text node or edge) for Ixia: the fabric SVG + // has no Ixia, and no solid/dashed edge references it. assert!( - !body.contains("Ixia") && !body.contains(">Ixia<"), - "no Ixia node markup" + !fabric_svg(&body).contains("Ixia"), + "no Ixia node markup in any diagram" ); } @@ -222,10 +227,7 @@ async fn aar_mention_does_not_imply_attachment() { let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; let text = strip_html(&body); - let attached_region = text - .find("Reviewed attached networks (Layer-2 fabric participants)") - .map(|i| &text[i..]) - .unwrap_or(""); + let attached_region = attached_section(&text); let other_region = text .find("Other incident context") .map(|i| &text[i..]) @@ -251,10 +253,7 @@ async fn reviewed_asn_does_not_imply_attachment() { let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; let text = strip_html(&body); - let attached_region = text - .find("Reviewed attached networks (Layer-2 fabric participants)") - .map(|i| &text[i..]) - .unwrap_or(""); + let attached_region = attached_section(&text); assert!( !attached_region.contains("TWAREN"), "TWAREN has a reviewed ASN but not established MAN LAN attachment" @@ -298,12 +297,10 @@ async fn unresolved_entity_not_rendered_as_network() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let svg_start = body.find("").unwrap_or(body.len()); - let fabric_svg = &body[svg_start..svg_end]; + let svg = fabric_svg(&body); for unresolved in ["OMAN", "NEAAR", "TWAREN"] { assert!( - !fabric_svg.contains(unresolved), + !svg.contains(unresolved), "{unresolved} must not be a fabric network node" ); } @@ -332,9 +329,7 @@ async fn diagram_entities_equal_reviewed_attached_networks() { .collect(); expected.sort(); // Node labels present in the fabric SVG. - let svg_start = body.find("").unwrap_or(body.len()); - let svg = &body[svg_start..svg_end]; + let svg = fabric_svg(&body); let mut found: Vec = expected .iter() .filter(|label| svg.contains(label.as_str())) @@ -357,9 +352,7 @@ async fn contextual_entities_not_given_attachment_edges() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let svg_start = body.find("").unwrap_or(body.len()); - let svg = &body[svg_start..svg_end]; + let svg = fabric_svg(&body); for contextual in ["Ixia", "NEAAR", "OMAN", "WIX", "TWAREN"] { assert!( !svg.contains(contextual), @@ -471,7 +464,19 @@ async fn event_summary_links_to_full_evidence() { return; } let (dbdir, rootdir) = setup_demo_catalog(); - let run_id = run_id_for_like(&dbdir, "%"); + let conn = db_conn(&dbdir); + let run_id: i64 = conn + .query_row( + "SELECT r.id FROM analysis_runs r + JOIN analysis_plans p ON p.id = r.plan_id + JOIN manifest_revisions m ON m.id = p.manifest_revision_id + JOIN catalog_events e ON e.id = m.event_id + WHERE e.external_id = 'INC0301970' AND r.status = 'Complete' + ORDER BY r.id DESC LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/events/INC0301970").await; assert!( @@ -480,7 +485,7 @@ async fn event_summary_links_to_full_evidence() { ); assert!( body.contains(&format!("/analyses/{run_id}")), - "full-evidence link targets the run workbench" + "full-evidence link targets the run workbench (run {run_id})" ); } @@ -589,12 +594,14 @@ async fn demo_imports_latest_reviewed_snapshot_when_present() { let (fetched_at, sha) = snapshot.expect("INC0301970 snapshot imported"); assert_eq!(fetched_at, "2026-08-04T00:01:37Z", "reviewed fetch time"); // The imported snapshot is the tracked immutable source snapshot. - let tracked = std::fs::read("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json") - .unwrap(); - let tracked_sha = { - crate::catalog::document::hex_sha256(&tracked) - }; - assert_eq!(sha, tracked_sha, "imported snapshot is the tracked source.json"); + let tracked = + std::fs::read("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json") + .unwrap(); + let tracked_sha = { crate::catalog::document::hex_sha256(&tracked) }; + assert_eq!( + sha, tracked_sha, + "imported snapshot is the tracked source.json" + ); } #[tokio::test] @@ -604,12 +611,12 @@ async fn no_immutable_snapshot_mutation() { } // The tracked immutable snapshot still hashes to the SHA-256 // recorded in the case-study README. - let tracked = std::fs::read("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json") - .unwrap(); + let tracked = + std::fs::read("case-studies/indiana-gigapop-smithville-2026/INC0301970.source.json") + .unwrap(); let sha = crate::catalog::document::hex_sha256(&tracked); assert_eq!( - sha, - "d911687c634a5efa7eafbea5816c4aa376f61c7c8fc14bd3611042873696de77", + sha, "d911687c634a5efa7eafbea5816c4aa376f61c7c8fc14bd3611042873696de77", "immutable snapshot content must be unchanged" ); } @@ -654,10 +661,7 @@ async fn complete_does_not_imply_event_closed() { text.contains("Source lifecycle") && text.contains("Open"), "source lifecycle Open shown alongside" ); - assert!( - text.contains("Analysis cutoff"), - "cutoff retained" - ); + assert!(text.contains("Analysis cutoff"), "cutoff retained"); assert!( !text.contains("event is closed") && !text.contains("Lifecycle Closed"), "Complete must not be presented as event closure" @@ -724,12 +728,10 @@ async fn equipment_never_rendered_as_as_node() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let svg_start = body.find("").unwrap_or(body.len()); - let svg = &body[svg_start..svg_end]; + let svg = fabric_svg(&body); assert!( !svg.contains("Ixia"), - "equipment is never an AS node in any diagram" + "equipment is never an AS node in the fabric diagram" ); } @@ -747,8 +749,8 @@ async fn equipment_never_rendered_as_peer() { .map(|i| &text[i..i + 300]) .unwrap_or(""); assert!( - !ixia_region.contains("peer") && !ixia_region.contains("BGP peer"), - "equipment is never described as a peer: {ixia_region}" + ixia_region.contains("not a BGP peer"), + "equipment explicitly not a peer: {ixia_region}" ); } @@ -785,11 +787,12 @@ async fn only_reviewed_attached_networks_enter_fabric_diagram() { .iter() .map(|a| a["label"].as_str().unwrap().to_string()) .collect(); - let svg_start = body.find("").unwrap_or(body.len()); - let svg = &body[svg_start..svg_end]; + let svg = fabric_svg(&body); for label in &expected { - assert!(svg.contains(label.as_str()), "attached network {label} drawn"); + assert!( + svg.contains(label.as_str()), + "attached network {label} drawn" + ); } // The diagram draws no entity outside the reviewed set. for token in ["Ixia", "NEAAR", "OMAN", "TWAREN", "WIX"] { diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 8a35582..20fa393 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -985,31 +985,32 @@ pub fn load_event_detail( // sidecar metadata (fetched time, lifecycle at snapshot, cutoff // provenance) when the case study carries one; else the manifest's // reviewed notes are the authority. - let (snapshot_lifecycle, snapshot_lifecycle_evidence, cutoff_provenance) = - if let Some(meta) = snapshot_meta_for(catalog_root, &snapshots) { - let lifecycle = meta - .get("lifecycle_at_snapshot") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let evidence = meta - .get("lifecycle_evidence") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let cutoff = meta - .get("cutoff_provenance") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - (lifecycle, evidence, cutoff) - } else { - ( + let (snapshot_lifecycle, snapshot_lifecycle_evidence, cutoff_provenance) = if let Some(meta) = + snapshot_meta_for(catalog_root, &snapshots) + { + let lifecycle = meta + .get("lifecycle_at_snapshot") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let evidence = meta + .get("lifecycle_evidence") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let cutoff = meta + .get("cutoff_provenance") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + (lifecycle, evidence, cutoff) + } else { + ( String::new(), String::new(), "Reviewed snapshot cutoff: the reviewed end of the provisional analysis window (manifest analysis_end_utc); not a source fetch time unless the reviewed record says so.".to_string(), ) - }; + }; Ok(Some(EventDetailView { event, @@ -2640,10 +2641,7 @@ fn interconnection_view( /// Parse one reviewed entity list (label / note / reviewed ASN) from /// the interconnection-context JSON. -fn parse_optional_entity_list( - v: &serde_json::Value, - key: &str, -) -> Vec { +fn parse_optional_entity_list(v: &serde_json::Value, key: &str) -> Vec { v.get(key) .and_then(|a| a.as_array()) .map(|arr| parse_attachment_views(arr)) From a1a162250dfdfc227989fadba8de5de2d93e6f2c Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:32:25 +0000 Subject: [PATCH 07/10] docs: inventory the entity-taxonomy findings audit --- docs/audits/2026-08-documentation-inventory.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/audits/2026-08-documentation-inventory.md b/docs/audits/2026-08-documentation-inventory.md index 68c5030..cae23d6 100644 --- a/docs/audits/2026-08-documentation-inventory.md +++ b/docs/audits/2026-08-documentation-inventory.md @@ -215,7 +215,7 @@ The following lists are compared with `git ls-files` by must be classified in a table above (or in `repository-inventory.json` for non-documentation files). -### Tracked Markdown files (88, excluding `spec/`) +### Tracked Markdown files (89, excluding `spec/`) ``` .github/PULL_REQUEST_TEMPLATE.md @@ -261,6 +261,7 @@ docs/audits/2026-08-clean-clone.md docs/audits/2026-08-documentation-clean-clone.md docs/audits/2026-08-documentation-inventory.md docs/audits/2026-08-documentation-spec-conformance.md +docs/audits/2026-08-entity-taxonomy-smithville-summary.md docs/audits/2026-08-evaluation-accessibility.md docs/audits/2026-08-evaluation-procedural-dry-run.md docs/audits/2026-08-evaluation-task-answerability.md @@ -313,7 +314,8 @@ tests/fixtures/README.md -### Tracked files under `docs/` (68) + +### Tracked files under `docs/` (69) ``` docs/ADRs/CASE-STUDY-LAYER.md @@ -340,6 +342,7 @@ docs/audits/2026-08-clean-clone.md docs/audits/2026-08-documentation-clean-clone.md docs/audits/2026-08-documentation-inventory.md docs/audits/2026-08-documentation-spec-conformance.md +docs/audits/2026-08-entity-taxonomy-smithville-summary.md docs/audits/2026-08-evaluation-accessibility.md docs/audits/2026-08-evaluation-procedural-dry-run.md docs/audits/2026-08-evaluation-task-answerability.md @@ -391,6 +394,7 @@ docs/sources/GRNOC_PUBLIC_TASK_VIEWER.md + ### Tracked evaluation files (3) ``` From 96cfe8f7ed38d25c62e58efc09d04cb6f9541880 Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:41:52 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20as?= =?UTF-8?q?sessment=20wording=20precision,=20template=20guard,=20fetched-a?= =?UTF-8?q?t=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/catalog/import.rs | 2 +- src/catalog/web/taxonomy_tests.rs | 4 ++-- src/catalog/web/templates/case_study.html | 2 +- src/catalog/web/view.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/catalog/import.rs b/src/catalog/import.rs index a689a91..446df91 100644 --- a/src/catalog/import.rs +++ b/src/catalog/import.rs @@ -157,7 +157,7 @@ pub(crate) fn import_one( let fetched_at = meta .get("fetched_at_utc") .and_then(|v| v.as_str()) - .unwrap_or("2026-07-31T00:00:00Z") + .unwrap_or("") .to_string(); // The tracked snapshot is the raw source record; the normalized // event is derived from it (title + window), mirroring the diff --git a/src/catalog/web/taxonomy_tests.rs b/src/catalog/web/taxonomy_tests.rs index 61f739b..c97a90e 100644 --- a/src/catalog/web/taxonomy_tests.rs +++ b/src/catalog/web/taxonomy_tests.rs @@ -680,7 +680,7 @@ async fn smithville_human_explanation_mentions_target_visibility() { let (_, body) = get(&app, "/events/INC0301970").await; let text = strip_html(&body); assert!( - text.contains("Target routes were visible at selected public collectors"), + text.contains("Target-origin routes were visible at selected public collectors"), "operator-readable explanation mentions target visibility" ); } @@ -695,7 +695,7 @@ async fn smithville_human_explanation_mentions_relationship_visibility() { let (_, body) = get(&app, "/events/INC0301970").await; let text = strip_html(&body); assert!( - text.contains("reviewed relationship was not exposed by the selected baselines"), + text.contains("no selected event baseline exposed the reviewed relationship"), "operator-readable explanation mentions relationship visibility" ); } diff --git a/src/catalog/web/templates/case_study.html b/src/catalog/web/templates/case_study.html index 8f9d345..c29d09d 100644 --- a/src/catalog/web/templates/case_study.html +++ b/src/catalog/web/templates/case_study.html @@ -50,7 +50,7 @@
    Test equipment
    RunStatusStartedVerdictAssessment
    {{ s.id }} {{ s.fetched_at }}{% if s.fixture_import %}GRNOC Public Task Viewer — external ID {{ event.external_id }}{% else %}{{ s.source_url }}{% endif %}{% if s.fixture_import %}imported from tracked offline fixture ({{ s.source_url }}){% else %}fetched from source{% endif %}{% if s.fixture_import %}{% if s.tracked_snapshot %}GRNOC Public Task Viewer — external ID {{ event.external_id }} (tracked immutable snapshot){% else %}GRNOC Public Task Viewer — external ID {{ event.external_id }}{% endif %}{% else %}{{ s.source_url }}{% endif %}{% if s.fixture_import %}{% if s.tracked_snapshot %}imported from tracked immutable snapshot ({{ s.source_url }}){% else %}imported from tracked offline fixture ({{ s.source_url }}){% endif %}{% else %}fetched from source{% endif %} {{ s.sha256 }} {{ s.raw_preview }}
    {% endif %} - {% if ic.interconnect_context.len() > 0 %} + {% if ic.interconnect_context.len() > 0 || ic.operational_services.len() > 0 %}
    Interconnect / service references
    diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 20fa393..7d398c6 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -948,7 +948,7 @@ pub fn load_event_detail( // assessment statement remains in the run artifact. if stored.contains("insufficient") && coverage.is_some() { assessment = String::from( - "Target routes were visible at selected public collectors, but the reviewed relationship was not exposed by the selected baselines and the required direct observer session was absent.", + "Target-origin routes were visible at selected public collectors, but no selected event baseline exposed the reviewed relationship and no direct observer session for the reviewed peer ASN was available.", ); } else if supporting_only { // Scope-mismatched supporting observation: the run row From 1ce9c3e1e9275bda7147bc14607e4f89c82d99d2 Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:54:57 +0000 Subject: [PATCH 09/10] fix: lead the event-page assessment with the operator-readable explanation --- src/catalog/web/taxonomy_tests.rs | 7 +++++++ src/catalog/web/view.rs | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/catalog/web/taxonomy_tests.rs b/src/catalog/web/taxonomy_tests.rs index c97a90e..5ef565b 100644 --- a/src/catalog/web/taxonomy_tests.rs +++ b/src/catalog/web/taxonomy_tests.rs @@ -506,6 +506,13 @@ async fn insufficient_visibility_not_no_change() { !text.contains("routing was stable") && !text.contains("no route-state change"), "insufficient visibility is not presented as no-change" ); + // The page leads with the operator-readable explanation; the + // stored 'pre-event route' assessment statement is not the primary + // wording (it remains in the run artifact only). + assert!( + !body.contains("pre-event"), + "pre-event wording not presented on the event page" + ); } // ── Snapshot / cutoff provenance (Part 9) ───────────────────────── diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 7d398c6..5c641ef 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -892,6 +892,18 @@ pub fn load_event_detail( .to_string(); let expectation = latest_expectation(conn, event.id)?; let (result, assessment) = latest_result(conn, event.id)?; + // Operator-readable explanation leads for insufficient-visibility + // events when the case study carries a reviewed observation- + // coverage summary; the stored assessment statement remains in the + // run artifact. + let coverage = observation_coverage_for(catalog_root, external_id); + let assessment = if coverage.is_some() && result.as_deref().is_some_and(|r| r.contains("Insufficient")) { + Some(String::from( + "Target-origin routes were visible at selected public collectors, but no selected event baseline exposed the reviewed relationship and no direct observer session for the reviewed peer ASN was available.", + )) + } else { + assessment + }; let applicability = reviewed_applicability(conn, event.id)?; let supporting_only = applicability.as_deref() == Some(crate::catalog::domain::applicability::NOT_DIRECTLY_OBSERVABLE); @@ -924,7 +936,6 @@ pub fn load_event_detail( tracked_snapshot: s.source_url.ends_with(".source.json"), }) .collect(); - let coverage = observation_coverage_for(catalog_root, external_id); let manifest_views = db::list_manifest_revisions(conn, event.id)? .iter() .map(|m| ManifestView { From 07c8845595b94de2de00fd495401d09ace7a3c03 Mon Sep 17 00:00:00 2001 From: Vadim Petrov Date: Tue, 4 Aug 2026 21:56:38 +0000 Subject: [PATCH 10/10] style: cargo fmt --- src/catalog/web/view.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 5c641ef..919bdcd 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -897,7 +897,11 @@ pub fn load_event_detail( // coverage summary; the stored assessment statement remains in the // run artifact. let coverage = observation_coverage_for(catalog_root, external_id); - let assessment = if coverage.is_some() && result.as_deref().is_some_and(|r| r.contains("Insufficient")) { + let assessment = if coverage.is_some() + && result + .as_deref() + .is_some_and(|r| r.contains("Insufficient")) + { Some(String::from( "Target-origin routes were visible at selected public collectors, but no selected event baseline exposed the reviewed relationship and no direct observer session for the reviewed peer ASN was available.", ))
    EntityReviewed ASNReviewed note