# Member Connections — data contract (v0.4.0)

The interface between the front end (Tristan) and the data pipeline (Charlie),
following the earnings-tracker convention: **contract and fixtures change
together in the same PR**.

v0.4.0 adds the **functional community** layer (`groups[].function`,
`members[].function`, and the `groups` filter on the Query call), from
senior-memberships-associate feedback
2026-09-30: group leaders want to narrow to "the HR function" or to an ad-hoc
set like "Finance 50 and HR 50", because some groups partner closely.

v0.2.0 incorporates Charlie's first real export (Explanations CSV,
2026-09-11, Analytics 50): confidence + caution + rank fields, call-transcript
evidence with quotes, and the feed layout below.

## Feed layout

```
data/fixtures/   synthetic fixtures — IN the repo, fictional members only
data/real/       real data — GITIGNORED, local-only, never committed or
                 deployed via git (Growth Radar rule). Built by:
                 node tools/build_real_feed.mjs <path-to-charlie-csv>
```

The app serves `data/fixtures/` by default; `?feed=real` switches to the
real layer and shows a "Real data" header tag. All feeds are **top-level
arrays** (except `meta.json`).

## Feeds

### `meta.json` (object)

| field | type | notes |
|---|---|---|
| `as_of` | ISO 8601 string | when recommendations were generated; shown in the header |
| `feed_version` | semver string | bump on any contract change |
| `synthetic` | boolean | `false` switches the footer data note + header tag |
| `roster_complete` | boolean | `false` = feed contains only members appearing in the export; the app then hides members with no recommendations instead of rendering a false "quiet" |

### `groups.json` (array)

| field | type | notes |
|---|---|---|
| `id` | string | stable id, e.g. `g-analytics50` |
| `name` | string | display name |
| `focus` | string | one-line description shown in the legend |
| `function` | string \| null | **v0.4:** the functional community this group belongs to, e.g. `Human Resources`, `Operations & Supply Chain`. Several groups may share one. `null` on every group → the app drops the function layer and offers a flat group filter |

### `members.json` (array)

One row per member in a covered group — including quiet members with no
recommendations this cycle when the roster is complete (fixture `m-11`
Elena Vasquez is the teaching case).

| field | type | notes |
|---|---|---|
| `id` | string | stable member id (real feed: `m-<name-slug>`) |
| `name` | string | |
| `title` | string \| null | null → row renders without it ("profile join pending") |
| `company` | object \| null | null → attributes omitted; see fixture shape for the full object (name, industry, ownership, ticker, sponsor, revenue_band, employee_band, hq) |
| `group_ids` | string[] | members can belong to multiple groups |
| `avatar_url` | string \| null | null → app renders initials |
| `priorities` | string[] | stated priorities; may be empty |
| `seniority` | `c_suite` \| `evp_svp` \| null | v0.3: drives the seniority query filter; null disables it |
| `region` | `na` \| `emea` \| `apac` \| `latam` \| null | v0.3: drives the region query filter; null disables it |
| `function` | string \| null | **v0.4:** the member's own functional community, authoritative when present. `null` (the fixtures today) → the app derives it from their groups' `function`. Supply this once the profile join lands, for members whose function differs from their group |

### `connections.json` (array)

One row per **directional** recommendation. **`id` must be stable across
regenerations** — feedback events join on it. The builder derives it from
the pair: `c-<need-member-slug>--<help-member-slug>`.

| field | type | notes |
|---|---|---|
| `id` | string | pair-derived, stable |
| `member_id` | string | who sees this recommendation (the NEED side) |
| `target_member_id` | string | who they should talk to (the HELP side) |
| `score` | number 0–1 | max SIMILARITY across the pair's evidence rows |
| `rank` | int \| null | RANK_FOR_MEMBER from the pipeline |
| `confidence` | `high` \| `medium` \| `low` \| null | null (fixtures) → gold falls back to `score ≥ 0.85` |
| `caution` | string \| null | e.g. alumni status, stale evidence — rendered as an amber warning |
| `headline` | string | one-line hook |
| `why[]` | array | rationale bullets: [0] = RATIONALE (carries the call citations), [1] = "Why them over the other candidates: " + TOP_PICK_REASON |
| `why[].text` | string | |
| `why[].evidence[]` | array | citations, rendered as numbered footnote markers |
| `generated_at` | ISO date | |

**Gold rule (one gold class, the member-first UI pattern):**
`confidence === "high" && !caution`. Medium/low confidence and cautioned
rows are *never* gold — they classify the member as "Flagged" and render
the caution prominently. Fixtures without a confidence field fall back to
`score ≥ 0.85`.

### Evidence / citation shape

| field | type | notes |
|---|---|---|
| `type` | enum | `call` (real feed) \| `salesforce` \| `mi` \| `gathering` \| `news` \| `profile` |
| `label` | string | e.g. `Mangesh — Enterprise knowledge management` |
| `speaker` | string | whose words the quote is (need or help member) |
| `quote` | string | verbatim call excerpt, shown in the citation popover |
| `topic` | string | |
| `memory_type` | string | pipeline's memory class: `priority_goal`, `member_need`, `connection_topic`, `expertise`, `career_governance`, `ask`, `offer` |
| `call_key` | string \| null | Salesforce record key (`EVT:…` / `VIN:…`) — becomes the deep link |
| `date` | ISO date \| null | call date |
| `cited` | boolean | `false` → popover shows a "paraphrase — verify before repeating" flag |
| `url` | string \| null | deep link when one exists |

## Query (v0.3 — the primary use case)

Group leaders field asks from members ("worried about the Russia/Ukraine
conflict — need someone who thinks about continuity of business in the
energy sector") and query on the member's behalf: member + free-text ask
+ optional filters. **The ask informs the recommendations** — this is a
retrieval call, not a filter over the standing feed.

Future endpoint (the prototype simulates it client-side with keyword
matching + a standing-recommendation boost — a placeholder, not the
semantics we want):

```
POST /api/connections/query
{
  "member_id": "m-…",
  "ask": "free text, the member's words",
  "filters": {
    "groups": ["g-finance50", "g-hr50"],          // v0.4: empty/absent = any group.
                                                   // A function is just its set of
                                                   // group ids — the client expands it,
                                                   // so the endpoint takes groups only.
    "seniority": "c_suite" | "any",
    "ownership": "public" | "private" | "pe_backed" | "any",
    "region": "na" | "emea" | "apac" | "latam" | "any",
    "industry": "<industry string>" | "any",
    "revenue_band": "<band string>" | "any"
  }
}
→ {
  "interpretation": { "terms": ["continuity", "energy", …] },   // what the retrieval keyed on — rendered back to the user
  "results": [ <connection-shaped rows, same schema as connections.json,
               plus "match": { "terms": [...], "source": "standing" | "generated" } > ]
}
```

Results reuse the connection schema so the front end renders one row
type everywhere. `source: "standing"` = an existing recommendation
surfaced by the ask; `"generated"` = built for this query (the app
currently renders its own thin "profile match" stand-in for these).
Generated results still need stable ids for feedback — suggested:
`q-<member>--<target>` plus a query hash if the same pair can recur
with different rationale.

## Feedback events (client → pipeline, future endpoint)

The app captures `connection_feedback` events
(`{event, connection_id, verdict: up|down, reasons[], comment, ask, ts}` +
`actor_email` from the session) in localStorage, exportable from the
Feedback panel. v0.3: `ask` carries the free-text ask when the vote is on
a **query result** (null for standing recommendations) — feedback on
retrieval is training data for retrieval, so the query context travels
with the verdict. Query-only results use `q-<member>--<target>` ids.
Reasons: `already_connected | not_relevant | bad_timing |
other`. A repeat vote on the same connection replaces the prior one —
which is why `connection_id` stability matters.

## Open questions for Charlie (updated after the 2026-09-11 export)

1. ~~connection_id stability~~ → settled: ids derive from the member pair.
   Confirm a pair means the same recommendation across runs.
2. **Profile join**: the export has names only. The app needs a members
   feed with title / company / industry / ownership / size to render the
   attribute layer — same join the earnings tracker already does
   (CURRENT_EMPLOYER__C). Until then rows show "profile join pending".
3. **Roster feed**: the export only contains members with recommendations.
   A full group roster (or `roster_complete: true`) unlocks the "quiet —
   none this cycle" state, which group leaders read as signal.
4. **`cited: FALSE` semantics**: confirmed as "quote not verbatim from the
   call record"? The app flags those as paraphrase-verify.
5. **Score semantics**: SIMILARITY reads as cosine-style, not calibrated —
   the app therefore shows the confidence tier, not a %. Confirm.
6. Deep links: can `call_key` (EVT:/VIN:) become a Salesforce URL the
   associate can open?
7. Refresh cadence + where feedback events should land (R2 object? a table?).
8. Only `high`/`medium` confidence appear in this export — does `low`
   exist and get filtered upstream, or should the app expect it?
9. **Top-N export**: the 2026-09-11 export has exactly one HELP member per
   NEED member, but RANK_FOR_MEMBER reaches 3 and every TOP_PICK_REASON
   compares against "the other candidates" — the ranking clearly exists
   upstream. Export the top 3–5 per member (rank + confidence per row);
   the UI already renders multi-recommendation members (fixture Dana
   Whitfield, 5 recs, is the model).
10. **Query endpoint (v0.3, now the primary use case)** — see the Query
    section: member + ask + filters → ranked, cited results. The
    prototype's client-side keyword match is a placeholder; the real
    thing should read intent the way the standing pipeline reads calls.
    Also needed: where does per-query latency land (sync request vs.
    job + poll)?
11. **Seniority + region in the members feed** — the query filters need
    `seniority` and `region` per member (see members schema). Region from
    HQ country; seniority from title banding.
12. **Functional community — is it a field, or do we derive it?** (v0.4, from
    senior-memberships-associate feedback 2026-09-30.) The app currently
    derives a member's function from their groups' `function`, which we set in
    the fixtures. Two questions: (a) does Salesforce carry a functional
    community / function on the person or the group, so the taxonomy is yours
    rather than ours? (b) can a member's function differ from their group's —
    if so we need `members[].function` populated, not just derived.
13. **Transcript coverage per group.** The associate's other ask is that
    results carry the direct quote so GLs can quality-check relevance — which
    the pipeline already does well for Analytics 50. Before this goes wider:
    which groups have call transcripts flowing, and how far back? A function
    filter across groups with uneven transcript coverage will silently look
    like "the HR function has no good matches" when it actually means "no
    transcripts yet".
