Skip to content

Total Rewards + PSU / Equity Calculator — Design (WW-125 epic)

Status: council-architected 2026-07-23 (opus tier; report llm-council/reports/2026-07-23_195341_architecture-review-for-workwingmans-total-rewar.md). Consensus ranking A(Clahadore) > D(Gronk) > B(Cedric) > C(Jenny); local seat timed out, house councils 401-gated — verdict rests on 4 frontier seats.

Anchor ticket: WW-125 (T0). Remaining tickets: WW-134+ (see §Decomposition). Sibling epics sharing the comp chain: benefits comparison (WW-117..124), Take-home tax+COL (WW-126..133, separate session), Simple Mode (WW-96).


1. Purpose & honesty posture (binding)

Help a user understand a company's/offer's total rewards — base + bonus + equity (RSU/PSU/ options) + benefits + net take-home — as one comparable, honest picture, and give a real PSU/equity calculator that models performance-stock payout as a range, never a fabricated point.

Equity/comp carry large, irreducible uncertainty. Every surface obeys:

  • Ranges, not points. Where the model is a band, the UI shows a band. Never collapse to a midpoint or single "expected" number.
  • Sourced or absent. Every dollar/equity figure carries SourcedField<T> provenance + as-of date. Only NotOnRecord may be citation-free. No fabricated values, ever.
  • Not financial/investment advice. This is a calculator/organizer. No take/reject recommendation, no ranking of offers, no "best offer", no net-of-tax equity computation. The prohibited-action rule on personalized investment advice applies — if a surface would imply a recommendation, it is a build failure.
  • As-of everywhere. Stock prices, tax rates, benefit facts all carry the date they were true.

The Kerr lens governs any "total": a single point-valued or ranked total rewards number is the gameable proxy ("maximize the number") standing in for the real goal ("choose the right role"). It is banned. A subtotal is permitted only under the strict guards in §4.


2. PSU model (Q1)

2.1 Data model — PsuTerms subrecord + GrantType.Psu

Add Psu to EquityGrantType. PSU-specific fields live in a separate PsuTerms record referenced by the grant — NOT flat nullable fields on EquityGrant (that pollutes the clean RSU/ISO/NSO discriminated shape and invites "looks priceable" bugs). EquityGrant stays the common envelope; PSU reuses its VestingYears/CliffMonths/GrantDate schedule.

public enum EquityGrantType { Unknown, Rsu, Iso, Nso, Psu }

public enum PsuMetricKind { Unspecified, RelativeTsr, AbsoluteTsr, FinancialMetric, Other }

/// <summary>
/// Performance conditions for a PSU grant. Payout = TargetUnits × multiplier, where the
/// multiplier is read off the anchor curve at the user-asserted attainment. NOTHING here is
/// inferred: relative-TSR percentile attainment is not computable offline (needs peer-set
/// returns + ranking), so UserAttainment is ALWAYS an explicit user assertion or null.
/// </summary>
public sealed class PsuTerms
{
    public PsuMetricKind MetricKind { get; set; } = PsuMetricKind.Unspecified;
    /// <summary>Free-text label of the actual metric ("Relative TSR vs S&P 500", "cumulative EPS"). Never parsed into a computation.</summary>
    public string? MetricLabel { get; set; }
    public int PerformancePeriodYears { get; set; } = 3;

    /// <summary>Shares granted AT TARGET (100%). Actual delivered = TargetUnits × multiplier.</summary>
    public decimal? TargetUnits { get; set; }

    /// <summary>Payout curve anchors. Multiplier interpolates linearly between them; clamps at Max; 0 below Threshold.</summary>
    public decimal ThresholdMultiplier { get; set; } = 0.5m;
    public decimal TargetMultiplier { get; set; } = 1.0m;
    public decimal MaxMultiplier { get; set; } = 2.0m;
    public decimal FloorMultiplier { get; set; } = 0m;

    /// <summary>
    /// User's explicit assertion of expected attainment (as a multiplier, e.g. 1.3×), or null.
    /// NEVER inferred. Null → payout shown as the threshold..max band with no point highlighted.
    /// </summary>
    public decimal? UserAttainment { get; set; }

    /// <summary>True when Threshold/Target/Max were NOT entered from the real grant but taken from typical defaults. Stamps every derived figure. Sticky until the user edits an anchor.</summary>
    public bool AssumedAnchors { get; set; }

    public List<SourceCitation> Sources { get; set; } = [];
    public DateTimeOffset? AsOf { get; set; }
}

EquityGrant gains: public PsuTerms? Psu { get; set; } (non-null only when GrantType == Psu).

2.2 Payout-range math

Extend EquityValuationService (pure, no network) with a PSU path:

  1. Resolve per-share price exactly as today (public quote / private price / UnpriceableReason).
  2. PSU settles in shares, no strike → per-share value = full price (the RSU path).
  3. Emit a share-count band:
  4. thresholdShares = TargetUnits × ThresholdMultiplier
  5. targetShares = TargetUnits × TargetMultiplier
  6. maxShares = TargetUnits × MaxMultiplier
  7. below floor → 0.
  8. Value band = each scenario × price × (vested-fraction where a vested view is requested).
  9. If UserAttainment is set (a user-asserted multiplier, never inferred): resolve it through the curve — below ThresholdMultiplier → FloorMultiplier (0×), otherwise capped at MaxMultiplier. Emit that as an additional labeled point inside the band — never replacing it. (Interpolation from a performance-level input, e.g. a percentile, is deferred: it needs a defined attainment scale, and for rTSR that scale is not computable offline anyway — so v1 takes the multiplier the user asserts directly.)
  10. Default display attainment = TargetMultiplier (1.0×), labeled "plan target — not a prediction."
  11. Missing TargetUnits or price → UnpriceableReason, never zeros. New enum member UnpriceableReason.PerformanceUnknown for "PSU attainment not asserted / not knowable offline".

New valuation output fields (additive, on EquityValuation or a PsuValuation sub-object): PsuValueBand { ThresholdValue, TargetValue, MaxValue, AssertedValue?, IsAssumedAnchors }, each a range-aware decimal, each UnpriceableReason-guarded.

2.3 General valuation strengthening

  • Options underwater state: price < strike → intrinsic $0, labeled "underwater at today's price" (a factual state, not a loss claim). Already intrinsic-only; add the explicit label.
  • Vesting schedules: keep the standard linear-after-cliff default. Additive VestCadence enum (Monthly default / Quarterly / Annual) — do NOT over-build back-loaded custom schedules until a real grant needs one.
  • Tax NOTE only (never advice, never computed): a one-line factual chip per grant type — RSU/ PSU = "ordinary income at vest (W-2)"; ISO = "AMT preference item; no ordinary tax at exercise if held"; NSO = "ordinary income on the spread at exercise". No withholding math, no bracket math, no net-of-tax number. Marginal-rate annotation (from the tax engine) may say "taxed near your ~X% marginal rate" — a factual annotation, not a net-equity computation.

3. Total Rewards home (Q2)

New read-time ITotalRewardsService / TotalRewardsService in Infrastructure that composes existing services and wires the stubbed IEquityValuation seam:

TotalRewardsService(
    ITakeHomeCalculator,          // consumes TaxEstimate from WW-126..133 (annual net, effective, marginal)
    IColAdjustmentProvider,       // COL index — SEPARATE axis, never pre-multiplied into comp
    IEquityValuationService,      // equity/PSU bands
    ICompanyBenefitsService,      // benefits facts (WW-118) — qualitative, excluded from any subtotal
    ICompensationInsightService)  // market context (P25/median/P75), side-by-side, never merged
  -> TotalRewards (a projection)
  • Projection, not a store. No persistence, no new comp entity. Computed at read time from the user's already-persisted EquityGrant / Offer / FinancialProfile / benefits data.
  • JobImpactService stays the two-job DELTA tool. OfferEvaluationService stays the banded-axes, no-composite decision-support tool. Neither is extended into an aggregation layer — that would conflate delta-vs-absolute and re-import the Kerr composite risk they deliberately refused.
  • Wire the IEquityValuation seam (currently EquityValuationAdapter, unregistered) into DI so TotalRewardsService (and, later, JobImpactService) fold equity in via the narrow seam. Single-writer on the seam.

3.1 Seam contract with the tax+COL engine (WW-126..133)

TotalRewardsService consumes TaxEstimate{Gross, Federal, Fica, State, Local, TakeHome, EffectiveRate, MarginalRate, DataVintage} from the existing IncomeTaxEstimator — never forks it. Binding notes:

  • Net take-home = one component row/group, separate from the pretax cash+equity rows. Never blended into a single pretax number (double-counts + launders a tax computation).
  • MarginalRate used ONLY for the factual equity-vest annotation (§2.3). Never to compute net-of-tax equity.
  • Filing-status degrade: the tax engine is Single-only today (throws on MFJ/HOH). Until their filing-status tier lands, the net-take-home row renders as NotOnRecord / "unavailable for your filing status" — never a wrong number.
  • COL stays a separate labeled axis (index / purchasing-power translation with as-of source). Andrew's "what's this worth here vs there" is honored as an explicit COL translation, never a blended "real comp" scalar or default sort key. Converged with the tax+COL council (WT-57e5, report 2026-07-23_195923_*):
  • Two axes, never multiplied. JobImpact.ColAdjustedDelta is deprecated and deleted — the rewards side never binds it (WW-136 consumes raw TakeHome + a separate COL column), so it can be removed outright, not just [Obsolete]. Never recomputed as net × COL under any alias.
  • Purchasing-power translation = a UNIT CONVERSION, not a score. "$X nominal take-home here buys ~what $Y buys at {home} COL." Always shown paired with the raw net number, never standalone, never a sort key; carries basket + SourceCitation + as-of; labeled "basket partial (rent/grocery/gas ± property/car); not after-tax welfare."
  • Default /rewards compare ordering = nominal net take-home DESC (one disclosed factual axis). COL + purchasing-power are separate visible columns, not foldable into the sort. Any composite / "best value" ordering is opt-in + banner only, never the default.
  • Filing coverage: the tax council shipped all four federal statuses (Single/MFJ/HOH/MFS) in its T1, so the wrong-filing gap closes early. The NotOnRecord net-row degrade is kept as a defensive guard for any status TaxEstimate can't price, not as an expected common path.

4. The "total" — Kerr ruling (Q3)

A cash-comparable subtotal is allowed, narrowly scoped. Pure component-rows would under-serve the real "what's this roughly worth" need. Permitted iff every guard holds:

  1. The subtotal is itself a RANGE — sum-of-lows … sum-of-highs, never a midpoint / point value. A single number is the gameable proxy. This is the load-bearing invariant.
  2. Net take-home is a SEPARATE row/group, never folded into the pretax cash+equity blob.
  3. Dollar-denominated components only: base + bonus + sign-on (amortized) + equity band + net take-home (separate group). Non-dollar benefits excluded — a blended benefits score is exactly the Kerr proxy. Benefits stay qualitative (their own epic's /compare).
  4. Each row carries its own range + as-of + source (SourcedField<T>). Multi-dated as-of shown, not silently unified.
  5. No ranking, no normalization, no "best offer", no Accept CTA off the subtotal. It is optional arithmetic of visible rows.

Rule of thumb (ships in the copy deck): honest when every uncertainty is on-screen and nothing collapses to a point; gameable the instant it is ranked or point-valued.


5. PSU curve input UX (Q4)

  • Real anchors are the primary path. The form asks for threshold/target/max from the user's grant.
  • Typical defaults (0.5 / 1.0 / 2.0×) allowed only behind PsuTerms.AssumedAnchors = true, which visibly stamps every derived figure "typical assumption — enter your real terms." Sticky until the user edits an anchor.
  • Real anchors required before any figure is saved/exported as "on-record." Assumed-anchor figures are exploratory only, never persisted as fact.
  • Empty + no defaults → unpriceable band (PerformanceUnknown), never silent fabrication.

6. Flags + routes (Q5)

  • Master ff.totalRewards AND-ed children {equity.psu, rewards.comparison}; unknown key = false (fail closed). WW-97 FeatureFlags pattern (static keys, master AND child). Master default-OFF; children default-ON under it.
  • /equity (existing, power-only, no simple flag) — PSU calculator extends it.
  • /rewards (new, power-only) cross-company comparison — dark until the demand signal (§7). data.simple omitted; guard redirects deep links to /home in simple mode.
  • Offer-detail rewards breakdown — subtractive body[data-mode='simple'] { display: none } on the scoped breakdown element, not the page <body>.
  • Simple-mode offer-card face — calm one-line range + on-record facts, never a score (e.g. "Base $X + equity (range) · net take-home on record"). --simple-* tokens, CSS-only.
  • Per-band maturity gate: where the comparison view renders equity/PSU bands, gate that rendering on equity.psu maturity so it can't display bands the calculator can't yet produce. Do NOT block non-equity comparison columns on PSU maturity — dependency is per-band, not whole-route.

Coordinated with Simple Mode (WT-c9de): consume persistedSignal<T> for local prefs; capability visibility = flagOn && modePolicy; no third toggle system.


7. Venture-evidence lens

Ship the PSU/equity calculator first. It stands alone (single-offer, low input burden, reuses EquityGrant + quotes, no comparison UI). Full cross-company aggregation now is premature.

  • /rewards comparison UI ships dark. The read-only single-offer composition API may land now.
  • Painted-door instrument: a "Compare Offers" control on the calculator that logs intent and shows coming-soon — demand is observed, not assumed.
  • Predeclared go-signal (committed before build): build the comparison engine only when, within 30 days of the calculator shipping, ≥5 distinct local users enter equity/PSU terms for ≥2 companies each, OR ≥3 explicit user requests for side-by-side. Until then the comparison UI stays gated.

Validate the total-rewards framing with the Sean/Shereeba cohort before deep comparison build.


8. Honesty guards (Q6)

Render-layer guard extends DraftHonestyGuard (benefits WW-121 pattern). All are build/test failures, not warnings:

Guard Enforcement
Citation Any dollar/equity figure without SourcedField provenance → build failure. Only NotOnRecord citation-free.
Certainty Banned-phrase scan on comp copy ("will be worth", "guaranteed", "you'll get", "will vest", "expected value" unless labeled plan-target) → build failure.
Advice No take/reject language; no rank-ordering offers by total.
Tax No net-of-tax equity anywhere — reject any equity × tax-rate computation. Equity stays a pre-tax band; tax applies only on the cash/take-home path.
Frame Mandatory NFA disclaimer + as-of on every equity/total surface.

Plus a service-layer invariant (not just copy-scan): TotalRewardsService / EquityValuationService never emit a point where the model is a band; UnpriceableReason over fabricated zeros. Golden test: PSU with no attainment returns a threshold..max band + reason, not a number.


9. Decomposition

Anchor WW-125 + block WW-134+ (WW-126..133 reserved by the tax+COL epic).

Key Tier Ticket Deps
WW-125 Sonnet T0: GrantType.Psu + PsuTerms subrecord + UnpriceableReason.PerformanceUnknown + TS mirror; walking skeleton, no math yet —
WW-134 Sonnet T1: PSU payout-range math in EquityValuationService (share-count band × price, anchor interpolation, clamp/floor, PerformanceUnknown guard) + golden tests WW-125
WW-135 Sonnet T2: equity valuation strengthening — options underwater label, VestCadence, per-type tax-NOTE chips WW-125
WW-136 Sonnet T3: TotalRewardsService read-time projection composing existing services + wire IEquityValuation seam in DI; single-offer composition API WW-134, tax seam
WW-137 Ceiling T4: honesty guard extension (citation/certainty/advice/tax/frame) + service-layer band invariant + 5 guard tests WW-136
WW-138 Sonnet T5: /equity PSU calculator UI (anchor form, assumed-anchor stamp, band display) + painted-door "Compare Offers" instrument WW-134, WW-137
WW-139 Sonnet T6: offer-detail rewards breakdown (subtractive simple-mode) + calm one-line offer-card face WW-136, WW-137
WW-140 Haiku T7: feature-flag wiring ff.totalRewards → {equity.psu, rewards.comparison} + docs WW-125
WW-141 Sonnet T8 (GATED): /rewards cross-company comparison view + per-band maturity gate — dark until §7 signal WW-136, WW-138

Ceiling-tier on WW-137 (honesty-critical, escalation-ladder skip-list). WW-141 build does not start until the predeclared demand signal fires.


10a. Council gate refinements (2026-07-24)

Applied from the pre-land council-code-review (Cedric/Codex + Gronk/Grok + Gaming-PC qwen3-coder; qwen3-next hit the 600s cap, Gemini needed re-auth):

  • Honesty guard must not flag its own disclaimer. The bare guarantee scan matched the StandardDisclaimer ("not a guarantee of realized value"). Certainty patterns are now negation-safe (not a guarantee / no guarantee / nothing will vest pass). Regression test asserts Check(StandardDisclaimer) is empty.
  • No blanket "plan target" exemption. A global exemption let "plan target is 1.0×; your expected payout is $2M" slip through. expected value/payout/return is now flagged unconditionally — the honest label is "plan target — not a prediction", never "expected value".
  • Net-of-tax copy ban class added (net-of-tax, after-tax equity, take-home equity).
  • PSU anchor sanity: anchors must satisfy 0 ≤ floor ≤ threshold ≤ target ≤ max or the band is unpriceable (never a negative/inverted range). Asserted attainment is Math.Clamp-ed to [floor, max].
  • AssumedAnchors defaults to true: an un-entered PsuTerms carries the typical default curve, so it is honestly stamped assumed until the write path records real anchors.
  • Options with no strike → unpriceable (not valued as if strike were $0). This tightens the pre-existing null-strike behavior in line with the honesty posture; the two remaining missing-input gaps (GrantDate-null vest split, thin-history vol) stay tracked as a separate follow-up.

10. Privacy / local-first

All equity/comp/PSU data is local-only, DPAPI-encrypted at rest like Offer.Comp and FinancialProfile, never mirrored. The only network call is the existing read-only, factual stock-quote lookup (Stooq/AlphaVantage). Family-forming/gender-affirming benefit fit stays on-device (benefits epic rule, inherited).