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:
- 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).
- 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.
- 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."
- 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.
- 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
Simple-Mode policy (WW-96 contract)¶
/compare— full comparison table. Power-only (route omitsdata.simple; the guard redirects deep links to/homein simple mode).- Benefits capture/edit — lives inside existing
/job-detailand/offerssurfaces (no new top-level route); power-oriented editor gated subtractively underbody[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.
Related docs¶
- 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