Skip to content

WorkWingman — Company Benefits & Insurance Comparison (Technical)

Capture each target company's benefits, then let the user compare them side-by-side across the companies in their queue/shortlist — so they weigh offers and opportunities on benefits, not just salary. This epic extends the shipped BenefitsInsight foundation (INS-9: carrier + a trans-inclusive CEI signal) into a fuller structured, per-field-sourced benefits model plus a cross-company comparison UI. It reuses the enrichment + card pattern; it does not rebuild it.

Council-architected 2026-07-23 (tier opus, 5 seats + 1 house voice). Report: llm-council/reports/2026-07-23_181250_you-are-architecting-an-epic-for.md. Consensus ranking D(Grok) > A(Claude) > B(Codex) > C(Gemini) — every seat ranked D first. This doc is the verdict made canonical. Where seats disagreed, the resolved call is marked [ruling].

The honesty posture that governs the whole epic

Same discipline as every other WorkWingman signal, sharpened for benefits:

  1. Every displayed benefit is sourced+cited or explicitly "not on record" — never guessed. A value with no citation is a build/test failure, not a soft UI degrade (Q3).
  2. No "AI benefits score." Benefits are compared qualitatively and per-employer; they are never blended into a single weighted number. Folding sparse, unsourced fields into a score fabricates precision you cannot source — the Kerr trap. Comparison informs; it does not score.
  3. The rich data is user-entered, and that's the honest product. No clean, legal, free per-plan source exists for PTO/premiums/parental-leave/adoption/perks. That data is captured from the user's own offer letter / a company's public benefits page with a provenance kind, or it is absent. Sell this as offer-stage capture + compare, not "Glassdoor with a local cache."
  4. Privacy: gender-affirming / family-forming benefits are surfaced as neutral company facts. Any matching to the user's own situation uses only local profile data and never leaves the machine — consistent with the local-first design.
  5. The mandatory plan-vs-carrier caveat (BenefitsCaveat.PlanVsCarrier) stays attached to every health/coverage cell. The CEI trans-inclusive flag is an employer-level signal, never a plan guarantee.

Foundation reused (do NOT rebuild)

Asset Role in this epic
BenefitsInsight (INS-9) Kept unchanged as the automated-signal subrecord (Form 5500 carriers + CEI). Composed into the new profile, never extended with user fields.
IBenefitsSourceAdapter + CompanyBenefitsService Stay public-signal-only (never-throw, null-on-nothing, most-recent-AsOf merge). User/paste data does not flow through here.
Form5500Source, HrcCeiSource Unchanged. Behind ff.benefits.autoSignals (default-ON).
CompanyBriefing (INS-8, sibling epic) Shares the new provenance primitive + per-company store. Stays a separate record (briefing = narrative/intel; benefits = structured comparable facts).
WW-97 FeatureFlagsService The capability axis. visibility = flagOn && modePolicy. No third toggle system.
DraftHonestyGuard Extended into a render-layer invariant for benefit cells (Q3).
Simple Mode (WW-96) contract /compare is power-only; a calm one-line benefit chip is simple-visible. See §Simple-Mode policy.

Q1 — Data model

[ruling] New CompanyBenefitsProfile that COMPOSES BenefitsInsight. Do not extend BenefitsInsight. It is a multi-adapter signal-merge record with "most-recent AsOf wins" semantics; dumping user-entered PTO/premiums/perks onto it corrupts the merge and the INS-9 TS mirror.

public record CompanyBenefitsProfile(
    string EmployerKey,                        // normalized employer name (shared normalizer)
    BenefitsInsight? AutomatedSignals,         // Form5500 + CEI, unchanged
    SourcedField<HealthCoverage[]> HealthPlans,
    SourcedField<LeavePolicy>      Pto,        // includes cash-out
    SourcedField<LeavePolicy>      SickLeave,
    SourcedField<ParentalLeave>    ParentalLeave,
    SourcedField<FamilyForming>    FamilyForming,   // fertility / adoption / surrogacy
    SourcedField<Retirement>       Retirement,      // 401k match formula
    SourcedField<MentalHealth>     MentalHealth,
    SourcedField<Perk[]>           Perks,           // e.g. "free Panera at Panera", discounts
    DateOnly AsOf);

Provenance is PER-FIELD, never per-record

[ruling] Adopt B's/D's explicit-status model over null-as-unknown: null cannot distinguish unknown from doesn't offer it from sources conflict, and those read very differently to a candidate.

public record SourcedField<T>(
    T? Value,
    FieldStatus Status,        // NotOnRecord | Recorded | NotOffered | Conflicting
    IReadOnlyList<SourceCitation> Sources,
    Confidence Confidence,     // OfficialDoc | UserOffer | PublicIndex | UnverifiedPaste
    string? Notes);

public record SourceCitation(
    SourceKind Kind,           // Form5500 | HrcCei | OfferLetter | RecruiterEmail | OfficialBenefitsPage | PersonalNote
    string? Title,
    string? UrlOrLocalRef,
    DateOnly? AsOf,            // data VINTAGE (plan year / CEI edition / doc date)
    DateTime  RetrievedAt,     // CAPTURE time — kept SEPARATE from AsOf (Codex's best catch)
    CaptureMethod Capture);    // HOW captured — the machine-extraction tag Q3 requires

public enum FieldStatus    { NotOnRecord, Recorded, NotOffered, Conflicting }
public enum Confidence     { OfficialDoc, UserOffer, PublicIndex, UnverifiedPaste }
public enum CaptureMethod  { Automated, UserTyped, LlmExtractedFromUserDoc }

CaptureMethod replaces an earlier bool EnteredByUser, which conflated two distinct origins that both come from user-supplied material (Codex P2, 2026-07-23 gate): a fact the user typed vs a fact an LLM extracted from the user's pasted document. Q3 requires extraction output to be visibly tagged machine-extracted — a bool cannot carry that, so the guard would be unimplementable. Automated = public-signal adapters (Form 5500 / CEI); UserTyped = hand-entered; LlmExtractedFromUserDoc = paste-importer output (renders lower-confidence, always shows the source snippet/span, never tagged Automated).

- `Status.NotOnRecord` with no `Value` is the **default rendered state** — a first-class "not on
  record," never an empty string, never a guess.
- **`AsOf` (vintage) vs `RetrievedAt` (capture)** are separate on purpose: a value transcribed today
  from a 2023 benefits PDF is fresh-capture / stale-vintage, and both matter.

### Field shapes — enum where compare needs it, free-text where data is messy

| Field | Shape (sketch) | Notes |
| --- | --- | --- |
| `HealthCoverage` | `{ Carrier?, PlanType (PPO\|HMO\|HDHP\|Other), EmployeePremium: Money?, EmployerContribution: Money?, TransInclusive: bool? }` | `TransInclusive` here is user/doc-sourced; the CEI *signal* stays on `AutomatedSignals`. |
| `LeavePolicy` | `{ Days: int?, Accrual?: string, CashOut: bool?, Unlimited: bool? }` | PTO cash-out is a first-class field. |
| `ParentalLeave` | `{ BirthingWeeks: int?, NonBirthingWeeks: int?, PaidPct: int? }` | |
| `FamilyForming` | `{ Fertility: bool?, FertilityMax: Money?, Adoption: bool?, AdoptionMax: Money?, Surrogacy: bool?, SurrogacyMax: Money? }` | Neutral company facts; privacy rules apply to any user-fit matching. |
| `Retirement` | `{ MatchFormula?: string, MatchPct: int?, MatchCapPct: int?, Vesting?: string }` | Numeric when parseable, free-text formula fallback. |
| `MentalHealth` | `{ Eap: bool?, SessionsPerYear: int?, Apps?: string[] }` | |
| `Perk` | `{ Label: string, Detail?: string }` | Free-form; comparable by presence + label ("free product," discount). |
| `Money` | `{ Amount: decimal?, Cadence (Annual\|Monthly\|PerPaycheck\|OneTime) }` | |

Every rich field is nullable/`NotOnRecord` by default — **sparse is the expected state.**

## Q2 — Comparison view

**[ruling] New `/compare` route.** Do not overload `/queue` (a list) or `/offers` (single-offer
weighing). Deep-link *into* `/compare` from both.

- **Selection model:** user multi-selects from the shortlist + active offers via an "add to compare"
  checkbox, capped at **~4–6 columns** for legibility. Rows = benefit dimensions grouped by
  category; columns = companies.
- **Sparse-but-honest UX** (the core design problem — most cells are empty):
  - Every empty cell renders a muted **"Not on record"** chip. Cited cells show a **source pill**
    (hover → citation title / URL-or-local-ref + `AsOf`). `Conflicting`/`NotOffered` render
    distinctly from `NotOnRecord`.
  - A per-company **coverage meter** ("6 / 18 fields on record") so sparsity reads as *honestly
    incomplete*, never "this company has bad benefits." This is the line between an honest tool and
    one that quietly defames employers with missing data.
  - **Filters:** *show only differences · only sourced · needs confirmation.* These turn a sparse
    matrix into "here's what to actually ask about at the offer stage."
- **[ruling] Keep SEPARATE from `OfferEvaluationService`.** Benefits comparison stays qualitative
  and employer-level; offer eval stays offer-instance / comp. If a bridge is ever built, it passes
  only **sourced** fields as read-only advisory context plus a coverage signal — **never a blended
  benefits number.**

## Q3 — Source / citation honesty guards

**[ruling] Extend `DraftHonestyGuard` into a render-layer invariant.** **`NotOnRecord` is the ONLY
citation-free status** — it is an absence-of-data state. Every **substantive claim** — `Recorded`
(a value), **`NotOffered`** ("this employer does NOT offer X"), and **`Conflicting`** — requires
**≥1 `SourceCitation`** (Codex P1, 2026-07-23 gate: "not offered" is an assertion about the
employer, not a blank, and stating it without evidence would defame the employer exactly as a
fabricated value would). A cell that carries any status other than `NotOnRecord` with zero
citations is a **build/test failure.**

- **User-entered data — require a provenance *kind*, make the URL optional.** On save the user picks
  where it came from: *offer letter · recruiter email · official benefits page · personal note.*
  "Personal note" is allowed but stamps `Confidence.UnverifiedPaste` and renders visibly
  lower-confidence. Kind is mandatory; URL is not. This closes the fabrication vector without
  blocking a candidate typing what a recruiter told them on a call.
- **LLM boundary (non-negotiable):** an LLM may **only extract/structure text the user pasted**
  (offer letter, official benefits page). It may **never originate a benefit value**, never infer
  from industry, never web-browse to "fill in Acme's PTO." Extraction output is tagged
  `EnteredByUser`/machine-extracted, carries the original source snippet/span, and is never tagged as
  an automated signal.

### Guard tests (all required, review-gate blockers)
1. No `SourcedField` in a substantive-claim status (`Recorded`/`NotOffered`/`Conflicting`) renders
   with zero citations — only `NotOnRecord` may be citation-free.
2. Banned-certainty scan ("unlocks", "guarantees", "typical", "likely", "industry-standard")
   extends to benefits copy.
3. **Golden test:** the extraction adapter fed empty/irrelevant input returns **zero** fields —
   never invented ones.
4. The CEI trans-inclusive signal is never rendered as a plan guarantee; `BenefitsCaveat.PlanVsCarrier`
   is always present on health/coverage cells.
5. Family-forming / gender-affirming fit-to-user scoring stays on-device and off any export path by
   default.

## Q4 — Enrichment adapters

**Build:**
1. **User-entry write path.** **[ruling]** User facts are a **first-class write API into
   `CompanyBenefitsProfile`, NOT an `IBenefitsSourceAdapter`.** Adapters remain public-signal-only
   (never-throw, most-recent-`AsOf`-wins); routing user truth through them confuses signal-merge with
   user-truth storage. User/paste writes land directly on the profile's fact store.
2. **Official-benefits-page / offer-letter structured-paste importer.** User supplies a URL or pasted
   text → **strict-JSON LLM extraction** → `EnteredByUser` `SourcedField`s → user **confirms before
   persist.** Highest-yield source that exists, fully local-first.

**Defer (not this epic):** **SEC proxy/10-K adapter.** Public-company proxy statements mention *some*
broad retirement/benefit facts but are noisy and exec-centric — never per-plan truth. If built later
it is a deterministic keyword harvest producing **low-confidence notes only**, clearly caveated.

**Do NOT build (ToS-forbidden or hallucination factories):** Glassdoor / Levels.fyi / Blind
scrapers; any HRC UI scraper (keep the curated CEI snapshot); any "LLM enrich from company name"
adapter; any paid premium-benefits API (breaks local-first). All public-signal adapters keep the
never-throw / null-on-nothing / graceful-degradation contract.

## Q5 — Sibling coordination (shared primitive, separate aggregates)

**[ruling]** Do **not** build one mega company-enrichment entity.

- Extract a shared **`WorkWingman.Core.Enrichment`** core: `SourcedField<T>`, `SourceCitation`,
  `FieldStatus`/`Confidence` enums, the **normalized employer-key normalizer**, and a **per-company
  local store** keyed by that normalized name.
- `CompanyBenefitsProfile` (this epic) and `CompanyBriefing` (INS-8 sibling) stay **separate domain
  records** sharing that core, both living in the shared store, cross-linked by employer key only.
  Boundary: **briefing = narrative/intel; benefits = structured comparable facts.** Where a benefit
  is a useful talking point, the briefing epic **references** the benefits store — it never copies
  the fields.

**Ownership (agreed boundary sent to the sibling session):**

| Owner | Fields |
| --- | --- |
| **Benefits epic** | health insurance, gender-affirming coverage, PTO (incl. cash-out), VTO, sick leave, parental leave, adoption/surrogacy/fertility, 401k match, mental health, perks/discounts |
| **Company-intel epic** | WhatTheyDo, TechStack, RecentNews, TalkingPoints, friendliness/culture ratings, interview intel |

### Feature flags
ff.companyEnrichment (master, default-OFF) ├─ ff.benefits.autoSignals (default-ON) — existing Form5500 + CEI (INS-9) ├─ ff.benefits.profileAndCompare(default-ON) — /compare + user entry + paste importer └─ ff.companyBriefing (default-ON) — sibling epic (INS-8) ``` SEC adapter and any future importer ride behind config or a single dev-only child, not their own top-level flags. Unknown flag = off, consistent with WW-97.

Simple-Mode policy (WW-96 contract)

  • /compare — full comparison table. Power-only (route omits data.simple; the guard redirects deep links to /home in simple mode).
  • Benefits capture/edit — lives inside existing /job-detail and /offers surfaces (no new top-level route); power-oriented editor gated subtractively under body[data-mode='simple'].
  • Simple-visible: a calm one-line benefit chip inside job/offer cards, e.g. "Aetna · trans-inclusive ✓ · PTO cash-out ✓ · 4 benefits on record" — CSS-only, no new route. This is the simple-mode face of the epic; full comparison stays power-only.
  • Local toggles (e.g. which companies are pinned to compare) use persistedSignal<T> per the Simple-Mode binding contract. visibility = flagOn && modePolicy.

Feasibility — stated honestly (make this the product framing)

Feasible this epic: side-by-side comparison of sparse, cited fields; automated carrier chips (Form 5500) and a trans-inclusive CEI signal; user/paste capture with provenance; a coverage meter that makes "unknown" honest.

NOT feasible — do not promise it: complete automated benefit catalogs, accurate premiums / plan-types at scale, or "AI knows every company's PTO/parental/adoption policy." No clean, legal, free per-plan source exists — that data is user-entered or absent, full stop. The single biggest thing to push back on if anyone asks for it: auto-fetched premiums and an "AI benefits score."

Decomposition (proposed tickets)

# Scope Tier Deps
T0 Shared Core.Enrichment: SourcedField<T>, SourceCitation, FieldStatus/Confidence, employer-key normalizer, per-company store. Shared contract with sibling epic. Sonnet + ceiling —
T1 CompanyBenefitsProfile record + typed sub-records + TS mirror Sonnet T0
T2 User-entry write path + store persistence (LocalJsonStore collection) Sonnet T1
T3 Paste importer (offer-letter / benefits-page) — strict-JSON LLM extraction, confirm-before-persist Sonnet + ceiling on prompt T2
T4 Render-layer honesty guard + 5 guard tests Ceiling (honesty-critical) T1
T5 /compare route + view (multi-select, coverage meter, sparse chips, filters) Sonnet T1, T4
T6 Simple-Mode calm benefit chip on job/offer cards Haiku T1
T7 Feature-flag wiring (ff.companyEnrichment tree) + docs Haiku T0

T0 lands first — it is shared with the running company-intel epic, so it must land once. Bulk model/adapter boilerplate is fine in a Fable worktree.

Tests

Mirror the INS-9 test discipline: pure-unit for the model/provenance logic, injected fixtures (zero network) for any extraction, HttpTest-style stubs only for the unchanged public adapters. The five Q3 guard tests are review-gate blockers. Stryker scoped to the new enrichment logic via the existing **/Enrichment/*.cs glob.

  • Foundation: benefits-insight.md (INS-9)
  • Sibling epic: company-research.md (INS-8)
  • Simple Mode: WW-96 binding contract (persistedSignal, UiModeService, subtractive gating)
  • Feature flags: WW-97 FeatureFlagsService
  • Council report: llm-council/reports/2026-07-23_181250_you-are-architecting-an-epic-for.md