--- type: trekreview review_version: "1.0" task: "S16 — saves manual-entry surface (lifts the original Non-Goal): optional saves in the analytics data model (types + parser + weekly/monthly + CLI), built location-agnostic for M0 (option c); + S16-pre: close the deferred onboarding Write MAJOR" slug: remediation project_dir: docs/remediation/ brief_path: docs/remediation/brief.md scope_sha_start: 8c52bdb scope_sha_end: 8c52bdb reviewed_files_count: 15 verdict: ALLOW mode: default effort: standard profile: premium findings: [] --- # Review — linkedin-studio S16 (saves manual-entry + S16-pre onboarding Write) ## Executive Summary **Verdict: ALLOW** for S16's delivered scope — 0 BLOCKER, 0 MAJOR, 0 MINOR, 0 SUGGESTION **open** against the diff. Two independent reviewers (brief-conformance, code-correctness) ran COLD, without cross-feeding, on the as-delivered uncommitted working tree (HEAD `8c52bdb` + the S16 delta). - **brief-conformance-reviewer:** **0 findings.** Every Success Criterion traces to delivered code (SC1 saves in types + parser + import/report; SC2 saves in the actionable-signal output; SC3 dwell still explicitly unmeasurable, no fabricated field; SC4 backward-compat + no-crash; SC5 onboarding `Write`). Every Non-Goal honored: **M0 not built** (I/O still routes through the unmodified `getAnalyticsRoot()` seam — option (c)), dwell not fabricated, surface counts (29/19/6/9) and version (v4.1.0) unchanged, no not-mine file touched. - **code-correctness-reviewer:** **2 MAJOR — both in S16's own new code, both FIXED in-session** (see Findings). Neither is pre-existing; both are lockstep misses in the saves parser delivered this session, so per the operator rule ("in-session fix of the session's *own* misses = completion") they were corrected here, not deferred. The M0 conflict (UI-brief §9b) was reconciled **before** building, by operator decision = **option (c): build now, location-agnostic**. The conformance reviewer independently confirmed the build honors it (no new hardcoded `assets/analytics` path; `storage.ts` untouched). ## Coverage Scope: HEAD `8c52bdb` (S15's commit) + the **uncommitted S16 working-tree delta** (annotated `[uncommitted]` — a brief-level contract; the brief's Assumptions allow uncommitted review). The 3 untracked not-mine files (`docs/linkedin-studio-persona-brief.md`, `…-ui-brief.md`, `docs/voyage-build/progress.json`) are explicitly excluded from scope and from the commit. **No silent skips.** | Treatment | Count | Notes | |-----------|-------|-------| | `deep-review` | 0 | nothing under `hooks/**` / `auth/**` / `crypto/**` / `**/security/**` (analytics CLI + one command frontmatter) | | `summary-only` | 15 | analytics src (5) + analytics tests (3) + 2 fixtures + assets/analytics/README.md + README.md + CHANGELOG.md + commands/onboarding.md | | `skip` | 0 | no lockfiles / svg / generated / dist | **Execution criteria (orchestrator-run):** - `npx tsc --noEmit` (analytics) → **clean** (the new `RankableMetric` type closes the `metrics[keyof]` widening the optional `saves` would otherwise introduce in `alerts.ts` + `cli.ts`). - `node --import tsx --test tests/*.test.ts` (analytics) → **116/116**. - `bash scripts/test-runner.sh` → **74 passed / 0 failed / 0 warnings**, exit 0. - `node --test hooks/scripts/__tests__/*.test.mjs` → **98/98** (no hook logic changed). - `ls commands/*.md` → **29** (no surface-count change; no version bump — within-v4.1.0 refinement, S11–S13 precedent). - **E2E:** import + weekly + monthly with a saves fixture surfaces saves without crashing; saves-free fixtures round-trip byte-identical; explicit `"0"` → `0`, blank/`"n/a"` → undefined. ## Findings **0 open findings.** Code-correctness raised 2 MAJOR; both are **S16's own** new code (not pre-existing — the saves parser is new this session), so both were **fixed in-session** as completion of the delivered work, then re-verified green. Recorded below — not dropped, not silenced. ### [MAJOR — FIXED in-session] Non-numeric Saves cell silently coerced to 0 (`unknown != 0` contract violated) *Raised by code-correctness (`PLAN_EXECUTE_DRIFT`), `scripts/analytics/src/parsers/csv-parser.ts`.* The original guard `if (savesRaw && savesRaw.trim() !== "")` then `metrics.saves = parseMetric(savesRaw)` gated only on emptiness; `parseMetric` ends in `parseFloat(...) || 0` + `Math.max(0, …)`, so a non-empty non-numeric cell (`"n/a"`, `"~40"`) or a negative value flattened to **0** — surfaced as a confident "top engagement signal". This contradicts the `unknown != 0` contract stated in the same diff (`types.ts` saves NOTE + the parser comment). **Fix (this session):** added a dedicated `parseOptionalCount()` helper returning `number | undefined` — blank / non-numeric / negative → `undefined`; a genuine number (including explicit `"0"`) → that number; reuses the EU/US separator normalization. The saves parse now routes through it. The blank-cell path is unchanged; the garbage/negative path now honors the contract. ### [MAJOR — FIXED in-session] No test for the explicit-`0` vs non-numeric Saves boundary *Raised by code-correctness (`MISSING_TEST`), `scripts/analytics/tests/csv-parser.test.ts`.* The original saves tests covered `42`, blank→undefined, no-column→undefined, and engagement-exclusion — but not the exact boundary where the parser's behavior diverged from its intent: a literal `"0"` (genuine zero) vs a non-numeric cell (unknown). **Fix (this session):** added `tests/fixtures/saves-edge-export.csv` (a `"0"` row + an `"n/a"` row) and two cases pinning `saves === 0` for `"0"` and `saves === undefined` for non-numeric. The non-numeric case was **red** before the parser fix above and **green** after (TDD), so the contract boundary is now regression-guarded. ### [MAJOR — CLOSED] (carried from S15) onboarding Phase 2 file-saves need `Write` *S15's deferred finding.* `commands/onboarding.md` Phase 2 saves voice/user-profile files but the frontmatter omitted `Write`. **Closed in S16-pre:** `Write` added to `allowed-tools` (matching `first-post.md:9-14`). ## Remediation Summary **Gate: ALLOW** for S16's delivered scope. brief-conformance is clean; code-correctness's two MAJORs were S16's own lockstep misses (not pre-existing design findings), so both were fixed in-session and re-verified (tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98) — this is a genuine ALLOW with **no open finding**, not a WARN-override. The M0 conflict was reconciled before building (option c, location-agnostic); the conformance reviewer confirmed M0 was not built and the data-dir seam (`getAnalyticsRoot()`) is untouched, so the planned migration relocates the root in one place. The S15-deferred onboarding `Write` MAJOR is closed. Per Handover 6, this `review.md` is consumable by `/trekplan --brief …`. ALLOW → S16 commits + pushes (own files only); the finish-plan continues at **S17** (C13–C46 triage). ```json { "verdict": "ALLOW", "verdict_scope": "S16 delivered changes (saves manual-entry + S16-pre onboarding Write); 15 files", "scope": { "sha_start": "8c52bdb", "sha_end": "8c52bdb", "reviewed_files_count": 15, "uncommitted_delta": true }, "counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 }, "findings": [], "fixed_in_session": [ { "severity": "MAJOR", "title": "Non-numeric Saves cell silently coerced to 0 (unknown != 0 contract)", "file": "scripts/analytics/src/parsers/csv-parser.ts", "rule_key": "PLAN_EXECUTE_DRIFT", "resolution": "added parseOptionalCount() returning number|undefined (blank/non-numeric/negative -> undefined; genuine 0 kept); saves parse routes through it" }, { "severity": "MAJOR", "title": "No test for explicit-0 vs non-numeric Saves boundary", "file": "scripts/analytics/tests/csv-parser.test.ts", "rule_key": "MISSING_TEST", "resolution": "added saves-edge-export.csv fixture + 2 cases (0 -> 0, n/a -> undefined); TDD red-before/green-after the parser fix" }, { "severity": "MAJOR", "title": "onboarding Phase 2 file-saves need Write (carried from S15)", "file": "commands/onboarding.md", "rule_key": "PLAN_EXECUTE_DRIFT", "resolution": "S16-pre: added Write to allowed-tools, matching first-post.md" } ], "deferred_findings": [], "dropped_findings": [] } ```