Closing Epic #210: Flagging Estimated vs. Actual Cost in the Vizor Effort & Cost Tab
The Broader Goal at the End of the Previous PR
PR #257 closed epic #210's four-for-four structured-output adapter scope — Codex, Claude, Agy, and Grok all now extract real vendor-reported token usage into a uniform _TokenCounts(basis="structured_output") contract before ever falling back to regex or t-shirt heuristics. That PR's closing note named the one deliverable epic #210 still owed: the display layer. All four adapters, plus Measurement Ledger Phase 1's cost_source/estimate_basis columns, had been populating provenance data that nothing downstream actually showed a human. project-docs/costs.md already prefixed non-actual rows with [est]/[legacy], but the Vizor web HUD's Effort & Cost tab — the place most people actually look — had zero references to cost_source anywhere in synlynk/viz.py. A structurally-measured row and a heuristically-estimated row rendered identically.
Strategic Shifts in This PR
None to the scope — this is the deliverable epic #210 named, built as named. The one design decision worth recording surfaced during brainstorming, not implementation: this codebase already overloads the word "estimate." story["cost_actual"]/dream["cost_est"] mean actual spend vs. budgeted target — a completely different axis from this PR's was this dollar figure structurally measured or heuristically guessed. A naive implementation that reused "actual"/"estimated" bucket names for both axes would have silently narrowed the existing "actual spend" fields to exclude anything estimated-by-provenance, quietly breaking the over-budget math (dream_color, over_budget counting) that already ships. The resolution: every new field speaks only to provenance (cost_prov_estimated, cost_total_estimated, total_usd_estimated), and two different aggregation patterns get applied depending on what a new field sits next to — a strict actual/estimated split for by_agent/by_stage (previously anonymous floats, no pre-existing "actual" semantic to preserve), and a subset-of-an-unchanged-total pattern for story["cost_actual"] and dream cost_total (existing fields whose "money spent, any provenance" meaning had to survive unchanged).
What This PR Shipped
The user's visual direction, given after an initial single-choice question was rejected in favor of a hybrid: combine a summary-stat card with a muted/opacity split within each existing bar — not a separate estimated-only chart, not a filter toggle. Two commits landed on synlynk/viz.py and tests/test_viz.py, dispatched across two agents and independently verified before cherry-pick:
- Task 1 (dispatched to Codex, job-3c6aeb57) — the data layer.
generate_viz_data()'scost_entriesquery now selectscost_source;by_agent/by_stagechange shape from{name: float}to{name: {"actual": float, "estimated": float}}; each dream gainscost_total_estimated;data["costs"]gainstotal_usd_estimated._dream_cost_actual()became_dream_cost_breakdown(), returning both the unchanged total and the new provenance-estimated subset via aGROUP BY cost_sourcequery. Anycost_sourcevalue other than"actual"— includingNULLfor pre-migration rows — buckets as estimated, mirroring the exact classificationcosts.py's[est]/[legacy]flagging already uses. Three new tests covered the by-agent split,NULL-as-estimated, and the dream-level split;tests/test_viz.py's sharedmake_test_db()fixture picked up thecost_sourcecolumn it had been missing since Phase 1 shipped the real schema. - Task 2 (dispatched to Agy, job-837c036c) — the rendering layer. A 5th always-visible summary card, "~Estimated" (
$X.XX (Y%)), landing the CSS grid atrepeat(5, ...)on desktop andrepeat(3, ...)at the 980px breakpoint.render_bar_chart()gained anestimated_keyparameter: when a row's estimated portion is greater than zero, it now draws two adjacent<rect>segments — a solid actual-portion rect keeping its rounded corner, and afill-opacity="0.4"estimated-portion rect immediately after it with no corner radius, so the two segments read as one continuous bar rather than two pills. Rows with zero estimated spend render exactly as before — no behavior change, no extra DOM.dream_label/agent_label/stage_labelappend(est: $Y.YY)when applicable, which can co-occur with the pre-existing dream budget suffix ("$42.00 (est: $12.00) / est $50.00"is valid, if verbose — a rare intersection requiring both a set budget and non-actual spend). A one-line addition to the page subtitle serves as the tab's only legend, avoiding a three-times-repeated per-panel note. Two new tests confirmed the(est: $suffix and faded segment appear when expected and are absent for fully-actual data; the two pre-existing tests built on the old flat-floatby_agent/by_stageshape stayed green unmodified, backed by a new_bucket_total()/_bucket_estimated()helper pair that handles both shapes viaisinstance. - Task 3 (Claude, verification-only, not dispatched) — full regression across
tests/test_viz.pyandtests/test_cost_ledger.py(152 passed), a manualgenerate_effort_html()smoke test confirming the faded segment and summary card render for populated data, and a grep audit confirming no stray consumer elsewhere inviz.pystill treatsby_agent/by_stageas flat floats.
A dispatch-status detection lesson, caught mid-execution. The first poll for Task 1's completion grepped the job's log for an "error" pattern and exited early — it had matched the word "Error" inside an expected TDD red-step test-failure traceback, not an actual failure. The job was still mid-implementation, four lines into a 33-line log. Corrected by polling for the dispatcher's own .log.exit marker file instead of pattern-matching arbitrary log content — a more reliable signal than either the marker-matching approach or synlynk jobs's own status column, which is separately known to be unreliable (tracked as #202).
Brainstorm Visuals Used
None — the visual-treatment question was resolved through plain-text dialogue (the user directly named "Summary row from 1 + 2" after rejecting a single-choice framing), not mockups. The design spec at docs/superpowers/specs/2026-07-15-vizor-cost-flagging-design.md captures the resulting SVG two-rect approach in exact code form instead.
What This Achieved on the Path to Autonomy
This closes epic #210 in full. Every number the Vizor Effort & Cost tab displays is now either structurally sourced or visibly labeled as an estimate, satisfying Fable's H0 pre-GA gate requirement stated at the top of the design spec. It's also the fifth PR in a row (three structured-output adapters plus this one) where all production code was dispatched to Codex/Agy across two independently-reviewed tasks, with Claude's own role limited to design, plan-writing, diff verification, test re-execution, and cherry-picking — the PM/reviewer discipline held through a full data-layer-plus-rendering-layer feature, not just isolated single-function adapters.
A side investigation during this cycle, prompted by a direct question about a "Dream" label appearing where GOVERNS's seven-stage vocabulary was expected, distinguished two entangled-but-separate naming issues: "Dream" as a still-valid display synonym for roadmap_arcs rows (not a bug) versus STAGE_CLASS/STAGE_ICON/_stage_color()'s genuinely stale six-and-five-value stage sets that never migrated to GOVERNS's canonical seven stages. The latter was filed as standalone issue #263, explicitly scoped out of this PR per direct instruction.
Strategic Note: The Goal at the End of This PR
Epic #210 is fully shipped, pending merge. Issue #259 (synlynk status surfacing rates_updated_at) remains the last item under the v0.12.0 Measurement Ledger Hardening theme before that theme's roadmap arc gets marked shipped — deliberately held until both #258 and #259 land, per standing instruction. #260 (Savings Ledger), #261 (dispatch-path unification), #262 (surface consolidation), and now #263 (stage constants) are filed and scoped for a later point release, none blocking.