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

Incident context

{{ incident_context }}

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

Reviewed attachment context (Layer-2)

+

Reviewed attached networks (Layer-2 fabric participants)

+

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

{{ ic.fabric_svg|safe }}

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

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

Other incident context

+

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

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

Classification review: {{ ic.entity_review }}

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

Limitations

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

    Analysis workflow

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

    Analysis workflow

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

    Observation coverage

    +

    {{ cv.summary }}

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

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

    +

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

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

    Full observation coverage and evidence

    +{% endif %} +

    Provenance: {{ cv.provenance }}

    +
    +{% endif %} +

    Analysis runs

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

    Source snapshot history (immutable)

    - - + + diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 27ee5d2..919bdcd 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -355,6 +355,42 @@ pub struct EventDetailView { pub supporting_only: bool, /// Reviewed BGP applicability (ticket_reviews), empty when none. pub applicability: String, + /// Reviewed observer-coverage summary (collector-by-collector) + /// when the case study carries one. + pub coverage: Option, + /// Latest imported snapshot's reviewed fetch time. + pub snapshot_fetched_at: String, + /// Source lifecycle at the latest imported snapshot (reviewed). + pub snapshot_lifecycle: String, + pub snapshot_lifecycle_evidence: String, + /// Reviewed provenance of the analysis cutoff. + pub cutoff_provenance: String, +} + +/// Reviewed observer coverage for an event (collector-by-collector +/// summary derived from canonical preflight/run evidence). +#[derive(Serialize)] +pub struct ObservationCoverageView { + pub event_id: String, + pub summary: String, + pub updates_acquired: bool, + pub updates_explanation: String, + pub provenance: String, + pub rows: Vec, +} + +#[derive(Serialize)] +pub struct CoverageRowView { + pub collector: String, + pub site: String, + pub family: String, + pub target_visible: bool, + pub target_prefixes: u64, + pub relationship_visible: bool, + pub direct_observer_session: bool, + pub human_label: String, + pub blocker_classification: String, + pub note: String, } pub struct SnapshotView { @@ -367,6 +403,10 @@ pub struct SnapshotView { /// fixture (file:// provenance) rather than fetched from the /// original public source. pub fixture_import: bool, + /// True when the snapshot is the tracked immutable case-study + /// snapshot (`.source.json`), the latest reviewed + /// snapshot when present. + pub tracked_snapshot: bool, } pub struct ManifestView { @@ -817,6 +857,7 @@ fn latest_result( pub fn load_event_detail( conn: &rusqlite::Connection, external_id: &str, + catalog_root: &std::path::Path, ) -> Result, String> { let Some(event) = db::get_event_by_external(conn, "local-repository", external_id)?.or( db::get_event_by_external(conn, "grnoc-public-task-viewer", external_id)?, @@ -851,6 +892,22 @@ pub fn load_event_detail( .to_string(); let expectation = latest_expectation(conn, event.id)?; let (result, assessment) = latest_result(conn, event.id)?; + // Operator-readable explanation leads for insufficient-visibility + // events when the case study carries a reviewed observation- + // coverage summary; the stored assessment statement remains in the + // run artifact. + let coverage = observation_coverage_for(catalog_root, external_id); + let assessment = if coverage.is_some() + && result + .as_deref() + .is_some_and(|r| r.contains("Insufficient")) + { + Some(String::from( + "Target-origin routes were visible at selected public collectors, but no selected event baseline exposed the reviewed relationship and no direct observer session for the reviewed peer ASN was available.", + )) + } else { + assessment + }; let applicability = reviewed_applicability(conn, event.id)?; let supporting_only = applicability.as_deref() == Some(crate::catalog::domain::applicability::NOT_DIRECTLY_OBSERVABLE); @@ -880,6 +937,7 @@ pub fn load_event_detail( sha256: s.content_sha256.clone(), raw_preview: s.raw_payload.chars().take(220).collect(), fixture_import: s.source_url.starts_with("file://"), + tracked_snapshot: s.source_url.ends_with(".source.json"), }) .collect(); let manifest_views = db::list_manifest_revisions(conn, event.id)? @@ -899,7 +957,15 @@ pub fn load_event_detail( let stored = r.verdict.clone().unwrap_or_default(); let (observed, expectation) = present_run_verdict(&stored); let mut assessment = r.assessment.clone().unwrap_or_default(); - if supporting_only { + // Operator-readable explanation leads for + // insufficient-visibility runs when the case study carries + // a reviewed observation-coverage summary; the stored + // assessment statement remains in the run artifact. + if stored.contains("insufficient") && coverage.is_some() { + assessment = String::from( + "Target-origin routes were visible at selected public collectors, but no selected event baseline exposed the reviewed relationship and no direct observer session for the reviewed peer ASN was available.", + ); + } else if supporting_only { // Scope-mismatched supporting observation: the run row // names the observed result but carries no expectation // assessment against the optical relationship. @@ -930,6 +996,36 @@ pub fn load_event_detail( let (workflow_status, workflow_link, workflow_detail, active_job_id, completed_run_id) = workflow_status_for(conn, event.id, &st, &result)?; let (analysis_cutoff, is_open) = event_cutoff(conn, event.id)?; + // Reviewed snapshot provenance: the latest imported snapshot's + // sidecar metadata (fetched time, lifecycle at snapshot, cutoff + // provenance) when the case study carries one; else the manifest's + // reviewed notes are the authority. + let (snapshot_lifecycle, snapshot_lifecycle_evidence, cutoff_provenance) = if let Some(meta) = + snapshot_meta_for(catalog_root, &snapshots) + { + let lifecycle = meta + .get("lifecycle_at_snapshot") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let evidence = meta + .get("lifecycle_evidence") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let cutoff = meta + .get("cutoff_provenance") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + (lifecycle, evidence, cutoff) + } else { + ( + String::new(), + String::new(), + "Reviewed snapshot cutoff: the reviewed end of the provisional analysis window (manifest analysis_end_utc); not a source fetch time unless the reviewed record says so.".to_string(), + ) + }; Ok(Some(EventDetailView { event, @@ -954,9 +1050,18 @@ pub fn load_event_detail( completed_run_id, analysis_cutoff, is_open, + coverage, + snapshot_fetched_at: snapshots + .first() + .map(|s| s.fetched_at.clone()) + .unwrap_or_default(), + snapshot_lifecycle, + snapshot_lifecycle_evidence, + cutoff_provenance, })) } +/// Reviewed snapshot cutoff for open events: the latest manifest /// Reviewed snapshot cutoff for open events: the latest manifest /// revision's `analysis_end_utc` when the manifest marks the event /// open. Returns (cutoff, is_open). @@ -982,6 +1087,141 @@ fn event_cutoff(conn: &rusqlite::Connection, event_id: i64) -> Result<(String, b Ok((cutoff, open)) } +/// Reviewed sidecar metadata for the latest imported snapshot +/// (`.source.json.meta.json` next to the tracked snapshot). +/// Generic: the sidecar path is derived from the snapshot's `file://` +/// source URL, so no case-study slug is hard-coded. +fn snapshot_meta_for( + catalog_root: &std::path::Path, + snapshots: &[crate::catalog::domain::EventSnapshot], +) -> Option { + let latest = snapshots.first()?; + let source_path = latest.source_url.strip_prefix("file://")?; + if !source_path.ends_with(".source.json") { + return None; + } + let meta_path = catalog_root.join(format!("{source_path}.meta.json")); + if !meta_path.is_file() { + return None; + } + let content = std::fs::read_to_string(&meta_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Reviewed observer coverage for an event, from +/// `case-studies//observation-coverage.json`. Generic: every +/// case-study directory is consulted and the file matching the event's +/// external ID is used — no case-study slug is hard-coded. +fn observation_coverage_for( + catalog_root: &std::path::Path, + external_id: &str, +) -> Option { + let Ok(entries) = std::fs::read_dir(catalog_root.join("case-studies")) else { + return None; + }; + let mut candidates: Vec = Vec::new(); + for entry in entries.flatten() { + let candidate = entry.path().join("observation-coverage.json"); + if candidate.is_file() { + candidates.push(candidate); + } + } + candidates.sort(); + for path in candidates { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(json) = serde_json::from_str::(&content) else { + continue; + }; + if json.get("event_id").and_then(|v| v.as_str()) != Some(external_id) { + continue; + } + let summary = json + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let updates_acquired = json + .get("updates_acquired") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let updates_explanation = json + .get("updates_explanation") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let provenance = json + .get("provenance") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let mut rows: Vec = Vec::new(); + if let Some(arr) = json.get("rows").and_then(|v| v.as_array()) { + for r in arr { + let collector = r + .get("collector") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let family = r + .get("family") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let human_label = r + .get("human_label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let blocker = r + .get("blocker_classification") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let note = r + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + rows.push(CoverageRowView { + collector: collector.clone(), + site: collector_site_for(catalog_root, &collector), + family, + target_visible: r + .get("target_visible") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + target_prefixes: r + .get("target_prefixes") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + relationship_visible: r + .get("relationship_visible") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + direct_observer_session: r + .get("direct_observer_session") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + human_label, + blocker_classification: blocker, + note, + }); + } + } + return Some(ObservationCoverageView { + event_id: external_id.to_string(), + summary, + updates_acquired, + updates_explanation, + provenance, + rows, + }); + } + None +} + /// The event-page workflow line: status text, link, detail, active job /// id (when any), completed run id (when any). type WorkflowStatus = (String, String, String, Option, Option); @@ -1708,8 +1948,9 @@ pub fn load_event_list_json( pub fn load_event_detail_json( conn: &rusqlite::Connection, external_id: &str, + catalog_root: &std::path::Path, ) -> Result, String> { - let Some(view) = load_event_detail(conn, external_id)? else { + let Some(view) = load_event_detail(conn, external_id, catalog_root)? else { return Ok(None); }; Ok(Some(serde_json::json!({ @@ -1916,6 +2157,13 @@ pub struct InterconnectionContextView { pub kind: String, pub label: String, pub attachments: Vec, + pub test_equipment: Vec, + pub interconnect_context: Vec, + pub operational_services: Vec, + pub unresolved_mentions: Vec, + pub entity_review: String, + pub attached_count: usize, + pub other_count: usize, pub provenance: String, pub limitations: Vec, pub fabric_svg: String, @@ -2355,28 +2603,15 @@ fn interconnection_view( let attachments: Vec = v .get("attachments") .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|a| { - let label = a.get("label")?.as_str()?.to_string(); - let note = a - .get("note") - .and_then(|n| n.as_str()) - .unwrap_or("") - .to_string(); - let asn_text = a - .get("asn") - .and_then(|n| n.as_u64()) - .map(|n| format!("AS{n}")) - .unwrap_or_else(|| "no reviewed ASN".to_string()); - Some(AttachmentView { - label, - note, - asn_text, - }) - }) - .collect() - }) + .map(|arr| parse_attachment_views(arr)) + .unwrap_or_default(); + let test_equipment = parse_optional_entity_list(&v, "test_equipment"); + let interconnect_context = parse_optional_entity_list(&v, "interconnect_context"); + let operational_services = parse_optional_entity_list(&v, "operational_services"); + let unresolved_mentions = parse_optional_entity_list(&v, "unresolved_mentions"); + let entity_review = v + .get("entity_review") + .map(|r| serde_json::to_string_pretty(r).unwrap_or_default()) .unwrap_or_default(); let fabric = crate::catalog::web::path_diagram::FabricView { label: label.clone(), @@ -2397,16 +2632,62 @@ fn interconnection_view( limitations: limitations.clone(), }; let fabric_svg = crate::catalog::web::path_diagram::render_fabric_svg(&fabric); + let attached_count = attachments.len(); + let other_count = test_equipment.len() + + interconnect_context.len() + + operational_services.len() + + unresolved_mentions.len(); Some(InterconnectionContextView { kind, label, attachments, + test_equipment, + interconnect_context, + operational_services, + unresolved_mentions, + entity_review, + attached_count, + other_count, provenance, limitations, fabric_svg, }) } +/// Parse one reviewed entity list (label / note / reviewed ASN) from +/// the interconnection-context JSON. +fn parse_optional_entity_list(v: &serde_json::Value, key: &str) -> Vec { + v.get(key) + .and_then(|a| a.as_array()) + .map(|arr| parse_attachment_views(arr)) + .unwrap_or_default() +} + +/// Parse reviewed entity entries (attachments and contextual lists +/// share the same shape; semantic class is the list membership). +fn parse_attachment_views(arr: &[serde_json::Value]) -> Vec { + arr.iter() + .filter_map(|a| { + let label = a.get("label")?.as_str()?.to_string(); + let note = a + .get("note") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let asn_text = a + .get("asn") + .and_then(|n| n.as_u64()) + .map(|n| format!("AS{n}")) + .unwrap_or_else(|| "no reviewed ASN".to_string()); + Some(AttachmentView { + label, + note, + asn_text, + }) + }) + .collect() +} + /// Manifest payload (target, origin, predicate) for a run. fn manifest_payload_for_run(conn: &rusqlite::Connection, run_id: i64) -> Option { let payload: Option = conn @@ -3650,7 +3931,7 @@ pub fn load_case_study( "Scope: public BGP observations of the reviewed target; not a complete incident-wide assessment.".to_string() }; let participants_heading = if interconnection.is_some() { - "Other operator-reported attached networks/connectors".to_string() + "Other source-mentioned entities (AAR)".to_string() } else { "Other operator-reported participants".to_string() }; diff --git a/src/catalog/web/workbench_fix_tests.rs b/src/catalog/web/workbench_fix_tests.rs index 8f38218..ce8d188 100644 --- a/src/catalog/web/workbench_fix_tests.rs +++ b/src/catalog/web/workbench_fix_tests.rs @@ -382,8 +382,8 @@ async fn demo_import_path_not_presented_as_original_source() { let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/events/INC0301970").await; assert!( - body.contains("imported from tracked offline fixture"), - "fixture import provenance is disclosed: {body}" + body.contains("imported from tracked"), + "import provenance is disclosed: {body}" ); // The fixture path is not the primary source identity. let primary = body.find("GRNOC Public Task Viewer"); @@ -451,7 +451,9 @@ async fn unreviewed_aar_participant_remains_unreviewed() { let (dbdir, rootdir) = setup_demo_catalog(); let app = build_app(state_from(&dbdir, &rootdir)); let (_, body) = get(&app, "/case-studies/manlan-2019").await; - let mentioned = body.find("operator-reported").unwrap(); + let mentioned = body + .find("Other source-mentioned entities (AAR)") + .unwrap_or_else(|| body.find("operator-reported").unwrap()); let segment = &body[mentioned..mentioned + 2000]; assert!( segment.to_lowercase().contains("canarie"),
    RunStatusStartedVerdictAssessment
    {{ s.id }} {{ s.fetched_at }}{% if s.fixture_import %}GRNOC Public Task Viewer — external ID {{ event.external_id }}{% else %}{{ s.source_url }}{% endif %}{% if s.fixture_import %}imported from tracked offline fixture ({{ s.source_url }}){% else %}fetched from source{% endif %}{% if s.fixture_import %}{% if s.tracked_snapshot %}GRNOC Public Task Viewer — external ID {{ event.external_id }} (tracked immutable snapshot){% else %}GRNOC Public Task Viewer — external ID {{ event.external_id }}{% endif %}{% else %}{{ s.source_url }}{% endif %}{% if s.fixture_import %}{% if s.tracked_snapshot %}imported from tracked immutable snapshot ({{ s.source_url }}){% else %}imported from tracked offline fixture ({{ s.source_url }}){% endif %}{% else %}fetched from source{% endif %} {{ s.sha256 }} {{ s.raw_preview }}