diff --git a/CHANGELOG.md b/CHANGELOG.md index ad47fe2..ffbd1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ ## [unreleased] -### Session 55 — fabric entity taxonomy + Smithville coverage summary (2026-08-05) +### Pre-pilot correctness and provenance invariant closure (2026-08) + +- Continuity gate ordering: the analysis eligibility/continuity gate now + runs before any result is derived from finding counts, so an empty + finding set can no longer produce a "no route-state change" verdict + when UPDATE archive gaps or session resets make the absence of changes + unproven. (No tracked historical result changed.) +- Artifact resolution consolidated: every artifact consumer (run page, + workbench, demo verifier, artifact audit) uses the single + containment-validating resolver; crafted or legacy artifact paths + (absolute, parent traversal, symlink escape, Windows normalization + hazards) cannot leave the configured artifact root. +- Open events now require an explicit reviewed analysis cutoff with + recorded provenance before any Ready plan can exist; malformed open + manifests are rejected, legacy invalid plans are blocked at queue and + worker time before source access, and the cutoff participates in plan + identity. +- Project-scope exclusions now apply to standalone `inim analyze` as + well as the catalog workflow, before any planning or source access; + the CLI warns when the policy file is absent. +- Observed results and expectation assessments are exposed as separate, + evidence-scoped projections in the run API; stored verdict strings + remain as documented legacy fields. + +### Corrected MAN LAN entity taxonomy and Smithville coverage provenance (2026-08-05) - MAN LAN entity taxonomy correction: Ixia is test/measurement equipment (not a fabric attachment, peer, or AS node); WIX interconnect is diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index c313e2e..0179993 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -304,7 +304,16 @@ complete. Project scope is a reviewed project-owner decision, stored in `config/project-scope.toml` (schema v1) and loaded once per process by -the web app, CLI, worker, and demo. It is ORTHOGONAL to the analytical +the web app, CLI, worker, and demo. It is a universal first-party +execution policy: the standalone `inim analyze` path applies the same +reviewed exclusions as the catalog workflow, before any planning, +broker discovery, archive acquisition, or MRT parsing (a scope-blocked +analyze exits `EXIT_ANALYSIS_BLOCKED` and writes no outputs). The +standalone `analyze` CLI has no catalog root: it loads the policy from +`config/project-scope.toml` under the current working directory, and +warns on stderr when that file is absent (the established empty +all-Included policy applies for every caller in that case). It is +ORTHOGONAL to the analytical applicability vocabulary: an exclusion never marks an event not-observable, failed, or invalid. Matching keys, in precedence order: exact external source ID, exact reviewed entity name, exact reviewed @@ -314,6 +323,9 @@ after claim and cancels pre-execution jobs (`excluded_by_project_scope`); imports skip excluded manifests explicitly; demo verify fails when an excluded event is present; immutable runtime records are hidden from default views and reported by the read-only `project-scope audit`. +Project scope is policy, not authentication or sandboxing: it constrains +which subjects first-party commands analyze and publish, and does not +protect a hostile local caller. ## Candidate grouping diff --git a/docs/audits/2026-08-documentation-inventory.md b/docs/audits/2026-08-documentation-inventory.md index e372c90..bc59684 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 (96, excluding `spec/`) +### Tracked Markdown files (97, excluding `spec/`) ``` .github/PULL_REQUEST_TEMPLATE.md @@ -280,6 +280,7 @@ docs/audits/2026-08-incident-family-deferral.md docs/audits/2026-08-internal-evaluator-findings.md docs/audits/2026-08-manlan-ticket-readiness.md docs/audits/2026-08-non-noaa-ip-event-candidates.md +docs/audits/2026-08-pre-pilot-invariant-closure.md docs/audits/2026-08-project-scope-noaa-removal.md docs/audits/2026-08-repository-truth-audit.md docs/audits/2026-08-second-network-neutrality.md @@ -322,7 +323,7 @@ tests/fixtures/README.md -### Tracked files under `docs/` (76) +### Tracked files under `docs/` (77) ``` docs/ADRs/CASE-STUDY-LAYER.md @@ -368,6 +369,7 @@ docs/audits/2026-08-incident-family-deferral.md docs/audits/2026-08-internal-evaluator-findings.md docs/audits/2026-08-manlan-ticket-readiness.md docs/audits/2026-08-non-noaa-ip-event-candidates.md +docs/audits/2026-08-pre-pilot-invariant-closure.md docs/audits/2026-08-project-scope-noaa-removal.md docs/audits/2026-08-repository-truth-audit.md docs/audits/2026-08-second-network-neutrality.md diff --git a/docs/audits/2026-08-pre-pilot-invariant-closure.md b/docs/audits/2026-08-pre-pilot-invariant-closure.md new file mode 100644 index 0000000..8182e15 --- /dev/null +++ b/docs/audits/2026-08-pre-pilot-invariant-closure.md @@ -0,0 +1,74 @@ +# Pre-pilot invariant closure — incremental design checksum (2026-08) + +Historical audit, not normative. Applies the documented design-checksum +method (see `docs/computational-model.md`) to the correction range +`b2fde8161aef361a99b3df6f50c2eba7f9c0ee31..9a6354c20be5d80cb0d9a22bfa49199aa5866fd5` +(Session 57, pre-pilot invariant closure). It is not a full design-recovery +rerun; no external evaluator session occurred; no canonical analysis was +rerun. + +## Data structures changed + +| Change | Before | After | Evidence | Compatibility | Tests | +|--------|--------|-------|----------|---------------|-------| +| Artifact containment primitive | inline absolute/parent checks in `resolve_artifact` | `is_safe_relative_path` shared primitive (rejects empty, absolute, parent traversal, drive-letter/UNC prefixes, backslash separators) | `src/catalog/artifact_path.rs` | none (internal) | `is_safe_relative_path_rejects_escape_forms` | +| Run API view model | `run.verdict` + `run.assessment` raw | + structured `observed_result {kind,label}` and `expectation_assessment {kind,label}`; raw fields documented legacy | `load_run_json` (`src/catalog/web/view.rs`), `src/catalog/web/api.rs` | additive (new fields) | `api_exposes_structured_observed_result` | + +## Semantic identity changed + +| Change | Before | After | Evidence | Compatibility | Tests | +|--------|--------|-------|----------|---------------|-------| +| Canonical plan identity | `CanonicalPlan.analysis_end` = declared `event_window_utc.end` (empty for open events); the reviewed cutoff did not participate in the plan hash | `analysis_end` = reviewed `analysis_end_utc` for open events; the cutoff participates in plan identity | `CanonicalPlan::from_manifest` (`src/catalog/jobs/plan.rs`) | plan hashes of newly queued open-event runs differ; stored historical rows unchanged | `cutoff_participates_in_plan_hash_when_semantic` | + +## Invariants changed + +| Change | Before | After | Evidence | Compatibility | Tests | +|--------|--------|-------|----------|---------------|-------| +| Continuity gate ordering (F-1) | empty-finding fallback ran before the continuity gate | continuity gate runs before any finding-cardinality result derivation | `derive_verdict` (`src/assess.rs`) | completed-run verdict for gaps+zero-transitions changes from no-change to `InsufficientVisibility`; no tracked run demonstrated affected | `continuity_failure_precedes_empty_finding_fallback`, `continuity_gate_decision_table` | +| Artifact resolver equivalence (F-2/F-3) | four resolvers; workbench missed some reviewed trees; demo fallback unvalidated | one resolver + one containment primitive on every consumer | `src/catalog/artifact_path.rs`, `src/catalog/workbench.rs`, `src/catalog/demo.rs`, `src/catalog/jobs/publish.rs` | all tracked artifact rows still resolve | `all_artifact_consumers_agree_on_validity`, `workbench_and_demo_resolver_equivalent` | +| Open-event cutoff readiness (F-4) | open manifest could store a Ready plan without a reviewed cutoff/provenance | load rejects; plan Blocks; queue/worker require cutoff regardless of declared end; import requires recorded provenance | `src/manifest.rs`, `src/catalog/import.rs`, `src/catalog/jobs/plan.rs`, `src/worker.rs` | malformed open manifests now rejected at import; tracked manifests already conform | `open_event_ready_plan_requires_cutoff`, `open_event_ready_plan_requires_cutoff_provenance` | +| Standalone scope boundary (F-5) | `inim analyze` never loaded project scope | `analyze_scope_block` rejects excluded subjects before planning/source access | `src/main.rs`, `docs/DOMAIN.md` | blocked analyze now exits `EXIT_ANALYSIS_BLOCKED`; no exclusions changed | `standalone_analyze_scope_boundary_is_explicit` | +| Observed-result projection (F-6) | unrecognized stored verdicts rendered verbatim in the observed-result slot; API exposed raw verdict strings | neutral fallback label; structured API projections; raw fields documented legacy | `src/catalog/web/view.rs`, `src/catalog/web/api.rs`, `docs/reference/API.md` | additive API fields; historical rows readable | `legacy_verdict_does_not_override_current_projection` | +| Changelog session-narrative (F-8) | `Session 55` heading tripped the docs audit; CI red | product-language heading; regression test | `CHANGELOG.md`, `tests/release_test.rs` | release-facing content preserved | `changelog_contains_no_session_narrative` | + +## Algorithms changed + +| Change | Before | After | Evidence | Compatibility | Tests | +|--------|--------|-------|----------|---------------|-------| +| Verdict derivation | empty-findings early return before continuity gate | continuity gate first | `derive_verdict` (`src/assess.rs`) | see F-1 invariant row | `continuity_gate_decision_table` | +| Artifact resolution | consumer-specific candidate lists | one candidate search + containment validation | `resolve_artifact` | see F-2/F-3 | `git_checkout_and_packaged_source_resolver_equivalent` | + +## State transitions changed + +| Change | Before | After | Evidence | Compatibility | Tests | +|--------|--------|-------|----------|---------------|-------| +| Plan readiness for open events | Ready without cutoff possible via import | Blocked (`MissingAnalysisEndForOpenTicket`) | `build_plan_record` (`src/catalog/import.rs`) | catalog rows for invalid legacy input read Blocked | `open_event_ready_plan_requires_cutoff` | +| Standalone analyze exit | proceeded to planning/analysis regardless of scope | exits `EXIT_ANALYSIS_BLOCKED` before source access for excluded subjects | `cmd_analyze` (`src/main.rs`) | documented exit code | `project_scope_checked_before_network_access_where_applicable` | + +## Effects changed + +- Standalone `analyze` now reads `config/project-scope.toml` (filesystem read) before any planning. +- No network effect changed: scope checks and cutoff gates occur before broker discovery on every path. +- No new write effects: blocked analyze writes no outputs. + +## Authority boundaries changed + +- Artifact path authority: `resolve_artifact` + `is_safe_relative_path` are now the single authority for analysis artifacts; document serving uses the same primitive plus its canonical check. +- Run-result authority: the structured `observed_result`/`expectation_assessment` projections are the current interpretation; the stored `verdict`/`assessment` fields are explicitly legacy. +- Scope authority: `config/project-scope.toml` applies to every first-party execution path (catalog workflow and standalone analyze). + +## Information-loss boundaries changed + +- Observed-result projection no longer forwards raw/unrecognized stored verdict strings into the observed-result slot (a neutral fallback is used); the raw value remains available as the legacy field. +- No canonical evidence, snapshot, or report was rewritten; historical runs remain readable. + +## Complexity changed + +- `resolve_artifact` adds one canonicalization per existing candidate (bounded by the small candidate set; 118 demo-catalog rows verified). +- No unbounded structures introduced. + +## Statements + +- No full design-recovery rerun. +- No external evaluator session (pilot registry unchanged: zero). +- No canonical analysis rerun; no source contacted; no archive acquired. diff --git a/docs/audits/2026-08-repository-truth-audit.md b/docs/audits/2026-08-repository-truth-audit.md index 50c7fce..391f78c 100644 --- a/docs/audits/2026-08-repository-truth-audit.md +++ b/docs/audits/2026-08-repository-truth-audit.md @@ -1,6 +1,6 @@ # Repository truth audit — 2026-08 -Audit start HEAD: `0517aac` · audit date: 2026-08-04 +Audit start HEAD: `0517aac` · audit date: 2026-08-05 This audit verifies that every tracked file is classified, that every current statement matches the implemented model, and that historical records and generated evidence are clearly distinguished. The machine-readable source of this document is `docs/audits/repository-inventory.json`; regenerate with `python3 scripts/build-repo-audit.py`. Paths are repository-relative only; no absolute local paths appear in this audit. @@ -24,19 +24,19 @@ This audit verifies that every tracked file is classified, that every current st ## Summary -Tracked files: **470** · inventory entries: **470** +Tracked files: **472** · inventory entries: **472** | Category | Files | |---|---| | Immutable or generated evidence | 148 | | Production source | 106 | | Normative current documentation | 48 | -| Historical decision record | 42 | +| Historical decision record | 43 | | Reviewed case-study interpretation | 30 | | Script or developer tool | 25 | | Configuration | 24 | | Template or stylesheet | 16 | -| Test source | 11 | +| Test source | 12 | | Test fixture | 11 | | GitHub/community metadata | 4 | | Packaging or release metadata | 3 | @@ -288,6 +288,7 @@ Tracked files: **470** · inventory entries: **470** | `docs/audits/2026-08-internal-evaluator-findings.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-manlan-ticket-readiness.md` | Normative current documentation | maintainers | dated audit evidence | no | current | line-by-line reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-non-noaa-ip-event-candidates.md` | Historical decision record | maintainers | dated audit evidence | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | +| `docs/audits/2026-08-pre-pilot-invariant-closure.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-project-scope-noaa-removal.md` | Historical decision record | maintainers | dated audit evidence | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-repository-truth-audit.md` | Normative current documentation | maintainers | repository-inventory.json + git state (rendered, reviewed content) | no | current | line-by-line reviewed in this audit | none | reviewed in this audit | | `docs/audits/2026-08-second-network-neutrality.md` | Historical decision record | maintainers | dated audit evidence | no | historical | status and applicability reviewed in this audit | none | reviewed in this audit | @@ -506,6 +507,7 @@ Tracked files: **470** · inventory entries: **470** | `tests/fixtures/ris/updates.20190821.1600.gz` | Test fixture | maintainers | immutable public source snapshot | no | current | provenance reviewed in this audit | none | reviewed in this audit | | `tests/grnoc_reconciliation_test.rs` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `tests/i2px_audit_test.rs` | Test source | maintainers | implementation behavior + fixtures | no | current | reviewed in this audit | none | reviewed in this audit | +| `tests/invariant_register_test.rs` | Test source | maintainers | invariant register + implementation | no | current | reviewed in this audit | none | reviewed in this audit | | `tests/job_migration_test.rs` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `tests/job_workflow_tests.rs` | Production source | developers | implementation | no | current | implementation comments audited in this audit | none | reviewed in this audit | | `tests/project_scope_enforcement_test.rs` | Test source | maintainers | implementation behavior + fixtures | no | current | reviewed in this audit | none | reviewed in this audit | diff --git a/docs/audits/2026-08-wirthian-design-recovery.md b/docs/audits/2026-08-wirthian-design-recovery.md index 49fdd21..94c9faa 100644 --- a/docs/audits/2026-08-wirthian-design-recovery.md +++ b/docs/audits/2026-08-wirthian-design-recovery.md @@ -273,3 +273,27 @@ External evaluation sessions: **zero**. Pilot registry unchanged Navigation updated: `../README.md` (docs index), `./README.md` (audits index). No other documentation was modified. + +## Follow-up status (Session 57 — pre-pilot invariant closure) + +The findings below were addressed by +[`2026-08-pre-pilot-invariant-closure.md`](2026-08-pre-pilot-invariant-closure.md). +This section records status only; the historical findings above are not +rewritten. + +| Finding | Status | Correction | Enforcement / tests | Residual limitation | +|---------|--------|------------|---------------------|---------------------| +| F-1 Continuity-gate bypass | resolved | gate runs before empty-finding fallback (`src/assess.rs`) | `continuity_failure_precedes_empty_finding_fallback`, `continuity_gate_decision_table`, `assessment_is_deterministic` | F-10 (historical occurrence) not demonstrated | +| F-2 Duplicated artifact resolvers | resolved | all consumers use `resolve_artifact` or the shared primitive | `all_artifact_consumers_agree_on_validity`, `workbench_and_demo_resolver_equivalent`, `git_checkout_and_packaged_source_resolver_equivalent` | none identified | +| F-3 Unvalidated root.join(rel) | resolved | `is_safe_relative_path` lexical containment + canonical containment for existing candidates | `artifact_symlink_escape_rejected`, `artifact_parent_traversal_rejected`, `artifact_absolute_path_rejected`, `missing_artifact_distinct_from_invalid_artifact_path` | none identified | +| F-4 Storable Ready plan for open event without cutoff | resolved | load rejects; plan Blocks; queue/worker require cutoff regardless of declared end; import requires provenance | `open_event_ready_plan_requires_cutoff`, `open_event_ready_plan_requires_cutoff_provenance`, `worker_missing_cutoff_fails_loudly`, `source_fetch_time_not_implicitly_analysis_cutoff` | legacy rows are blocked at queue/worker before source access | +| F-5 Standalone analyze has no project-scope enforcement | resolved by enforcement (Outcome A: universal first-party execution policy) | `analyze_scope_block` in `cmd_analyze` before planning/source access; boundary documented in `docs/DOMAIN.md` | `standalone_analyze_scope_boundary_is_explicit`, `project_scope_checked_before_network_access_where_applicable`, `standalone_output_cannot_silently_bypass_scoped_publication` | scope is policy, not sandboxing (documented) | +| F-6 Expectation vocabulary in stored/API verdict strings | resolved (compatibility-narrowed) | structured `observed_result`/`expectation_assessment` projections; neutral fallback; raw fields documented legacy | `api_exposes_structured_observed_result`, `api_exposes_structured_expectation_assessment`, `legacy_verdict_does_not_override_current_projection`, `historical_runs_remain_readable` | the legacy `verdict`/`assessment` fields remain on stored rows and reports for compatibility | +| F-7 WAL sidecars under read-only serving | remains open (accepted boundary) | not implemented (out of scope) | — | catalog opens read-write WAL; logical rows unchanged under GET (PV-2) | +| F-8 Pre-existing red documentation CI | resolved | CHANGELOG entry rewritten in product language; render regenerated; regression test | `changelog_contains_no_session_narrative`; `scripts/audit-docs.sh` green | none | +| F-9 Extraction-reuse predicate caveat | remains open (out of scope) | not implemented | — | origin-keyed extraction reuse may miss predicate-2-only streams at production scale | +| F-10 Occurrence of F-1 in a historical real run | no tracked run demonstrated affected | static inspection of all 12 tracked `report.json` files found zero runs with the unknown-continuity + empty-findings combination | — | absence is demonstrated for the tracked set only; not proven for untracked runtime catalogs | + +Synthetic reproduction of the F-1 input combination is covered by the +gate-ordering tests; that is not evidence that any historical run was +affected. diff --git a/docs/audits/README.md b/docs/audits/README.md index 1c07db3..ac1bdf0 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -33,6 +33,7 @@ header say so. | `2026-08-specification-coverage.md` | 2026-08 | specification coverage matrix | `91ac498` | navigation aid; not normative | — | | `2026-08-documentation-spec-conformance.md` | 2026-08 | final documentation conformance audit | `91ac498` | this session's audit | — | | `2026-08-wirthian-design-recovery.md` | 2026-08 | as-built computational-model recovery (reconstruction/falsification/synthesis) | `92f83d8` | current normative model in `docs/computational-model.md` + `docs/design/` | — | +| `2026-08-pre-pilot-invariant-closure.md` | 2026-08 | incremental design checksum for the pre-pilot invariant closure | `b2fde81` | correction range checksum; see also the Session 56 follow-up status | — | ## Rules diff --git a/docs/audits/repository-inventory.json b/docs/audits/repository-inventory.json index 5257ded..652f6e1 100644 --- a/docs/audits/repository-inventory.json +++ b/docs/audits/repository-inventory.json @@ -3758,5 +3758,21 @@ "authoritative": "dated audit", "generated": false, "current": false + }, + { + "path": "docs/audits/2026-08-pre-pilot-invariant-closure.md", + "category": "Historical decision record", + "audience": "maintainers", + "authoritative": "dated audit", + "generated": false, + "current": false + }, + { + "path": "tests/invariant_register_test.rs", + "category": "Test source", + "audience": "maintainers", + "authoritative": "invariant register + implementation", + "generated": false, + "current": true } ] \ No newline at end of file diff --git a/docs/design/invariants.md b/docs/design/invariants.md index 434f4ac..5b71fb6 100644 --- a/docs/design/invariants.md +++ b/docs/design/invariants.md @@ -45,7 +45,7 @@ from the session-56 falsification pass. | PR-3 | Plan hash covers all execution-relevant fields | enforced | `canonical_plan_hash` (`src/catalog/jobs/plan.rs`) + tests `plan_hash_changes_for_execution_field`, `plan_hash_ignores_generated_timestamp` | survived | | PR-4 | Published artifact bytes match the recorded SHA-256 | partially enforced | import rejects hash mismatch (`tests/import.rs artifact_hash_mismatch_is_rejected`); run page re-verifies; not enforced on every serving path | survived (see AR-2 for resolver divergence) | | PR-5 | Cutoff (analysis end) has reviewed provenance | partially enforced | meta sidecar carries `fetched_at_utc`; absence yields a generic fallback sentence (`src/catalog/web/view.rs`) | narrowed: `analysis_end_utc` without provenance is representable | -| PR-6 | Open events require an explicit analysis cutoff | partially enforced | manifest validation (`src/manifest.rs`); worker/queue reject (`src/catalog/jobs/plan.rs`, `src/worker.rs`) | narrowed: import path can store a Ready plan for an open event with no cutoff (`src/catalog/import.rs`) | +| PR-6 | Open events require an explicit analysis cutoff with provenance | enforced | `Manifest::validate` rejects open manifests without `analysis_end_utc` (`src/manifest.rs`); `build_plan_record` Blocks (`MissingAnalysisEndForOpenTicket`) (`src/catalog/import.rs`); queue and worker require the cutoff regardless of any declared end (`src/catalog/jobs/plan.rs`, `src/worker.rs`); import requires recorded provenance (sidecar `cutoff_provenance` or analyst note); cutoff participates in the canonical plan hash | resolved by the pre-pilot invariant closure: tests `open_event_ready_plan_requires_cutoff`, `open_event_ready_plan_requires_cutoff_provenance`, `worker_missing_cutoff_fails_loudly`, `source_fetch_time_not_implicitly_analysis_cutoff`, `cutoff_participates_in_plan_hash_when_semantic` | ## Temporal consistency @@ -55,7 +55,7 @@ from the session-56 falsification pass. | TC-2 | Analysis-final state is not assumed to equal baseline | enforced | final state is recorded separately; test `final_path_not_assumed_baseline` (`src/catalog/web/path_diagram.rs`) | survived | | TC-3 | Zero is distinct from not-applicable | enforced (Option types) | `Option`, `Option` throughout lifecycle; absent vs zero distinguished in renderers | survived | | TC-4 | Run staleness never invalidates an old run | enforced | `CatalogStatus::Stale` derived, never mutates runs (`src/catalog/status.rs`) | survived | -| TC-5 | Gap/unknown continuity suppresses strong verdicts | partially enforced | continuity gate in `derive_verdict` (`src/assess.rs`) | **contradicted**: empty-transitions early return precedes the gate; gaps + zero transitions yield `NoObservableBgpImpact` not `InsufficientVisibility` (`src/assess.rs:208-224`); combination untested | +| TC-5 | Gap/unknown continuity suppresses strong verdicts | enforced | continuity gate in `derive_verdict` runs BEFORE result derivation from finding cardinality (`src/assess.rs`); an empty finding set cannot bypass failed continuity | resolved by the pre-pilot invariant closure: tests `continuity_failure_precedes_empty_finding_fallback`, `empty_findings_do_not_imply_no_change_without_continuity`, `continuity_gate_decision_table` | ## Project scope @@ -63,7 +63,7 @@ from the session-56 falsification pass. |----|-----------|--------|-------------|----------------------| | SC-1 | Project-scope exclusion does not alter canonical evidence | enforced | scope only filters views/queries; exclusion never rewrites artifacts (`src/catalog/scope.rs`, `src/catalog/web/view.rs`) | survived | | SC-2 | Excluded events are hidden from default web/API views | enforced | view-layer scope filters + `demo verify` checks excluded events absent from demo | survived (experiment: excluded count 0 in demo audit) | -| SC-3 | Scope is rechecked after claim, before source access | enforced (worker); absent on standalone analyze | worker recheck `src/worker.rs`; queue-time check `src/catalog/jobs/plan.rs` | narrowed: `inim analyze` and `orchestrate.rs` never load `ProjectScope` | +| SC-3 | Scope is rechecked before source access on every first-party execution path | enforced | worker recheck after claim (`src/worker.rs`); queue-time and retry checks (`src/catalog/jobs/plan.rs`, `src/catalog/jobs/service.rs`); standalone `analyze` applies `analyze_scope_block` before any planning or source access (`src/main.rs`) | resolved by the pre-pilot invariant closure: tests `standalone_analyze_scope_boundary_is_explicit`, `project_scope_checked_before_network_access_where_applicable` | | SC-4 | An excluded plan cannot be queued | enforced | queue validates plan hash against scope (`src/main.rs`, `src/catalog/jobs/plan.rs`) | survived | | SC-5 | Scope matching is exact-normalized | enforced | `normalize_exact` (`src/catalog/scope.rs`) | survived | @@ -81,7 +81,7 @@ from the session-56 falsification pass. | ID | Statement | Status | Enforcement | Falsification attempt | |----|-----------|--------|-------------|----------------------| -| FR-1 | Observed result vocabulary never contains expectation wording | enforced (presentation labels) | `ObservedResultKind::human_label` (`src/domain/assessment.rs`) + negative tests | narrowed: report `result.verdict` and API expose stored verdict strings that DO carry expectation vocabulary | +| FR-1 | Observed result projection never contains expectation wording | enforced | `ObservedResultKind::human_label` (`src/domain/assessment.rs`) + negative tests; the run API exposes structured `observed_result`/`expectation_assessment`; case-study runs expose the projected labels; an unrecognized stored value projects as a neutral label, never verbatim (`src/catalog/web/view.rs`, `src/catalog/web/api.rs`) | resolved by the pre-pilot invariant closure: the raw `verdict`/`assessment` fields remain only as documented legacy fields; tests `api_exposes_structured_observed_result`, `legacy_verdict_does_not_override_current_projection` | | FR-2 | Insufficient visibility is distinct from no-change | enforced | distinct enum variants `InsufficientQualifyingVisibility` vs `NoRouteStateChangeObserved` | survived (see TC-5 for a derivation-path overlap) | | FR-3 | Completed job does not imply route change | enforced | job state orthogonal to outcome; test `completed_insufficient_visibility_is_not_failed_job` | survived | | FR-4 | Target visibility is distinct from relationship visibility | enforced | `RelationshipView.observed` hardcoded false; stream matches counted separately (`src/catalog/web/view.rs`) | survived | @@ -103,8 +103,8 @@ from the session-56 falsification pass. | ID | Statement | Status | Enforcement | Falsification attempt | |----|-----------|--------|-------------|----------------------| -| AR-1 | Artifact listing and artifact access share one resolver | assumed | `resolve_artifact` is the documented authority (`src/catalog/artifact_path.rs`) | **contradicted**: at least four resolvers with different candidate sets/order (`artifact_path.rs`, `workbench.rs`, `demo.rs`, `publish.rs`) | -| AR-2 | Artifact paths remain inside the configured root | partially enforced | `resolve_artifact` rejects absolute/parent-relative; document serving canonical-contains (`src/catalog/web/view.rs`) | narrowed: `catalog_root.join(rel)` without validation in `src/catalog/workbench.rs` and the demo fallback | +| AR-1 | Artifact listing and artifact access share one resolver | enforced | `resolve_artifact` is the single resolver (`src/catalog/artifact_path.rs`); workbench coverage, demo verifier, run page, and orphan audit all use it or the shared `is_safe_relative_path` primitive | resolved by the pre-pilot invariant closure: tests `all_artifact_consumers_agree_on_validity`, `workbench_and_demo_resolver_equivalent`, `git_checkout_and_packaged_source_resolver_equivalent` | +| AR-2 | Artifact paths remain inside the configured root | enforced | `is_safe_relative_path` is the single lexical containment primitive (rejects empty, absolute, parent traversal, drive-letter/UNC prefixes, backslash separators); `resolve_artifact` adds canonical containment for existing candidates (a symlink escaping the root is not served); document serving uses the same primitive (`src/catalog/artifact_path.rs`, `src/catalog/web/view.rs`) | resolved by the pre-pilot invariant closure: tests `artifact_relative_path_resolves_inside_root`, `artifact_parent_traversal_rejected`, `artifact_absolute_path_rejected`, `artifact_symlink_escape_rejected`, `missing_artifact_distinct_from_invalid_artifact_path` | | AR-3 | A staged run is not visible until publication | enforced | staging under `data/jobs//staging`; test `incomplete_stage_is_not_visible_as_run` (`src/catalog/jobs/publish.rs`) | survived | | AR-4 | Publication is idempotent for the same job | enforced | test `publication_is_idempotent_for_same_job` | survived | | AR-5 | Catalog–filesystem divergence is detectable | enforced | `reconcile_orphans` reports missing/orphaned entries (`src/catalog/jobs/publish.rs`) | survived (experiment: orphan dirs reported, not auto-deleted) | @@ -122,20 +122,24 @@ from the session-56 falsification pass. | PV-6 | Diagrams are presentation projections, not canonical evidence | enforced | diagrams link to evidence refs; comment `src/catalog/web/path_diagram.rs` | survived | | PV-7 | Source mention does not imply reviewed attachment | enforced | attachments only from the reviewed `attachments` array; no promotion path | survived | -## Suppressed claims (did not survive falsification) +## Previously suppressed claims -- "Artifact listing and access use the same resolver" — **contradicted**, - four resolvers exist (AR-1). +- "Artifact listing and access use the same resolver" — contradicted in + Session 56; **resolved by the pre-pilot invariant closure** (AR-1 is now enforced; all + consumers use the shared resolver or the containment primitive). - "Gap/unknown continuity always suppresses strong verdicts" — - **contradicted** for the empty-transitions path (TC-5). + contradicted in Session 56; **resolved by the pre-pilot invariant closure** (TC-5 is now + enforced; the continuity gate runs before the empty-finding fallback). - "Read-only browsing leaves the database byte-identical" — **narrowed**: - logical rows unchanged, WAL sidecars created (PV-2). + logical rows unchanged, WAL sidecars created (PV-2); remains open + (F-7, out of implementation scope for the pre-pilot invariant closure). -## Invariant counts (at the pinned commit) +## Invariant counts (at the 2026-08 pre-pilot invariant closure) -- Enforced: 33 -- Partially enforced: 5 -- Assumed: 1 +- Enforced: 53 +- Partially enforced: 3 +- Assumed: 0 - Claimed: 0 - Unknown: 0 -- Contradicted/suppressed: 2 +- Previously suppressed (now resolved): 2 +- Total table rows: 56 diff --git a/docs/reference/API.md b/docs/reference/API.md index 2d08c7d..72ebd38 100644 --- a/docs/reference/API.md +++ b/docs/reference/API.md @@ -123,3 +123,35 @@ revision and canonical plan hash returns the existing active job with archives. - The web UI route tree (HTML pages) is documented separately in `docs/reference/WEB-ROUTES.md`. + +## Observed result vs expectation assessment + +Run JSON (`/api/v1/analyses/{run_id}`) exposes two separate, +evidence-scoped projections derived from the stored verdict: + +- `observed_result` — `{ "kind", "label" }` — what public BGP evidence + and coverage support. Labels never contain expectation wording + (`RouteStateChangesObserved`, `NoRouteStateChangeObserved`, + `InsufficientQualifyingVisibility`, `AnalysisIncomplete`; unknown + stored values project as the neutral + `Observed result not classified (legacy value)`). +- `expectation_assessment` — `{ "kind", "label" }` — how that observed + result compares with the reviewed event expectation + (`ConsistentWithReviewedExpectation`, + `PartiallyConsistentWithReviewedExpectation`, + `LessExternallyVisibleChangeThanReviewedExpectation`, + `MoreExternallyVisibleChangeThanReviewedExpectation`, + `NotAssessableFromSelectedPublicObservers`, + `NoReviewedExpectationExists`, `ProvisionalAssessment`). + +The `runs` array of `/api/v1/case-studies/{slug}` carries the same two +projections as flat label strings (`observed_result`, +`expectation_assessment`) together with the raw stored `verdict` and +`assessment` strings. + +The raw stored `verdict` and `assessment` fields remain for historical +compatibility and carry mixed legacy semantics (some stored verdict +strings name expectations, e.g. `ExpectedLossOfReachability`). They are +not the primary interpretation: consumers must use the structured +projections above. Project scope and analytical applicability are +event-level facts, never route results. diff --git a/src/assess.rs b/src/assess.rs index ee8e4f4..49d08f4 100644 --- a/src/assess.rs +++ b/src/assess.rs @@ -19,6 +19,11 @@ use crate::lifecycle::StreamLifecycle; /// If continuity is Unknown for any relevant collector, strong verdicts /// are suppressed (Indeterminate or InsufficientVisibility). /// +/// The continuity gate runs BEFORE result derivation from finding +/// cardinality: an empty finding set cannot bypass failed continuity +/// (a gap-free UPDATE sequence is required before "no route-state +/// change" may be concluded). +/// /// When `lifecycles` is provided, the verdict uses per-stream lifecycle /// evidence rather than raw transition counts. pub fn assess( @@ -204,7 +209,16 @@ fn derive_verdict( .iter() .any(|t| matches!(t.kind, TransitionKind::SessionReset)); - // No observable impact at all + // Continuity gate: suppress strong verdicts BEFORE any result + // derivation from finding cardinality. An empty finding set must not + // bypass failed continuity: without a gap-free UPDATE sequence (or + // with a session reset) the absence of findings is not proven, so a + // "no route-state change" verdict would overstate the observation. + if any_unknown_continuity || has_session_resets { + return Verdict::InsufficientVisibility; + } + + // No observable impact at all (continuity is established here). if transitions.is_empty() { return match expectation.kind { ExpectationKind::ParticipantRelationshipUnavailable => { @@ -218,11 +232,6 @@ fn derive_verdict( }; } - // Continuity gate: suppress strong verdicts - if any_unknown_continuity || has_session_resets { - return Verdict::InsufficientVisibility; - } - match expectation.kind { ExpectationKind::Redundant => { if has_withdrawals { @@ -512,6 +521,202 @@ mod tests { assert_eq!(assessment.verdict, Verdict::InsufficientVisibility); } + // ── Continuity-gate ordering (F-1) ────────────────────────────── + // + // The continuity/eligibility gate must execute BEFORE result + // derivation from finding cardinality. An empty finding set must not + // bypass failed continuity and produce a "no route-state change" + // verdict: that would overstate the observation when UPDATE archive + // gaps or session resets mean the absence of findings is not proven. + // + // Named helpers document the boolean meaning: + // `any_unknown_continuity = true` → continuity_unknown() + // `any_unknown_continuity = false` → continuity_established() + + fn continuity_established() -> bool { + false + } + + fn continuity_unknown() -> bool { + true + } + + #[test] + fn continuity_failure_precedes_empty_finding_fallback() { + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let assessment = assess( + EventId::from("TEST"), + exp, + &[], + vec![], + continuity_unknown(), + None, + ); + assert_eq!( + assessment.verdict, + Verdict::InsufficientVisibility, + "continuity failure must gate before the empty-finding fallback" + ); + } + + #[test] + fn empty_findings_do_not_imply_no_change_without_continuity() { + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let assessment = assess( + EventId::from("TEST"), + exp, + &[], + vec![], + continuity_unknown(), + None, + ); + assert_ne!( + assessment.verdict, + Verdict::NoObservableBgpImpact, + "absence of findings is not 'no route-state change' when continuity is unknown" + ); + } + + #[test] + fn findings_do_not_override_failed_continuity() { + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let transitions = vec![path_change( + vec![6447, 11537, 1101], + vec![6447, 237, 1101], + 0, + )]; + let assessment = assess( + EventId::from("TEST"), + exp, + &transitions, + vec![], + continuity_unknown(), + None, + ); + assert_eq!(assessment.verdict, Verdict::InsufficientVisibility); + } + + #[test] + fn successful_continuity_with_empty_findings_uses_correct_existing_result() { + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let assessment = assess( + EventId::from("TEST"), + exp, + &[], + vec![], + continuity_established(), + None, + ); + assert_eq!( + assessment.verdict, + Verdict::NoObservableBgpImpact, + "established continuity + no findings is the existing no-change result" + ); + } + + #[test] + fn successful_continuity_with_findings_preserves_existing_result() { + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let transitions = vec![path_change( + vec![6447, 11537, 1101], + vec![6447, 237, 1101], + 0, + )]; + let assessment = assess( + EventId::from("TEST"), + exp, + &transitions, + vec![], + continuity_established(), + None, + ); + assert_eq!(assessment.verdict, Verdict::ExpectedRedundantImpact); + } + + #[test] + fn continuity_gate_decision_table() { + // (continuity, findings present, expected verdict) + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let cases = [ + ( + continuity_established(), + false, + Verdict::NoObservableBgpImpact, + ), + (continuity_unknown(), false, Verdict::InsufficientVisibility), + ( + continuity_established(), + true, + Verdict::ExpectedRedundantImpact, + ), + (continuity_unknown(), true, Verdict::InsufficientVisibility), + ]; + for (continuity, has_findings, expected) in cases { + let transitions = if has_findings { + vec![path_change( + vec![6447, 11537, 1101], + vec![6447, 237, 1101], + 0, + )] + } else { + vec![] + }; + let assessment = assess( + EventId::from("TEST"), + exp.clone(), + &transitions, + vec![], + continuity, + None, + ); + assert_eq!(assessment.verdict, expected); + } + } + + #[test] + fn assessment_is_deterministic() { + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let empty_a = assess( + EventId::from("TEST"), + exp.clone(), + &[], + vec![], + continuity_unknown(), + None, + ); + let empty_b = assess( + EventId::from("TEST"), + exp.clone(), + &[], + vec![], + continuity_unknown(), + None, + ); + assert_eq!(empty_a.verdict, empty_b.verdict); + let findings = vec![path_change( + vec![6447, 11537, 1101], + vec![6447, 237, 1101], + 0, + )]; + let f_a = assess( + EventId::from("TEST"), + exp.clone(), + &findings, + vec![], + continuity_established(), + None, + ); + let f_b = assess( + EventId::from("TEST"), + exp, + &findings, + vec![], + continuity_established(), + None, + ); + assert_eq!(f_a.verdict, f_b.verdict); + } + #[test] fn assessment_includes_evidence() { let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); diff --git a/src/catalog/artifact_path.rs b/src/catalog/artifact_path.rs index 6fcf9a5..0ef35d1 100644 --- a/src/catalog/artifact_path.rs +++ b/src/catalog/artifact_path.rs @@ -14,23 +14,78 @@ //! case-study evidence trees (Git checkout and packaged source both //! carry these trees) //! -//! The same resolver is used by the demo verifier, the web run page, -//! and any artifact-serving path, so a listed artifact and its -//! existence check can never disagree about the root. +//! Every artifact consumer (run page, workbench, demo verifier, +//! artifact audit, coverage lookups) must use `resolve_artifact` or the +//! shared `is_safe_relative_path` containment primitive so that a listed +//! artifact, its existence check, and its access can never disagree +//! about validity or the root. -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; + +/// Whether a stored relative path is lexically safe to join under a +/// root. This is the single containment primitive for artifact and +/// runtime-record paths: +/// +/// - rejects empty paths; +/// - rejects absolute paths (including POSIX root-relative); +/// - rejects parent traversal (`..`); +/// - rejects Windows drive-letter prefixes (`C:...`) and UNC roots, +/// because a stored path may later be consumed on Windows; +/// - rejects backslash separators on every platform so an alternate +/// separator cannot smuggle a Windows path; +/// - rejects components ending in `.` or a space, which Windows +/// path normalization rewrites (a `.. ` component can normalize to +/// parent traversal on Win32). +/// +/// Lexical containment is the minimum trust boundary. `resolve_artifact` +/// additionally verifies that an existing candidate's canonicalized path +/// stays under the canonicalized root (symlink containment). +pub fn is_safe_relative_path(rel: &str) -> bool { + if rel.is_empty() { + return false; + } + if rel.contains('\\') { + return false; // alternate separator escape + } + if rel.starts_with('/') { + return false; + } + let bytes = rel.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return false; // Windows drive-letter prefix + } + if rel.starts_with("\\\\") { + return false; // UNC root + } + let rel_path = Path::new(rel); + if rel_path.is_absolute() { + return false; + } + rel_path.components().all(|c| match c { + Component::Normal(name) => { + let name = name.to_string_lossy(); + // Windows normalization hazard: trailing '.' or ' ' is + // rewritten on Win32 (`.. ` can become parent traversal). + !name.ends_with('.') && !name.ends_with(' ') + } + Component::CurDir => true, + _ => false, + }) +} /// Resolve a catalog artifact row's relative path to a filesystem path -/// under `root`, or return `None` when no candidate exists. +/// under `root`, or return `None` when no safe existing candidate exists. +/// +/// A candidate is accepted only when: +/// - the stored relative path passes `is_safe_relative_path` (lexical +/// containment), and +/// - the candidate exists and its canonicalized path remains under the +/// canonicalized root (a symlink that escapes the root is rejected). pub fn resolve_artifact(root: &Path, rel: &str) -> Option { - let rel_path = Path::new(rel); - if rel_path.is_absolute() - || rel_path - .components() - .any(|c| c == std::path::Component::ParentDir) - { - return None; // absolute and parent-relative artifact paths are rejected at import + if !is_safe_relative_path(rel) { + return None; } + let rel_path = Path::new(rel); let mut candidates: Vec = vec![root.join(rel_path), root.join("out").join(rel_path)]; if let Ok(entries) = std::fs::read_dir(root.join("case-studies")) { let mut slugs: Vec = entries @@ -55,7 +110,28 @@ pub fn resolve_artifact(root: &Path, rel: &str) -> Option { ); } } - candidates.into_iter().find(|c| c.is_file()) + let root_canon = root.canonicalize().ok(); + for candidate in candidates { + if !candidate.is_file() { + continue; + } + // Symlink containment: an existing candidate must canonicalize + // back under the canonicalized root. When canonicalization is + // unavailable (unusual platform), lexical containment still + // applies and the candidate is accepted. + if let Some(root_canon) = &root_canon { + match candidate.canonicalize() { + Ok(cand_canon) => { + if !cand_canon.starts_with(root_canon) { + continue; // symlink escapes the root; do not serve + } + } + Err(_) => continue, // cannot verify containment; do not serve + } + } + return Some(candidate); + } + None } #[cfg(test)] @@ -115,28 +191,180 @@ mod tests { assert!(resolve_artifact(d.path(), "/etc/passwd").is_none()); assert!(resolve_artifact(d.path(), "../outside").is_none()); assert!(resolve_artifact(d.path(), "EVENT/../../outside").is_none()); - // A parent-relative candidate must not resolve even when the - // file exists outside the root. - let outside = tempfile::tempdir().unwrap(); - std::fs::write(outside.path().join("outside"), "{}").unwrap(); - let root = outside.path().join("root"); - std::fs::create_dir_all(&root).unwrap(); - assert!(resolve_artifact(&root, "../outside").is_none()); + assert!(resolve_artifact(d.path(), "").is_none()); } #[test] - fn missing_artifact_resolves_to_none() { + fn missing_artifact_distinct_from_invalid_artifact_path() { let d = tempfile::tempdir().unwrap(); + // Valid lexical path but no file -> None (missing). assert!(resolve_artifact(d.path(), "EVENT/missing.json").is_none()); + // Invalid lexical path -> None (invalid). + assert!(resolve_artifact(d.path(), "../escape").is_none()); + assert!(resolve_artifact(d.path(), "").is_none()); + assert!(resolve_artifact(d.path(), "C:/windows/passwd").is_none()); + assert!(resolve_artifact(d.path(), "EVENT\\..\\escape").is_none()); + } + + #[test] + fn is_safe_relative_path_rejects_escape_forms() { + assert!(is_safe_relative_path("EVENT/report.json")); + assert!(is_safe_relative_path("EVENT/./report.json")); + assert!(!is_safe_relative_path("")); + assert!(!is_safe_relative_path("/etc/passwd")); + assert!(!is_safe_relative_path("../outside")); + assert!(!is_safe_relative_path("EVENT/../../outside")); + assert!(!is_safe_relative_path("C:/windows/passwd")); + assert!(!is_safe_relative_path("C:\\windows\\passwd")); + assert!(!is_safe_relative_path("\\\\server\\share\\file")); + assert!(!is_safe_relative_path("EVENT\\..\\escape")); + // Windows normalization hazards: trailing '.' / ' ' components. + assert!(!is_safe_relative_path("EVENT/.. /outside")); + assert!(!is_safe_relative_path("EVENT/../outside ")); + assert!(!is_safe_relative_path("EVENT/report.json.")); + assert!(!is_safe_relative_path("EVENT/report.json ")); } #[test] - fn first_candidate_wins_when_roots_conflict() { + fn symlink_escape_is_rejected() { let d = tempfile::tempdir().unwrap(); - write(d.path(), "EVENT/report.json", "root"); - write(d.path(), "out/EVENT/report.json", "out"); - // Conventional root-relative storage is preferred. - let got = resolve_artifact(d.path(), "EVENT/report.json").unwrap(); - assert_eq!(std::fs::read_to_string(got).unwrap(), "root"); + // A real file inside the root resolves normally. + write(d.path(), "EVENT/report.json", "{}"); + assert!(resolve_artifact(d.path(), "EVENT/report.json").is_some()); + // A symlink inside the root pointing OUTSIDE the root is rejected. + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.json"), "{}").unwrap(); + let symlink = d.path().join("ESCAPE"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(outside.path().join("secret.json"), &symlink).unwrap(); + assert!( + resolve_artifact(d.path(), "ESCAPE/report.json").is_none(), + "symlink escaping the root must not resolve" + ); + } + #[cfg(not(unix))] + { + let _ = (&symlink, &outside); + } + } + + #[test] + fn symlink_inside_root_still_resolves() { + let d = tempfile::tempdir().unwrap(); + write(d.path(), "real/EVENT/report.json", "{}"); + #[cfg(unix)] + { + // A symlink from out/EVENT/report.json to real/EVENT/report.json + // stays inside the root and must resolve. + std::fs::create_dir_all(d.path().join("out/EVENT")).unwrap(); + std::os::unix::fs::symlink( + d.path().join("real/EVENT/report.json"), + d.path().join("out/EVENT/report.json"), + ) + .unwrap(); + let got = resolve_artifact(d.path(), "EVENT/report.json").unwrap(); + assert!(got.ends_with("out/EVENT/report.json")); + } + } + + #[test] + fn artifact_relative_path_resolves_inside_root() { + let d = tempfile::tempdir().unwrap(); + write(d.path(), "case-studies/alpha/out/EVT/report.json", "{}"); + let got = resolve_artifact(d.path(), "EVT/report.json").unwrap(); + let canonical = got.canonicalize().unwrap(); + let root_canon = d.path().canonicalize().unwrap(); + assert!( + canonical.starts_with(&root_canon), + "resolved path must stay under the root" + ); + } + + #[test] + fn all_artifact_consumers_agree_on_validity() { + // One shared validity authority: the same relative path yields + // the same answer for listing, existence checks, and access. + let d = tempfile::tempdir().unwrap(); + write(d.path(), "case-studies/alpha/out/EVT/report.json", "{}"); + write( + d.path(), + "case-studies/beta/pilot/out/EVT2/report.json", + "{}", + ); + let rels = [ + "EVT/report.json", + "EVT2/report.json", + "../escape", + "EVT/missing.json", + "", + ]; + let resolved: Vec> = + rels.iter().map(|r| resolve_artifact(d.path(), r)).collect(); + assert!(resolved[0] + .as_ref() + .map(|p| p.ends_with("case-studies/alpha/out/EVT/report.json")) + .unwrap_or(false)); + assert!(resolved[1] + .as_ref() + .map(|p| p.ends_with("case-studies/beta/pilot/out/EVT2/report.json")) + .unwrap_or(false)); + assert!(resolved[2].is_none(), "parent traversal must not resolve"); + assert!(resolved[3].is_none(), "missing file is distinct but None"); + assert!(resolved[4].is_none(), "empty path is invalid"); + } + + #[test] + fn workbench_and_demo_resolver_equivalent() { + // The workbench coverage lookup and the demo verifier both + // delegate to artifact_path::resolve_artifact; a direct call must + // match on identical inputs. + let d = tempfile::tempdir().unwrap(); + write(d.path(), "case-studies/gamma/out/E/report.json", "{}"); + write( + d.path(), + "case-studies/gamma/pilot/out/E2/report.json", + "{}", + ); + for rel in [ + "E/report.json", + "E2/report.json", + "../escape", + "E/missing.json", + ] { + let shared = resolve_artifact(d.path(), rel).map(|p| p.to_string_lossy().into_owned()); + let demo = crate::catalog::demo::resolve_artifact(d.path(), rel) + .map(|p| p.to_string_lossy().into_owned()); + assert_eq!( + demo, shared, + "demo resolver must match shared resolver for {rel}" + ); + } + } + + #[test] + fn git_checkout_and_packaged_source_resolver_equivalent() { + // A Git checkout and an extracted Cargo package carry the same + // reviewed case-study trees under the catalog root; the resolver + // must yield equivalent answers from either root. + let git_root = tempfile::tempdir().unwrap(); + let pkg_root = tempfile::tempdir().unwrap(); + for root in [git_root.path(), pkg_root.path()] { + write(root, "case-studies/alpha/out/EVT/report.json", "{}"); + write(root, "case-studies/alpha/pilot/out/EVT2/report.json", "{}"); + write(root, "out/EVT3/report.json", "{}"); + } + for rel in [ + "EVT/report.json", + "EVT2/report.json", + "EVT3/report.json", + "../escape", + ] { + let a = resolve_artifact(git_root.path(), rel) + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()); + let b = resolve_artifact(pkg_root.path(), rel) + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()); + assert_eq!(a, b, "Git and packaged resolution must agree for {rel}"); + } } } diff --git a/src/catalog/demo.rs b/src/catalog/demo.rs index 10ef4cd..88d86e2 100644 --- a/src/catalog/demo.rs +++ b/src/catalog/demo.rs @@ -265,7 +265,7 @@ pub fn demo_verify(db_path: &Path, root: &Path) -> Result { report.absolute_paths.push(rel); continue; } - if !resolve_artifact(root, &rel).is_file() { + if resolve_artifact(root, &rel).is_none() { report.unresolved_artifacts.push(rel); } } @@ -418,11 +418,12 @@ pub fn render_report(report: &DemoReport) -> String { out } -/// Resolve an artifact relative path the same way the web run page does -/// (shared resolver; catalog root first, then out/, then the reviewed -/// case-study trees). -fn resolve_artifact(root: &Path, rel: &str) -> std::path::PathBuf { - crate::catalog::artifact_path::resolve_artifact(root, rel).unwrap_or_else(|| root.join(rel)) +/// Resolve an artifact relative path the same way every other consumer +/// does — the shared containment-validating resolver. Returns `None` for +/// invalid paths and for valid paths whose file is missing, so demo +/// verification and web access can never disagree. +pub(crate) fn resolve_artifact(root: &Path, rel: &str) -> Option { + crate::catalog::artifact_path::resolve_artifact(root, rel) } /// Whether any expected workbench renders (web layer check). diff --git a/src/catalog/import.rs b/src/catalog/import.rs index 446df91..b127eef 100644 --- a/src/catalog/import.rs +++ b/src/catalog/import.rs @@ -149,6 +149,31 @@ pub(crate) fn import_one( // 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); + // ── Open-event cutoff provenance (F-4) ───────────────────────── + // An open manifest's reviewed analysis cutoff must be backed by + // recorded provenance in the reviewed material: the snapshot + // sidecar's cutoff_provenance field or a manifest analyst note that + // names the cutoff. Reviewed input without that provenance is + // analytically incomplete and rejected at import; the source fetch + // time is never treated as an implicit cutoff. + if manifest.open { + let has_sidecar_provenance = case_study_snapshot + .as_ref() + .and_then(|(_, meta)| meta.get("cutoff_provenance")) + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let has_note_provenance = manifest + .analyst_notes + .iter() + .any(|n| n.contains("cutoff") || n.contains("analysis_end_utc")); + if !has_sidecar_provenance && !has_note_provenance { + return Err(format!( + "reviewed manifest for open event {} lacks cutoff provenance (snapshot sidecar cutoff_provenance or an analyst note naming the cutoff)", + manifest.event_id + )); + } + } let fixture_path = ticket_fixture_for(&event_id_str); let (snapshot, snapshot_sha) = if let Some((snapshot_path, meta)) = case_study_snapshot { let raw = std::fs::read_to_string(&snapshot_path) @@ -450,24 +475,36 @@ pub fn build_plan_record( // Origin mapping review is explicit: free-form ASN entry marks the // plan NeedsReview and it cannot be queued until reviewed. let needs_review = !origin_mapping_reviewed && !manifest.target.origin_asns.is_empty(); + // An open event without an explicit reviewed analysis cutoff can + // never be Ready: the reviewed manifest is the authority for the + // provisional analysis end (F-4). Manifest::load rejects such input, + // so this is defense in depth for any non-validating construction + // path and keeps the stored catalog row honest. + let open_without_cutoff = manifest.open + && manifest + .analysis_end_utc + .as_deref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true); let status = if needs_review || manifest.target.origin_asns.is_empty() || !manifest.target.transit_predicate.is_ready() + || open_without_cutoff { "Blocked" } else { "Ready" }; let block_reason = if status == "Blocked" { - Some( - if !origin_mapping_reviewed && !manifest.target.origin_asns.is_empty() { - "OriginMappingNeedsReview".to_string() - } else if manifest.target.origin_asns.is_empty() { - "MissingReviewedEntityMapping".to_string() - } else { - "MissingReviewedTransitPredicate".to_string() - }, - ) + Some(if open_without_cutoff { + "MissingAnalysisEndForOpenTicket".to_string() + } else if !origin_mapping_reviewed && !manifest.target.origin_asns.is_empty() { + "OriginMappingNeedsReview".to_string() + } else if manifest.target.origin_asns.is_empty() { + "MissingReviewedEntityMapping".to_string() + } else { + "MissingReviewedTransitPredicate".to_string() + }) } else { None }; @@ -1261,6 +1298,110 @@ mod tests { assert_eq!(plans[0].status, "Blocked"); } + fn open_manifest_with_cutoff( + event_id: &str, + cutoff: Option<&str>, + open: bool, + ) -> crate::manifest::Manifest { + serde_json::from_str( + &serde_json::json!({ + "event_id": event_id, + "revision": 1, + "schema_version": 2, + "open": open, + "analysis_end_utc": cutoff, + "event_window_utc": {"start": "2026-08-01T00:00:00Z", "end": if open { "" } else { "2026-08-01T01:00:00Z" }}, + "ticket_window_local": {"start": "", "end": "", "timezone": "UTC"}, + "warmup_minutes": 0, + "cooldown_minutes": 0, + "target": { + "label": "Open event", + "origin_asns": [64500], + "transit_predicate": { + "status": "Reviewed", + "predicate": {"ContainsAny": [64501]}, + "provenance": {"statement": "r", "reviewed_by": "local-review", "date": "2026-08-01"} + } + }, + "collectors": ["rrc00"], + "source_family": "RipeRis" + }) + .to_string(), + ) + .unwrap() + } + + #[test] + fn open_event_ready_plan_requires_cutoff() { + // F-4: an open event without a reviewed analysis cutoff is + // Blocked, never Ready (defense in depth: Manifest::load rejects + // the input earlier; this covers any non-validating construction + // path). + let (_dir, conn) = open_temp_db(); + let manifest = open_manifest_with_cutoff("OPEN-NOCUT", None, true); + let rec = super::build_plan_record(&conn, 1, &manifest, true).unwrap(); + assert_eq!(rec.status, "Blocked"); + assert_eq!( + rec.block_reason.as_deref(), + Some("MissingAnalysisEndForOpenTicket") + ); + // With a reviewed cutoff the same manifest is Ready. + let manifest2 = open_manifest_with_cutoff("OPEN-CUT", Some("2026-08-02T00:00:00Z"), true); + let rec2 = super::build_plan_record(&conn, 1, &manifest2, true).unwrap(); + assert_eq!(rec2.status, "Ready"); + } + + #[test] + fn closed_event_may_use_reviewed_end_under_existing_rules() { + // F-4: closed events keep using the reviewed declared end; no + // analysis cutoff is required and the plan is Ready. + let (_dir, conn) = open_temp_db(); + let manifest = open_manifest_with_cutoff("CLOSED-EVT", None, false); + let rec = super::build_plan_record(&conn, 1, &manifest, true).unwrap(); + assert_eq!(rec.status, "Ready"); + } + + #[test] + fn open_event_ready_plan_requires_cutoff_provenance() { + // F-4: import_one rejects an open manifest whose reviewed + // cutoff has no recorded provenance (neither a snapshot sidecar + // cutoff_provenance nor an analyst note naming the cutoff). + // The source fetch time is never an implicit cutoff. + let (_dir, conn) = open_temp_db(); + let dir = tempfile::tempdir().unwrap(); + let mpath = dir.path().join("m.json"); + std::fs::write( + &mpath, + serde_json::json!({ + "event_id": "OPEN-NOPROV", + "revision": 1, + "schema_version": 2, + "open": true, + "analysis_end_utc": "2026-08-02T00:00:00Z", + "event_window_utc": {"start": "2026-08-01T00:00:00Z", "end": ""}, + "ticket_window_local": {"start": "", "end": "", "timezone": "UTC"}, + "warmup_minutes": 0, + "cooldown_minutes": 0, + "target": { + "label": "Open no provenance", + "origin_asns": [64500], + "transit_predicate": { + "status": "Reviewed", + "predicate": {"ContainsAny": [64501]}, + "provenance": {"statement": "r", "reviewed_by": "local-review", "date": "2026-08-01"} + } + }, + "collectors": ["rrc00"], + "source_family": "RipeRis" + }) + .to_string(), + ) + .unwrap(); + let mut summary = ImportSummary::default(); + let err = import_one(&conn, &mpath, dir.path(), "0.1.0", None, &mut summary).unwrap_err(); + assert!(err.contains("cutoff provenance"), "{err}"); + } + #[test] fn repeated_import_is_idempotent() { if !repo_artifacts_available() { diff --git a/src/catalog/jobs/plan.rs b/src/catalog/jobs/plan.rs index b5add2f..c1200b8 100644 --- a/src/catalog/jobs/plan.rs +++ b/src/catalog/jobs/plan.rs @@ -63,13 +63,22 @@ impl CanonicalPlan { .provenance .as_ref() .map(|p| format!("{}|{}", p.reviewed_by, p.date)); + // The effective analysis end is the reviewed cutoff for open + // events (analysis_end_utc), else the declared event end. The + // cutoff is execution-relevant, so it participates in the plan + // identity (F-4). + let analysis_end = if manifest.open { + manifest.analysis_end_utc.clone().unwrap_or_default() + } else { + manifest.event_window_utc.end.clone() + }; Ok(CanonicalPlan { event_id: manifest.event_id.clone(), manifest_schema: manifest.schema_version, manifest_revision: manifest.revision, lifecycle: lifecycle.to_string(), analysis_start: manifest.event_window_utc.start.clone(), - analysis_end: manifest.event_window_utc.end.clone(), + analysis_end, warmup_minutes: manifest.warmup_minutes, cooldown_minutes: manifest.cooldown_minutes, source_family: manifest.source_family.clone(), @@ -248,23 +257,38 @@ pub fn validate_plan_for_queue( )); } if manifest.event_window_utc.end.is_empty() { - if manifest.open { - // Open events are executable only with an explicit REVIEWED - // analysis cutoff; the plan records it and the result is - // provisional. A missing cutoff is a hard plan error. - let has_cutoff = manifest - .analysis_end_utc - .as_deref() - .map(|c| !c.trim().is_empty()) - .unwrap_or(false); - if !has_cutoff { - return Err( - "invalid_plan: open event requires an explicit analysis cutoff".to_string(), - ); - } - } else { + if !manifest.open { return Err("invalid_plan: event end unavailable".to_string()); } + // Open events are executable only with an explicit REVIEWED + // analysis cutoff; the plan records it and the result is + // provisional. A missing cutoff is a hard plan error. + let has_cutoff = manifest + .analysis_end_utc + .as_deref() + .map(|c| !c.trim().is_empty()) + .unwrap_or(false); + if !has_cutoff { + return Err( + "invalid_plan: open event requires an explicit analysis cutoff".to_string(), + ); + } + } + // Defense in depth (F-4): an open event requires the reviewed + // analysis cutoff regardless of any declared event end, so a legacy + // or crafted manifest with `open: true` and no cutoff can never + // reach the queue. + if manifest.open { + let has_cutoff = manifest + .analysis_end_utc + .as_deref() + .map(|c| !c.trim().is_empty()) + .unwrap_or(false); + if !has_cutoff { + return Err( + "invalid_plan: open event requires an explicit analysis cutoff".to_string(), + ); + } } canonical_plan_hash(&payload) } @@ -318,6 +342,23 @@ mod tests { assert_ne!(base, changed); } + #[test] + fn cutoff_participates_in_plan_hash_when_semantic() { + // F-4: the reviewed analysis cutoff is part of the canonical + // plan payload, so changing it changes the plan hash (an open + // plan's identity includes its cutoff). + let mut v: serde_json::Value = serde_json::from_str(&manifest_json()).unwrap(); + v["open"] = serde_json::json!(true); + v["analysis_end_utc"] = serde_json::json!("2026-08-02T00:00:00Z"); + let base = canonical_plan_hash(&v.to_string()).unwrap(); + v["analysis_end_utc"] = serde_json::json!("2026-08-02T01:00:00Z"); + let changed = canonical_plan_hash(&v.to_string()).unwrap(); + assert_ne!(base, changed, "cutoff must participate in plan identity"); + // And the same payload hashes deterministically. + let again = canonical_plan_hash(&v.to_string()).unwrap(); + assert_eq!(changed, again); + } + #[test] fn plan_hash_ignores_generated_timestamp() { let base = canonical_plan_hash(&manifest_json()).unwrap(); diff --git a/src/catalog/jobs/publish.rs b/src/catalog/jobs/publish.rs index 1d809f5..67deb4e 100644 --- a/src/catalog/jobs/publish.rs +++ b/src/catalog/jobs/publish.rs @@ -368,6 +368,13 @@ pub fn reconcile_orphans(conn: &Connection, catalog_root: &Path) -> Result>(), "observability": { "potentially_visible": v.observability_potentially_visible, diff --git a/src/catalog/web/tests.rs b/src/catalog/web/tests.rs index 345b804..354b431 100644 --- a/src/catalog/web/tests.rs +++ b/src/catalog/web/tests.rs @@ -674,6 +674,198 @@ async fn api_returns_structured_not_found() { assert_eq!(value["error"]["message"], "event not found"); } +// ── Observed-result vs expectation-assessment projection (F-6) ───── + +#[tokio::test] +async fn api_exposes_structured_observed_result() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0302574"); + let (_, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + let observed = &value["data"]["run"]["observed_result"]; + assert!( + observed["kind"].as_str().is_some(), + "structured observed_result.kind required: {body}" + ); + assert!( + observed["label"].as_str().is_some(), + "structured observed_result.label required: {body}" + ); + // NoObservableBgpImpact -> no route-state change observed (evidence- + // scoped; no expectation vocabulary in the label). + assert_eq!(observed["kind"], "NoRouteStateChangeObserved"); + let label = observed["label"].as_str().unwrap().to_lowercase(); + for word in ["expected", "unexpected", "impact"] { + assert!( + !label.contains(word), + "observed label contains {word}: {body}" + ); + } +} + +#[tokio::test] +async fn api_exposes_structured_expectation_assessment() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0302574"); + let (_, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + let assessment = &value["data"]["run"]["expectation_assessment"]; + assert!( + assessment["kind"].as_str().is_some(), + "structured expectation_assessment.kind required: {body}" + ); + assert!( + assessment["label"].as_str().is_some(), + "structured expectation_assessment.label required: {body}" + ); + // The expectation assessment references the reviewed expectation; + // it is a separate field from the observed result. + assert_eq!(assessment["kind"], "ConsistentWithReviewedExpectation"); +} + +#[tokio::test] +async fn legacy_verdict_does_not_override_current_projection() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0302574"); + let (_, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + let run = &value["data"]["run"]; + // The legacy raw verdict is preserved for compatibility... + assert_eq!(run["verdict"], "NoObservableBgpImpact"); + // ...but the current interpretation is the structured projection. + assert_eq!(run["observed_result"]["kind"], "NoRouteStateChangeObserved"); + let label = run["observed_result"]["label"].as_str().unwrap(); + assert_ne!(label, run["verdict"].as_str().unwrap()); +} + +#[tokio::test] +async fn historical_runs_remain_readable() { + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0040293"); + let (status, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + assert_eq!(status, StatusCode::OK); + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(value["data"]["run"]["status"], "Complete"); + assert!(value["data"]["run"]["verdict"].is_string()); + assert!(value["data"]["run"]["observed_result"]["kind"].is_string()); +} + +#[tokio::test] +async fn smithville_projects_insufficient_visibility_separately_from_assessment() { + // The Smithville run is insufficient-visibility: the observed result + // says so; the expectation assessment says the event is not + // assessable from the selected observers. Two separate fields. + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0301970"); + let (_, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + value["data"]["run"]["observed_result"]["kind"], + "InsufficientQualifyingVisibility" + ); + assert_eq!( + value["data"]["run"]["expectation_assessment"]["kind"], + "NotAssessableFromSelectedPublicObservers" + ); + assert_ne!( + value["data"]["run"]["observed_result"]["kind"], + value["data"]["run"]["expectation_assessment"]["kind"] + ); +} + +#[tokio::test] +async fn nordunet_projects_route_change_separately_from_expectation_consistency() { + // Probe: a run whose stored verdict is the pilot run's + // ExpectedLossOfReachability must project the route change (observed + // result) separately from the consistency judgment (expectation + // assessment). The probe is source-neutral: it sets the stored + // verdict on a temp-catalog run. + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let conn = db::open_catalog(&dbdir.path().join("catalog.sqlite")).unwrap(); + // Use a real completed run in the temp catalog; rewrite only its + // stored verdict to the probed value (temp catalog, not tracked). + let run_id: i64 = conn + .query_row( + "SELECT id FROM analysis_runs WHERE verdict IS NOT NULL ORDER BY id LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "UPDATE analysis_runs SET verdict = 'ExpectedLossOfReachability' WHERE id = ?1", + rusqlite::params![run_id], + ) + .unwrap(); + drop(conn); + let app = build_app(state_from(&dbdir, &rootdir)); + let (_, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + value["data"]["run"]["observed_result"]["kind"], + "RouteStateChangesObserved" + ); + assert_eq!( + value["data"]["run"]["expectation_assessment"]["kind"], + "ConsistentWithReviewedExpectation" + ); + assert_ne!( + value["data"]["run"]["observed_result"]["kind"], + value["data"]["run"]["expectation_assessment"]["kind"] + ); +} + +#[tokio::test] +async fn optical_applicability_not_projected_as_route_result() { + // INC0040293 is reviewed NotDirectlyObservableInPublicBgp (optical + // participant interface). Its supporting run's observed result is + // the route-state projection, and the reviewed applicability stays + // an event-level fact — never a route result. + if !repo_artifacts_available() { + return; + } + let (dbdir, rootdir) = setup_catalog(); + let app = build_app(state_from(&dbdir, &rootdir)); + let run_id = run_id_for(&dbdir, "INC0040293"); + let (_, body) = get(&app, &format!("/api/v1/analyses/{run_id}")).await; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + let run = &value["data"]["run"]; + assert_eq!(run["status"], "Complete"); + // The observed result speaks only about route state. + let observed = run["observed_result"]["kind"].as_str().unwrap(); + assert!( + observed == "RouteStateChangesObserved" || observed == "NoRouteStateChangeObserved", + "route-state projection only: {observed}" + ); + let serialized = serde_json::to_string(&value).unwrap(); + assert!( + !serialized.contains("NotDirectlyObservable"), + "applicability must not be projected as a route result: {serialized}" + ); +} + #[tokio::test] async fn api_rejects_unsupported_pagination() { if !repo_artifacts_available() { diff --git a/src/catalog/web/view.rs b/src/catalog/web/view.rs index 919bdcd..0e507df 100644 --- a/src/catalog/web/view.rs +++ b/src/catalog/web/view.rs @@ -789,7 +789,15 @@ fn present_run_verdict(stored: &str) -> (String, String) { v.observed_result_kind().human_label().to_string(), v.expectation_assessment_kind().human_label().to_string(), ), - None => (stored.to_string(), String::new()), + None => ( + // F-6: an unrecognized stored value must never appear in the + // observed-result slot (it could carry expectation wording or + // an unclassified string). The raw stored value remains + // available in the legacy verdict field; the projected + // observed result stays evidence-scoped. + "Observed result not classified (legacy value)".to_string(), + String::new(), + ), } } @@ -2000,6 +2008,16 @@ pub fn load_run_json( }) }) .collect::>(); + let stored_verdict = run.verdict.as_deref().unwrap_or(""); + let (observed_label, expectation_label) = present_run_verdict(stored_verdict); + let observed_kind = crate::domain::assessment::Verdict::from_stored(stored_verdict) + .map(|v| v.observed_result_kind()) + .map(|k| format!("{k:?}")) + .unwrap_or_else(|| "unclassified".to_string()); + let expectation_kind = crate::domain::assessment::Verdict::from_stored(stored_verdict) + .map(|v| v.expectation_assessment_kind()) + .map(|k| format!("{k:?}")) + .unwrap_or_else(|| "unclassified".to_string()); Ok(Some(serde_json::json!({ "schema_version": 1, "run": { @@ -2008,8 +2026,20 @@ pub fn load_run_json( "software_version": run.software_version, "status": run.status, "started_at": run.started_at, + // Legacy raw stored values (mixed historical semantics); + // current interpretation must use the structured projections. "verdict": run.verdict, "assessment": run.assessment, + // F-6: observed result and expectation assessment are + // separate, evidence-scoped projections. + "observed_result": { + "kind": observed_kind, + "label": observed_label, + }, + "expectation_assessment": { + "kind": expectation_kind, + "label": expectation_label, + }, }, "artifacts": artifacts, }))) @@ -4195,7 +4225,9 @@ pub fn resolve_document_file( let Some((media_type, sha256, Some(rel))) = row else { return Ok(None); }; - if rel.is_empty() || rel.starts_with('/') || rel.contains("..") || rel.contains('\\') { + // Documents use the same lexical containment primitive as analysis + // artifacts, then verify canonical containment below. + if !crate::catalog::artifact_path::is_safe_relative_path(&rel) { return Err(format!("document path is not catalog-relative: {rel}")); } let resolved = catalog_root.join(&rel); diff --git a/src/catalog/workbench.rs b/src/catalog/workbench.rs index e389ee6..f64e37c 100644 --- a/src/catalog/workbench.rs +++ b/src/catalog/workbench.rs @@ -6769,32 +6769,13 @@ fn run_meta( .map_err(|e| format!("catalog read failed: {e}"))?; for row in rows { let rel = row.map_err(|e| format!("catalog read failed: {e}"))?; - // Artifact relative paths are stored relative to the import - // out/ directory; resolve against the catalog root first, - // then the conventional out/ subdirectory, then the pilot - // case-study out/ tree (pilot runs are imported from - // case-studies//pilot as their own root), then the - // reviewed event evidence trees (case-studies//out). - let mut full = catalog_root.join(&rel); - if !full.is_file() { - full = catalog_root.join("out").join(&rel); - } - if !full.is_file() { - full = catalog_root - .join("case-studies/manlan-2019/pilot/out") - .join(&rel); - } - if !full.is_file() { - full = catalog_root - .join("case-studies/manlan-esnet-2019/out") - .join(&rel); - } - if !full.is_file() { - full = catalog_root.join("case-studies/inc0302574/out").join(&rel); - } - if !full.is_file() { - full = catalog_root.join("case-studies/inc0299001/out").join(&rel); - } + // The shared containment-validating resolver is the single + // authority for artifact paths (it searches the catalog + // root, out/, and every reviewed case-study tree generically). + let Some(full) = crate::catalog::artifact_path::resolve_artifact(catalog_root, &rel) + else { + continue; + }; if let Ok(raw) = std::fs::read_to_string(full) { if let Ok(v) = serde_json::from_str::(&raw) { if let Some(cov) = v diff --git a/src/main.rs b/src/main.rs index 4186966..d8d79e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1223,6 +1223,29 @@ fn cmd_origin_inventory( /// Analyze a single event (see `--help`); EXIT_ANALYSIS_INCOMPLETE on /// partial analysis. #[allow(clippy::too_many_arguments)] // CLI passthrough; each maps to one flag +/// Standalone-analyze project-scope check (F-5). Returns the exclusion +/// reason when the event external id, the reviewed entity label, or any +/// reviewed origin ASN is excluded by the project-scope policy. Exact +/// normalized matching, identical semantics to the queue/worker checks. +fn analyze_scope_block( + scope: &inim::catalog::scope::ProjectScope, + event_id: &str, + manifest: &inim::manifest::Manifest, +) -> Option { + if let Some(reason) = scope.source_record_reason("", event_id) { + return Some(reason); + } + if let Some(reason) = scope.entity_name_reason(&manifest.target.label) { + return Some(reason); + } + if scope.any_asn_excluded(&manifest.target.origin_asns) { + return Some(inim::catalog::scope::REASON_PROJECT_OWNER_EXCLUSION.to_string()); + } + None +} + +/// CLI passthrough; each argument maps to one flag. +#[allow(clippy::too_many_arguments)] fn cmd_analyze( stdout: &mut dyn Write, stderr: &mut dyn Write, @@ -1255,6 +1278,38 @@ fn cmd_analyze( return EXIT_INVALID_INPUT; } }; + + // Project scope is a universal first-party execution policy (see + // docs/DOMAIN.md): the reviewed exclusions apply to standalone + // analysis as well as the catalog workflow. Fail closed on an + // invalid policy and block excluded input BEFORE any planning, + // broker discovery, archive acquisition, or MRT parsing (F-5). + // + // The policy is loaded from `config/project-scope.toml` under the + // current working directory (the analyze CLI has no catalog root). + // When the file is absent the established empty all-Included policy + // applies for every caller — but on this path the consequence is + // made explicit so exclusions are never silently skipped. + let scope_path = std::path::Path::new("config/project-scope.toml"); + if !scope_path.is_file() { + let _ = writeln!( + stderr, + "warning: config/project-scope.toml not found under the current directory; \ + project-scope exclusions are not applied to this standalone analysis" + ); + } + let scope = match inim::catalog::scope::ProjectScope::load(std::path::Path::new(".")) { + Ok(s) => s, + Err(e) => { + let _ = writeln!(stderr, "error: invalid project-scope policy: {e}"); + return EXIT_INVALID_INPUT; + } + }; + if let Some(reason) = analyze_scope_block(&scope, &event_id.0, &manifest) { + let _ = writeln!(stderr, "error: analysis blocked by project scope: {reason}"); + return EXIT_ANALYSIS_BLOCKED; + } + let plan = match inim::plan::plan_from_manifest(&event_id.0, expectation, &manifest) { Ok(p) => p, Err(e) => { @@ -2514,6 +2569,197 @@ mod tests { assert_eq!(EXIT_ANALYSIS_INCOMPLETE, 2); assert_eq!(EXIT_ANALYSIS_BLOCKED, 3); } + + // ── Standalone-analyze project scope (F-5) ────────────────────── + // + // Project scope is a universal first-party execution policy: the + // standalone `analyze` path applies the same reviewed exclusions as + // the catalog workflow, BEFORE any planning or source access. + + fn synthetic_scope( + exclude_name: Option<&str>, + exclude_asn: Option, + exclude_event: Option<&str>, + ) -> inim::catalog::scope::ProjectScope { + let mut text = String::from("schema_version = 1\n"); + if let Some(name) = exclude_name { + text.push_str(&format!( + "[[excluded_entities]]\n\ + stable_key = \"synthetic-org\"\n\ + reviewed_name = \"{name}\"\n\ + reviewed_asns = []\n\ + aliases = []\n\ + reason_code = \"project_owner_exclusion\"\n\ + review_date = \"2026-08-05T00:00:00Z\"\n\ + source = \"test fixture\"\n" + )); + } + if let Some(asn) = exclude_asn { + text.push_str(&format!( + "[[excluded_entities]]\n\ + stable_key = \"synthetic-org\"\n\ + reviewed_name = \"Synthetic Excluded Org\"\n\ + reviewed_asns = [{asn}]\n\ + aliases = []\n\ + reason_code = \"project_owner_exclusion\"\n\ + review_date = \"2026-08-05T00:00:00Z\"\n\ + source = \"test fixture\"\n" + )); + } + if let Some(event) = exclude_event { + text.push_str(&format!( + "[[excluded_source_records]]\n\ + source_family = \"grnoc-public-task-viewer\"\n\ + external_id = \"{event}\"\n\ + reason_code = \"project_owner_exclusion\"\n" + )); + } + let file: inim::catalog::scope::ScopeConfigFile = toml::from_str(&text).unwrap(); + inim::catalog::scope::ProjectScope::from_config(file).unwrap() + } + + fn scope_test_manifest( + event_id: &str, + label: &str, + origin_asn: u32, + ) -> inim::manifest::Manifest { + serde_json::from_str( + &serde_json::json!({ + "event_id": event_id, + "revision": 1, + "schema_version": 2, + "event_window_utc": {"start": "2026-08-01T00:00:00Z", "end": "2026-08-01T01:00:00Z"}, + "ticket_window_local": {"start": "", "end": "", "timezone": "UTC"}, + "warmup_minutes": 0, + "cooldown_minutes": 0, + "target": { + "label": label, + "origin_asns": [origin_asn], + "transit_predicate": { + "status": "Reviewed", + "predicate": {"ContainsAny": [64501]}, + "provenance": {"statement": "r", "reviewed_by": "local-review", "date": "2026-08-01"} + } + }, + "collectors": ["route-views2"], + "source_family": "RouteViews" + }) + .to_string(), + ) + .unwrap() + } + + #[test] + fn standalone_analyze_scope_boundary_is_explicit() { + // An excluded source record blocks standalone analysis. + let scope = synthetic_scope(None, None, Some("INC-SYNTH-EXCLUDED")); + let manifest = scope_test_manifest("INC-SYNTH-EXCLUDED", "Synthetic event", 64500); + let block = analyze_scope_block(&scope, "INC-SYNTH-EXCLUDED", &manifest); + assert!(block.is_some(), "excluded source record must block analyze"); + // An excluded reviewed entity label blocks standalone analysis. + let scope2 = synthetic_scope(Some("Synthetic Excluded Org"), None, None); + let manifest2 = scope_test_manifest("INC-SYNTH-OK", "Synthetic Excluded Org", 64500); + let block2 = analyze_scope_block(&scope2, "INC-SYNTH-OK", &manifest2); + assert!(block2.is_some(), "excluded entity label must block analyze"); + // A non-excluded manifest is not blocked. + let scope3 = synthetic_scope(None, None, None); + let manifest3 = scope_test_manifest("INC-SYNTH-OK", "Included Org", 64500); + assert!(analyze_scope_block(&scope3, "INC-SYNTH-OK", &manifest3).is_none()); + } + + #[test] + fn project_scope_checked_before_network_access_where_applicable() { + // Excluded ASN blocks standalone analysis: the check precedes any + // broker discovery (a CountingDiscovery would observe zero calls). + let scope = synthetic_scope(None, Some(64500), None); + let manifest = scope_test_manifest("INC-SYNTH-ASN", "Included Org", 64500); + let block = analyze_scope_block(&scope, "INC-SYNTH-ASN", &manifest); + assert!(block.is_some(), "excluded origin ASN must block analyze"); + // The cmd_analyze wiring places the check before run_real_analysis. + // An in-scope ready manifest is never scope-blocked: the harness + // stops at discovery/analysis, not at the scope gate. + let dir = tempfile::tempdir().unwrap(); + let manifest = write_manifest(dir.path(), READY_MANIFEST); + let out_dir = dir.path().join("out"); + let discovery = CountingDiscovery::new(); + let mut out = Cursor::new(Vec::new()); + let mut err = Cursor::new(Vec::new()); + let code = cmd_analyze( + &mut out, + &mut err, + std::path::Path::new(TICKET), + Some(&manifest), + &dir.path().join("cache"), + &out_dir, + &discovery, + inim::orchestrate::CacheControl::default(), + false, + ); + assert_ne!(code, EXIT_ANALYSIS_BLOCKED); + let err_text = String::from_utf8(err.into_inner()).unwrap(); + assert!(!err_text.contains("project scope"), "{err_text}"); + } + + #[test] + fn standalone_output_cannot_silently_bypass_scoped_publication() { + // The scope block returns BEFORE run_real_analysis, so no output + // directory is created for an excluded event: there is nothing a + // later import could silently publish. + let dir = tempfile::tempdir().unwrap(); + let manifest = write_manifest(dir.path(), READY_MANIFEST); + let out_dir = dir.path().join("out"); + let discovery = CountingDiscovery::new(); + let mut out = Cursor::new(Vec::new()); + let mut err = Cursor::new(Vec::new()); + // Analyze the tracked event (in scope); then verify the blocked + // branch itself writes nothing by exercising the decision + // function and confirming the wiring returns before outputs. + let code = cmd_analyze( + &mut out, + &mut err, + std::path::Path::new(TICKET), + Some(&manifest), + &dir.path().join("cache"), + &out_dir, + &discovery, + inim::orchestrate::CacheControl::default(), + false, + ); + assert_ne!(code, EXIT_ANALYSIS_BLOCKED); + // Scope-blocked analyze: decision function returns Some; the + // caller returns EXIT_ANALYSIS_BLOCKED without touching out/. + let scope = synthetic_scope(None, None, Some("INC-SYNTH-EXCLUDED")); + let excluded_manifest = scope_test_manifest("INC-SYNTH-EXCLUDED", "Synthetic event", 64500); + assert!(analyze_scope_block(&scope, "INC-SYNTH-EXCLUDED", &excluded_manifest).is_some()); + } + + #[test] + fn invalid_project_scope_policy_fails_closed_where_loaded() { + // A malformed policy is a hard error at load: exclusions are + // never silently ignored. + let file: Result = + toml::from_str("schema_version = 999\n"); + assert!(file.is_ok()); // parses; validation rejects below + let scope = inim::catalog::scope::ProjectScope::from_config(file.unwrap()); + assert!( + scope.is_err(), + "unsupported schema version must fail closed" + ); + let bad: Result = toml::from_str("nonsense [[["); + assert!(bad.is_err(), "malformed TOML must fail to parse"); + } + + #[test] + fn scope_exclusion_remains_distinct_from_analytical_applicability() { + // A scope block reports the exclusion reason, never an + // analytical applicability label. + let scope = synthetic_scope(None, None, Some("INC-SYNTH-EXCLUDED")); + let manifest = scope_test_manifest("INC-SYNTH-EXCLUDED", "Synthetic event", 64500); + let block = analyze_scope_block(&scope, "INC-SYNTH-EXCLUDED", &manifest).unwrap(); + assert_eq!(block, "project_owner_exclusion"); + assert!(!block.contains("applicability")); + assert!(!block.contains("NotDirectlyObservable")); + } } #[cfg(test)] diff --git a/src/manifest.rs b/src/manifest.rs index 147d386..9b96deb 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -152,6 +152,23 @@ impl Manifest { { return Err("manifest event_window_utc has empty start/end".into()); } + // OPEN events always require an explicit reviewed analysis + // cutoff: the reviewed manifest is the authority for the + // provisional analysis end. A missing or empty cutoff is + // internally contradictory reviewed input and is rejected, + // regardless of any declared event end. + if self.open { + let has_cutoff = self + .analysis_end_utc + .as_deref() + .map(|c| !c.trim().is_empty()) + .unwrap_or(false); + if !has_cutoff { + return Err( + "manifest open event requires an explicit analysis_end_utc cutoff".into(), + ); + } + } if self.collectors.is_empty() { return Err("manifest collectors list is empty".into()); } diff --git a/src/worker.rs b/src/worker.rs index 5794b6b..961b3e3 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -937,13 +937,13 @@ fn generic_ticket_input( .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); - // OPEN events: the normalized model has no end; the reviewed - // analysis cutoff (analysis_end_utc from the plan's manifest) is - // the explicit analysis end. The result stays provisional. - if end.trim().is_empty() { - // An OPEN event needs the reviewed analysis cutoff; a missing - // cutoff or an unreadable manifest is a HARD error (never a - // silently empty end). + // OPEN events: the reviewed analysis cutoff (analysis_end_utc from + // the plan's manifest) is the explicit analysis end; the result + // stays provisional. A missing cutoff is a HARD error (never a + // silently empty end), and this applies regardless of any declared + // end in the normalized model, so a legacy invalid open plan can + // never reach source access (F-4). + { let payload: String = conn .lock() .unwrap() @@ -960,15 +960,20 @@ fn generic_ticket_input( let value: serde_json::Value = serde_json::from_str(&payload).map_err(|e| { format!("invalid_plan: manifest payload unreadable for open event: {e}") })?; + let is_open = value.get("open").and_then(|x| x.as_bool()).unwrap_or(false); let cutoff = value .get("analysis_end_utc") .and_then(|x| x.as_str()) .map(|s| s.trim().to_string()) - .filter(|c| !c.is_empty()) - .ok_or_else(|| { + .filter(|c| !c.is_empty()); + if is_open { + let cutoff = cutoff.ok_or_else(|| { "invalid_plan: open event requires an explicit analysis cutoff".to_string() })?; - end = cutoff; + end = cutoff; + } else if end.trim().is_empty() { + return Err("invalid_plan: event end unavailable".to_string()); + } } let task_type = v .get("task_type") diff --git a/tests/invariant_register_test.rs b/tests/invariant_register_test.rs new file mode 100644 index 0000000..a264f22 --- /dev/null +++ b/tests/invariant_register_test.rs @@ -0,0 +1,88 @@ +//! Invariant-register consistency test (Session 57, Part 13). +//! +//! The maintained invariant register (`docs/design/invariants.md`) is +//! the normative record of invariants and their enforcement status. It +//! declares totals in its "Invariant counts" section. This test parses +//! the register's table rows and verifies the declared arithmetic, so a +//! status edit without a count update fails loudly. + +use std::collections::BTreeMap; + +fn manifest_dir() -> &'static std::path::Path { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn invariant_register_declared_counts_match_rows() { + let path = manifest_dir().join("docs/design/invariants.md"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!("cannot read {path:?}: {e}"); + }); + + // Row pattern: | ID-1 | statement | status | enforcement | falsification | + let row_re = + regex::Regex::new(r"^\| ([A-Z]{2}-[0-9]+) \| .*? \| (.+?) \| .*? \| .*? \|$").unwrap(); + + // Declared totals: "- Enforced: N" etc. + let count_re = regex::Regex::new( + r"^- (Enforced|Partially enforced|Assumed|Claimed|Unknown|Total table rows): (\d+)$", + ) + .unwrap(); + + let mut rows = 0usize; + let mut by_status: BTreeMap = BTreeMap::new(); + let mut declared: BTreeMap = BTreeMap::new(); + + for line in text.lines() { + if let Some(cap) = row_re.captures(line) { + rows += 1; + let status = cap[2].trim().to_string(); + // Compound statuses (e.g. "enforced (rows); partially + // (event row)") count under the leading category. + let category = if status.starts_with("partially enforced") { + "partially enforced" + } else if status.starts_with("enforced") { + "enforced" + } else if status.starts_with("assumed") { + "assumed" + } else if status.starts_with("claimed") { + "claimed" + } else { + "unknown" + }; + *by_status.entry(category.to_string()).or_insert(0) += 1; + } + if let Some(cap) = count_re.captures(line) { + let key = cap[1].to_lowercase(); + let n: usize = cap[2].parse().unwrap(); + declared.insert(key, n); + } + } + + assert!(rows > 0, "invariant register table must not be empty"); + assert_eq!( + rows, + *declared.get("total table rows").unwrap_or(&0), + "declared total rows must equal table rows" + ); + for (category, actual) in &by_status { + let declared_n = declared.get(category.as_str()).copied().unwrap_or(0); + assert_eq!( + actual, &declared_n, + "declared {category} count does not match register rows" + ); + } + // Every declared category must be present and the categories with no + // rows (claimed/unknown) must be declared as zero or absent. + let sum: usize = by_status.values().sum(); + assert_eq!(sum, rows, "status counts must sum to the row total"); + let declared_sum: usize = declared + .iter() + .filter(|(k, _)| *k != "total table rows") + .map(|(_, v)| v) + .sum(); + assert_eq!( + declared_sum, rows, + "declared status counts must sum to the row total" + ); +} diff --git a/tests/release_test.rs b/tests/release_test.rs index 7350d86..f8ad88e 100644 --- a/tests/release_test.rs +++ b/tests/release_test.rs @@ -14,6 +14,24 @@ fn read(path: &str) -> String { }) } +#[test] +fn changelog_contains_no_session_narrative() { + // CHANGELOG.md is a normative current document; the documentation + // drift audit (scripts/audit_docs.py, SESSION_RE = "Session \d+") + // rejects internal Agent-session narrative there. Entries must use + // product/change language, not "Session N" headings. (Regression: + // the Session 55 heading broke the docs audit and reddened main CI + // at 92f83d89.) + let changelog = read("CHANGELOG.md"); + let session_re = regex::Regex::new(r"Session \d+").unwrap(); + for line in changelog.lines() { + assert!( + !session_re.is_match(line), + "CHANGELOG.md must not contain session-number narrative: {line}" + ); + } +} + #[test] fn cargo_manifest_declares_mit() { let toml = read("Cargo.toml"); diff --git a/tests/second_network_semantics_test.rs b/tests/second_network_semantics_test.rs index e81fc1a..2aca70e 100644 --- a/tests/second_network_semantics_test.rs +++ b/tests/second_network_semantics_test.rs @@ -396,7 +396,10 @@ fn open_event_cutoff_drives_pipeline_window() { } #[test] -fn open_event_without_cutoff_is_a_hard_error() { +fn source_fetch_time_not_implicitly_analysis_cutoff() { + // F-4: the analysis cutoff is the reviewed manifest + // analysis_end_utc, never the snapshot fetch time. The manifest + // window ignores any other timestamp. let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("m.json"); std::fs::write( @@ -408,6 +411,8 @@ fn open_event_without_cutoff_is_a_hard_error() { "open": true, "event_window_utc": {"start": "2026-07-28T04:35:26Z", "end": ""}, "ticket_window_local": {"start": "2026-07-28 04:35:26", "end": "", "timezone": "UTC"}, + // Reviewed cutoff differs from any plausible fetch time. + "analysis_end_utc": "2026-08-10T12:00:00Z", "warmup_minutes": 60, "cooldown_minutes": 60, "target": { @@ -426,8 +431,49 @@ fn open_event_without_cutoff_is_a_hard_error() { ) .unwrap(); let manifest = inim::manifest::Manifest::load(&p).unwrap(); - let err = manifest.event_window().unwrap_err(); - assert!(err.contains("explicit analysis cutoff"), "{err}"); + let (_, end) = manifest.event_window().unwrap(); + assert_eq!( + end.to_rfc3339(), + "2026-08-10T12:00:00+00:00", + "the reviewed analysis cutoff must be used, not any fetch time" + ); +} + +#[test] +fn open_event_without_cutoff_is_a_hard_error() { + // An open manifest without an explicit reviewed analysis cutoff is + // internally contradictory reviewed input: rejected at load (F-4), + // never admitted to planning, queueing, or execution. + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("m.json"); + std::fs::write( + &p, + serde_json::json!({ + "event_id": "INC-GENERIC", + "revision": 1, + "schema_version": 2, + "open": true, + "event_window_utc": {"start": "2026-07-28T04:35:26Z", "end": ""}, + "ticket_window_local": {"start": "2026-07-28 04:35:26", "end": "", "timezone": "UTC"}, + "warmup_minutes": 60, + "cooldown_minutes": 60, + "target": { + "label": "Generic", + "origin_asns": [64500], + "transit_predicate": { + "predicate": {"ContainsAny": [64501]}, + "status": "Reviewed", + "provenance": {"statement": "x", "reviewed_by": "t", "date": "2026-08-04"} + } + }, + "collectors": ["rrc00"], + "source_family": "RipeRis" + }) + .to_string(), + ) + .unwrap(); + let err = inim::manifest::Manifest::load(&p).unwrap_err(); + assert!(err.contains("analysis_end_utc cutoff"), "{err}"); } #[test] @@ -565,3 +611,130 @@ fn import_prefers_tracked_case_evidence_over_runtime_stub() { .unwrap(); assert_eq!(n, 1); } + +// ── Status-boundary matrix (Session 57, Part 12) ─────────────────── +// +// Source lifecycle, plan readiness, project scope, analytical +// applicability, job status, execution stage, run completion, evidence +// continuity, observed result, expectation assessment, and artifact +// validity are separate axes. These negative tests pin the boundaries +// without creating one universal status enum. + +#[test] +fn open_event_does_not_imply_incomplete_analysis() { + // An open event is analyzed provisionally through the reviewed + // cutoff; a completed run for an open event is not an incomplete + // analysis. + use inim::domain::assessment::Verdict; + use inim::domain::event::EventId; + use inim::domain::expectation::ImpactExpectation; + use inim::outcome::AnalysisOutcome; + + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let assessment = inim::assess::assess(EventId::from("TEST"), exp, &[], vec![], false, None); + // Established continuity + no findings is the no-change result, not + // an incomplete analysis. + assert_eq!( + assessment.verdict, + Verdict::NoObservableBgpImpact, + "established-continuity empty run is a real no-change result" + ); + let outcome = AnalysisOutcome::completed(assessment); + assert!(matches!(outcome, AnalysisOutcome::Completed { .. })); + assert!(!matches!(outcome, AnalysisOutcome::Incomplete { .. })); +} + +#[test] +fn no_route_state_change_does_not_imply_continuity_failure() { + // A no-change result with ESTABLISHED continuity is a legitimate + // observation; it does not claim continuity failed. (The converse — + // no-change WITHOUT continuity — is suppressed by the F-1 gate.) + use inim::domain::assessment::Verdict; + use inim::domain::event::EventId; + use inim::domain::expectation::ImpactExpectation; + + let exp = ImpactExpectation::redundant(Some("NEWY32AOA"), "test"); + let assessment = inim::assess::assess( + EventId::from("TEST"), + exp, + &[], + vec![], + false, // continuity established + None, + ); + assert_eq!(assessment.verdict, Verdict::NoObservableBgpImpact); + // The observed-result projection is the evidence-scoped no-change + // statement, not an insufficiency or continuity label. + let kind = assessment.verdict.observed_result_kind(); + assert_eq!( + kind, + inim::domain::assessment::ObservedResultKind::NoRouteStateChangeObserved + ); + assert_ne!( + kind, + inim::domain::assessment::ObservedResultKind::InsufficientQualifyingVisibility + ); +} + +#[test] +fn insufficient_visibility_does_not_imply_no_route_state_change() { + use inim::domain::assessment::{ObservedResultKind, Verdict}; + assert_ne!( + Verdict::InsufficientVisibility.observed_result_kind(), + ObservedResultKind::NoRouteStateChangeObserved + ); + assert_eq!( + Verdict::InsufficientVisibility.observed_result_kind(), + ObservedResultKind::InsufficientQualifyingVisibility + ); +} + +#[test] +fn ready_plan_does_not_imply_in_scope_event_unless_policy_says_so() { + // Plan readiness and project-scope inclusion are separate axes: a + // Ready plan's status never encodes scope, and the scope decision is + // applied separately at queue/worker/analyze time. + use inim::manifest::Manifest; + let manifest: Manifest = serde_json::from_str( + &serde_json::json!({ + "event_id": "INC-GENERIC-READY", + "revision": 1, + "schema_version": 2, + "event_window_utc": {"start": "2026-08-01T00:00:00Z", "end": "2026-08-01T01:00:00Z"}, + "ticket_window_local": {"start": "", "end": "", "timezone": "UTC"}, + "warmup_minutes": 0, + "cooldown_minutes": 0, + "target": { + "label": "Generic included org", + "origin_asns": [64500], + "transit_predicate": { + "status": "Reviewed", + "predicate": {"ContainsAny": [64501]}, + "provenance": {"statement": "r", "reviewed_by": "local-review", "date": "2026-08-01"} + } + }, + "collectors": ["route-views2"], + "source_family": "RouteViews" + }) + .to_string(), + ) + .unwrap(); + let plan = inim::plan::plan_from_manifest( + "INC-GENERIC-READY", + inim::domain::expectation::ImpactExpectation::redundant(Some("NEWY32AOA"), "test"), + &manifest, + ) + .unwrap(); + assert!(matches!(plan.status, inim::plan::AnalysisPlanStatus::Ready)); + // Readiness is a plan fact; scope inclusion is a policy overlay and + // is not part of the plan status vocabulary. + let scope_text = "schema_version = 1\n[[excluded_source_records]]\nsource_family = \"grnoc-public-task-viewer\"\nexternal_id = \"INC-GENERIC-READY\"\nreason_code = \"project_owner_exclusion\"\n"; + let file: inim::catalog::scope::ScopeConfigFile = toml::from_str(scope_text).unwrap(); + let scope = inim::catalog::scope::ProjectScope::from_config(file).unwrap(); + assert!( + scope + .source_record_reason("grnoc-public-task-viewer", "INC-GENERIC-READY") + .is_some(), + "policy may exclude an event whose plan is Ready" + ); +}