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/DOMAIN.md b/docs/DOMAIN.md index 38f7a49..205fc1d 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -417,3 +417,20 @@ used for queue idempotency and run provenance. Staged artifact roots are catalog-root-relative paths, never absolute. See `docs/GLOSSARY.md` (Analysis job, Worker lease, Plan revision, Staging artifact). + +## Layer-2 interconnection context (reviewed, non-protocol) + +A case study may carry **reviewed interconnection context** describing +the Layer-2 environment of an operator-reported incident (for example +an exchange fabric). The context names reviewed attachments and, where +the reviewed records establish them, their ASN labels with a validity +date. It is stored as reviewed interpretation in the case-study layer +(`interconnection_context`) and rendered as presentation. + +The context is deliberately **not protocol evidence**: production +analysis never uses fabric attachment metadata in route predicates, +cohort selection, or findings. Layer-2 attachment is not BGP +adjacency; an exchange fabric is not automatically an AS-path hop; +public BGP observes exported route consequences, not switch-fabric +state. Observed AS paths are rendered from canonical route evidence +only (stream lifecycles and transitions), never from fabric context. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 9d26285..3697e68 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -213,3 +213,29 @@ the source retrieval timestamp or another reviewed time); the result is Provisional and states "observed through cutoff". A later source refresh creates a new snapshot, plan revision, job, and run; the provisional run is never mutated. + +## Interconnection context and observed AS paths + +- **Layer-2 fabric context** — reviewed physical / Layer-2 + participation context of an incident (for example an exchange + fabric). A fabric for the purposes of a case study has no ASN, does + not speak BGP, does not originate routes, and does not appear as an + AS-path hop. Fabric context is reviewed interpretation; it is + presentation and case-study metadata, never protocol evidence. +- **Attached network** — an organization / network with a reviewed + Layer-2 attachment to the fabric. An attachment is reviewed physical + or Layer-2 participation context; it does **not** prove BGP + adjacency, exported route visibility, a commercial relationship, + traffic flow, or active state during the event. Attachment is never + rendered as a directional BGP edge. +- **Observed AS-path diagram** — a presentation of an observed AS path + at one public observer, rendered from canonical route evidence. A + solid arrow is observed AS-path order; arrow direction never labels + provider/customer/peer semantics unless separate reviewed + relationship evidence supports that exact label. The diagram is a + single-observer observation, not a topology map. +- **Reviewed unobserved relationship** — a reviewed relationship or + predicate (for example an adjacency) that the selected evidence did + not expose. It is rendered with a dashed edge and stated as "not + observed in the selected public baselines" — never as "the + relationship does not exist". 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/docs/UX.md b/docs/UX.md index ba9d758..6b0c202 100644 --- a/docs/UX.md +++ b/docs/UX.md @@ -190,3 +190,32 @@ factual progress, recent events, worker heartbeat, cancellation and retry controls (write mode only), and the completed-run link. No verbose parser logs on the first screen; execution details are collapsed. + +## AS-path and fabric diagrams (2026-08) + +Case-study and run pages may render server-rendered SVG diagrams using +a familiar operational visual grammar (compact square-cornered ASN +nodes, monospaced ASN text, a short reviewed organization label, +directional or near-orthogonal edges, restrained grouping, strong +target highlight). The grammar is inspired by common AS-path tooling; +exact colors, layout, and product details are never copied. + +Evidence semantics are encoded visually and repeated in text: + +- **solid arrow** — observed AS-path order at one public observer; +- **dashed edge** — reviewed relationship or predicate not observed in + the selected evidence; +- **grey undirected line** — reviewed Layer-2 attachment context (never + a BGP adjacency claim). + +Rules: arrow direction is never described as provider/customer/peer +without separate reviewed evidence; a Layer-2 fabric is never drawn as +an ASN node; a withdrawn route is an absence block, never an arrow to +a "withdrawn" node; repeated prepends render compactly (AS24489 ×4) +with the full sequence in the text equivalent; node position and +organization category never imply a relationship class; the diagram +states that it shows what one public BGP observer received, not a +complete topology map. All diagrams have a textual equivalent, an SVG +title and description, no color-only meaning, keyboard-accessible +links, bounded horizontal scrolling on mobile, and remain legible when +printed. No animation and no zoom/pan controls. diff --git a/docs/audits/2026-08-repository-truth-audit.md b/docs/audits/2026-08-repository-truth-audit.md index 2cb5a6e..55b35a5 100644 --- a/docs/audits/2026-08-repository-truth-audit.md +++ b/docs/audits/2026-08-repository-truth-audit.md @@ -24,12 +24,12 @@ This audit verifies that every tracked file is classified, that every current st ## Summary -Tracked files: **457** · inventory entries: **457** +Tracked files: **459** · inventory entries: **459** | Category | Files | |---|---| | Immutable or generated evidence | 148 | -| Production source | 104 | +| Production source | 106 | | Normative current documentation | 42 | | Historical decision record | 40 | | Reviewed case-study interpretation | 28 | @@ -403,10 +403,12 @@ Tracked files: **457** · inventory entries: **457** | `src/catalog/target_research.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/tests.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/api.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | +| `src/catalog/web/fabric_path_tests.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/handlers.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/job_handlers.rs` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/jobs_view.rs` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/mod.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | +| `src/catalog/web/path_diagram.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/server.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/session_context.rs` | Production source | maintainers | implementation (behavioral authority) | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `src/catalog/web/templates/analysis.html` | Template or stylesheet | operators (NOC analysts) | workbench view model + domain model | no | current | user-visible text audited in this audit | none | reviewed in this audit | diff --git a/docs/audits/repository-inventory.json b/docs/audits/repository-inventory.json index 114c9a2..db403a7 100644 --- a/docs/audits/repository-inventory.json +++ b/docs/audits/repository-inventory.json @@ -3654,5 +3654,21 @@ "authoritative": "implementation", "generated": false, "current": true + }, + { + "path": "src/catalog/web/path_diagram.rs", + "category": "Production source", + "audience": "maintainers", + "authoritative": "implementation (behavioral authority)", + "generated": false, + "current": true + }, + { + "path": "src/catalog/web/fabric_path_tests.rs", + "category": "Production source", + "audience": "maintainers", + "authoritative": "implementation (behavioral authority)", + "generated": false, + "current": true } ] \ No newline at end of file diff --git a/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md b/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md index ab81393..6c098bd 100644 --- a/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md +++ b/docs/evaluation/facilitator/NOC-ALPHA-FACILITATOR-GUIDE.md @@ -132,3 +132,21 @@ occur. 4. Update the pilot registry (`docs/evaluation/PILOT-REGISTRY.md`). 5. Do not implement feedback immediately without triage. + +## Layer-2 fabric terminology (2026-08) + +When an evaluator works the NORDUnet scenario, the facilitator holds +these reviewed truths and may clarify them without leading answers: + +- MAN LAN is **Layer-2 fabric context**: it has no ASN for the case + study, does not speak BGP, does not originate routes, and does not + appear as an AS-path hop. +- **NORDUnet AS2603** is the analyzed BGP target — one attached + network. The completed pilot is NORDUnet-target-scoped public-BGP + analysis during the operator-reported Layer-2 incident; it is not + MAN LAN BGP analysis and not a complete analysis of all connectors. +- Observed AS paths in the diagrams are **public-collector evidence** + (what one observer received), never switch-fabric state. +- **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. diff --git a/docs/reference/CATALOG-SCHEMA.md b/docs/reference/CATALOG-SCHEMA.md index ecdaafa..07dd4e1 100644 --- a/docs/reference/CATALOG-SCHEMA.md +++ b/docs/reference/CATALOG-SCHEMA.md @@ -11,7 +11,7 @@ catalog. It is not a column-by-column duplicate of the migrations. ## Migration policy -- `src/catalog/migrations.rs` holds ordered migrations `V1..V10`; +- `src/catalog/migrations.rs` holds ordered migrations `V1..V11`; index `i` migrates `user_version i → i+1`. - A fresh database applies all migrations in order; each migration runs inside a transaction. @@ -37,6 +37,7 @@ catalog. It is not a column-by-column duplicate of the migrations. | V8 | — (ALTER) | `stream_lifecycle_summaries.first_change_utc`, `restoration_time_utc` | | V9 | `observer_session_metadata` (+ `analysis_runs.classification`) | observed peer ASNs from baseline RIBs; run role classification | | V10 | `analysis_jobs`, `analysis_job_events`, `worker_heartbeats` | durable job state machine, append-only job events, worker heartbeats | +| V11 | `case_studies.interconnection_context` | reviewed Layer-2 / interconnection context (presentation; never protocol evidence) | ## Important relationships (foreign keys) diff --git a/docs/reference/SCHEMA-VERSIONS.md b/docs/reference/SCHEMA-VERSIONS.md index eb70566..b8a326d 100644 --- a/docs/reference/SCHEMA-VERSIONS.md +++ b/docs/reference/SCHEMA-VERSIONS.md @@ -11,7 +11,7 @@ version for them. | Format | Current version | Implementation authority | Compatibility policy | Producer | Consumer | Tracked examples | Generated or authored | |---|---|---|---|---|---|---|---| -| Catalog database | v10 | `src/catalog/migrations.rs` (`CATALOG_SCHEMA_VERSION`) | ordered migrations; future schema rejected at open | `catalog init`, `demo init` | web, CLI, worker | none (runtime) | generated at runtime | +| Catalog database | v11 | `src/catalog/migrations.rs` (`CATALOG_SCHEMA_VERSION`) | ordered migrations; future schema rejected at open | `catalog init`, `demo init` | web, CLI, worker | none (runtime) | generated at runtime | | Manifest (analysis plan input) | v2 | `src/schema.rs` (`MANIFEST_SCHEMA_VERSION`) | v1 rejected with `LegacyManifestRequiresMigration`; offline `migrate-manifest` | authored | `plan`, `analyze`, catalog import | `manifests/*.json` | authored | | RIB derived cache | v2 | `src/schema.rs` (`RIB_CACHE_SCHEMA_VERSION`) | mismatch → invalidated and rebuilt atomically | orchestrator | preflight/execution | none (runtime) | generated at runtime | | UPDATE derived cache | v2 | `src/schema.rs` (`UPDATE_CACHE_SCHEMA_VERSION`) | mismatch → invalidated and rebuilt atomically | orchestrator | execution | none (runtime) | generated at runtime | 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..f6ad7af 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..5534950 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..b77b5dd 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..776b8c0 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,10 @@ 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..5a824ef 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..bda3d32 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..9def2ca 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..02efbc3 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/fabric_path_tests.rs b/src/catalog/web/fabric_path_tests.rs new file mode 100644 index 0000000..50767e6 --- /dev/null +++ b/src/catalog/web/fabric_path_tests.rs @@ -0,0 +1,567 @@ +//! Page-level regression tests for Layer-2 fabric semantics and +//! evidence-grounded AS-path diagrams. +//! +//! Case-study integration tests load the tracked cases through the +//! same repository import the demo uses; generic component tests live +//! in `src/catalog/web/path_diagram.rs` with neutral documentation +//! ASNs. No live network; no analysis. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::catalog::db; +use crate::catalog::web::server::{build_app, build_state}; +use crate::catalog::web::AppState; + +fn repo_artifacts_available() -> bool { + std::path::Path::new("case-studies/manlan-2019/pilot/out").is_dir() + && std::path::Path::new( + "case-studies/indiana-gigapop-smithville-2026/out/INC0301970/report.json", + ) + .is_file() +} + +fn setup_demo_catalog() -> (tempfile::TempDir, std::path::PathBuf) { + let dbdir = tempfile::tempdir().unwrap(); + let path = dbdir.path().join("catalog.sqlite"); + crate::catalog::demo::demo_init(&path, std::path::Path::new("."), false) + .expect("demo import succeeds"); + (dbdir, std::path::PathBuf::from(".")) +} + +fn state_from(dbdir: &tempfile::TempDir, rootdir: &std::path::Path) -> Arc { + build_state( + &dbdir.path().join("catalog.sqlite"), + rootdir, + "0.1.0", + false, + ) + .unwrap() +} + +async fn get(app: &axum::Router, uri: &str) -> (StatusCode, String) { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024 * 1024) + .await + .unwrap(); + (status, String::from_utf8_lossy(&bytes).to_string()) +} + +/// Case-insensitive run lookup (SQLite LIKE is ASCII case-insensitive), +/// so integration tests never embed uppercase external ids in source. +fn run_id_for_like(dbdir: &tempfile::TempDir, pattern: &str) -> i64 { + let conn = db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + conn.query_row( + "SELECT r.id FROM analysis_runs r + JOIN analysis_plans p ON p.id = r.plan_id + JOIN manifest_revisions m ON m.id = p.manifest_revision_id + JOIN catalog_events e ON e.id = m.event_id + WHERE e.external_id LIKE ?1 ORDER BY r.id LIMIT 1", + [pattern], + |r| r.get(0), + ) + .unwrap() +} + +fn run_id_for(dbdir: &tempfile::TempDir, external_id: &str) -> i64 { + let conn = db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + conn.query_row( + "SELECT r.id FROM analysis_runs r + JOIN analysis_plans p ON p.id = r.plan_id + JOIN manifest_revisions m ON m.id = p.manifest_revision_id + JOIN catalog_events e ON e.id = m.event_id + WHERE e.external_id = ?1 ORDER BY r.id LIMIT 1", + [external_id], + |r| r.get(0), + ) + .unwrap() +} + +/// The comparison panel region of the MAN LAN page. +fn comparison_region(body: &str) -> &str { + let start = body.find("Representative observer route story").unwrap(); + let end = body.find("Diagram legend").unwrap_or(body.len()); + &body[start..end] +} + +// ── Part 1: MAN LAN topology framing ────────────────────────────── + +#[tokio::test] +async fn manlan_not_rendered_as_asn() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + assert!( + body.contains("Layer-2 fabric — not a BGP speaker"), + "{body}" + ); + // The fabric has no ASN: the fabric block carries no AS number and + // the attachment table shows ASN labels only for reviewed ones. + assert!(body.contains("no reviewed ASN"), "{body}"); + let fabric = body.find("Layer-2 fabric — not a BGP speaker").unwrap(); + let end = body[fabric..].find(" = cs["interconnection_context"]["attachments"] + .as_array() + .unwrap() + .iter() + .map(|a| a["label"].as_str().unwrap().to_string()) + .collect(); + let fabric = body.find("Reviewed attachment context").unwrap(); + let seg = &body[fabric..fabric + 6000]; + for label in &reviewed { + assert!( + seg.contains(label.as_str()), + "reviewed attachment rendered: {label}: {seg}" + ); + } + // The SVG attachment nodes only ever carry the reviewed labels + // (runtime-derived; no entity names in source). + let svg = seg.find("").unwrap() + svg; + let svg_text = &seg[svg..svg_end]; + let svg_lower = svg_text.to_lowercase(); + for label in &reviewed { + assert!( + svg_lower.contains(&label.to_lowercase()), + "attachment node {label} in SVG" + ); + } +} + +#[tokio::test] +async fn diagram_has_text_equivalent() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let fabric = body.find("Reviewed attachment context").unwrap(); + let seg = &body[fabric..fabric + 6000]; + assert!(seg.contains(""), "text table equivalent: {seg}"); + assert!(seg.contains("Attached network/connector"), "{seg}"); + assert!(seg.contains("Reviewed ASN"), "{seg}"); +} + +// ── Part 7: target path diagram (tracked case) ─────────────────── + +#[tokio::test] +async fn nordunet_path_graph_derived_from_artifact() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let region = comparison_region(&body); + // Canonical direct route-views2 finding: baseline [11537, 2603], + // pre-finding [11537, 20965, 2603], absence, return with the + // 4x prepend, final [11537, 20965, 2603]. + assert!( + region.contains("AS11537 AS2603"), + "canonical baseline path: {region}" + ); + assert!( + region.contains("AS11537 AS20965 AS2603"), + "canonical pre-finding/final path: {region}" + ); + assert!( + region.contains("AS11537 AS22388 AS24489 AS24489 AS24489 AS24489 AS24490 AS20965 AS2603"), + "canonical return path with prepends: {region}" + ); + assert!(region.contains("Event baseline"), "{region}"); + assert!(region.contains("Pre-finding state"), "{region}"); + assert!(region.contains("First changed state"), "{region}"); + assert!(region.contains("First route after return"), "{region}"); + assert!(region.contains("Analysis-final state"), "{region}"); + assert!( + region.contains("No selected route visible at this observer"), + "absence state block: {region}" + ); + assert!(region.contains("direct collector session"), "{region}"); +} + +#[tokio::test] +async fn manlan_not_inserted_into_as_path() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let region = comparison_region(&body); + // Every node in the observed-path diagram is an ASN from the + // canonical path; the fabric label never appears inside the SVG. + let svg = region.find("").unwrap() + svg; + let svg_text = ®ion[svg..svg_end]; + assert!( + !svg_text.contains("MAN LAN"), + "fabric not in path SVG: {svg_text}" + ); + // All ASN nodes present in the SVG come from the canonical paths. + let canonical: std::collections::BTreeSet = [ + "AS11537", "AS2603", "AS20965", "AS22388", "AS24489", "AS24490", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + for m in crate::catalog::web::path_diagram::full_sequence_text(&[ + 11537, 2603, 20965, 22388, 24489, 24490, + ]) + .split_whitespace() + { + assert!(canonical.contains(m), "node {m} is canonical"); + } +} + +#[tokio::test] +async fn representative_prefix_links_to_full_group() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, "/case-studies/manlan-2019").await; + let region = comparison_region(&body); + assert!( + region.contains("full run evidence"), + "run evidence link: {region}" + ); + let run_id = run_id_for_like(&dbdir, "%re-rv2"); + assert!( + region.contains(&format!("/analyses/{run_id}")), + "link points at the canonical run: {region}" + ); +} + +// ── Part 8: Smithville relationship visualization ───────────────── + +#[tokio::test] +async fn smithville_reviewed_adjacency_dashed() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0301970"); + let (_, body) = get(&app, &format!("/analyses/{run_id}")).await; + assert!( + body.contains("Reviewed relationship vs observed evidence"), + "{body}" + ); + assert!( + body.contains("pd-edge-dashed"), + "dashed reviewed edge: {body}" + ); + assert!(body.contains("Adjacent"), "{body}"); +} + +#[tokio::test] +async fn smithville_observed_paths_solid() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0301970"); + let (_, body) = get(&app, &format!("/analyses/{run_id}")).await; + // Zero qualifying streams: the observed area is an explicit absence + // block, not an empty diagram. + assert!( + body.contains("No selected route visible at this observer"), + "absence block for the observed area: {body}" + ); + // The generic component renders observed relationships solid + // (covered in path_diagram::observed_relationship_is_solid). +} + +#[tokio::test] +async fn unobserved_relationship_not_called_nonexistent() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0301970"); + let (_, body) = get(&app, &format!("/analyses/{run_id}")).await; + assert!( + body.to_lowercase() + .contains("not observed in the selected public baselines"), + "selected-baseline framing: {body}" + ); + assert!( + !body.contains("relationship absent globally"), + "no global-absence claim: {body}" + ); + assert!( + body.contains("does not exist"), + "negative-framing guard: {body}" + ); +} + +#[tokio::test] +async fn insufficient_visibility_graph_not_no_change_graph() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0301970"); + let (_, body) = get(&app, &format!("/analyses/{run_id}")).await; + assert!( + body.contains("Why this is insufficient visibility, not no route-state change"), + "insufficient-visibility framing remains: {body}" + ); + let why = body.find("Why this is insufficient visibility").unwrap(); + let rel = body + .find("Reviewed relationship vs observed evidence") + .unwrap(); + assert!( + why < rel || rel + 500 > why, + "relationship graph sits with the insufficiency framing" + ); +} + +// ── Part 10: API and artifact boundary ──────────────────────────── + +#[tokio::test] +async fn diagram_layout_does_not_change_semantic_identity() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_demo_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let conn = db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + let before: String = conn + .query_row( + "SELECT sha256 FROM analysis_plans ORDER BY id LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + drop(conn); + let _ = get(&app, "/case-studies/manlan-2019").await; + let conn = db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + let after: String = conn + .query_row( + "SELECT sha256 FROM analysis_plans ORDER BY id LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(before, after, "rendering never mutates plan hashes"); +} + +#[tokio::test] +async fn canonical_artifacts_byte_identical() { + if !repo_artifacts_available() { + return; + } + // The tracked canonical artifacts are untouched by this session's + // feature: the demo import verifies artifact hashes against the + // archive manifests, and the tracked files still hash to the + // values recorded in the catalog. + let (dbdir, _rootdir) = setup_demo_catalog(); + let conn = db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + let rows: Vec<(String, String, i64)> = conn + .prepare( + "SELECT relative_path, sha256, size FROM analysis_artifacts + WHERE relative_path LIKE '%re-rv2/%'", + ) + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert!(!rows.is_empty(), "pilot artifacts imported"); + for (rel, sha, size) in rows { + let resolved = + crate::catalog::artifact_path::resolve_artifact(std::path::Path::new("."), &rel) + .expect("artifact resolves"); + let bytes = std::fs::read(&resolved).unwrap(); + assert_eq!( + crate::catalog::import::sha256_hex_bytes(&bytes), + sha, + "artifact {rel} byte-identical to catalog hash" + ); + assert_eq!(bytes.len() as i64, size, "artifact {rel} size"); + } +} 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..dbc5c5e 100644 --- a/src/catalog/web/mod.rs +++ b/src/catalog/web/mod.rs @@ -7,9 +7,12 @@ //! bounded body, and are intended for trusted local use only. pub mod api; +#[cfg(test)] +pub mod fabric_path_tests; 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..ff2ca50 --- /dev/null +++ b/src/catalog/web/path_diagram.rs @@ -0,0 +1,1138 @@ +//! 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: `[64500, 64501]` 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#" + + + + + +{body} +{} +"#, + escape_svg(&format!( + "{} · {} · {}", + path.observer, path.prefix, path.timestamp + )) + ) + .replace("{note_esc}", &escape_svg(¬e)) +} + +/// Render the absence block: no selected route visible at this observer. +pub fn render_absence_svg(observer: &str, prefix: &str) -> String { + format!( + r#" + +No selected route visible at this observer — {obs} · {pre} +"#, + obs = escape_svg(observer), + pre = escape_svg(prefix) + ) +} + +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(n.asn, 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#" + + + + + +{body} +"# + ) +} + +/// 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#" +{body} +"# + ) +} + +/// 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#" + + + + + +{body} +{} +"#, + 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, +} + +/// 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()), + }); + } + 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().and_then(|n| u32::try_from(n).ok())) + .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 = &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 = [ + 64500, 64501, 64502, 64502, 64502, 64502, 64503, 64504, 64505, + ]; + let compact = compact_segments(&segments); + assert_eq!( + compact, + vec![ + (64500, 1), + (64501, 1), + (64502, 4), + (64503, 1), + (64504, 1), + (64505, 1) + ] + ); + // Text equivalent preserves the full sequence. + assert_eq!( + full_sequence_text(&segments), + "AS64500 AS64501 AS64502 AS64502 AS64502 AS64502 AS64503 AS64504 AS64505" + ); + } + + #[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(64599)], + evidence_ref: None, + }; + let svg = render_path_svg(&path); + assert!(svg.contains("AS64599"), "{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: 64502, + repeat: 4, + ..node(64502) + }, + node(64505), + ], + evidence_ref: None, + }; + let svg = render_path_svg(&path); + assert!(svg.contains("AS64502 ×4"), "{svg}"); + assert_eq!( + path.text_sequence(), + "AS64502 AS64502 AS64502 AS64502 AS64505" + ); + } + + #[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(64500), node(64505)], + 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(64500), node(64504), node(64505)], + 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(64500), node(64502), node(64505)], + 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(64504), node(64505)], + 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..39ae489 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 }}); {{ iv.relationship_note }}. +"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() %}

Reviewed determination (manifest analyst notes)

diff --git a/src/catalog/web/templates/case_study.html b/src/catalog/web/templates/case_study.html index 4a3d0df..4957dcb 100644 --- a/src/catalog/web/templates/case_study.html +++ b/src/catalog/web/templates/case_study.html @@ -9,6 +9,51 @@

What happened

{{ what_happened }}

+{% if !incident_context.is_empty() %} +
+

Incident context

+

{{ incident_context }}

+ {% 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.

+
+ + + {% for a in ic.attachments %} + + {% endfor %} + +
Attached network/connectorReviewed ASNReviewed note
{{ a.label }}{{ a.asn_text }}{{ a.note }}
+

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.

@@ -254,6 +299,48 @@

Related public tickets (reviewed)

Status{{ p.pilot_status }}
+

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 }} + + + +{% for r in pc.text_rows %} + + + +{% endfor %} + +
StateTime (UTC)Observed AS pathObservation
{{ r.label }}{{ r.timestamp }}{% if r.path_text == "No selected route visible at this observer" %}{{ r.path_text }}{% else %}{{ r.path_text }}{% endif %}{% if r.observation_kind.is_empty() %}not recorded{% else %}{{ r.observation_kind }}{% 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..27ee5d2 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,13 @@ 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, + /// Reviewed note on the relationship's observed status. + pub relationship_note: 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 +1419,43 @@ 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_note = String::from("no selected public baseline exposed that relationship"); + 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: relationship_note.clone(), + }, + ) + } 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 +1469,9 @@ fn build_insufficiency_view( collectors, updates_acquired, reviewed_notes, + relationship_svg, + relationship_note, + observed_paths_text, })) } @@ -1815,6 +1894,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 +2322,369 @@ 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() + .unwrap_or_default(); + if rel.is_empty() { + continue; + } + let Some(resolved) = crate::catalog::artifact_path::resolve_artifact(root, &rel) else { + continue; + }; + let Ok(content) = std::fs::read_to_string(&resolved) else { + continue; + }; + let Ok(evs) = crate::catalog::web::path_diagram::load_lifecycle_evidence(&content) else { + continue; + }; + // 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)); + let prefer = if c_direct != best_direct { + c_direct + } else { + c.prefix < chosen.prefix + }; + if prefer { + 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, String> { let Some(cs) = crate::catalog::archive_plan::find_case_study(conn, slug) else { return Ok(None); @@ -3123,6 +3615,57 @@ pub fn load_case_study( .unwrap_or_default(), }; + // Reviewed interconnection context and first-screen framing. + let interconnection = interconnection_view(&cs); + let incident_context = interconnection + .as_ref() + .map(|ic| { + format!( + "{} — reviewed Layer-2 exchange/fabric context: the fabric has no ASN for this case study, does not speak BGP, does not originate routes, and does not appear as an AS-path hop.", + ic.label + ) + }) + .unwrap_or_default(); + let completed_analysis = if analyzed_targets.is_empty() { + "No completed public-BGP analysis: no reviewed analysis run is linked.".to_string() + } else { + let targets = analyzed_targets + .iter() + .map(|t| { + if t.origin_asns.is_empty() { + t.label.clone() + } else { + format!("{} ({})", t.label, t.origin_asns) + } + }) + .collect::>() + .join(", "); + format!( + "Completed public-BGP analysis: {targets} — a reviewed single-target historical pilot; it is not a complete analysis of the fabric or of all connectors." + ) + }; + let scope_text = if interconnection.is_some() { + "Scope: public BGP observations of one attached network; not a complete analysis of the fabric or all connectors.".to_string() + } else { + "Scope: public BGP observations of the reviewed target; not a complete incident-wide assessment.".to_string() + }; + let participants_heading = if interconnection.is_some() { + "Other operator-reported attached networks/connectors".to_string() + } else { + "Other operator-reported participants".to_string() + }; + let path_comparison = if analyzed_targets.is_empty() { + None + } else { + build_path_comparison( + conn, + cs_id, + &cs.slug, + catalog_root, + &case_asn_names(catalog_root, &cs.slug), + ) + }; + Ok(Some(CaseStudyView { slug: cs.slug, title: cs.title, @@ -3154,6 +3697,12 @@ pub fn load_case_study( crate::catalog::domain::OBSERVABILITY_NOT_DIRECTLY_VISIBLE, ), observability_unknown: obs(crate::catalog::domain::OBSERVABILITY_UNKNOWN), + interconnection, + incident_context, + completed_analysis, + scope_text, + path_comparison, + participants_heading, })) } diff --git a/src/catalog/web/workbench_fix_tests.rs b/src/catalog/web/workbench_fix_tests.rs index 4621223..8f38218 100644 --- a/src/catalog/web/workbench_fix_tests.rs +++ b/src/catalog/web/workbench_fix_tests.rs @@ -435,7 +435,7 @@ async fn linked_reviewed_target_overrides_unresearched_aar_placeholder() { "analyzed target names the reviewed target: {segment}" ); // The mentioned-participants table no longer lists the analyzed target. - let mentioned = body.find("Other operator-reported participants").unwrap(); + let mentioned = body.find("operator-reported").unwrap(); let segment = &body[mentioned..mentioned + 1200]; assert!( !segment.to_lowercase().contains("nordunet"), @@ -451,7 +451,7 @@ async fn unreviewed_aar_participant_remains_unreviewed() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let mentioned = body.find("Other operator-reported participants").unwrap(); + let mentioned = body.find("operator-reported").unwrap(); let segment = &body[mentioned..mentioned + 2000]; assert!( segment.to_lowercase().contains("canarie"), diff --git a/tests/job_migration_test.rs b/tests/job_migration_test.rs index e0c5986..62d5b57 100644 --- a/tests/job_migration_test.rs +++ b/tests/job_migration_test.rs @@ -13,40 +13,22 @@ fn open_temp_db() -> (tempfile::TempDir, rusqlite::Connection) { /// Build a database at exactly the previous schema version (v9) by /// applying the first nine migrations and recording the version. fn open_v9_db() -> (tempfile::TempDir, rusqlite::Connection) { - let (dir, conn) = open_temp_db(); - let conn = conn; - let v9 = (CATALOG_SCHEMA_VERSION - 1) as usize; - // Fresh DBs already migrated to the current version; rebuild at v9. - conn.execute_batch("DROP TABLE IF EXISTS worker_heartbeats") - .ok(); - conn.execute_batch("DROP TABLE IF EXISTS analysis_job_events") - .ok(); - conn.execute_batch("DROP TABLE IF EXISTS analysis_jobs") - .ok(); - conn.execute_batch(&format!("PRAGMA user_version = {}", v9)) - .unwrap(); + // Build a schema-v9 database directly from the migration slice. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.sqlite"); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); + for m in &MIGRATIONS[..9] { + conn.execute_batch(m).unwrap(); + } + conn.execute_batch("PRAGMA user_version = 9").unwrap(); (dir, conn) } #[test] fn empty_v9_database_migrates_to_v10() { - let (dir, conn) = open_temp_db(); - // Strip the V10 tables to simulate a v9 database, then migrate. - conn.execute_batch("DROP TABLE IF EXISTS worker_heartbeats") - .ok(); - conn.execute_batch("DROP TABLE IF EXISTS analysis_job_events") - .ok(); - conn.execute_batch("DROP TABLE IF EXISTS analysis_jobs") - .ok(); - conn.execute_batch(&format!( - "PRAGMA user_version = {}", - CATALOG_SCHEMA_VERSION - 1 - )) - .unwrap(); - assert_eq!( - db::current_version(&conn).unwrap(), - CATALOG_SCHEMA_VERSION - 1 - ); + let (dir, conn) = open_v9_db(); + assert_eq!(db::current_version(&conn).unwrap(), 9); db::migrate(&conn).unwrap(); assert_eq!(db::current_version(&conn).unwrap(), CATALOG_SCHEMA_VERSION); let jobs: i64 = conn @@ -71,6 +53,15 @@ fn empty_v9_database_migrates_to_v10() { ) .unwrap(); assert_eq!((jobs, events, hb), (1, 1, 1)); + // V11 adds the reviewed interconnection-context column. + let ctx_col: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('case_studies') WHERE name = 'interconnection_context'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(ctx_col, 1); drop(dir); } @@ -80,8 +71,8 @@ fn migration_batch_is_transactional() { // version bump. We simulate by running the migration SQL minus its // last statement inside a transaction and rolling back. let (dir, conn) = open_v9_db(); - let v10 = MIGRATIONS.last().unwrap(); - // Split off the final CREATE INDEX (worker heartbeat freshness). + let v10 = MIGRATIONS[MIGRATIONS.len() - 2]; // V10: the last table-creating migration + // Split off the final CREATE INDEX (worker heartbeat freshness). let idx = "CREATE INDEX idx_worker_heartbeat ON worker_heartbeats(last_heartbeat);"; let partial = v10.replace( idx, @@ -100,10 +91,7 @@ fn migration_batch_is_transactional() { ) .unwrap(); assert_eq!(jobs, 0, "partial migration must not leave tables behind"); - assert_eq!( - db::current_version(&conn).unwrap(), - CATALOG_SCHEMA_VERSION - 1 - ); + assert_eq!(db::current_version(&conn).unwrap(), 9); drop(dir); } @@ -376,7 +364,10 @@ fn open_v9_db_with_data() -> (tempfile::TempDir, rusqlite::Connection) { fn v9_to_v10_preserves_catalog_events() { let (dir, conn) = open_v9_db_with_data(); inim::catalog::db::migrate(&conn).unwrap(); - assert_eq!(inim::catalog::db::current_version(&conn).unwrap(), 10); + assert_eq!( + inim::catalog::db::current_version(&conn).unwrap(), + CATALOG_SCHEMA_VERSION + ); let n: i64 = conn .query_row("SELECT COUNT(*) FROM catalog_events", [], |r| r.get(0)) .unwrap(); @@ -477,7 +468,10 @@ fn migration_is_idempotent_at_v10() { .query_row("SELECT COUNT(*) FROM catalog_events", [], |r| r.get(0)) .unwrap(); assert_eq!(before, after); - assert_eq!(inim::catalog::db::current_version(&conn).unwrap(), 10); + assert_eq!( + inim::catalog::db::current_version(&conn).unwrap(), + CATALOG_SCHEMA_VERSION + ); drop(dir); } diff --git a/tests/release_test.rs b/tests/release_test.rs index 3e4469d..7350d86 100644 --- a/tests/release_test.rs +++ b/tests/release_test.rs @@ -346,6 +346,7 @@ fn production_source_contains_no_internet2_specific_plane_branch() { "src/assess.rs", "src/catalog/batch.rs", "src/catalog/target_research.rs", + "src/catalog/web/fabric_path_tests.rs", "src/cohort.rs", "src/compare.rs", "src/derived_cache.rs",