# Substack archive shape

The "Export your data" download from Substack arrives as a zip. Once extracted, the top-level directory contains the following entries. Filenames have varied across export versions — the skill probes the listed candidates in order and binds to the first match.

## Directory layout

```
<archive-root>/
├── posts.csv                     # post metadata index (required)
├── posts/
│   ├── <post-id>.<slug>.html     # one HTML body per post (filename = "<post_id>.<slug>.html")
│   └── …
├── subscribers.csv               # subscriber roster (required)
├── email_activity.csv            # engagement events (one of these names, optional)
│   OR subscriber_activity.csv
│   OR emails.csv
├── images/                       # image enclosures referenced from post bodies
│   └── <files>
└── (other files — manifest.json, etc.) ignored by this skill
```

Required: `posts.csv` and `subscribers.csv` must exist. If either is missing, abort the walk with `[substack-import:error] file=<archive-root> reason="missing required file: <name>"`.

Optional: engagement events file. If none of the three candidate names exist, emit `[substack-import:warn] no-engagement-file` once and continue with engagement properties absent on every `:MENTIONS` edge.

## `posts.csv` columns this skill consumes

| Column | Used as | Notes |
|---|---|---|
| `post_id` | `substackPostId` (stable across edits) | Required. MERGE key via synthetic `attachmentId = "substack-post-${post_id}"`. |
| `post_date` | `publishedAt` (ISO 8601) | Required. |
| `is_published` | filter | Only `true` rows are imported; drafts are dropped at walk. |
| `type` | filter | Only `newsletter` / `podcast` / `thread` rows imported; system rows dropped. |
| `audience` | `isPaid` | `everyone` → `isPaid=false`; `only_paid` / `founding` → `isPaid=true`. |
| `slug` | `slug` | Used to compose the post body filename `posts/<post_id>.<slug>.html`. |
| `title` | `title` | KD title. |
| `subtitle` | `subtitle` | Optional KD property. |
| `email_sent_at` | optional context | Recorded if present. |

Columns the skill does not read (`url`, `webRecommendations`, etc.) are ignored — adding them later does not require a parser change.

## `subscribers.csv` columns this skill consumes

| Column | Used as | Notes |
|---|---|---|
| `email` | `:Person.email` (MERGE key) | Required. Lowercased. |
| `name` | `:Person.givenName` + `:Person.familyName` | Split on first whitespace; surname = remainder. Single-token names use the token as `givenName`. |
| `type` (or `membership_state` / `subscription_state`) | `:MENTIONS.tier` | `free` / `paid` / `comp` / `gift`. Unknown values pass through verbatim. |
| `created_at` (or `subscribed_at`) | `:MENTIONS.subscribedAt` | ISO 8601. |
| `expiry` / `unsubscribed_at` | `:MENTIONS.unsubscribedAt` | Optional. Present only for unsubscribed rows. |

Columns the skill does not read (e.g. `stripe_customer_id`) are ignored.

## Engagement-events filename detection

The skill probes for the engagement events file in this order. The first match binds `$engagementFile`:

1. `email_activity.csv`
2. `subscriber_activity.csv`
3. `emails.csv`

If a new export version uses a different name, add it to this list — the skill does not auto-discover.

## Hard limits

- `posts.csv` row count > 200: bulk-gate fires.
- `subscribers.csv` row count > 2000: bulk-gate fires.
- Individual post body > 50 MB: skipped with `[substack-import:error] file=<…> reason="body exceeds 50MB"`. (Substack does not generally produce bodies this large; the limit is a defence against malformed archives.)
