# Post body handling

Per-post writes route through `librarian` / `document-ingest`. This reference documents how the body is prepared and what the dispatch brief carries.

## File pairing

For each row in `posts.csv`, the body file is at `posts/${post_id}.${slug}.html`. If the file is missing:

- If `is_published=true` and the post is `paid_only` and the operator's export does not include paid content: write the post KD anyway with `bodyTruncated=true` and an empty body (so the metadata + subscription-derivation work; the body lands when the operator re-exports with paid-content entitlement).
- Otherwise: emit `[substack-import:error] file=posts/<filename> reason="body file missing"` and skip the post.

## HTML → markdown conversion

Substack post bodies use a constrained HTML subset. Convert before dispatch so `document-ingest`'s in-turn classification sees clean markdown.

Block-level elements:

| HTML | Markdown |
|---|---|
| `<h1>` … `<h6>` | `#` … `######` |
| `<p>` | paragraph |
| `<blockquote>` | `> ` prefix per line |
| `<ul>` / `<ol>` | `-` / `1.` per item |
| `<pre><code>` | fenced ``` block |
| `<hr>` | `---` |

Inline elements:

| HTML | Markdown |
|---|---|
| `<a href="…">text</a>` | `[text](href)` |
| `<strong>` / `<b>` | `**text**` |
| `<em>` / `<i>` | `*text*` |
| `<code>` | `` `text` `` |
| `<img src="…" alt="…">` | `![alt](src)` — `src` is preserved verbatim for the attachment walk to resolve |

Substack-specific embeds (`<div class="subscription-widget">`, paywall stubs, audio player blocks): replace with a single placeholder line `[Substack embed: <type>]` and continue. The embed-detail-loss is documented; full embed preservation would require re-rendering the player on read which is out of scope.

## Paywall stub detection

A paywall stub is a body where:

- `audience` (from `posts.csv`) is `only_paid` or `founding`, AND
- the converted-markdown body length is < 200 characters, AND
- the body contains a recognisable paywall marker (e.g., "This post is for paid subscribers", `<div class="paywall">`, etc.).

When detected, set `bodyTruncated=true` on the dispatch brief's `extraProperties`. The KD still writes; downstream "this post body is incomplete" handling can branch on the property.

## Inline `@person` mentions

Substack post bodies occasionally reference `@handle`-style mentions. `document-ingest`'s in-turn typed-section classification handles these — the dispatch brief does not pre-resolve them. Per `archive-ingest-operator.md`'s account-isolation rule, mentions resolve against `:Person {accountId}` only; no auto-create from mention text.

## Dispatch brief shape (the Task-tool input the operator builds)

```
Anchor: <importerNodeId> (:AdminUser or :Person)
attachmentId: "substack-post-<post_id>"
storagePath: {accountDir}/archive/substack/<importId>/posts/<post_id>.<slug>.html
mimeType: text/html
sourceUrl: https://<publication-domain>/p/<slug>   (omit if unknown)
sourceType: 'substack'
scope: 'admin'
extraProperties:
  kind: 'substack-post'
  substackPostId: <post_id>
  slug: <slug>
  publishedAt: <ISO 8601>
  isPaid: <bool>
  publicationName: $publicationName
  bodyTruncated: <bool>            (only when paywall-stub)
  importId: $importId
  createdByAgent: 'substack-import'
  createdBySession: $sessionId
```

The librarian dispatched against this brief writes the `:KnowledgeDocument` and classified `:Section` children, then returns `{ elementId, outcome: 'created' | 'updated' | 'skipped' }` which feeds the per-pass counter.

## Storage path

Before dispatch, copy `posts/${post_id}.${slug}.html` from the operator's export directory to `{accountDir}/archive/substack/<importId>/posts/${post_id}.${slug}.html` (perms 0o600). This is the stable on-disk location the KD's `storagePath` property references — the operator may delete the export directory after import; the archived copy survives.
