# Subscriber roster handling

The subscribers pass writes one `:KnowledgeDocument {kind:'substack-subscriber-roster'}` and one `:Person` per row, with a `:MENTIONS` edge between them carrying subscription tier and engagement aggregates.

## Per-row parse

For each row in `subscribers.csv` (after bulk-gate filter):

1. **Email — required.** Lowercase. If missing or malformed, emit `[substack-import:error] file=subscribers.csv reason="missing email at row <n>"` and skip.
2. **Name — optional.**
   - Split on first whitespace.
   - First token → `givenName`.
   - Remainder (if any) → `familyName`.
   - Single-token names: `givenName=token, familyName=null`.
   - Empty name: both null. The `:Person` writes with email only.
3. **Tier — required.** Read from `type` / `membership_state` / `subscription_state` (first present). Pass through verbatim — `free`, `paid`, `comp`, `gift`, anything else. Validation is downstream's responsibility.
4. **Subscribed-at — required.** Read from `created_at` / `subscribed_at`. ISO 8601.
5. **Unsubscribed-at — optional.** Present only on unsubscribed rows. ISO 8601.

## Per-row write

The MERGE shape (one `memory-write` Cypher call per subscriber row):

```cypher
MERGE (p:Person {accountId: $accountId, email: $email})
ON CREATE SET p.givenName            = $givenName,
              p.familyName           = $familyName,
              p.source               = 'substack',
              p.createdByAgent       = 'substack-import',
              p.createdBySession     = $sessionId,
              p.createdAt            = datetime(),
              p.importId             = $importId
WITH p
MATCH (r:KnowledgeDocument) WHERE elementId(r) = $rosterNodeId
MERGE (r)-[m:MENTIONS {mentionContext: 'substack-subscription'}]->(p)
ON CREATE SET m.tier                 = $tier,
              m.subscribedAt         = $subscribedAt,
              m.createdByAgent       = 'substack-import',
              m.createdAt            = datetime()
SET m.unsubscribedAt                 = $unsubscribedAt,
    m.totalOpens                     = $totalOpens,
    m.totalClicks                    = $totalClicks,
    m.lastOpenedAt                   = $lastOpenedAt,
    m.lastClickedAt                  = $lastClickedAt,
    m.engagementWindowDays           = $engagementWindowDays
```

Notes:

- The `ON CREATE` block on `:Person` sets `givenName` / `familyName` only on first write. Existing `:Person` nodes (e.g., a contact already imported from LinkedIn) keep their operator-set names; the subscription edge still attaches.
- The `ON CREATE` block on `:MENTIONS` sets `tier` and `subscribedAt` only once (the original subscription). Subsequent imports overwriting tier (e.g., free → paid upgrade) need `SET m.tier = $tier` explicitly — see "tier updates on re-import" below.
- The unconditional `SET` block at the bottom overwrites engagement properties and `unsubscribedAt`. The export carries totals-to-date, not deltas — overwrite is correct.
- Engagement properties from `$totalOpens` etc. set to Cypher `null` (because the events file lacked an entry for this email) remove the properties from the edge; Neo4j does not distinguish "property=null" from "property absent". This is the intended semantic — absent engagement is absent, not zero.

## Tier updates on re-import

Substack tier can change (free → paid upgrade, paid → comp, etc.). The current MERGE keeps the original `tier` from the first import. To overwrite on re-import, the writer adds `SET m.tier = $tier` to the unconditional `SET` block. This sacrifices "first-subscribed-as" auditability — if the operator needs that, the original tier can be queried from the per-import roster KD's `createdAt` lineage.

For v1, the unconditional `SET m.tier = $tier` is the right call (engagement-warm signal depends on current tier, not historical). The `subscribedAt` stays `ON CREATE` because that is the immutable first-subscription timestamp.

## Anchor-edge from the importing operator

After the per-row loop, write one `:IMPORTED` edge from the importing operator to the roster KD:

```cypher
MATCH (a) WHERE elementId(a) = $importerNodeId
MATCH (r:KnowledgeDocument) WHERE elementId(r) = $rosterNodeId
MERGE (a)-[i:IMPORTED]->(r)
ON CREATE SET i.importId = $importId,
              i.source = 'substack',
              i.createdAt = datetime()
```

This edge is the provenance trail: "who pushed the button for this subscription roster."
