From 97aaaf0f744aceaaca966e939a94b7256e47b16d Mon Sep 17 00:00:00 2001
From: Vadim Petrov
Date: Tue, 4 Aug 2026 19:11:16 +0000
Subject: [PATCH 1/6] feat: Layer-2 fabric semantics + evidence-grounded
AS-path diagrams
- V11: optional reviewed interconnection_context on case studies
(Layer-2 fabric; attachments with reviewed ASN labels; presentation
only, never protocol evidence).
- New source-neutral path_diagram module: observed AS-path SVG nodes
(reviewed names, plane-matched, origin highlight, prepend
compaction), before/after state comparison with absence blocks,
Layer-2 fabric diagram (undirected grey attachment lines, no fabric
ASN), reviewed-unobserved relationship edges (dashed).
- MAN LAN page: incident-context / completed-analysis / scope
hierarchy, fabric diagram + text table + limitations, representative
observer route story derived from canonical lifecycle.json (direct
route-views2 stream), diagram legend, 'attached
networks/connectors' heading; pilot no longer worded as complete
incident verdict.
- Smithville run page: dashed reviewed adjacency (sought, not
observed) vs observed evidence, absence block, 'not observed in the
selected public baselines' wording.
- API: structured interconnection context + path comparison facts
(no layout serialization).
- OBSERVABILITY.md + manlan README: Layer-2 fabric semantics; the
ContainsAny[11537] predicate is a reviewed transit-presence proxy,
never a MAN LAN ASN.
- Answer key: explicit incident-context truths for the MAN LAN
scenario; regenerated.
---
case-studies/manlan-2019/README.md | 30 +
case-studies/manlan-2019/case-study.json | 851 ++++++++--------
docs/OBSERVABILITY.md | 9 +-
evaluation/generated/answer-key.json | 7 +
evaluation/generated/answer-key.md | 8 +
scripts/build-evaluation-answer-key.py | 16 +
src/catalog/archive_plan.rs | 4 +-
src/catalog/batch.rs | 1 +
src/catalog/case_study_compare.rs | 1 +
src/catalog/case_study_import.rs | 43 +
src/catalog/domain.rs | 3 +
src/catalog/grnoc_viewer.rs | 1 +
src/catalog/grouping.rs | 1 +
src/catalog/migrations.rs | 16 +-
src/catalog/observer_compare.rs | 1 +
src/catalog/phase_summary.rs | 1 +
src/catalog/store.rs | 7 +-
src/catalog/tests.rs | 1 +
src/catalog/web/api.rs | 25 +-
src/catalog/web/handlers.rs | 2 +-
src/catalog/web/mod.rs | 1 +
src/catalog/web/path_diagram.rs | 1102 +++++++++++++++++++++
src/catalog/web/templates/analysis.html | 14 +
src/catalog/web/templates/case_study.html | 91 +-
src/catalog/web/view.rs | 491 ++++++++-
25 files changed, 2320 insertions(+), 407 deletions(-)
create mode 100644 src/catalog/web/path_diagram.rs
diff --git a/case-studies/manlan-2019/README.md b/case-studies/manlan-2019/README.md
index 80899e9..3ec6a30 100644
--- a/case-studies/manlan-2019/README.md
+++ b/case-studies/manlan-2019/README.md
@@ -16,6 +16,36 @@ created metadata-only, and the file was attached in a later local import
repository** and must not enter the crate package until redistribution
rights are explicitly established.
+## Layer-2 fabric semantics (2026-08-04 correction)
+
+MAN LAN is a **Layer-2 exchange/fabric** operated by Internet2 for
+research-and-education interconnection. For the purposes of this case
+study:
+
+- MAN LAN has **no ASN**;
+- 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);
+- **Layer-2 attachment is not BGP adjacency**: an attached network may
+ or may not have exchanged routes directly with other attachments;
+- public BGP observes **exported route consequences** at public
+ collectors, never switch-fabric state.
+
+The completed historical pilot is a **NORDUnet (AS2603) target-scoped
+BGP analysis** of route observations during the operator-reported
+Layer-2 incident. It is not MAN LAN BGP analysis, not a MAN LAN
+routing-plane analysis, not evidence that MAN LAN announced or
+withdrew routes, not a complete analysis of all connectors, and not
+evidence that every attached network shared the same BGP topology.
+
+Where reviewed records say "MAN LAN attachment predicate", read it as
+the reviewed **proxy** for Internet2 R&E-plane transit presence
+(AS11537-in-path) in NORDUnet paths — a modeling convenience, never a
+MAN LAN ASN.
+
## What this directory contains
`case-study.json` — the single canonical reviewed data file (schema v1):
diff --git a/case-studies/manlan-2019/case-study.json b/case-studies/manlan-2019/case-study.json
index 8ac1131..b0d4b32 100644
--- a/case-studies/manlan-2019/case-study.json
+++ b/case-studies/manlan-2019/case-study.json
@@ -1,395 +1,464 @@
{
- "schema_version": 1,
- "slug": "manlan-2019",
- "title": "MAN LAN Core Node Hardware Upgrade",
- "summary": "Operator-authored after-action report for the MAN LAN core node hardware upgrade of 2019-08-21. The AAR records a scheduled migration that encountered physical and configuration problems, a subsequent traffic-replication incident associated with the deployed EVPN and inter-switch configuration, and a rollback with service restoration. Operator-reported; treated as operational evidence, not infallible global truth.",
- "start_utc": "2019-08-21T04:00:00Z",
- "end_utc": "2019-08-21T22:38:00Z",
- "status": "Active",
- "documents": [
- {
- "title": "MAN LAN Core Node Hardware Upgrade After Action Report",
- "source_url": "https://docs.globalnoc.iu.edu/uploads/c5/88/c5881bec35cb83807dd4b0a7ee32effe/MANLAN-20190821-Postmortem.pdf",
- "doc_type": "AfterActionReport",
- "media_type": "application/pdf",
- "sha256": "d29df26a269962afeb4c671063ea64dec6103e226c039e5939d5af99eedd7114",
- "page_count": 15,
- "publication_date": "2019-08-22",
- "provenance": "operator-authored after-action report; supplied SHA-256 from the review brief. The local PDF was not available at import time; attach it with `inim catalog document import` (redistribution rights not established).",
- "redistribution_status": "Unknown"
- }
+ "schema_version": 1,
+ "slug": "manlan-2019",
+ "title": "MAN LAN Core Node Hardware Upgrade",
+ "summary": "Operator-authored after-action report for the MAN LAN core node hardware upgrade of 2019-08-21. The AAR records a scheduled migration that encountered physical and configuration problems, a subsequent traffic-replication incident associated with the deployed EVPN and inter-switch configuration, and a rollback with service restoration. Operator-reported; treated as operational evidence, not infallible global truth.",
+ "start_utc": "2019-08-21T04:00:00Z",
+ "end_utc": "2019-08-21T22:38:00Z",
+ "status": "Active",
+ "documents": [
+ {
+ "title": "MAN LAN Core Node Hardware Upgrade After Action Report",
+ "source_url": "https://docs.globalnoc.iu.edu/uploads/c5/88/c5881bec35cb83807dd4b0a7ee32effe/MANLAN-20190821-Postmortem.pdf",
+ "doc_type": "AfterActionReport",
+ "media_type": "application/pdf",
+ "sha256": "d29df26a269962afeb4c671063ea64dec6103e226c039e5939d5af99eedd7114",
+ "page_count": 15,
+ "publication_date": "2019-08-22",
+ "provenance": "operator-authored after-action report; supplied SHA-256 from the review brief. The local PDF was not available at import time; attach it with `inim catalog document import` (redistribution rights not established).",
+ "redistribution_status": "Unknown"
+ }
+ ],
+ "document_links": [
+ {
+ "document": 0,
+ "relationship": "PrimarySource",
+ "reviewed_note": "The AAR is the primary operator-reported source for this case study."
+ }
+ ],
+ "phases": [
+ {
+ "label": "Scheduled migration",
+ "start_utc": "2019-08-21T04:00:00Z",
+ "end_utc": "2019-08-21T10:00:00Z",
+ "start_precision": "exact",
+ "end_precision": "summarized",
+ "description": "AAR timeline records the scheduled maintenance window for the core node hardware upgrade; AAR states installation work was delayed by the electrician.",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed"
+ },
+ {
+ "label": "Physical and configuration troubleshooting",
+ "start_utc": "2019-08-21T10:00:00Z",
+ "end_utc": "2019-08-21T14:14:00Z",
+ "start_precision": "summarized",
+ "end_precision": "exact",
+ "description": "AAR states several physical and configuration problems were addressed during this period; IS-IS was established at 14:14 UTC.",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed"
+ },
+ {
+ "label": "Traffic-replication incident",
+ "start_utc": "2019-08-21T14:14:00Z",
+ "end_utc": "2019-08-21T18:01:00Z",
+ "start_precision": "exact",
+ "end_precision": "exact",
+ "description": "AAR associates subsequent traffic replication with the deployed EVPN and inter-switch configuration; replication is believed to have begun around 14:14 UTC. Some connectors shut ports during this period.",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed"
+ },
+ {
+ "label": "Rollback and service restoration",
+ "start_utc": "2019-08-21T18:01:00Z",
+ "end_utc": "2019-08-21T22:22:00Z",
+ "start_precision": "exact",
+ "end_precision": "exact",
+ "description": "AAR states rollback was initiated at 18:01 UTC and the last connection was migrated by 22:22 UTC.",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed"
+ },
+ {
+ "label": "Final maintenance closure",
+ "start_utc": "2019-08-21T22:22:00Z",
+ "end_utc": "2019-08-21T22:38:00Z",
+ "start_precision": "exact",
+ "end_precision": "summarized",
+ "description": "AAR records emergency-maintenance closure at approximately 22:38 UTC; the closing boundary is summarized, not an independently measured time.",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed"
+ }
+ ],
+ "related_events": [
+ {
+ "external_identifier": "CHG0038258",
+ "relationship": "PrimaryChange",
+ "reviewed_note": "scheduled core-node hardware upgrade change record as listed by the AAR; not independently retrieved",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040257",
+ "relationship": "PrimaryIncident",
+ "reviewed_note": "incident record associated with the upgrade disruption as listed by the AAR; not independently retrieved",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "CHG0038386",
+ "relationship": "RollbackChange",
+ "reviewed_note": "rollback change record as listed by the AAR; not independently retrieved",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040258",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040272",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "TASK0038206",
+ "relationship": "OperationalTask",
+ "reviewed_note": "operational task record as listed by the AAR; not independently retrieved",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040289",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040290",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040291",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040293",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "TASK0038211",
+ "relationship": "OperationalTask",
+ "reviewed_note": "operational task record as listed by the AAR; not independently retrieved",
+ "source_document": 0
+ },
+ {
+ "external_identifier": "INC0040318",
+ "relationship": "Related",
+ "reviewed_note": "AAR-listed record associated with the maintenance closure; not independently retrieved",
+ "source_document": 0
+ }
+ ],
+ "claims": [
+ {
+ "claim_type": "ReportedImpact",
+ "claim_text": "The scheduled core replacement caused Layer-2 and Layer-3 disruption for MAN LAN connectors.",
+ "qualification": "operator-reported impact; the AAR does not quantify every connector's experience",
+ "source_document": 0,
+ "source_page_or_section": "Summary (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:0",
+ "observability": "PotentiallyVisibleInPublicBgp",
+ "observability_rationale": "Participant BGP path withdrawal or shifts to alternate transit may be visible for participants announcing public prefixes."
+ },
+ {
+ "claim_type": "ReportedRecovery",
+ "claim_text": "Rollback of the deployed configuration was required.",
+ "qualification": "AAR states rollback was initiated at 18:01 UTC",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:3",
+ "observability": "PotentiallyVisibleInPublicBgp",
+ "observability_rationale": "Rollback restoration may be visible through route restoration."
+ },
+ {
+ "claim_type": "ReportedImpact",
+ "claim_text": "All MAN LAN connectors were affected in some fashion.",
+ "qualification": "AAR statement; the nature and extent of the effect varied",
+ "source_document": 0,
+ "source_page_or_section": "Summary (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:1",
+ "observability": "IndirectlyVisible",
+ "observability_rationale": "Effects may appear through exported route consequences where participants announce public prefixes."
+ },
+ {
+ "claim_type": "ReportedImpact",
+ "claim_text": "Participant impact varied with physical and Layer-3 redundancy.",
+ "qualification": "AAR analysis: some multihomed networks may have uniquely routed portions of their networks through MAN LAN and could have experienced complete outage for those portions; this does not mean all participants experienced complete outage",
+ "source_document": 0,
+ "source_page_or_section": "Summary (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:2",
+ "observability": "PotentiallyVisibleInPublicBgp",
+ "observability_rationale": "Participants with unique-route portions may show path loss; redundant participants may show no change."
+ },
+ {
+ "claim_type": "ReportedImpact",
+ "claim_text": "Some customers disabled interfaces in response to instability.",
+ "qualification": "operator report; customer action is not directly observable",
+ "source_document": 0,
+ "source_page_or_section": "After-action review (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:2",
+ "observability": "IndirectlyVisible",
+ "observability_rationale": "Administratively disabled customer interfaces are visible only through exported consequences."
+ },
+ {
+ "claim_type": "ReportedLimitation",
+ "claim_text": "Telemetry was incomplete and delayed understanding of the incident.",
+ "qualification": "AAR states missing switch management telemetry",
+ "source_document": 0,
+ "source_page_or_section": "After-action review (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:1",
+ "observability": "NotDirectlyVisible",
+ "observability_rationale": "Missing switch management telemetry is not observable in public BGP."
+ },
+ {
+ "claim_type": "ReportedMechanism",
+ "claim_text": "Traffic replication was associated with the deployed EVPN and inter-switch configuration.",
+ "qualification": "AAR association; mechanism inferred by the operators",
+ "source_document": 0,
+ "source_page_or_section": "After-action review (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:2",
+ "observability": "NotDirectlyVisible",
+ "observability_rationale": "Layer-2 broadcast/unknown-unicast/multicast replication itself is not directly observable in public BGP."
+ },
+ {
+ "claim_type": "ReportedTimeline",
+ "claim_text": "IS-IS was established and traffic replication is believed to have begun at 14:14 UTC.",
+ "qualification": "AAR timeline records IS-IS establishment at 14:14 UTC; the replication onset is retrospective belief, not a measured boundary",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:2",
+ "observability": "IndirectlyVisible",
+ "observability_rationale": "Route-state consequences may follow; the replication mechanism itself is not directly visible."
+ },
+ {
+ "claim_type": "ReportedTimeline",
+ "claim_text": "Rollback began at 18:01 UTC.",
+ "qualification": "AAR timeline records rollback initiation",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:3",
+ "observability": "PotentiallyVisibleInPublicBgp",
+ "observability_rationale": "Restoration may appear as route restoration."
+ },
+ {
+ "claim_type": "ReportedRecovery",
+ "claim_text": "The last connection was migrated by 22:22 UTC and emergency maintenance closed at approximately 22:38 UTC.",
+ "qualification": "AAR timeline records these times; the closing boundary is summarized",
+ "source_document": 0,
+ "source_page_or_section": "Timeline (detailed)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:4",
+ "observability": "PotentiallyVisibleInPublicBgp",
+ "observability_rationale": "Restoration completion may be visible through route restoration."
+ },
+ {
+ "claim_type": "ReportedLimitation",
+ "claim_text": "Physical cross-connect swaps and OESS circuit migrations are not directly visible as operations.",
+ "qualification": "AAR describes physical and configuration actions; only route consequences may be visible",
+ "source_document": 0,
+ "source_page_or_section": "After-action review (AAR)",
+ "review_status": "Reviewed",
+ "time_or_phase": "phase:1",
+ "observability": "NotDirectlyVisible",
+ "observability_rationale": "Physical operations are not observable in public BGP; OESS circuit migration is not visible as an operation."
+ }
+ ],
+ "targets": [
+ {
+ "source_label": "CANARIE",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "NORDUnet",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "ESnet",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "GÉANT",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "Ixia",
+ "role_in_report": "connector context",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR connector context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "may be not applicable to public BGP; requires review of the AAR role"
+ },
+ {
+ "source_label": "NEAAR",
+ "role_in_report": "connector context",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR connector context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "may be not applicable to public BGP; requires review of the AAR role"
+ },
+ {
+ "source_label": "TWAREN",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "OMAN",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "SINET",
+ "role_in_report": "participant",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR participant context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
+ },
+ {
+ "source_label": "WIX interconnect",
+ "role_in_report": "interconnect context",
+ "candidate_org_identity": null,
+ "candidate_origin_asns": [],
+ "candidate_predicate": null,
+ "historical_validity_status": "Unresearched",
+ "provenance": "AAR interconnect context (per review brief); no independent identity research performed",
+ "research_status": "Unresearched",
+ "reviewed_note": "interconnect, not a single origin; may be not applicable to public BGP analysis as an origin target"
+ }
+ ],
+ "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.",
+ "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.",
+ "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."
],
- "document_links": [
- {
- "document": 0,
- "relationship": "PrimarySource",
- "reviewed_note": "The AAR is the primary operator-reported source for this case study."
- }
- ],
- "phases": [
- {
- "label": "Scheduled migration",
- "start_utc": "2019-08-21T04:00:00Z",
- "end_utc": "2019-08-21T10:00:00Z",
- "start_precision": "exact",
- "end_precision": "summarized",
- "description": "AAR timeline records the scheduled maintenance window for the core node hardware upgrade; AAR states installation work was delayed by the electrician.",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed"
- },
- {
- "label": "Physical and configuration troubleshooting",
- "start_utc": "2019-08-21T10:00:00Z",
- "end_utc": "2019-08-21T14:14:00Z",
- "start_precision": "summarized",
- "end_precision": "exact",
- "description": "AAR states several physical and configuration problems were addressed during this period; IS-IS was established at 14:14 UTC.",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed"
- },
- {
- "label": "Traffic-replication incident",
- "start_utc": "2019-08-21T14:14:00Z",
- "end_utc": "2019-08-21T18:01:00Z",
- "start_precision": "exact",
- "end_precision": "exact",
- "description": "AAR associates subsequent traffic replication with the deployed EVPN and inter-switch configuration; replication is believed to have begun around 14:14 UTC. Some connectors shut ports during this period.",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed"
- },
- {
- "label": "Rollback and service restoration",
- "start_utc": "2019-08-21T18:01:00Z",
- "end_utc": "2019-08-21T22:22:00Z",
- "start_precision": "exact",
- "end_precision": "exact",
- "description": "AAR states rollback was initiated at 18:01 UTC and the last connection was migrated by 22:22 UTC.",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed"
- },
- {
- "label": "Final maintenance closure",
- "start_utc": "2019-08-21T22:22:00Z",
- "end_utc": "2019-08-21T22:38:00Z",
- "start_precision": "exact",
- "end_precision": "summarized",
- "description": "AAR records emergency-maintenance closure at approximately 22:38 UTC; the closing boundary is summarized, not an independently measured time.",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed"
- }
- ],
- "related_events": [
- {
- "external_identifier": "CHG0038258",
- "relationship": "PrimaryChange",
- "reviewed_note": "scheduled core-node hardware upgrade change record as listed by the AAR; not independently retrieved",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040257",
- "relationship": "PrimaryIncident",
- "reviewed_note": "incident record associated with the upgrade disruption as listed by the AAR; not independently retrieved",
- "source_document": 0
- },
- {
- "external_identifier": "CHG0038386",
- "relationship": "RollbackChange",
- "reviewed_note": "rollback change record as listed by the AAR; not independently retrieved",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040258",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040272",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
- "source_document": 0
- },
- {
- "external_identifier": "TASK0038206",
- "relationship": "OperationalTask",
- "reviewed_note": "operational task record as listed by the AAR; not independently retrieved",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040289",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040290",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040291",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040293",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record; precise role requires the AAR text, assigned conservatively",
- "source_document": 0
- },
- {
- "external_identifier": "TASK0038211",
- "relationship": "OperationalTask",
- "reviewed_note": "operational task record as listed by the AAR; not independently retrieved",
- "source_document": 0
- },
- {
- "external_identifier": "INC0040318",
- "relationship": "Related",
- "reviewed_note": "AAR-listed record associated with the maintenance closure; not independently retrieved",
- "source_document": 0
- }
- ],
- "claims": [
- {
- "claim_type": "ReportedImpact",
- "claim_text": "The scheduled core replacement caused Layer-2 and Layer-3 disruption for MAN LAN connectors.",
- "qualification": "operator-reported impact; the AAR does not quantify every connector's experience",
- "source_document": 0,
- "source_page_or_section": "Summary (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:0",
- "observability": "PotentiallyVisibleInPublicBgp",
- "observability_rationale": "Participant BGP path withdrawal or shifts to alternate transit may be visible for participants announcing public prefixes."
- },
- {
- "claim_type": "ReportedRecovery",
- "claim_text": "Rollback of the deployed configuration was required.",
- "qualification": "AAR states rollback was initiated at 18:01 UTC",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:3",
- "observability": "PotentiallyVisibleInPublicBgp",
- "observability_rationale": "Rollback restoration may be visible through route restoration."
- },
- {
- "claim_type": "ReportedImpact",
- "claim_text": "All MAN LAN connectors were affected in some fashion.",
- "qualification": "AAR statement; the nature and extent of the effect varied",
- "source_document": 0,
- "source_page_or_section": "Summary (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:1",
- "observability": "IndirectlyVisible",
- "observability_rationale": "Effects may appear through exported route consequences where participants announce public prefixes."
- },
- {
- "claim_type": "ReportedImpact",
- "claim_text": "Participant impact varied with physical and Layer-3 redundancy.",
- "qualification": "AAR analysis: some multihomed networks may have uniquely routed portions of their networks through MAN LAN and could have experienced complete outage for those portions; this does not mean all participants experienced complete outage",
- "source_document": 0,
- "source_page_or_section": "Summary (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:2",
- "observability": "PotentiallyVisibleInPublicBgp",
- "observability_rationale": "Participants with unique-route portions may show path loss; redundant participants may show no change."
- },
- {
- "claim_type": "ReportedImpact",
- "claim_text": "Some customers disabled interfaces in response to instability.",
- "qualification": "operator report; customer action is not directly observable",
- "source_document": 0,
- "source_page_or_section": "After-action review (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:2",
- "observability": "IndirectlyVisible",
- "observability_rationale": "Administratively disabled customer interfaces are visible only through exported consequences."
- },
- {
- "claim_type": "ReportedLimitation",
- "claim_text": "Telemetry was incomplete and delayed understanding of the incident.",
- "qualification": "AAR states missing switch management telemetry",
- "source_document": 0,
- "source_page_or_section": "After-action review (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:1",
- "observability": "NotDirectlyVisible",
- "observability_rationale": "Missing switch management telemetry is not observable in public BGP."
- },
- {
- "claim_type": "ReportedMechanism",
- "claim_text": "Traffic replication was associated with the deployed EVPN and inter-switch configuration.",
- "qualification": "AAR association; mechanism inferred by the operators",
- "source_document": 0,
- "source_page_or_section": "After-action review (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:2",
- "observability": "NotDirectlyVisible",
- "observability_rationale": "Layer-2 broadcast/unknown-unicast/multicast replication itself is not directly observable in public BGP."
- },
- {
- "claim_type": "ReportedTimeline",
- "claim_text": "IS-IS was established and traffic replication is believed to have begun at 14:14 UTC.",
- "qualification": "AAR timeline records IS-IS establishment at 14:14 UTC; the replication onset is retrospective belief, not a measured boundary",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:2",
- "observability": "IndirectlyVisible",
- "observability_rationale": "Route-state consequences may follow; the replication mechanism itself is not directly visible."
- },
- {
- "claim_type": "ReportedTimeline",
- "claim_text": "Rollback began at 18:01 UTC.",
- "qualification": "AAR timeline records rollback initiation",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:3",
- "observability": "PotentiallyVisibleInPublicBgp",
- "observability_rationale": "Restoration may appear as route restoration."
- },
- {
- "claim_type": "ReportedRecovery",
- "claim_text": "The last connection was migrated by 22:22 UTC and emergency maintenance closed at approximately 22:38 UTC.",
- "qualification": "AAR timeline records these times; the closing boundary is summarized",
- "source_document": 0,
- "source_page_or_section": "Timeline (detailed)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:4",
- "observability": "PotentiallyVisibleInPublicBgp",
- "observability_rationale": "Restoration completion may be visible through route restoration."
- },
- {
- "claim_type": "ReportedLimitation",
- "claim_text": "Physical cross-connect swaps and OESS circuit migrations are not directly visible as operations.",
- "qualification": "AAR describes physical and configuration actions; only route consequences may be visible",
- "source_document": 0,
- "source_page_or_section": "After-action review (AAR)",
- "review_status": "Reviewed",
- "time_or_phase": "phase:1",
- "observability": "NotDirectlyVisible",
- "observability_rationale": "Physical operations are not observable in public BGP; OESS circuit migration is not visible as an operation."
- }
- ],
- "targets": [
- {
- "source_label": "CANARIE",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "NORDUnet",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "ESnet",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "GÉANT",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "Ixia",
- "role_in_report": "connector context",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR connector context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "may be not applicable to public BGP; requires review of the AAR role"
- },
- {
- "source_label": "NEAAR",
- "role_in_report": "connector context",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR connector context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "may be not applicable to public BGP; requires review of the AAR role"
- },
- {
- "source_label": "TWAREN",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "OMAN",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "SINET",
- "role_in_report": "participant",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR participant context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "historical mapping not researched; current organization ASN metadata must not be used as 2019 truth"
- },
- {
- "source_label": "WIX interconnect",
- "role_in_report": "interconnect context",
- "candidate_org_identity": null,
- "candidate_origin_asns": [],
- "candidate_predicate": null,
- "historical_validity_status": "Unresearched",
- "provenance": "AAR interconnect context (per review brief); no independent identity research performed",
- "research_status": "Unresearched",
- "reviewed_note": "interconnect, not a single origin; may be not applicable to public BGP analysis as an origin target"
- }
+ "attachments": [
+ {
+ "label": "NORDUnet",
+ "note": "research/education network operator; analyzed target of the completed historical pilot",
+ "asn": 2603,
+ "asn_validity_date": "2019-08-21"
+ },
+ {
+ "label": "ESnet",
+ "note": "research/education network operator",
+ "asn": 293,
+ "asn_validity_date": "2019-08-21"
+ },
+ {
+ "label": "GÉANT",
+ "note": "research/education network operator",
+ "asn": 21320,
+ "asn_validity_date": "2019-08-21"
+ },
+ {
+ "label": "CANARIE",
+ "note": "research/education network operator",
+ "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",
+ "asn": 2907,
+ "asn_validity_date": "2019-08-21"
+ },
+ {
+ "label": "Ixia",
+ "note": "connector/test context; no reviewed ASN label",
+ "asn": null
+ },
+ {
+ "label": "NEAAR",
+ "note": "ambiguous service identity; no reviewed ASN label",
+ "asn": null
+ },
+ {
+ "label": "OMAN",
+ "note": "unresolved identity; no reviewed ASN label",
+ "asn": null
+ },
+ {
+ "label": "WIX interconnect",
+ "note": "interconnect context; no reviewed ASN label",
+ "asn": null
+ }
]
+ }
}
diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md
index 9d3b9a2..8e178c8 100644
--- a/docs/OBSERVABILITY.md
+++ b/docs/OBSERVABILITY.md
@@ -87,10 +87,15 @@ missed detection; `no BGP change does not refute a Layer-2 incident`, and
## Historical predicate validation
-A MAN LAN attachment predicate is a **candidate** until validated by
+A candidate path predicate for the reviewed Internet2 transit presence in
+NORDUnet paths (ContainsAny[11537]) is **candidate** until validated by
contemporaneous observation: the 2019-08-21 RouteViews RIB (Stage A
preflight) confirmed ContainsAny[11537] for AS2603 (33 streams), so the
-pilot predicate is reviewed-by-observation rather than assumed. A
+pilot predicate is reviewed-by-observation rather than assumed. The
+predicate is a proxy for the reviewed R&E-plane transit presence in the
+NORDUnet (AS2603) target's paths; it never represents MAN LAN itself
+(MAN LAN is a Layer-2 exchange fabric with no ASN for the case study,
+and does not appear as an AS-path hop). A
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.
diff --git a/evaluation/generated/answer-key.json b/evaluation/generated/answer-key.json
index 52cca2d..b566467 100644
--- a/evaluation/generated/answer-key.json
+++ b/evaluation/generated/answer-key.json
@@ -25,6 +25,13 @@
"the exact-baseline restoration timestamps per prefix (lifecycle.json)",
"the RRC15 cooldown transitions (report.json transitions.cooldown = 11)"
],
+ "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.",
+ "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.",
+ "text": "MAN LAN is a Layer-2 exchange/fabric: it has no ASN for this case study, does not speak BGP, does not originate routes, and does not appear as an AS-path hop."
+ },
"likely_confusions": [
"exact baseline returned (17:02:03Z) versus final route state",
"one observer's result (route-views2) versus all observers",
diff --git a/evaluation/generated/answer-key.md b/evaluation/generated/answer-key.md
index 334c2bb..c738380 100644
--- a/evaluation/generated/answer-key.md
+++ b/evaluation/generated/answer-key.md
@@ -16,6 +16,14 @@ is authoritative and the contradiction is a P0 defect.
- **Source event**: MAN LAN 2019-08-21 (multi-ticket operator incident)
- **Target**: NORDUnet (AS2603)
- **Reviewed relationship**: NORDUnet (AS2603) routes via the Internet2 R&E plane (AS11537)
+
+### Incident context (Layer-2 fabric)
+
+- **text**: MAN LAN is a Layer-2 exchange/fabric: it has no ASN for this case study, does not speak BGP, does not originate routes, and does not appear as an AS-path hop.
+- **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.
+- **reference**: `case-studies/manlan-2019/case-study.json`
- **Artifact**: `case-studies/manlan-2019/pilot/cross-observer-matrix.json`
### Route state answers
diff --git a/scripts/build-evaluation-answer-key.py b/scripts/build-evaluation-answer-key.py
index 8990c07..a63442f 100644
--- a/scripts/build-evaluation-answer-key.py
+++ b/scripts/build-evaluation-answer-key.py
@@ -171,6 +171,13 @@ def iso_parse(s: str):
"predicate": "origin AS2603 AND baseline AS path contains AS11537",
"reference": path_as_ref("case-studies/manlan-2019/pilot/cross-observer-matrix.json"),
},
+ "incident_context": {
+ "text": "MAN LAN is a Layer-2 exchange/fabric: it has no ASN for this case study, does not speak BGP, does not originate routes, and does not appear as an AS-path hop.",
+ "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.",
+ "reference": path_as_ref("case-studies/manlan-2019/case-study.json"),
+ },
"analysis_window_utc": pilot["window_start_utc"] + " .. " + pilot["window_end_utc"],
"observers": [
{
@@ -755,6 +762,15 @@ def render_markdown(doc: dict) -> str:
lines.append(f"- **Source event**: {s['source_event']['id']}")
lines.append(f"- **Target**: {s['target']['name']}")
lines.append(f"- **Reviewed relationship**: {s['reviewed_relationship']['text']}")
+ if "incident_context" in s:
+ ic = s["incident_context"]
+ lines.append("")
+ lines.append("### Incident context (Layer-2 fabric)")
+ lines.append("")
+ for k, v in ic.items():
+ if k != "reference":
+ lines.append(f"- **{k}**: {v}")
+ lines.append(f"- **reference**: `{ic.get('reference', '')}`")
lines.append(f"- **Artifact**: `{s['reviewed_relationship'].get('reference', '')}`")
lines.append("")
lines.append("### Route state answers")
diff --git a/src/catalog/archive_plan.rs b/src/catalog/archive_plan.rs
index c389a64..d9bd19f 100644
--- a/src/catalog/archive_plan.rs
+++ b/src/catalog/archive_plan.rs
@@ -643,7 +643,7 @@ pub fn list_targets(conn: &Connection, case_study_id: i64) -> Result Option {
conn.query_row(
"SELECT id, slug, title, summary, start_utc, end_utc, status, content_sha256,
- created_utc, updated_utc
+ created_utc, updated_utc, interconnection_context
FROM case_studies WHERE slug = ?1",
[slug],
|r| {
@@ -658,6 +658,7 @@ pub fn find_case_study(conn: &Connection, slug: &str) -> Option {
content_sha256: r.get(7)?,
created_utc: r.get(8)?,
updated_utc: r.get(9)?,
+ interconnection_context: r.get(10)?,
})
},
)
@@ -688,6 +689,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2019-09-01T00:00:00Z".to_string(),
updated_utc: "2019-09-01T00:00:00Z".to_string(),
+ interconnection_context: None,
}
}
diff --git a/src/catalog/batch.rs b/src/catalog/batch.rs
index 73678bf..7a7b846 100644
--- a/src/catalog/batch.rs
+++ b/src/catalog/batch.rs
@@ -223,6 +223,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2026-08-01T00:00:00Z".to_string(),
updated_utc: "2026-08-01T00:00:00Z".to_string(),
+ interconnection_context: None,
};
archive_plan::build_plan(&cs, &[], 2, 2).unwrap()
}
diff --git a/src/catalog/case_study_compare.rs b/src/catalog/case_study_compare.rs
index baf20c8..9ac941c 100644
--- a/src/catalog/case_study_compare.rs
+++ b/src/catalog/case_study_compare.rs
@@ -675,6 +675,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2019-09-01T00:00:00Z".to_string(),
updated_utc: "2019-09-01T00:00:00Z".to_string(),
+ interconnection_context: None,
};
let cs_id = store::insert_case_study(conn, &cs).unwrap();
seed_phases(conn, cs_id);
diff --git a/src/catalog/case_study_import.rs b/src/catalog/case_study_import.rs
index 54d1bb4..111ec26 100644
--- a/src/catalog/case_study_import.rs
+++ b/src/catalog/case_study_import.rs
@@ -51,6 +51,46 @@ pub struct CaseStudyDataFile {
pub related_events: Vec,
pub claims: Vec,
pub targets: Vec,
+ /// Reviewed interconnection context (Layer-2 fabric, exchange,
+ /// shared medium). Reviewed interpretation; presentation-only;
+ /// never protocol evidence (attachment is not BGP adjacency).
+ #[serde(default)]
+ pub interconnection_context: Option,
+}
+
+/// Reviewed Layer-2 / interconnection context of an incident.
+#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct DataInterconnectionContext {
+ /// Context kind, currently `Layer2Fabric`.
+ pub kind: String,
+ /// Short label of the fabric / exchange.
+ pub label: String,
+ /// Reviewed attachments (physical / Layer-2 participation context).
+ #[serde(default)]
+ pub attachments: Vec,
+ /// Where this reviewed context comes from.
+ pub provenance: String,
+ /// Explicit limitations of the attachment evidence class.
+ #[serde(default)]
+ pub limitations: Vec,
+}
+
+/// One reviewed attachment to a Layer-2 fabric.
+#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct DataAttachment {
+ /// Organization / network label as reviewed.
+ pub label: String,
+ /// Reviewed short context note.
+ #[serde(default)]
+ pub note: String,
+ /// Reviewed ASN label where established; `None` when the reviewed
+ /// record does not establish one (never guessed).
+ pub asn: Option,
+ /// ASN validity date when `asn` is present.
+ #[serde(default)]
+ pub asn_validity_date: Option,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
@@ -479,6 +519,9 @@ pub fn import_case_study(
content_sha256: content_sha.clone(),
created_utc: now.clone(),
updated_utc: now,
+ interconnection_context: data.interconnection_context.as_ref().map(|c| {
+ serde_json::to_string(c).expect("interconnection context is serializable")
+ }),
};
let case_study_id = store::insert_case_study(&tx, &cs)?;
diff --git a/src/catalog/domain.rs b/src/catalog/domain.rs
index 760e1a3..96d729c 100644
--- a/src/catalog/domain.rs
+++ b/src/catalog/domain.rs
@@ -355,6 +355,9 @@ pub struct CaseStudy {
pub content_sha256: String,
pub created_utc: String,
pub updated_utc: String,
+ /// Reviewed interconnection context (Layer-2 fabric etc.), as a
+ /// JSON document. Reviewed interpretation, never protocol evidence.
+ pub interconnection_context: Option,
}
/// Link between a case study and a related ticket.
diff --git a/src/catalog/grnoc_viewer.rs b/src/catalog/grnoc_viewer.rs
index 8a8ae20..b07d727 100644
--- a/src/catalog/grnoc_viewer.rs
+++ b/src/catalog/grnoc_viewer.rs
@@ -738,6 +738,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2026-08-01T00:00:00Z".to_string(),
updated_utc: "2026-08-01T00:00:00Z".to_string(),
+ interconnection_context: None,
};
let cs_id = store::insert_case_study(conn, &cs).unwrap();
for (i, id) in ids.iter().enumerate() {
diff --git a/src/catalog/grouping.rs b/src/catalog/grouping.rs
index e272b65..4b7c461 100644
--- a/src/catalog/grouping.rs
+++ b/src/catalog/grouping.rs
@@ -488,6 +488,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2026-08-01T00:00:00Z".to_string(),
updated_utc: "2026-08-01T00:00:00Z".to_string(),
+ interconnection_context: None,
};
let cs_id = store::insert_case_study(&conn, &cs).unwrap();
for (i, ext) in ["INC0040101", "INC0040102"].iter().enumerate() {
diff --git a/src/catalog/migrations.rs b/src/catalog/migrations.rs
index f132c8d..6b83561 100644
--- a/src/catalog/migrations.rs
+++ b/src/catalog/migrations.rs
@@ -5,10 +5,10 @@
//! reopened database at the current version is a no-op.
/// Current catalog schema version.
-pub const CATALOG_SCHEMA_VERSION: u32 = 10;
+pub const CATALOG_SCHEMA_VERSION: u32 = 11;
/// Ordered migrations. Index i migrates user_version i -> i+1.
-pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6, V7, V8, V9, V10];
+pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11];
const V1: &str = r#"
CREATE TABLE catalog_events (
@@ -545,3 +545,15 @@ CREATE TABLE worker_heartbeats (
);
CREATE INDEX idx_worker_heartbeat ON worker_heartbeats(last_heartbeat);
"#;
+
+/// V11 — reviewed interconnection context on case studies.
+///
+/// `interconnection_context` is an optional reviewed-presentation JSON
+/// document describing the Layer-2 / interconnection context an
+/// operator-reported incident took place in (for example an exchange
+/// fabric). It is reviewed interpretation: it names attachments and
+/// their reviewed ASN labels where established, and it never becomes
+/// protocol evidence in analysis (attachment is not BGP adjacency).
+const V11: &str = r#"
+ALTER TABLE case_studies ADD COLUMN interconnection_context TEXT;
+"#;
diff --git a/src/catalog/observer_compare.rs b/src/catalog/observer_compare.rs
index ffe3b4d..89e6dc3 100644
--- a/src/catalog/observer_compare.rs
+++ b/src/catalog/observer_compare.rs
@@ -398,6 +398,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2026-08-01T00:00:00Z".to_string(),
updated_utc: "2026-08-01T00:00:00Z".to_string(),
+ interconnection_context: None,
};
store::insert_case_study(conn, &cs).unwrap();
let event_id =
diff --git a/src/catalog/phase_summary.rs b/src/catalog/phase_summary.rs
index 682f25d..38e195c 100644
--- a/src/catalog/phase_summary.rs
+++ b/src/catalog/phase_summary.rs
@@ -351,6 +351,7 @@ mod tests {
content_sha256: "abc".to_string(),
created_utc: "2019-09-01T00:00:00Z".to_string(),
updated_utc: "2019-09-01T00:00:00Z".to_string(),
+ interconnection_context: None,
};
let cs_id = store::insert_case_study(conn, &cs).unwrap();
let doc = store::insert_reference_document(
diff --git a/src/catalog/store.rs b/src/catalog/store.rs
index a06cfc9..e084f23 100644
--- a/src/catalog/store.rs
+++ b/src/catalog/store.rs
@@ -312,8 +312,8 @@ pub fn insert_case_study(conn: &Connection, cs: &CaseStudy) -> Result Result CaseStudy {
content_sha256: sha,
created_utc: "2019-09-01T00:00:00Z".to_string(),
updated_utc: "2019-09-01T00:00:00Z".to_string(),
+ interconnection_context: None,
}
}
diff --git a/src/catalog/web/api.rs b/src/catalog/web/api.rs
index 651132b..78f877b 100644
--- a/src/catalog/web/api.rs
+++ b/src/catalog/web/api.rs
@@ -180,7 +180,7 @@ pub async fn api_case_study(
AxumPath(slug): AxumPath,
) -> Response {
let db = state.db.lock().unwrap();
- match super::view::load_case_study(&db, &slug) {
+ match super::view::load_case_study(&db, &slug, &state.catalog_root) {
Ok(Some(v)) => envelope(serde_json::json!({
"slug": v.slug,
"title": v.title,
@@ -229,6 +229,25 @@ pub async fn api_case_study(
"not_directly_visible": v.observability_not_directly_visible,
"unknown": v.observability_unknown,
},
+ "interconnection_context": v.interconnection.as_ref().map(|ic| serde_json::json!({
+ "kind": ic.kind,
+ "label": ic.label,
+ "attachments": ic.attachments.iter().map(|a| serde_json::json!({
+ "label": a.label, "note": a.note, "asn": a.asn_text,
+ })).collect::>(),
+ "limitations": ic.limitations,
+ })),
+ "path_comparison": v.path_comparison.as_ref().map(|pc| serde_json::json!({
+ "observer": pc.observer,
+ "prefix": pc.prefix,
+ "run_id": pc.run_id,
+ "states": pc.states.iter().map(|s| serde_json::json!({
+ "label": s.label,
+ "timestamp": s.timestamp,
+ "path": s.path.as_ref().map(|p| p.text_sequence()),
+ "observation_kind": s.path.as_ref().map(|p| p.observation_kind.clone()),
+ })).collect::>(),
+ })),
})),
Ok(None) => super::handlers::json_error(StatusCode::NOT_FOUND, "case study not found"),
Err(e) => super::handlers::json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
@@ -242,7 +261,7 @@ pub async fn api_case_study_timeline(
AxumPath(slug): AxumPath,
) -> Response {
let db = state.db.lock().unwrap();
- match super::view::load_case_study(&db, &slug) {
+ match super::view::load_case_study(&db, &slug, &state.catalog_root) {
Ok(Some(v)) => envelope(serde_json::json!({
"slug": v.slug,
"phases": v.phases.iter().map(|p| serde_json::json!({
@@ -272,7 +291,7 @@ pub async fn api_case_study_comparison(
AxumPath(slug): AxumPath,
) -> Response {
let db = state.db.lock().unwrap();
- match super::view::load_case_study(&db, &slug) {
+ match super::view::load_case_study(&db, &slug, &state.catalog_root) {
Ok(Some(v)) => envelope(serde_json::json!({
"slug": v.slug,
"rows": v.comparison.iter().map(|c| serde_json::json!({
diff --git a/src/catalog/web/handlers.rs b/src/catalog/web/handlers.rs
index faa41a7..157b2f9 100644
--- a/src/catalog/web/handlers.rs
+++ b/src/catalog/web/handlers.rs
@@ -141,7 +141,7 @@ pub async fn case_study_detail(
AxumPath(slug): AxumPath,
) -> Response {
let db = state.db.lock().unwrap();
- match super::view::load_case_study(&db, &slug) {
+ match super::view::load_case_study(&db, &slug, &state.catalog_root) {
Ok(Some(view)) => render_view(view),
Ok(None) => not_found_view("case study"),
Err(e) => server_error(&e),
diff --git a/src/catalog/web/mod.rs b/src/catalog/web/mod.rs
index 51ccd21..dee2c7e 100644
--- a/src/catalog/web/mod.rs
+++ b/src/catalog/web/mod.rs
@@ -10,6 +10,7 @@ pub mod api;
pub mod handlers;
pub mod job_handlers;
pub mod jobs_view;
+pub mod path_diagram;
pub mod server;
pub mod session_context;
#[cfg(test)]
diff --git a/src/catalog/web/path_diagram.rs b/src/catalog/web/path_diagram.rs
new file mode 100644
index 0000000..3569c0b
--- /dev/null
+++ b/src/catalog/web/path_diagram.rs
@@ -0,0 +1,1102 @@
+//! Source-neutral presentation of observed AS paths, Layer-2 fabric
+//! context, and reviewed-but-unobserved relationships.
+//!
+//! All diagrams are server-rendered SVG: no JavaScript, no Graphviz,
+//! no runtime dependencies. Layout is presentation only — it is never
+//! serialized into canonical artifacts, never alters finding IDs, run
+//! IDs, or plan hashes, and never adds an ASN that is absent from the
+//! canonical or reviewed input.
+//!
+//! Evidence semantics:
+//! - a **solid arrow** is an observed AS-path order at one public
+//! observer (canonical route evidence);
+//! - a **dashed edge** is a reviewed relationship or predicate that
+//! was NOT observed in the selected evidence;
+//! - a **grey undirected line** is reviewed Layer-2 attachment
+//! context (never a BGP adjacency claim).
+//!
+//! Arrow direction is never labeled provider/customer/peer unless
+//! separate reviewed relationship evidence supports that exact label;
+//! the default edge meaning is "observed AS-path sequence".
+
+/// One normalized AS-path segment with reviewed presentation context.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct PathNode {
+ pub asn: u32,
+ /// Consecutive repeats at this position (1 = single occurrence).
+ pub repeat: u32,
+ /// Reviewed short name when established; empty when unresolved.
+ pub label: String,
+ /// ASN matches the reviewed plane predicate of the analysis.
+ pub plane_matched: bool,
+ /// The analyzed target origin ASN.
+ pub origin: bool,
+}
+
+/// A complete observed AS path at one observer.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ObservedPath {
+ pub observer: String,
+ /// "direct collector session" | "indirect AS-path observation" | "not recorded".
+ pub observation_kind: String,
+ pub prefix: String,
+ pub timestamp: String,
+ pub nodes: Vec,
+ /// Optional link to the run/evidence page.
+ pub evidence_ref: Option,
+}
+
+impl ObservedPath {
+ pub fn text_sequence(&self) -> String {
+ let mut out = String::new();
+ for (i, n) in self.nodes.iter().enumerate() {
+ if i > 0 {
+ out.push(' ');
+ }
+ out.push_str(&format!("AS{}", n.asn));
+ for _ in 1..n.repeat {
+ out.push_str(&format!(" AS{}", n.asn));
+ }
+ }
+ out
+ }
+}
+
+/// One state in a before/after path comparison.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct PathStateView {
+ /// "Event baseline", "Pre-finding state", "First changed state",
+ /// "First route after return", "Analysis-final state".
+ pub label: String,
+ pub timestamp: String,
+ /// `None` renders the absence block (no selected route visible).
+ pub path: Option,
+}
+
+/// Reviewed Layer-2 attachment context of a case study.
+#[derive(Debug, Clone)]
+pub struct FabricView {
+ pub label: String,
+ pub attachments: Vec,
+ pub provenance: String,
+ pub limitations: Vec,
+}
+
+#[derive(Debug, Clone)]
+pub struct FabricAttachmentView {
+ pub label: String,
+ pub note: String,
+ pub asn: Option,
+}
+
+/// Reviewed relationship or predicate with its observed status.
+#[derive(Debug, Clone)]
+pub struct RelationshipView {
+ pub label: String,
+ pub asns: Vec,
+ /// Whether selected evidence observed the relationship.
+ pub observed: bool,
+ pub note: String,
+}
+
+/// Compact consecutive-run representation: `[11537, 20965, 2603]` stays
+/// as-is; `[24489, 24489, 24489, 24489]` becomes one node with
+/// `repeat = 4`. Order is preserved.
+pub fn compact_segments(asns: &[u32]) -> Vec<(u32, u32)> {
+ let mut out: Vec<(u32, u32)> = Vec::new();
+ for &asn in asns {
+ match out.last_mut() {
+ Some((last, count)) if *last == asn => *count += 1,
+ _ => out.push((asn, 1)),
+ }
+ }
+ out
+}
+
+/// Full normalized AS-path text (no compaction) — the text equivalent
+/// always preserves the complete sequence.
+pub fn full_sequence_text(asns: &[u32]) -> String {
+ asns.iter()
+ .map(|a| format!("AS{a}"))
+ .collect::>()
+ .join(" ")
+}
+
+pub fn escape_svg(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+const NODE_W: f64 = 132.0;
+const NODE_H: f64 = 46.0;
+const NODE_GAP: f64 = 64.0;
+const ROW_H: f64 = 78.0;
+
+fn node_x(index: usize) -> f64 {
+ 16.0 + index as f64 * (NODE_W + NODE_GAP)
+}
+
+fn node_svg(n: &PathNode, x: f64, y: f64, changed: bool) -> String {
+ let mut cls = String::from("pd-node");
+ if n.origin {
+ cls.push_str(" pd-node-origin");
+ }
+ if n.plane_matched {
+ cls.push_str(" pd-node-plane");
+ }
+ if n.label.is_empty() {
+ cls.push_str(" pd-node-unknown");
+ }
+ if changed {
+ cls.push_str(" pd-node-changed");
+ }
+ let title = if n.repeat > 1 {
+ format!(
+ "AS{} appears {} consecutive times in the observed sequence",
+ n.asn, n.repeat
+ )
+ } else {
+ format!("AS{}", n.asn)
+ };
+ let label = if n.label.is_empty() {
+ "name not reviewed".to_string()
+ } else {
+ n.label.clone()
+ };
+ let asn_text = if n.repeat > 1 {
+ format!("AS{} ×{}", n.asn, n.repeat)
+ } else {
+ format!("AS{}", n.asn)
+ };
+ format!(
+ r#"
+{title}
+
+{}
+{}
+"#,
+ x + NODE_W / 2.0,
+ y + 20.0,
+ escape_svg(&asn_text),
+ x + NODE_W / 2.0,
+ y + 36.0,
+ escape_svg(&label)
+ )
+}
+
+fn arrow_svg(x1: f64, y1: f64, x2: f64, y2: f64) -> String {
+ format!(
+ r#""#
+ )
+}
+
+/// Render one observed AS path as an SVG row.
+pub fn render_path_svg(path: &ObservedPath) -> String {
+ let width = node_x(path.nodes.len().max(1)) + NODE_W + 8.0;
+ let mut body = String::new();
+ for (i, n) in path.nodes.iter().enumerate() {
+ let x = node_x(i);
+ body.push_str(&node_svg(n, x, 24.0, false));
+ if i + 1 < path.nodes.len() {
+ body.push_str(&arrow_svg(
+ x + NODE_W,
+ 47.0,
+ node_x(i + 1),
+ 47.0,
+ ));
+ }
+ }
+ let note = format!(
+ "{} — observed AS-path sequence at {}",
+ path.observer, path.prefix
+ );
+ format!(
+ r#""#,
+ escape_svg(&format!(
+ "{} · {} · {}",
+ path.observer, path.prefix, path.timestamp
+ ))
+ )
+}
+
+/// Render the absence block: no selected route visible at this observer.
+pub fn render_absence_svg(observer: &str, prefix: &str) -> String {
+ format!(
+ r#""#
+ )
+}
+
+fn state_row(
+ state: &PathStateView,
+ row: usize,
+ prev_path: Option<&[u32]>,
+) -> (String, Option>) {
+ let y = 14.0 + row as f64 * ROW_H;
+ let label_x = 12.0;
+ let path_x0 = 178.0;
+ let mut out = String::new();
+ out.push_str(&format!(
+ r#"{}"#,
+ y + 26.0,
+ escape_svg(&state.label)
+ ));
+ out.push_str(&format!(
+ r#"{}"#,
+ y + 42.0,
+ escape_svg(&state.timestamp)
+ ));
+ match &state.path {
+ None => {
+ out.push_str(&format!(
+ r#""#,
+ y
+ ));
+ out.push_str(&render_absence_svg_inner());
+ out.push_str("");
+ (out, None)
+ }
+ Some(path) => {
+ let nodes = &path.nodes;
+ let mut changed: Vec = Vec::with_capacity(nodes.len());
+ let prev = prev_path.unwrap_or(&[]);
+ for (i, n) in nodes.iter().enumerate() {
+ let same_as_prev = prev.get(i).copied() == Some(n.asn);
+ changed.push(!same_as_prev);
+ }
+ for (i, n) in nodes.iter().enumerate() {
+ let x = path_x0 + node_x(i) - 16.0;
+ out.push_str(&node_svg(
+ &PathNode {
+ asn: n.asn,
+ repeat: n.repeat,
+ label: n.label.clone(),
+ plane_matched: n.plane_matched,
+ origin: n.origin,
+ },
+ x,
+ y + 4.0,
+ changed[i],
+ ));
+ if i + 1 < nodes.len() {
+ out.push_str(&arrow_svg(
+ x + NODE_W,
+ y + 27.0,
+ path_x0 + node_x(i + 1) - 16.0,
+ y + 27.0,
+ ));
+ }
+ }
+ let full: Vec = nodes.iter().flat_map(|n| std::iter::repeat(n.asn).take(n.repeat as usize)).collect();
+ (out, Some(full))
+ }
+ }
+}
+
+fn render_absence_svg_inner() -> String {
+ r#"
+No selected route visible at this observer"#
+ .to_string()
+}
+
+/// Render a before/after state comparison (baseline → pre-finding →
+/// first changed → first return → final). Absence states are blocks,
+/// never ASN nodes; changed segments are marked, never attributed as
+/// the cause of the change.
+pub fn render_comparison_svg(states: &[PathStateView]) -> String {
+ let max_nodes = states
+ .iter()
+ .filter_map(|s| s.path.as_ref())
+ .map(|p| p.nodes.len())
+ .max()
+ .unwrap_or(0);
+ let width = 190.0 + node_x(max_nodes.max(1)) + NODE_W;
+ let height = 20.0 + states.len() as f64 * ROW_H + 8.0;
+ let mut body = String::new();
+ let mut prev: Option> = None;
+ for (i, state) in states.iter().enumerate() {
+ let (row, full) = state_row(state, i, prev.as_deref());
+ body.push_str(&row);
+ prev = full;
+ }
+ format!(
+ r#""#
+ )
+}
+
+/// Render the reviewed Layer-2 fabric context. The fabric is a wide
+/// neutral rectangle, never an ASN node; attachment lines are
+/// undirected grey lines, never BGP edges.
+pub fn render_fabric_svg(fabric: &FabricView) -> String {
+ let count = fabric.attachments.len().max(1);
+ let width = 120.0 + count as f64 * 150.0;
+ let height = 190.0;
+ let fabric_y = 26.0;
+ let attach_y = 130.0;
+ let center = width / 2.0;
+ let fabric_w = (count as f64 * 150.0).max(220.0);
+ let mut body = String::new();
+ // Fabric block.
+ body.push_str(&format!(
+ r#"
+Layer-2 fabric — not a BGP speaker
+
+{}
+Layer-2 fabric — not a BGP speaker
+"#,
+ center - fabric_w / 2.0,
+ fabric_y + 20.0,
+ escape_svg(&fabric.label),
+ fabric_y + 36.0,
+ ));
+ for (i, a) in fabric.attachments.iter().enumerate() {
+ let x = 30.0 + i as f64 * 150.0 + 60.0;
+ // Undirected grey attachment line — no arrowhead.
+ body.push_str(&format!(
+ r#""#,
+ fabric_y + 44.0
+ ));
+ let asn_text = match a.asn {
+ Some(asn) => format!("AS{asn}"),
+ None => String::from("no reviewed ASN"),
+ };
+ body.push_str(&format!(
+ r#"
+{att_label} — reviewed Layer-2 attachment (not BGP adjacency)
+
+{}
+{}
+"#,
+ x - 60.0,
+ x,
+ attach_y + 19.0,
+ escape_svg(&asn_text),
+ x,
+ attach_y + 35.0,
+ escape_svg(&a.label),
+ att_label = escape_svg(&a.label)
+ ));
+ }
+ format!(
+ r#""#
+ )
+}
+
+/// Render a reviewed relationship/predicate. Dashed when unobserved in
+/// the selected evidence; solid when observed.
+pub fn render_relationship_svg(rel: &RelationshipView) -> String {
+ let width = 16.0 + rel.asns.len() as f64 * (NODE_W + NODE_GAP);
+ let y = 26.0;
+ let edge_cls = if rel.observed {
+ "pd-edge"
+ } else {
+ "pd-edge-dashed"
+ };
+ let mut body = String::new();
+ for (i, &asn) in rel.asns.iter().enumerate() {
+ let x = node_x(i);
+ body.push_str(&node_svg(
+ &PathNode {
+ asn,
+ repeat: 1,
+ label: String::new(),
+ plane_matched: false,
+ origin: false,
+ },
+ x,
+ y,
+ false,
+ ));
+ if i + 1 < rel.asns.len() {
+ let line = if rel.observed {
+ format!(
+ r#""#,
+ x + NODE_W,
+ y + 23.0,
+ node_x(i + 1),
+ y + 23.0
+ )
+ } else {
+ format!(
+ r#""#,
+ x + NODE_W,
+ y + 23.0,
+ node_x(i + 1),
+ y + 23.0
+ )
+ };
+ body.push_str(&line);
+ }
+ }
+ let note_y = y + NODE_H + 22.0;
+ let title = if rel.observed {
+ format!("Observed in selected evidence — {label}", label = rel.note)
+ } else {
+ format!("Reviewed relationship sought — not observed in selected evidence — {label}", label = rel.note)
+ };
+ format!(
+ r#""#,
+ escape_svg(&format!(
+ "{label} — {status}",
+ label = rel.label,
+ status = if rel.observed {
+ "observed in selected evidence"
+ } else {
+ "reviewed relationship sought — not observed in selected evidence"
+ }
+ ))
+ )
+}
+
+
+/// One canonical transition of a stream (from `lifecycle.json`).
+#[derive(Debug, Clone)]
+pub struct PathTransition {
+ pub timestamp: String,
+ pub kind: String,
+ pub before_path: Vec,
+ pub after_path: Vec,
+}
+
+/// Canonical stream path evidence loaded from a run's `lifecycle.json`
+/// artifact (schema_version 1). The artifact is the authority; the
+/// catalog's compact transition index deliberately has no paths.
+#[derive(Debug, Clone)]
+pub struct StreamPathEvidence {
+ pub collector: String,
+ pub peer_ip: String,
+ pub prefix: String,
+ pub baseline_path: Vec,
+ pub transitions: Vec,
+ pub final_path: Vec,
+ pub restoration_time_utc: Option,
+ pub first_change_utc: Option,
+ /// Observation ids of the transitions, for exact evidence links.
+ pub observation_ids: Vec,
+}
+
+/// Load all stream path evidence from a `lifecycle.json` artifact.
+pub fn load_lifecycle_evidence(json: &str) -> Result, String> {
+ let v: serde_json::Value = serde_json::from_str(json)
+ .map_err(|e| format!("invalid lifecycle.json: {e}"))?;
+ let mut out = Vec::new();
+ let lifecycles = v
+ .get("lifecycles")
+ .and_then(|x| x.as_array())
+ .ok_or_else(|| "lifecycle.json: missing lifecycles array".to_string())?;
+ for lc in lifecycles {
+ let prefix = lc.get("prefix").and_then(|x| x.as_str()).unwrap_or("").to_string();
+ let collector = lc.get("collector").and_then(|x| x.as_str()).unwrap_or("").to_string();
+ let peer_ip = lc.get("peer_ip").and_then(|x| x.as_str()).unwrap_or("").to_string();
+ let baseline_path = asn_vec(lc.get("baseline_path"));
+ let final_path = lc
+ .get("final_state")
+ .and_then(|f| f.get("attributes"))
+ .and_then(|a| a.get("as_path"))
+ .map(|p| asn_vec(Some(p)))
+ .unwrap_or_default();
+ let mut transitions = Vec::new();
+ let mut observation_ids = Vec::new();
+ if let Some(ts) = lc.get("transitions").and_then(|x| x.as_array()) {
+ for tr in ts {
+ transitions.push(PathTransition {
+ timestamp: tr.get("timestamp").and_then(|x| x.as_str()).unwrap_or("").to_string(),
+ kind: tr.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string(),
+ before_path: asn_vec(tr.get("before_path")),
+ after_path: asn_vec(tr.get("after_path")),
+ });
+ if let Some(oid) = tr.get("observation_id") {
+ observation_ids.push(oid.to_string());
+ }
+ }
+ }
+ out.push(StreamPathEvidence {
+ collector,
+ peer_ip,
+ prefix,
+ baseline_path,
+ transitions,
+ final_path,
+ restoration_time_utc: lc
+ .get("restoration_time")
+ .and_then(|x| x.as_str())
+ .map(|s| s.to_string()),
+ first_change_utc: lc
+ .get("first_change")
+ .and_then(|x| x.as_str())
+ .map(|s| s.to_string()),
+ observation_ids,
+ });
+ }
+ Ok(out)
+}
+
+fn asn_vec(v: Option<&serde_json::Value>) -> Vec {
+ v.and_then(|x| x.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|n| n.as_u64().map(|n| n as u32))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Reviewed ASN display names from `asn-identities.json` files.
+///
+/// Only identities with a non-empty `display_name` are used; nothing is
+/// inferred when a name is absent.
+pub fn load_asn_names(roots: &[&std::path::Path]) -> std::collections::BTreeMap {
+ let mut names: std::collections::BTreeMap = std::collections::BTreeMap::new();
+ for root in roots {
+ let file = root.join("asn-identities.json");
+ let Ok(content) = std::fs::read_to_string(&file) else {
+ continue;
+ };
+ let Ok(v) = serde_json::from_str::(&content) else {
+ continue;
+ };
+ let Some(ids) = v.get("identities").and_then(|x| x.as_array()) else {
+ continue;
+ };
+ for id in ids {
+ let Some(asn) = id.get("asn").and_then(|x| x.as_u64()).map(|n| n as u32) else {
+ continue;
+ };
+ let name = id.get("display_name").and_then(|x| x.as_str()).unwrap_or("");
+ if !name.is_empty() && !names.contains_key(&asn) {
+ names.insert(asn, name.to_string());
+ }
+ }
+ }
+ names
+}
+
+/// Build the before/after state comparison for one stream from
+/// canonical evidence. Absence (`after_path` empty after a Withdrawal)
+/// is a state block, never an ASN node. States that did not occur are
+/// omitted (no fabricated rows).
+pub fn comparison_states(
+ ev: &StreamPathEvidence,
+ origin_asn: Option,
+ plane_asns: &[u32],
+ names: &std::collections::BTreeMap,
+) -> Vec {
+ let mut states = Vec::new();
+ let observer = format!("{} (peer {})", ev.collector, ev.peer_ip);
+ let to_path = |asns: &[u32], ts: &str| -> Option {
+ if asns.is_empty() {
+ return None;
+ }
+ Some(ObservedPath {
+ observer: observer.clone(),
+ observation_kind: String::new(),
+ prefix: ev.prefix.clone(),
+ timestamp: ts.to_string(),
+ nodes: compact_segments(asns)
+ .into_iter()
+ .map(|(asn, repeat)| PathNode {
+ asn,
+ repeat,
+ label: names.get(&asn).cloned().unwrap_or_default(),
+ plane_matched: plane_asns.contains(&asn),
+ origin: origin_asn == Some(asn),
+ })
+ .collect(),
+ evidence_ref: None,
+ })
+ };
+ states.push(PathStateView {
+ label: "Event baseline".to_string(),
+ timestamp: "baseline RIB".to_string(),
+ path: to_path(&ev.baseline_path, "baseline RIB"),
+ });
+ let mut first_change: Option<&PathTransition> = None;
+ let mut absence_at: Option<&PathTransition> = None;
+ let mut return_at: Option<&PathTransition> = None;
+ for tr in &ev.transitions {
+ if first_change.is_none() && (tr.kind != "Announcement" || !tr.before_path.is_empty()) {
+ first_change = Some(tr);
+ }
+ if absence_at.is_none() && tr.kind == "Withdrawal" && tr.after_path.is_empty() {
+ absence_at = Some(tr);
+ }
+ if absence_at.is_some()
+ && return_at.is_none()
+ && tr.kind != "Withdrawal"
+ && !tr.after_path.is_empty()
+ {
+ return_at = Some(tr);
+ }
+ }
+ if let Some(fc) = first_change {
+ let pre = if fc.kind == "Withdrawal" && fc.after_path.is_empty() {
+ &fc.before_path
+ } else {
+ &fc.before_path
+ };
+ states.push(PathStateView {
+ label: "Pre-finding state".to_string(),
+ timestamp: fc.timestamp.clone(),
+ path: to_path(pre, &fc.timestamp),
+ });
+ if fc.kind == "Withdrawal" && fc.after_path.is_empty() {
+ states.push(PathStateView {
+ label: "First changed state".to_string(),
+ timestamp: fc.timestamp.clone(),
+ path: None,
+ });
+ } else {
+ states.push(PathStateView {
+ label: "First changed state".to_string(),
+ timestamp: fc.timestamp.clone(),
+ path: to_path(&fc.after_path, &fc.timestamp),
+ });
+ }
+ }
+ if let Some(rt) = return_at {
+ states.push(PathStateView {
+ label: "First route after return".to_string(),
+ timestamp: rt.timestamp.clone(),
+ path: to_path(&rt.after_path, &rt.timestamp),
+ });
+ }
+ states.push(PathStateView {
+ label: "Analysis-final state".to_string(),
+ timestamp: "analysis end".to_string(),
+ path: to_path(&ev.final_path, "analysis end"),
+ });
+ states
+}
+
+#[cfg(test)]
+mod tests {
+
+ #[test]
+ fn loader_reads_canonical_lifecycle_states() {
+ let json = r#"{
+ "schema_version": 1,
+ "event_id": "X",
+ "lifecycles": [{
+ "collector": "collector-x",
+ "peer_ip": "192.0.2.1",
+ "prefix": "198.51.100.0/24",
+ "baseline_path": [64500, 64501],
+ "category": "Withdrawn",
+ "flags": {"restored": true},
+ "first_change": "2020-01-01T00:00:01Z",
+ "restoration_time": "2020-01-01T00:00:03Z",
+ "transitions": [
+ {"timestamp": "2020-01-01T00:00:01Z", "kind": "Withdrawal",
+ "before_path": [64500, 64502, 64501], "after_path": [],
+ "observation_id": 1},
+ {"timestamp": "2020-01-01T00:00:02Z", "kind": "Announcement",
+ "before_path": [], "after_path": [64500, 64503, 64501],
+ "observation_id": 2}
+ ],
+ "final_state": {"prefix": "198.51.100.0/24", "attributes": {"as_path": [64500, 64502, 64501]}}
+ }]
+ }"#;
+ let evs = load_lifecycle_evidence(json).unwrap();
+ assert_eq!(evs.len(), 1);
+ let ev = &evs[0];
+ assert_eq!(ev.baseline_path, vec![64500, 64501]);
+ assert_eq!(ev.transitions.len(), 2);
+ assert_eq!(ev.transitions[0].kind, "Withdrawal");
+ assert!(ev.transitions[0].after_path.is_empty());
+ assert_eq!(ev.final_path, vec![64500, 64502, 64501]);
+ let names = std::collections::BTreeMap::new();
+ let states = comparison_states(ev, Some(64501), &[64500], &names);
+ let labels: Vec<&str> = states.iter().map(|s| s.label.as_str()).collect();
+ assert_eq!(
+ labels,
+ vec![
+ "Event baseline",
+ "Pre-finding state",
+ "First changed state",
+ "First route after return",
+ "Analysis-final state"
+ ]
+ );
+ assert!(states[2].path.is_none(), "absence is a state block");
+ assert_eq!(
+ states[3].path.as_ref().unwrap().text_sequence(),
+ "AS64500 AS64503 AS64501"
+ );
+ }
+
+ #[test]
+ fn comparison_omits_states_that_did_not_occur() {
+ let json = r#"{
+ "schema_version": 1,
+ "event_id": "X",
+ "lifecycles": [{
+ "collector": "collector-x",
+ "peer_ip": "192.0.2.1",
+ "prefix": "198.51.100.0/24",
+ "baseline_path": [64500, 64501],
+ "category": "PathChangedStillViaTransit",
+ "transitions": [
+ {"timestamp": "2020-01-01T00:00:01Z", "kind": "PathReplacement",
+ "before_path": [64500, 64501], "after_path": [64500, 64502, 64501],
+ "observation_id": 3}
+ ],
+ "final_state": {"prefix": "198.51.100.0/24", "attributes": {"as_path": [64500, 64501]}}
+ }]
+ }"#;
+ let evs = load_lifecycle_evidence(json).unwrap();
+ let names = std::collections::BTreeMap::new();
+ let states = comparison_states(&evs[0], None, &[], &names);
+ let labels: Vec<&str> = states.iter().map(|s| s.label.as_str()).collect();
+ assert!(!labels.contains(&"First route after return"), "{labels:?}");
+ assert_eq!(states.len(), 4, "baseline + pre-finding + changed + final");
+ }
+
+ use super::*;
+
+ fn node(asn: u32) -> PathNode {
+ PathNode {
+ asn,
+ repeat: 1,
+ label: String::new(),
+ plane_matched: false,
+ origin: false,
+ }
+ }
+
+ #[test]
+ fn compact_segments_preserves_order_and_counts() {
+ let segments = [11537, 22388, 24489, 24489, 24489, 24489, 24490, 20965, 2603];
+ let compact = compact_segments(&segments);
+ assert_eq!(
+ compact,
+ vec![
+ (11537, 1),
+ (22388, 1),
+ (24489, 4),
+ (24490, 1),
+ (20965, 1),
+ (2603, 1)
+ ]
+ );
+ // Text equivalent preserves the full sequence.
+ assert_eq!(
+ full_sequence_text(&segments),
+ "AS11537 AS22388 AS24489 AS24489 AS24489 AS24489 AS24490 AS20965 AS2603"
+ );
+ }
+
+ #[test]
+ fn unknown_asn_not_given_guessed_name() {
+ let mut path = ObservedPath {
+ observer: "collector-x (peer 192.0.2.1)".to_string(),
+ observation_kind: "indirect AS-path observation".to_string(),
+ prefix: "198.51.100.0/24".to_string(),
+ timestamp: "2020-01-01T00:00:00Z".to_string(),
+ nodes: vec![node(20965)],
+ evidence_ref: None,
+ };
+ let svg = render_path_svg(&path);
+ assert!(svg.contains("AS20965"), "{svg}");
+ assert!(svg.contains("name not reviewed"), "{svg}");
+ assert!(!svg.contains(">DreamHost<"), "no guessed organization");
+ // Unknown-node class is present.
+ assert!(svg.contains("pd-node-unknown"), "{svg}");
+ path.nodes[0].label = "Reviewed Org".to_string();
+ let svg2 = render_path_svg(&path);
+ assert!(svg2.contains("Reviewed Org"), "{svg2}");
+ assert!(!svg2.contains("pd-node-unknown"), "{svg2}");
+ }
+
+ #[test]
+ fn observed_path_not_labeled_commercial_relationship() {
+ let path = ObservedPath {
+ observer: "collector-x (peer 192.0.2.1)".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "198.51.100.0/24".to_string(),
+ timestamp: "2020-01-01T00:00:00Z".to_string(),
+ nodes: vec![node(64500), node(64501)],
+ evidence_ref: None,
+ };
+ let svg = render_path_svg(&path);
+ assert!(svg.contains("observed AS-path sequence"), "{svg}");
+ assert!(!svg.contains("provider"), "no provider label");
+ assert!(!svg.contains("customer"), "no customer label");
+ assert!(!svg.contains("peer relationship"), "no peer label");
+ }
+
+ #[test]
+ fn as_path_order_matches_input() {
+ let path = ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "t".to_string(),
+ nodes: vec![node(64500), node(64501), node(64502)],
+ evidence_ref: None,
+ };
+ let svg = render_path_svg(&path);
+ let i0 = svg.find("AS64500").unwrap();
+ let i1 = svg.find("AS64501").unwrap();
+ let i2 = svg.find("AS64502").unwrap();
+ assert!(i0 < i1 && i1 < i2, "order preserved in SVG: {svg}");
+ assert_eq!(path.text_sequence(), "AS64500 AS64501 AS64502");
+ }
+
+ #[test]
+ fn prepend_compaction_preserves_count() {
+ let path = ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "t".to_string(),
+ nodes: vec![
+ PathNode {
+ asn: 24489,
+ repeat: 4,
+ ..node(24489)
+ },
+ node(2603),
+ ],
+ evidence_ref: None,
+ };
+ let svg = render_path_svg(&path);
+ assert!(svg.contains("AS24489 ×4"), "{svg}");
+ assert_eq!(path.text_sequence(), "AS24489 AS24489 AS24489 AS24489 AS2603");
+ }
+
+ #[test]
+ fn absence_not_rendered_as_as_node() {
+ let svg = render_absence_svg("collector-x", "198.51.100.0/24");
+ assert!(svg.contains("No selected route visible at this observer"), "{svg}");
+ assert!(!svg.contains("withdrawn"), "withdrawal is not an ASN node");
+ assert!(!svg.contains("pd-node"), "no ASN node rendered");
+ }
+
+ #[test]
+ fn diagram_links_to_evidence_reference() {
+ let mut path = ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "t".to_string(),
+ nodes: vec![node(64500)],
+ evidence_ref: Some("/analyses/7".to_string()),
+ };
+ // Evidence links are rendered by the page layer (HTML anchors);
+ // the component keeps the reference addressable.
+ assert_eq!(path.evidence_ref.as_deref(), Some("/analyses/7"));
+ path.evidence_ref = None;
+ assert!(path.evidence_ref.is_none());
+ }
+
+ #[test]
+ fn comparison_preserves_lifecycle_states_and_absence() {
+ let states = vec![
+ PathStateView {
+ label: "Event baseline".to_string(),
+ timestamp: "baseline RIB".to_string(),
+ path: Some(ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "baseline".to_string(),
+ nodes: vec![node(11537), node(2603)],
+ evidence_ref: None,
+ }),
+ },
+ PathStateView {
+ label: "Pre-finding state".to_string(),
+ timestamp: "2020-01-01T00:00:00Z".to_string(),
+ path: Some(ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "2020-01-01T00:00:00Z".to_string(),
+ nodes: vec![node(11537), node(20965), node(2603)],
+ evidence_ref: None,
+ }),
+ },
+ PathStateView {
+ label: "First changed state".to_string(),
+ timestamp: "2020-01-01T00:00:01Z".to_string(),
+ path: None,
+ },
+ PathStateView {
+ label: "First route after return".to_string(),
+ timestamp: "2020-01-01T00:00:02Z".to_string(),
+ path: Some(ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "2020-01-01T00:00:02Z".to_string(),
+ nodes: vec![node(11537), node(24489), node(2603)],
+ evidence_ref: None,
+ }),
+ },
+ PathStateView {
+ label: "Analysis-final state".to_string(),
+ timestamp: "analysis end".to_string(),
+ path: Some(ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "analysis end".to_string(),
+ nodes: vec![node(11537), node(20965), node(2603)],
+ evidence_ref: None,
+ }),
+ },
+ ];
+ let svg = render_comparison_svg(&states);
+ for label in [
+ "Event baseline",
+ "Pre-finding state",
+ "First changed state",
+ "First route after return",
+ "Analysis-final state",
+ ] {
+ assert!(svg.contains(label), "missing {label}: {svg}");
+ }
+ assert!(svg.contains("No selected route visible at this observer"), "{svg}");
+ // State labels never imply the segments caused the change.
+ assert!(!svg.contains("caused"), "{svg}");
+ // The final state is rendered separately from the baseline.
+ let baseline = svg.find("Event baseline").unwrap();
+ let final_idx = svg.find("Analysis-final state").unwrap();
+ assert!(baseline < final_idx);
+ }
+
+ #[test]
+ fn final_path_not_assumed_baseline() {
+ // Distinct node sequences for baseline and final must both render.
+ let svg = render_comparison_svg(&[
+ PathStateView {
+ label: "Event baseline".to_string(),
+ timestamp: "baseline".to_string(),
+ path: Some(ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "baseline".to_string(),
+ nodes: vec![node(64500), node(64501)],
+ evidence_ref: None,
+ }),
+ },
+ PathStateView {
+ label: "Analysis-final state".to_string(),
+ timestamp: "analysis end".to_string(),
+ path: Some(ObservedPath {
+ observer: "o".to_string(),
+ observation_kind: "direct collector session".to_string(),
+ prefix: "p".to_string(),
+ timestamp: "analysis end".to_string(),
+ nodes: vec![node(64500), node(64502), node(64501)],
+ evidence_ref: None,
+ }),
+ },
+ ]);
+ assert!(svg.contains("AS64502"), "final path rendered: {svg}");
+ }
+
+ #[test]
+ fn fabric_diagram_contains_no_fabric_asn() {
+ let fabric = FabricView {
+ label: "Exchange Fabric".to_string(),
+ attachments: vec![
+ FabricAttachmentView {
+ label: "Network One".to_string(),
+ note: "reviewed".to_string(),
+ asn: Some(64500),
+ },
+ FabricAttachmentView {
+ label: "Network Two".to_string(),
+ note: "no reviewed ASN".to_string(),
+ asn: None,
+ },
+ ],
+ provenance: "reviewed".to_string(),
+ limitations: vec!["not adjacency".to_string()],
+ };
+ let svg = render_fabric_svg(&fabric);
+ assert!(svg.contains("Layer-2 fabric — not a BGP speaker"), "{svg}");
+ assert!(svg.contains("AS64500"), "reviewed ASN label rendered");
+ assert!(svg.contains("no reviewed ASN"), "unestablished ASN not guessed");
+ assert!(!svg.contains("AS64501"), "no fabricated ASN");
+ assert!(svg.contains("pd-attach"), "attachment lines present");
+ assert!(!svg.contains("marker-end"), "no directional BGP edges");
+ }
+
+ #[test]
+ fn fabric_edges_are_not_directional_bgp_edges() {
+ let fabric = FabricView {
+ label: "F".to_string(),
+ attachments: vec![FabricAttachmentView {
+ label: "N".to_string(),
+ note: "n".to_string(),
+ asn: Some(64500),
+ }],
+ provenance: "p".to_string(),
+ limitations: vec![],
+ };
+ let svg = render_fabric_svg(&fabric);
+ assert!(!svg.contains("pd-edge"), "attachment lines are not BGP edges");
+ assert!(svg.contains("pd-attach"), "{svg}");
+ }
+
+ #[test]
+ fn relationship_unobserved_is_dashed_not_solid() {
+ let rel = RelationshipView {
+ label: "Adjacent(AS64500, AS64501)".to_string(),
+ asns: vec![64500, 64501],
+ observed: false,
+ note: "reviewed".to_string(),
+ };
+ let svg = render_relationship_svg(&rel);
+ assert!(svg.contains("pd-edge-dashed"), "{svg}");
+ assert!(
+ svg.contains("reviewed relationship sought — not observed in selected evidence"),
+ "{svg}"
+ );
+ }
+
+ #[test]
+ fn observed_relationship_is_solid() {
+ let rel = RelationshipView {
+ label: "Adjacent(AS64500, AS64501)".to_string(),
+ asns: vec![64500, 64501],
+ observed: true,
+ note: "observed".to_string(),
+ };
+ let svg = render_relationship_svg(&rel);
+ assert!(svg.contains("pd-edge"), "{svg}");
+ assert!(!svg.contains("pd-edge-dashed"), "{svg}");
+ }
+}
diff --git a/src/catalog/web/templates/analysis.html b/src/catalog/web/templates/analysis.html
index 3975e90..0f96f4f 100644
--- a/src/catalog/web/templates/analysis.html
+++ b/src/catalog/web/templates/analysis.html
@@ -87,6 +87,20 @@
Why this is insufficient visibility, not no route-state change
qualifying cohort and is not claimed here.
+{% if !iv.relationship_svg.is_empty() %}
+
+
Reviewed relationship vs observed evidence
+{{ iv.relationship_svg|safe }}
+
The dashed edge is the reviewed relationship sought
+({{ iv.predicate_text }}); it was not observed in the selected baselines.
+"Not observed in the selected public baselines" is not "the relationship
+does not exist".
+{% if !iv.observed_paths_text.is_empty() %}
+
{{ iv.observed_paths_text }}
+{% endif %}
+
+{% endif %}
+
{% if !iv.reviewed_notes.is_empty() %}
+ {% if interconnection.is_some() %}
+ {% let ic = interconnection.as_ref().unwrap() %}
+
Reviewed attachment context (Layer-2)
+ {{ 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.
+
+
Attached network/connector
Reviewed ASN
Reviewed note
+
+ {% for a in ic.attachments %}
+
{{ a.label }}
{{ a.asn_text }}
{{ a.note }}
+ {% endfor %}
+
+
+
Limitations
+
+ {% for l in ic.limitations %}
+
{{ l }}
+ {% endfor %}
+
+
Provenance: {{ ic.provenance }}
+ {% endif %}
+
+{% endif %}
+
+{% if !completed_analysis.is_empty() %}
+
+
Completed public-BGP analysis
+
{{ completed_analysis }}
+
+{% endif %}
+
+{% if !scope_text.is_empty() %}
+
+
Scope
+
{{ scope_text }}
+
+{% endif %}
+
What public BGP showed
{% if !route_story.is_empty() %}
@@ -112,7 +157,7 @@
Analyzed targets
{% endif %}
-
Other operator-reported participants
+
{{ participants_heading }}
Entities mentioned by the source record but not reviewed for
analysis. No identity is inferred for them here.
@@ -162,7 +207,7 @@
Historical analysis plan
Historical pilot
{% if plan.is_some() && plan.as_ref().unwrap().pilot_status != "Not planned" %}
{% let p = plan.as_ref().unwrap() %}
-
Historical pilot — {{ p.pilot_target }}. A single-target, single-collector, bounded-window pilot; it is not a complete MAN LAN incident verdict.
+
Historical pilot — {{ p.pilot_target }}. A single-target, single-collector, bounded-window pilot; it is not a complete incident-wide verdict.
Status
{{ p.pilot_status }}
@@ -254,6 +299,48 @@
Related public tickets (reviewed)
+
Representative observer route story
+{% if path_comparison.is_some() %}
+{% let pc = path_comparison.as_ref().unwrap() %}
+
{{ pc.disclaimer }}
+{% if interconnection.is_some() %}
+{% let ic = interconnection.as_ref().unwrap() %}
+
The diagram shows what this public BGP observer received.
+{{ ic.label }} itself is the Layer-2 incident context and does not appear as an
+AS-path hop.
+{% endif %}
+{{ pc.comparison_svg|safe }}
+
+
State
Time (UTC)
Observed AS path
Observation
+
+{% for r in pc.text_rows %}
+
{{ r.label }}
{{ r.timestamp }}
+
{% if r.path_text == "No selected route visible at this observer" %}{{ r.path_text }}{% else %}{{ r.path_text }}{% endif %}
Observer: {{ pc.observer }} · prefix {{ pc.prefix }} ·
+full run evidence (all affected prefixes,
+transitions, and observation references).
+{% else %}
+
No observed-path comparison: no linked run exposes a
+changed-then-returned route story from canonical lifecycle evidence.
+{% endif %}
+
+
+
Diagram legend
+
+
solid arrow — observed AS-path order
+
dashed edge — reviewed relationship or predicate not observed in selected evidence
+
grey undirected line — reviewed Layer-2 attachment context
+
+
Arrow direction shows observed AS-path order; it never labels
+ provider/customer/peer relationships. Node position and organization category
+ never imply a relationship class. The diagram shows what a public BGP observer
+ received; it is not a complete topology map.
+
+
Public-BGP observer comparison
{% if observer_comparison.rows.is_empty() %}
No independent observer runs are linked yet (RouteViews pilot plus selected RIPE RIS runs). Comparison appears once runs are linked.
diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs
index 80437d2..3150bf8 100644
--- a/src/catalog/web/view.rs
+++ b/src/catalog/web/view.rs
@@ -198,6 +198,38 @@ a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px soli
/* ── Narrow / mobile (Part 12): stacked header, scrollable tables,
definition-list episode rows. Text never shrinks below readable size;
timestamps, ASNs, and prefixes never break. */
+/* Path diagrams (server-rendered SVG; evidence-semantic legend) */
+.pd-svg { width:100%; height:auto; display:block; background:#fff; border:1px solid var(--line); margin:10px 0; border-radius:4px; }
+.pd-node rect { fill:#fff; stroke:var(--ink); stroke-width:1.2; }
+.pd-node-origin rect { fill:#eef3fb; stroke:var(--ink); stroke-width:2.4; }
+.pd-node-plane rect { fill:#eef6ee; }
+.pd-node-unknown rect { stroke-dasharray:3 2; }
+.pd-node-changed rect { fill:#fdf3e3; }
+.pd-asn { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:13px; font-weight:700; fill:var(--ink); }
+.pd-name { font-size:10px; fill:var(--muted); }
+.pd-edge { stroke:var(--ink); stroke-width:1.6; }
+.pd-arrow-head { fill:var(--ink); }
+.pd-edge-dashed { stroke:#8a5a00; stroke-width:1.6; stroke-dasharray:6 4; }
+.pd-absence { fill:#fafafa; stroke:#c0392b; stroke-width:1.4; stroke-dasharray:5 3; }
+.pd-absence-text { font-size:13px; fill:#8a2b1d; }
+.pd-caption { font-size:10px; fill:var(--muted); }
+.pd-state-label { font-size:12px; font-weight:600; fill:var(--ink); }
+.pd-state-time { font-size:10px; fill:var(--muted); }
+.pd-fabric { fill:#f3efe4; stroke:#8a7a4a; stroke-width:1.6; }
+.pd-fabric-title { font-size:14px; font-weight:700; fill:var(--ink); }
+.pd-fabric-sub { font-size:10px; fill:#8a7a4a; }
+.pd-attach { stroke:#9a9a9a; stroke-width:1.4; }
+.pd-attach-node rect { fill:#fff; stroke:#9a9a9a; stroke-width:1.2; }
+.pd-attach-asn { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; font-weight:700; fill:var(--ink); }
+.pd-attach-name { font-size:10px; fill:var(--muted); }
+.pd-legend { list-style:none; display:flex; flex-wrap:wrap; gap:0 1.6rem; padding:0; margin:0.4rem 0 0.6rem; }
+.pd-legend li { display:flex; align-items:center; gap:0.45rem; font-size:12px; }
+.pd-legend-swatch { width:28px; border-top:2.5px solid var(--ink); }
+.pd-legend-swatch.dashed { border-top-style:dashed; border-color:#8a5a00; }
+.pd-legend-swatch.attach { border-top-color:#9a9a9a; }
+.pd-scroll { overflow-x:auto; }
+@media print { .pd-svg { border:none; } }
+
@media (max-width: 640px) {
body { font-size: 13px; }
main { padding: 0.5rem 8px 1.5rem; }
@@ -414,6 +446,11 @@ pub struct InsufficientVisibilityView {
/// Reviewed manifest notes (analyst_notes), quoted as reviewed
/// determination rather than machine-discovered fact.
pub reviewed_notes: Vec,
+ /// Dashed reviewed-relationship SVG (sought, not observed).
+ pub relationship_svg: String,
+ /// Observed-path area: absence text when no selected route was
+ /// visible (zero qualifying streams), empty when streams exist.
+ pub observed_paths_text: String,
}
#[derive(Template)]
@@ -1380,6 +1417,44 @@ fn build_insufficiency_view(
let qualifying_streams = stream_count(conn, run.id)?;
let relationship_matches = relationship_match_count(conn, run.id)?;
+ // Reviewed relationship sought vs observed evidence: the reviewed
+ // adjacency is rendered dashed (sought, not observed in selected
+ // baselines); observed target-origin paths would render solid. With
+ // zero qualifying streams the observed area is an explicit absence
+ // block — never an empty diagram and never a no-change claim.
+ let predicate_asns: Vec = target
+ .get("transit_predicate")
+ .and_then(|p| p.get("predicate"))
+ .and_then(|p| p.as_object())
+ .map(|obj| {
+ obj.values()
+ .filter_map(|v| v.as_array())
+ .flatten()
+ .filter_map(|n| n.as_u64().map(|n| n as u32))
+ .collect()
+ })
+ .unwrap_or_default();
+ let relationship_svg = if predicate_asns.len() >= 2 {
+ crate::catalog::web::path_diagram::render_relationship_svg(
+ &crate::catalog::web::path_diagram::RelationshipView {
+ label: predicate_text.clone(),
+ asns: predicate_asns,
+ observed: false,
+ note: String::from(
+ "no selected public baseline exposed that relationship",
+ ),
+ },
+ )
+ } else {
+ String::new()
+ };
+ let observed_paths_text = if qualifying_streams == 0 {
+ String::from(
+ "No selected route visible at this observer — zero qualifying observer-prefix streams were frozen.",
+ )
+ } else {
+ String::new()
+ };
Ok(Some(InsufficientVisibilityView {
relationship_label: label,
predicate_text,
@@ -1393,6 +1468,8 @@ fn build_insufficiency_view(
collectors,
updates_acquired,
reviewed_notes,
+ relationship_svg,
+ observed_paths_text,
}))
}
@@ -1815,6 +1892,59 @@ pub struct CaseStudyView {
pub public_tickets: Vec,
/// Cross-observer comparison over linked runs.
pub observer_comparison: ObserverComparisonView,
+ /// Reviewed interconnection (Layer-2 fabric) context, when present.
+ pub interconnection: Option,
+ /// First-screen incident context sentence.
+ pub incident_context: String,
+ /// First-screen completed-analysis sentence.
+ pub completed_analysis: String,
+ /// Explicit scope sentence for the first screen.
+ pub scope_text: String,
+ /// Representative observed-path comparison (when linked runs have
+ /// canonical lifecycle evidence).
+ pub path_comparison: Option,
+ /// Heading for the unreviewed participants section.
+ pub participants_heading: String,
+}
+
+/// Reviewed Layer-2 / interconnection context of a case study
+/// (reviewed interpretation; attachment is not BGP adjacency).
+#[derive(Serialize)]
+pub struct InterconnectionContextView {
+ pub kind: String,
+ pub label: String,
+ pub attachments: Vec,
+ pub provenance: String,
+ pub limitations: Vec,
+ pub fabric_svg: String,
+}
+
+#[derive(Serialize)]
+pub struct AttachmentView {
+ pub label: String,
+ pub note: String,
+ pub asn_text: String,
+}
+
+/// Representative observed-path comparison for a case-study page.
+#[derive(Serialize)]
+pub struct PathComparisonView {
+ pub observer: String,
+ pub prefix: String,
+ pub run_id: i64,
+ pub run_href: String,
+ pub states: Vec,
+ pub comparison_svg: String,
+ pub text_rows: Vec,
+ pub disclaimer: String,
+}
+
+#[derive(Serialize)]
+pub struct PathStateTextView {
+ pub label: String,
+ pub timestamp: String,
+ pub path_text: String,
+ pub observation_kind: String,
}
/// A reviewed analyzed target: the reviewed target of one or more
@@ -2190,9 +2320,316 @@ fn observer_conclusion(
}
}
+/// Parse the reviewed interconnection context of a case study.
+fn interconnection_view(cs: &crate::catalog::domain::CaseStudy) -> Option {
+ let json = cs.interconnection_context.as_ref()?;
+ let v: serde_json::Value = serde_json::from_str(json).ok()?;
+ let kind = v.get("kind").and_then(|k| k.as_str()).unwrap_or("").to_string();
+ let label = v.get("label").and_then(|k| k.as_str()).unwrap_or("").to_string();
+ let provenance = v.get("provenance").and_then(|k| k.as_str()).unwrap_or("").to_string();
+ let limitations: Vec = v
+ .get("limitations")
+ .and_then(|l| l.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|l| l.as_str().map(|s| s.to_string()))
+ .collect()
+ })
+ .unwrap_or_default();
+ 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()
+ })
+ .unwrap_or_default();
+ let fabric = crate::catalog::web::path_diagram::FabricView {
+ label: label.clone(),
+ attachments: attachments
+ .iter()
+ .map(|a| crate::catalog::web::path_diagram::FabricAttachmentView {
+ label: a.label.clone(),
+ note: a.note.clone(),
+ asn: a
+ .asn_text
+ .strip_prefix("AS")
+ .and_then(|n| n.parse::().ok()),
+ })
+ .collect(),
+ provenance: provenance.clone(),
+ limitations: limitations.clone(),
+ };
+ let fabric_svg = crate::catalog::web::path_diagram::render_fabric_svg(&fabric);
+ Some(InterconnectionContextView {
+ kind,
+ label,
+ attachments,
+ provenance,
+ limitations,
+ fabric_svg,
+ })
+}
+
+/// Manifest payload (target, origin, predicate) for a run.
+fn manifest_payload_for_run(conn: &rusqlite::Connection, run_id: i64) -> Option {
+ let payload: Option = conn
+ .query_row(
+ "SELECT m.payload
+ 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
+ WHERE r.id = ?1",
+ [run_id],
+ |row| row.get(0),
+ )
+ .ok();
+ payload
+ .and_then(|s| serde_json::from_str(&s).ok())
+ .and_then(|v: serde_json::Value| v.get("target").cloned())
+}
+
+/// Plane ASNs from a reviewed `transit_predicate` object.
+fn plane_asns_from_predicate(target: &serde_json::Value) -> Vec {
+ target
+ .get("transit_predicate")
+ .and_then(|p| p.get("predicate"))
+ .and_then(|p| p.as_object())
+ .map(|obj| {
+ obj.values()
+ .filter_map(|v| v.as_array())
+ .flatten()
+ .filter_map(|n| n.as_u64().map(|n| n as u32))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Reviewed peer-IP → peer-ASN map from `session-audit*.json` files
+/// under the case-study pilot directory (generic discovery).
+fn peer_asns_for_case(root: &std::path::Path, slug: &str) -> std::collections::BTreeMap<(String, String), u32> {
+ let mut out = std::collections::BTreeMap::new();
+ let pilot = root.join("case-studies").join(slug).join("pilot");
+ let Ok(entries) = std::fs::read_dir(&pilot) else {
+ return out;
+ };
+ for entry in entries.flatten() {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if !name.starts_with("session-audit") || !name.ends_with(".json") {
+ continue;
+ }
+ let Ok(content) = std::fs::read_to_string(entry.path()) else {
+ continue;
+ };
+ let Ok(v) = serde_json::from_str::(&content) else {
+ continue;
+ };
+ let arr = match v.as_array() {
+ Some(arr) => arr,
+ None => continue,
+ };
+ for row in arr {
+ let collector = row.get("collector").and_then(|c| c.as_str()).unwrap_or("");
+ let peer_ip = row.get("peer_ip").and_then(|c| c.as_str()).unwrap_or("");
+ let asn = row.get("peer_asn").and_then(|c| c.as_u64()).map(|n| n as u32);
+ if let (Some(asn), false) = (asn, collector.is_empty() || peer_ip.is_empty()) {
+ out.entry((collector.to_string(), peer_ip.to_string()))
+ .or_insert(asn);
+ }
+ }
+ }
+ out
+}
+
+/// Representative observed-path comparison for a case study: the first
+/// direct-observation stream with an absence-and-return story across
+/// the linked runs, derived from canonical lifecycle artifacts.
+fn build_path_comparison(
+ conn: &rusqlite::Connection,
+ cs_id: i64,
+ slug: &str,
+ root: &std::path::Path,
+ names: &std::collections::BTreeMap,
+) -> Option {
+ let run_ids: Vec = conn
+ .prepare(
+ "SELECT l.run_id FROM case_study_analysis_links l
+ WHERE l.case_study_id = ?1 ORDER BY l.run_id",
+ )
+ .ok()?
+ .query_map([cs_id], |r| r.get(0))
+ .ok()?
+ .filter_map(|r| r.ok())
+ .collect();
+ let peer_asns = peer_asns_for_case(root, slug);
+ let mut best: Option<(i64, String, String, Vec)> = None;
+ for run_id in run_ids {
+ let Some(target) = manifest_payload_for_run(conn, run_id) else {
+ continue;
+ };
+ let origin_asn = target
+ .get("origin_asns")
+ .and_then(|a| a.as_array())
+ .and_then(|a| a.first())
+ .and_then(|n| n.as_u64())
+ .map(|n| n as u32);
+ let plane_asns = plane_asns_from_predicate(&target);
+ let rel: String = conn
+ .query_row(
+ "SELECT relative_path FROM analysis_artifacts
+ WHERE run_id = ?1 AND relative_path LIKE '%lifecycle.json'
+ ORDER BY relative_path LIMIT 1",
+ [run_id],
+ |r| r.get(0),
+ )
+ .ok()?;
+ let resolved = crate::catalog::artifact_path::resolve_artifact(root, &rel)?;
+ let content = std::fs::read_to_string(&resolved).ok()?;
+ let evs = crate::catalog::web::path_diagram::load_lifecycle_evidence(&content).ok()?;
+ // Candidate streams: absence-and-return stories (Withdrawal with
+ // an empty after_path followed by a return).
+ let candidates: Vec<&crate::catalog::web::path_diagram::StreamPathEvidence> = evs
+ .iter()
+ .filter(|ev| {
+ ev.transitions.iter().any(|t| t.kind == "Withdrawal" && t.after_path.is_empty())
+ && ev
+ .transitions
+ .iter()
+ .any(|t| t.kind != "Withdrawal" && !t.after_path.is_empty())
+ })
+ .collect();
+ if candidates.is_empty() {
+ continue;
+ }
+ let direct = |ev: &crate::catalog::web::path_diagram::StreamPathEvidence| -> bool {
+ peer_asns
+ .get(&(ev.collector.clone(), ev.peer_ip.clone()))
+ .map(|asn| plane_asns.contains(asn))
+ .unwrap_or(false)
+ };
+ let mut chosen = candidates[0];
+ for c in candidates.iter().skip(1) {
+ let (c_direct, best_direct) = (direct(c), direct(chosen));
+ if c_direct && !best_direct {
+ chosen = c;
+ } else if c_direct == best_direct && c.prefix < chosen.prefix {
+ chosen = c;
+ }
+ }
+ let mut states =
+ crate::catalog::web::path_diagram::comparison_states(chosen, origin_asn, &plane_asns, names);
+ let direct_flag = direct(chosen);
+ for st in states.iter_mut() {
+ if let Some(p) = st.path.as_mut() {
+ p.observation_kind = if direct_flag {
+ "direct collector session".to_string()
+ } else {
+ "indirect AS-path observation".to_string()
+ };
+ p.evidence_ref = Some(format!("/analyses/{run_id}"));
+ }
+ }
+ let score = states.iter().filter(|s| s.path.is_none()).count();
+ let replace = match &best {
+ None => true,
+ Some((_, _, _, cur_states)) => {
+ let cur_score = cur_states.iter().filter(|s| s.path.is_none()).count();
+ score > cur_score || (score == cur_score && chosen.prefix < best_prefix_of(cur_states))
+ }
+ };
+ if replace {
+ best = Some((
+ run_id,
+ chosen.collector.clone(),
+ chosen.prefix.clone(),
+ states,
+ ));
+ }
+ }
+ let (run_id, collector, prefix, states) = best?;
+ let observer = format!("{collector} (peer {})", first_peer(&states));
+ let comparison_svg = crate::catalog::web::path_diagram::render_comparison_svg(&states);
+ let text_rows = states
+ .iter()
+ .map(|s| PathStateTextView {
+ label: s.label.clone(),
+ timestamp: s.timestamp.clone(),
+ path_text: s
+ .path
+ .as_ref()
+ .map(|p| p.text_sequence())
+ .unwrap_or_else(|| "No selected route visible at this observer".to_string()),
+ observation_kind: s
+ .path
+ .as_ref()
+ .map(|p| p.observation_kind.clone())
+ .unwrap_or_default(),
+ })
+ .collect();
+ Some(PathComparisonView {
+ observer,
+ prefix,
+ run_id,
+ run_href: format!("/analyses/{run_id}"),
+ states,
+ comparison_svg,
+ text_rows,
+ disclaimer: "This is an observed AS path at one public observer, not a complete topology map.".to_string(),
+ })
+}
+
+fn best_prefix_of(states: &[crate::catalog::web::path_diagram::PathStateView]) -> String {
+ states
+ .iter()
+ .filter_map(|s| s.path.as_ref())
+ .map(|p| p.prefix.clone())
+ .next()
+ .unwrap_or_default()
+}
+
+fn first_peer(states: &[crate::catalog::web::path_diagram::PathStateView]) -> String {
+ states
+ .iter()
+ .filter_map(|s| s.path.as_ref())
+ .map(|p| {
+ p.observer
+ .split("(peer ")
+ .nth(1)
+ .and_then(|s| s.strip_suffix(')'))
+ .unwrap_or("")
+ .to_string()
+ })
+ .next()
+ .unwrap_or_default()
+}
+
+/// Load reviewed ASN display names for a case study (pilot registry
+/// first, then the case-study directory).
+fn case_asn_names(root: &std::path::Path, slug: &str) -> std::collections::BTreeMap {
+ crate::catalog::web::path_diagram::load_asn_names(&[
+ &root.join("case-studies").join(slug).join("pilot"),
+ &root.join("case-studies").join(slug),
+ ])
+}
+
pub fn load_case_study(
conn: &rusqlite::Connection,
slug: &str,
+ catalog_root: &std::path::Path,
) -> Result