diff --git a/CHANGELOG.md b/CHANGELOG.md index f48fe226..0c6500ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - session-ledger heuristic intent extractor property surface (WBS-6.2 #463): `crates/sl-viewer/tests/properties_session_ledger_intent.rs` adds 10 proptest properties pinning `session_ledger::distill::extractor::HeuristicIntentExtractor::extract_intent` (the P1 SSOT for what the user wants — drives resume prompt, search index, and wiki/docs view). `user_turn_count` always equals the count of `Role::User` messages and ignores `Assistant` / `Subagent` / `Tool` / `System` messages. Empty sessions produce an empty `Intent`. Repeated acceptance / constraint patterns are deduplicated. Every documented acceptance pattern (16 of them: `looks good`, `works`, `that's correct`, `correct`, `done`, `fixed`, `passes`, `approved`, `looks right`, `looks great`, `all good`, `that works`, `nice`, `perfect`, `exactly`, `confirmed`) and every documented constraint pattern (19 of them: `don't change`, `do not change`, `must not`, `should not`, `keep`, `maintain`, `preserve`, `never`, `don't touch`, `do not touch`, `don't modify`, `do not modify`, `only`, `but don't`, `but do not`, `without changing`, `without modifying`, `leave alone`, `leave as is`) is recognized in any user message. Labeled `Goal:` / `Objective:` / `Task:` lines win over surrounding preamble. Labeled `Constraint:` / `Requirement:` / `Boundary:` lines contribute their full text. The extractor is deterministic across calls. - session-ledger heuristic context extractor property surface (WBS-6.2 #464): `crates/sl-viewer/tests/properties_session_ledger_context.rs` adds 20 proptest properties pinning `session_ledger::distill::context_extractor::HeuristicContextExtractor::extract_context` (the P2 adapter for [`ContextExtractor`] — drives the working-context slice of any `ContinuationBundle`). `cwd` and `title` are always copied verbatim from the session. Empty sessions and whitespace-only messages yield an empty `Context`. Every detected file path is non-empty, ≥3 chars, and either contains a `/` or ends with a documented extension. Every documented file extension (28 of them: `.rs`, `.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, `.java`, `.kt`, `.rb`, `.c`, `.h`, `.cpp`, `.hpp`, `.cs`, `.swift`, `.toml`, `.json`, `.yaml`, `.yml`, `.md`, `.sql`, `.css`, `.scss`, `.html`, `.sh`, `.tf`, `.lock`) is detected. Files / symbols / decisions / environment notes are deduplicated across messages. Decision summaries all read `Session contains '' language` and rationale equals the source message. Every documented decision pattern (15 of them) and every documented environment pattern (19 of them) is detected, both case-insensitively. Symbols are extracted only from tokens containing `::` or `()`. `extract_context` is deterministic across calls, and the `ContextExtractor` trait path returns the same `Context` as the associated function. - session-ledger heuristic contract extractor property surface (WBS-6.2 #465): `crates/sl-viewer/tests/properties_session_ledger_contract.rs` adds 18 proptest properties pinning `session_ledger::distill::contract_extractor::HeuristicContractExtractor::extract_contract` (the P2 adapter for [`ContractExtractor`] — drives the acceptance-contract slice of any `ContinuationBundle`). Empty sessions and whitespace-only messages yield an empty `Contract`. Every section string has a documented prefix and suffix shape (`Goal/requirement: '...'`, `Verification: '...'`, `Constraint: '...'`, `Do-not-touch: '...'`). Every documented criteria pattern (12 of them), test pattern (21 of them), constraint pattern (15 of them), and do-not-touch pattern (14 of them) is detected, and all patterns match case-insensitively. Success criteria / tests / constraints / do-not-touch lists are deduplicated across messages. `extract_contract` is deterministic across calls, and the `ContractExtractor` trait path returns the same `Contract` as the associated function. `is_empty()` is true iff every collection is empty. -- session-ledger token estimator property surface (WBS-6.2 #467): `crates/sl-viewer/tests/properties_session_ledger_token_estimator.rs` adds 24 proptest properties pinning `session_ledger::distill::token_estimator::CharCountTokenEstimator` (the bounded token-budget accountant for the distill pipeline — backs every `token_estimate()` query on a `Bundle` slice). `estimate_text` always equals `(chars + 3) / 4` (ceiling-division rounding up at every 4-char boundary), the documented fixed points (`n=1..=9` → `1, 1, 1, 1, 2, 2, 2, 2, 3`) hold, and the estimator counts unicode scalar values (chars) — not UTF-8 bytes (4 × `🦀` = 4 chars = 1 token, even though those are 16 UTF-8 bytes). `estimate_text` is deterministic, finite (u32), never panics on any unicode input (CJK / RTL / emoji / combining marks), and is monotonic for prefixes (`est(text) ≥ est(prefix)`). `estimate_text(s.repeat(n))` follows the exact `(chars(s) * n + 3) / 4` contract. `estimate_json(&v)` always equals `estimate_text(&v.to_string())` — i.e. it uses compact serialisation with no whitespace — and is total over `Null / Bool / Number / String / Array / Object`. `CharCountTokenEstimator` is `Default + Copy + Clone`, and the trait-default `estimate_json` delegates to `estimate_text` (verified with a dummy estimator that overrides `estimate_text`). +- session-ledger dedup compiler property surface (WBS-6.2 #468): `crates/sl-viewer/tests/properties_session_ledger_dedup.rs` adds 15 proptest properties pinning `session_ledger::distill::dedup_compiler::DedupCompiler::compile` (the deterministic scope-keyed manifest pipeline that compiles same-scope sessions into a typed, token-sized Dedup slice). Empty sessions always return `Err(EmptySessions)`. Whitespace-only or empty topic returns `Err(EmptyTopic)`. Topic is trimmed and lowercased before use. Output bundle always has `kind = BundleKind::Dedup` and a positive `token_estimate`. Manifest body always deserializes to `DedupManifest`. `dedup_key` is exactly 64 hex chars (sha256 fingerprint), independent of input order, and differs across topics and cwds. Duplicate members (same id + corpus) appear once preserving first occurrence order. Members preserve input order. Different corpora for same cwd produce different members. Mixed-scope sessions return `Err(ScopeMismatch)` with the offending session_id. Compilation is deterministic across calls and `token_estimate` is monotonic non-decreasing in session count. - Commit signing header scan (C04 L34): `commit-signing-check.ps1` reads bounded commit headers via line-scanner (no unbounded `git cat-file` buffers or `(?ms)` regex); `-SelfCheck` + `tests/commit_signing_check.rs`. - Loom permutation CI timeout (P0 stability): split blocking `loom-permutation.yml` into core + per-daemon `loom_model` jobs with `LOOM_MAX_PREEMPTIONS` on broadcast/pipeline/shutdown; mirror in soft `loom-smoke.yml` so Wave-40 tokio-shaped daemon graph tests no longer exceed single-job ceilings. diff --git a/crates/sl-viewer/tests/properties_session_ledger_dedup.rs b/crates/sl-viewer/tests/properties_session_ledger_dedup.rs new file mode 100644 index 00000000..bca4aa95 --- /dev/null +++ b/crates/sl-viewer/tests/properties_session_ledger_dedup.rs @@ -0,0 +1,302 @@ +//! Property evidence for `session_ledger::distill::dedup_compiler::DedupCompiler`. +//! +//! The dedup compiler is the deterministic scope-keyed manifest pipeline that +//! compiles same-scope sessions into a typed, token-sized Dedup slice. If +//! `DedupKey::derive` drifts, manifest dedup breaks, or scope validation +//! stops rejecting mixed-cwd inputs, every merge downstream consumes stale +//! dedup data. + +use proptest::prelude::*; +use session_ledger::distill::dedup_compiler::{DedupCompileError, DedupCompiler}; +use session_ledger::distill::token_estimator::CharCountTokenEstimator; +use session_ledger::domain::bundle::{Bundle, BundleKind}; +use session_ledger::domain::dedup::DedupManifest; +use session_ledger::domain::session::{Corpus, Session}; + +fn make_session(id: &str, cwd: Option<&str>, corpus: Corpus) -> Session { + let mut session = Session::new(id, corpus); + session.cwd = cwd.map(str::to_owned); + session +} + +// ── Empty inputs ─────────────────────────────────────────────────────────── + +proptest! { + /// Empty sessions always return `Err(EmptySessions)`. + #[test] + fn empty_input_returns_empty_sessions_error(topic in "[a-zA-Z][a-zA-Z0-9-_]{0,30}") { + let compiler = DedupCompiler::new(CharCountTokenEstimator); + let result = compiler.compile(&[], &topic); + prop_assert_eq!(result.err(), Some(DedupCompileError::EmptySessions)); + } + + /// Whitespace-only or empty topic always returns `Err(EmptyTopic)` even + /// with non-empty sessions. + #[test] + fn blank_topic_returns_empty_topic_error(ws in "[ \t\n]{1,8}") { + let compiler = DedupCompiler::new(CharCountTokenEstimator); + let sessions = [make_session("s1", Some("/repo"), Corpus::Forge)]; + let result = compiler.compile(&sessions, &ws); + prop_assert_eq!(result.err(), Some(DedupCompileError::EmptyTopic)); + } +} + +// ── Topic normalization ──────────────────────────────────────────────────── + +proptest! { + /// Topic is trimmed and lowercased before use. + #[test] + fn topic_is_normalized(body in "[A-Z][a-zA-Z0-9-]{0,20}") { + let owned = body.clone(); + let upper = owned.to_uppercase(); + let topic = format!(" {upper} "); + let sessions = [make_session("s1", Some("/repo"), Corpus::Forge)]; + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic) + .expect("compilation should succeed"); + let manifest: DedupManifest = + serde_json::from_value(bundle.body).expect("manifest should deserialize"); + prop_assert_eq!(manifest.topic_slug, owned.to_lowercase()); + } +} + +// ── Output shape ─────────────────────────────────────────────────────────── + +proptest! { + /// Output bundle always has `kind = BundleKind::Dedup` and a positive + /// `token_estimate`. + #[test] + fn output_bundle_has_dedup_kind_and_positive_estimate( + n in 1_usize..4, + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let sessions: Vec = (0..n) + .map(|i| make_session(&format!("s{i}"), Some("/repo"), Corpus::Forge)) + .collect(); + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic) + .expect("compilation should succeed"); + prop_assert_eq!(bundle.kind, BundleKind::Dedup); + prop_assert!(bundle.token_estimate > 0); + } + + /// Manifest body always deserializes to `DedupManifest`. + #[test] + fn manifest_body_deserializes( + n in 1_usize..4, + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let sessions: Vec = (0..n) + .map(|i| make_session(&format!("s{i}"), Some("/repo"), Corpus::Forge)) + .collect(); + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic) + .expect("compilation should succeed"); + let manifest: DedupManifest = + serde_json::from_value(bundle.body).expect("manifest should deserialize"); + prop_assert_eq!(manifest.sessions.len(), n); + } +} + +// ── DedupKey invariants ──────────────────────────────────────────────────── + +proptest! { + /// `dedup_key` is exactly 64 hex chars (sha256 fingerprint). + #[test] + fn dedup_key_length_is_64(topic in "[a-z][a-z0-9-]{0,10}") { + let sessions = [make_session("s1", Some("/repo"), Corpus::Forge)]; + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic) + .expect("compilation should succeed"); + let manifest: DedupManifest = + serde_json::from_value(bundle.body).expect("manifest should deserialize"); + prop_assert_eq!(manifest.dedup_key.as_str().len(), 64); + } + + /// Same scope → same dedup_key regardless of input order. + #[test] + fn dedup_key_independent_of_input_order( + ids in proptest::collection::hash_set("[a-z]{1,3}", 2..4), + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let mut ids: Vec = ids.into_iter().collect(); + ids.sort(); + let sessions_a: Vec = ids + .iter() + .map(|i| make_session(i.as_str(), Some("/repo"), Corpus::Forge)) + .collect(); + let mut sessions_b = sessions_a.clone(); + sessions_b.reverse(); + let bundle_a = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions_a, &topic) + .expect("compilation should succeed"); + let bundle_b = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions_b, &topic) + .expect("compilation should succeed"); + let manifest_a: DedupManifest = + serde_json::from_value(bundle_a.body).expect("manifest should deserialize"); + let manifest_b: DedupManifest = + serde_json::from_value(bundle_b.body).expect("manifest should deserialize"); + prop_assert_eq!(manifest_a.dedup_key, manifest_b.dedup_key); + } + + /// Different topic → different dedup_key (same sessions). + #[test] + fn dedup_key_differs_by_topic( + topic_a in "[a-z]{1,6}", + topic_b in "[a-z]{7,12}", + ) { + let sessions = [make_session("s1", Some("/repo"), Corpus::Forge)]; + let bundle_a = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic_a) + .expect("compilation should succeed"); + let bundle_b = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic_b) + .expect("compilation should succeed"); + let manifest_a: DedupManifest = + serde_json::from_value(bundle_a.body).expect("manifest should deserialize"); + let manifest_b: DedupManifest = + serde_json::from_value(bundle_b.body).expect("manifest should deserialize"); + prop_assert_ne!(manifest_a.dedup_key, manifest_b.dedup_key); + } + + /// Different cwd → different dedup_key. + #[test] + fn dedup_key_differs_by_cwd( + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let session_a = make_session("s1", Some("/repo-a"), Corpus::Forge); + let session_b = make_session("s1", Some("/repo-b"), Corpus::Forge); + let bundle_a = DedupCompiler::new(CharCountTokenEstimator) + .compile(&[session_a], &topic) + .expect("compilation should succeed"); + let bundle_b = DedupCompiler::new(CharCountTokenEstimator) + .compile(&[session_b], &topic) + .expect("compilation should succeed"); + let manifest_a: DedupManifest = + serde_json::from_value(bundle_a.body).expect("manifest should deserialize"); + let manifest_b: DedupManifest = + serde_json::from_value(bundle_b.body).expect("manifest should deserialize"); + prop_assert_ne!(manifest_a.dedup_key, manifest_b.dedup_key); + } +} + +// ── Dedup / ordering ─────────────────────────────────────────────────────── + +proptest! { + /// Duplicate members (same id + corpus) appear once, preserving first + /// occurrence order. + #[test] + fn duplicate_member_deduped(topic in "[a-z][a-z0-9-]{0,10}") { + let session = make_session("s1", Some("/repo"), Corpus::Forge); + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&[session.clone(), session.clone(), session], &topic) + .expect("compilation should succeed"); + let manifest: DedupManifest = + serde_json::from_value(bundle.body).expect("manifest should deserialize"); + prop_assert_eq!(manifest.sessions.len(), 1); + } + + /// Members preserve input order in the manifest. + #[test] + fn members_preserve_input_order( + ids in proptest::collection::hash_set("[a-z]{1,3}", 2..5), + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let mut ids: Vec = ids.into_iter().collect(); + let sessions: Vec = ids + .iter() + .map(|i| make_session(i.as_str(), Some("/repo"), Corpus::Forge)) + .collect(); + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions, &topic) + .expect("compilation should succeed"); + let manifest: DedupManifest = + serde_json::from_value(bundle.body).expect("manifest should deserialize"); + let manifest_ids: Vec<&str> = manifest.sessions.iter() + .map(|m| m.session_id.as_str()) + .collect(); + let input_ids: Vec<&str> = ids.iter().map(String::as_str).collect(); + prop_assert_eq!(manifest_ids, input_ids); + } + + /// Different corpora for same cwd produce different members. + #[test] + fn corpora_preserved_per_member(topic in "[a-z][a-z0-9-]{0,10}") { + let s_forge = make_session("s1", Some("/repo"), Corpus::Forge); + let s_cursor = make_session("s2", Some("/repo"), Corpus::Cursor); + let bundle = DedupCompiler::new(CharCountTokenEstimator) + .compile(&[s_forge, s_cursor], &topic) + .expect("compilation should succeed"); + let manifest: DedupManifest = + serde_json::from_value(bundle.body).expect("manifest should deserialize"); + prop_assert_eq!(manifest.sessions.len(), 2); + prop_assert_eq!(manifest.sessions[0].corpus, Corpus::Forge); + prop_assert_eq!(manifest.sessions[1].corpus, Corpus::Cursor); + } +} + +// ── Scope validation ─────────────────────────────────────────────────────── + +proptest! { + /// Mixed-scope sessions return `Err(ScopeMismatch)` with the offending + /// session_id. + #[test] + fn mixed_scopes_return_scope_mismatch(topic in "[a-z][a-z0-9-]{0,10}") { + let s_a = make_session("aaa", Some("/repo-a"), Corpus::Forge); + let s_b = make_session("bbb", Some("/repo-b"), Corpus::Forge); + let err = DedupCompiler::new(CharCountTokenEstimator) + .compile(&[s_a, s_b], &topic) + .expect_err("mixed scopes must be rejected"); + match err { + DedupCompileError::ScopeMismatch { session_id, .. } => { + prop_assert_eq!(session_id, "bbb"); + } + other => prop_assert!(false, "expected ScopeMismatch, got {other:?}"), + } + } +} + +// ── Determinism ──────────────────────────────────────────────────────────── + +proptest! { + /// Compilation is deterministic across repeated calls. + #[test] + fn compile_is_deterministic( + n in 1_usize..4, + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let sessions: Vec = (0..n) + .map(|i| make_session(&format!("s{i}"), Some("/repo"), Corpus::Forge)) + .collect(); + let compiler = DedupCompiler::new(CharCountTokenEstimator); + let a = compiler.compile(&sessions, &topic).expect("compilation should succeed"); + let b = compiler.compile(&sessions, &topic).expect("compilation should succeed"); + prop_assert_eq!(a.kind, b.kind); + prop_assert_eq!(a.token_estimate, b.token_estimate); + prop_assert_eq!(a.body, b.body); + } +} + +// ── Token scaling ────────────────────────────────────────────────────────── + +proptest! { + /// `token_estimate` is monotonic non-decreasing in session count. + #[test] + fn token_estimate_monotonic_in_count( + topic in "[a-z][a-z0-9-]{0,10}", + ) { + let sessions_1 = [make_session("s1", Some("/repo"), Corpus::Forge)]; + let sessions_2 = [ + make_session("s1", Some("/repo"), Corpus::Forge), + make_session("s2", Some("/repo"), Corpus::Forge), + ]; + let bundle_1 = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions_1, &topic) + .expect("compilation should succeed"); + let bundle_2 = DedupCompiler::new(CharCountTokenEstimator) + .compile(&sessions_2, &topic) + .expect("compilation should succeed"); + prop_assert!(bundle_2.token_estimate >= bundle_1.token_estimate); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index cec48665..afc9f0ab 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -333,7 +333,7 @@ "crates/sl-viewer/tests/properties_session_ledger_intent.rs", "crates/sl-viewer/tests/properties_session_ledger_context.rs", "crates/sl-viewer/tests/properties_session_ledger_contract.rs", - "crates/sl-viewer/tests/properties_session_ledger_token_estimator.rs", + "crates/sl-viewer/tests/properties_session_ledger_dedup.rs", "fuzz/fuzz_targets/okf_roundtrip.rs", "fuzz/fuzz_targets/jsonl_ingest.rs", ".github/workflows/ci.yml", diff --git a/docs/ops/WBS.md b/docs/ops/WBS.md index d52cbbec..2daabe7b 100644 --- a/docs/ops/WBS.md +++ b/docs/ops/WBS.md @@ -29,7 +29,7 @@ without a new audit. | WBS-4.2 | P4 FTS recall via context-mode and explicit TUI decision | partial | human | `docs/DESIGN.md` §3, §7; `crates/sl-viewer/` | DESIGN P4 residual; C00, C11 | | WBS-5.1 | P5 deterministic dedup merge and crash/lost-work recovery E2E | done | machine | `src/domain/merge.rs`; `src/domain/worklog.rs`; `tests/merge_recovery.rs` | FR-011; T-024, T-035; C03 | | WBS-6.1 | P6 85% coverage gate and deterministic golden corpus | done | machine | `.github/workflows/ci.yml`; `tests/okf_golden.rs`; `tests/fixtures/okf/` | T-037, T-038; C01, C08 | -| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `crates/sl-viewer/tests/properties_viewer_web_exports.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs`; `crates/sl-viewer/tests/properties_viewer_mock_data.rs`; `crates/sl-viewer/tests/properties_viewer_cli_help.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_cta.rs`; `crates/sl-viewer/tests/properties_viewer_theme.rs`; `crates/sl-viewer/tests/properties_viewer_settings.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_paths.rs`; `crates/sl-viewer/tests/properties_viewer_help_overlay.rs`; `crates/sl-viewer/tests/properties_viewer_settings_tab.rs`; `crates/sl-viewer/tests/properties_viewer_menu.rs`; `crates/sl-viewer/tests/properties_viewer_async_states.rs`; `crates/sl-viewer/tests/properties_session_ledger_okf.rs`; `crates/sl-viewer/tests/properties_session_ledger_worklog.rs`; `crates/sl-viewer/tests/properties_session_ledger_export.rs`; `crates/sl-viewer/tests/properties_session_ledger_distill.rs`; `crates/sl-viewer/tests/properties_session_ledger_intent.rs`; `crates/sl-viewer/tests/properties_session_ledger_context.rs`; `crates/sl-viewer/tests/properties_session_ledger_token_estimator.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; viewer web_exports properties #437; viewer bundle_list + detail_pane properties #436; viewer mock_data fixture properties #451; viewer cli_help / command_palette properties #452; viewer corpus_cta constants properties #453; viewer theme + settings properties #454; viewer corpus_paths round-trip properties #446; viewer help_overlay shortcuts properties #455; viewer settings_tab HealthStatus properties #456; viewer menu id taxonomy properties #457; viewer async_states SkeletonLayout properties #458; session-ledger OKF document validator properties #459; session-ledger worklog projector properties #460; session-ledger OKF export adapter properties #461; session-ledger distill compiler properties #462; session-ledger heuristic intent extractor properties #463; session-ledger heuristic context extractor properties #464; session-ledger heuristic contract extractor properties #465; session-ledger token estimator properties #467; full loom/shuttle unpaid | +| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.rs`; `crates/sl-viewer/tests/properties_viewer_web_exports.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_diff.rs`; `crates/sl-viewer/tests/properties_viewer_mock_data.rs`; `crates/sl-viewer/tests/properties_viewer_cli_help.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_cta.rs`; `crates/sl-viewer/tests/properties_viewer_theme.rs`; `crates/sl-viewer/tests/properties_viewer_settings.rs`; `crates/sl-viewer/tests/properties_viewer_corpus_paths.rs`; `crates/sl-viewer/tests/properties_viewer_help_overlay.rs`; `crates/sl-viewer/tests/properties_viewer_settings_tab.rs`; `crates/sl-viewer/tests/properties_viewer_menu.rs`; `crates/sl-viewer/tests/properties_viewer_async_states.rs`; `crates/sl-viewer/tests/properties_session_ledger_okf.rs`; `crates/sl-viewer/tests/properties_session_ledger_worklog.rs`; `crates/sl-viewer/tests/properties_session_ledger_export.rs`; `crates/sl-viewer/tests/properties_session_ledger_distill.rs`; `crates/sl-viewer/tests/properties_session_ledger_intent.rs`; `crates/sl-viewer/tests/properties_session_ledger_context.rs`; `crates/sl-viewer/tests/properties_session_ledger_dedup.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; viewer web_exports properties #437; viewer bundle_list + detail_pane properties #436; viewer mock_data fixture properties #451; viewer cli_help / command_palette properties #452; viewer corpus_cta constants properties #453; viewer theme + settings properties #454; viewer corpus_paths round-trip properties #446; viewer help_overlay shortcuts properties #455; viewer settings_tab HealthStatus properties #456; viewer menu id taxonomy properties #457; viewer async_states SkeletonLayout properties #458; session-ledger OKF document validator properties #459; session-ledger worklog projector properties #460; session-ledger OKF export adapter properties #461; session-ledger distill compiler properties #462; session-ledger heuristic intent extractor properties #463; session-ledger heuristic context extractor properties #464; session-ledger heuristic contract extractor properties #465; session-ledger dedup compiler properties #468; full loom/shuttle unpaid | ## audit-v38 waves