# CSV Output

<!-- BEGIN GENERATED: guide-map -->
## Guide map

Read only the section needed for the next decision. Line ranges are exact
for this generated runtime target.

| Section | What it covers | Lines |
| --- | --- | --- |
| [Purpose](#purpose) | Scope, ownership, and the central operating question. | 24-33 |
| [Use This When](#use-this-when) | Tasks and conditions that belong in this guide. | 34-40 |
| [Do Not Use This For](#do-not-use-this-for) | Boundaries and the correct routes for excluded work. | 41-50 |
| [The Three Rules](#the-three-rules) | **Bad:** the play ends and the user has `companies.csv` + `people.csv` + `enrichment_results.csv` + `personalized_copy.csv` and has to join them in Sheets to... | 51-95 |
| [Preflight](#preflight) | State, authority, inputs, and readiness to verify before execution. | 96-103 |
| [Canonical Pattern](#canonical-pattern) | **Acquire rows at person grain.** If the source returns company-grain rows with embedded people, explode immediately: | 104-121 |
| [Risk And Side Effects](#risk-and-side-effects) | Effects, approvals, cost, privacy, and retry boundaries. | 122-130 |
| [Examples](#examples) | Representative usage patterns and code. | 131-190 |
| [Verification (before declaring the deliverable done)](#verification-before-declaring-the-deliverable-done) | Operating guidance and contracts for this section. | 191-200 |
| [Troubleshooting](#troubleshooting) | Failure diagnosis and corrective routes. | 201-210 |
| [Related References](#related-references) | Adjacent boundaries and when to cross them. | 211-220 |
<!-- END GENERATED: guide-map -->

## Purpose

This reference owns the **shape of the final deliverable CSV** the agent hands back to the user (or to sequence staging, CRM import, or any downstream consumer). The shape is non-negotiable:

1. **One single CSV file.** Not many CSVs the user has to join in Excel.
2. **One row = one person.** Never multiple people stuffed into one row.
3. **Every enrichment is its own column.** Wide and flat, not JSON blobs or semicolon-delimited bundles.

If the play touches more than one person, the final output is one wide CSV with one row per person, period. This file is what "the deliverable" means.

## Use This When

- You're producing the final lead-list / contact-list CSV after audience discovery + enrichment + qualification + personalization.
- You ran an enrichment, qualification, or annotation job and need to merge the results back into the master rowset before handing off.
- You're about to write the file the user will open, upload to a sequence, or import into a CRM.
- You're about to print "here is the output" — read this first to make sure the file actually matches the shape.

## Do Not Use This For

| Task | Owning Reference |
| --- | --- |
| HTML / Play UI report output | `workflow_reports.md`, `dashboard.md` |
| Local row transforms or CSV joins (how, not what shape) | `row_operations.md` |
| Acquiring people/companies | `audience_discovery.md`, `crm.md`, `linkedin.md`, `clay.md` |
| Running enrichment passes (capability semantics) | `enrichment.md` |
| Staging the CSV into a sequence | `sequences.md` |

## The Three Rules

### Rule 1 — One single CSV, not many

**Bad:** the play ends and the user has `companies.csv` + `people.csv` + `enrichment_results.csv` + `personalized_copy.csv` and has to join them in Sheets to actually use the lead list.

**Good:** the play ends and the user has one file (e.g. `lead_list.csv`) with every column. They can sort, filter, upload, or hand it to a salesperson without doing any joining.

When a step produces its own artifact (a qualification job result, an enrichment job result, a custom `ai_enrich` annotation), keep the per-step artifact for auditing — but **left-join its columns back onto the master rowset before writing the final deliverable**.

### Rule 2 — One row = one person, never multiple

**Bad row shapes:**

- A row for `Acme Corp` with columns `contact_1_name, contact_1_email, contact_2_name, contact_2_email, contact_3_name, contact_3_email`.
- A row for `Acme Corp` with `contacts: "Alice <alice@acme.com>; Bob <bob@acme.com>; Carol <carol@acme.com>"`.
- A row keyed by company with a `people_json` column holding `[{...}, {...}, {...}]`.

**Good row shape:** one row for Alice, one row for Bob, one row for Carol. Each carries the company columns (denormalized — `company_name`, `company_domain`, `company_industry`, etc. repeated on each row).

Why this is non-negotiable:

- Sequence staging enrolls **by row**. A row that bundles 3 contacts cannot enroll 3 contacts; it enrolls 1 (and the others vanish).
- CRM imports enroll by row too.
- A salesperson scanning the file expects one line per outreach target.
- Any per-person enrichment (work email, LinkedIn fields, phone) has nowhere to land if the row grain is the company.

For finds that return N people per company (`vf.sources.people.at_company`, Apollo people-search per account, etc.):

- **Explode immediately** to N person rows in the same step that fetched them.
- Denormalize the company columns onto each person row.
- Preserve the company key (`company_domain` or similar) as a column so downstream consumers can group if they want — but the file's grain is person.

### Rule 3 — Columns are enrichments

Every fact you produced gets its own named column. Don't bundle.

**Bad:** a `notes` column with `"ARR: $5M, headcount: 42, stage: Series A, last raised: 2025-03"`.

**Good:** separate columns `company_arr`, `company_headcount`, `company_stage`, `company_last_raise_date`.

Reason: wide flat tables are sortable, filterable, and grep-able in any tool. Bundled strings force the user to parse them by hand the first time they want to filter on headcount.

The one exception: if a single enrichment naturally produces an unbounded list per person (e.g. "last 5 LinkedIn posts"), a JSON-encoded column is acceptable, but only for that one field — and name it `recent_posts_json` so it's obvious why it's a blob.

## Preflight

Before running expensive enrichment / qualification / personalization:

- **Decide the row grain up front.** Person grain is almost always correct. If you start at company grain expecting to "split later", you'll burn credits enriching at the wrong grain.
- **Identify the stable per-person key.** Preference order: `linkedin_profile_url` > `email` (lowercased, trimmed) > deterministic composite (`company_domain` + `full_name` + `title`, only when nothing else is available).
- **Confirm every enrichment output is keyed by that same identifier.** A step that returns rows keyed by email cannot be joined back to a master keyed by LinkedIn URL without an extra lookup.

## Canonical Pattern

1. **Acquire rows at person grain.** If the source returns company-grain rows with embedded people, explode immediately:
   ```python
   master = []
   for company in company_rows:
       people = company.pop("people", []) or []
       for person in people:
           master.append({**company, **person})
   ```
2. **Persist the master.** Write `<play_root>/outputs/people.csv` with the person key as a leading column. This is the row of record.
3. **Per enrichment pass:**
   - Run the job; preserve `job_id` and `artifact_id`.
   - Export the per-step result to `<play_root>/outputs/enrichments/<step>.csv` (auditing — do not delete).
   - Left-join its new columns onto the master, keyed by the person key.
   - **Verify the master's row count is unchanged.** A join that changes the row count means the join key is wrong (duplicates on the right side, or missing left rows).
4. **Final deliverable.** Write `<play_root>/outputs/lead_list.csv` — the master after every join. Print the absolute path, row count, and column list to the user.

## Risk And Side Effects

| Operation | Side Effect | Rule |
| --- | --- | --- |
| Write final CSV under play root | `local_file_write` | Always `<play_root>/outputs/`. Never `/tmp`, `/var/tmp`, scratchpad, or `cwd` — those get wiped (see "Where Play State Lives" in `SKILL.md`). |
| Left-join enrichment back to master | local | Verify row count pre/post. If it changed, the join key is wrong — fix before continuing. |
| Hand off CSV to user | external visibility | Print absolute path, row count, full column list. The user should not have to open the file to know what's in it. |
| Stage the CSV into a sequence | staging | Route to `sequences.md`. The shape this file documents is the precondition for clean staging; activation/publish gates still apply. |

## Examples

**Explode company-grain rows into person grain, then write the master:**

```python
import verbiflow as vf
from pathlib import Path

output_root = Path("/absolute/path/play_root/outputs")

# company_rows = [{"company_domain": "acme.com", "company_name": "Acme",
#                  "people": [{"full_name": "Alice", "linkedin_profile_url": "..."},
#                             {"full_name": "Bob",   "linkedin_profile_url": "..."}]}, ...]

master_rows = []
for company in company_rows:
    people = company.pop("people", []) or []
    for person in people:
        master_rows.append({**company, **person})

master = vf.from_records(master_rows, label="lead_list_master")
print("master row count:", master.count())
print("columns:", list(master.to_list()[0].keys()) if master.count() else [])
master.to_csv(output_root / "people.csv")
```

**Left-join an enrichment artifact back without changing the row grain:**

```python
import verbiflow as vf
from pathlib import Path

output_root = Path("/absolute/path/play_root/outputs")

before = vf.from_records(
    list(vf.rows.join_csv(  # local-only deterministic join, see row_operations.md
        left_csv_path=output_root / "people.csv",
        right_csv_path=output_root / "enrichments/profiles.csv",
        left_on="linkedin_profile_url",
        right_on="linkedin_profile_url",
        how="left",
        normalize_key="lowercase",
    ).to_list())
)
print("rows in:", "<source count>", "rows out:", before.count())
# If counts differ, the right side has duplicates on the join key — fix before writing.
before.to_csv(output_root / "lead_list.csv")
```

**Final hand-off message shape** (what to report after writing the deliverable):

```
Lead list written: /abs/play_root/outputs/lead_list.csv
  rows:    287 (one row per person)
  columns: full_name, title, linkedin_profile_url, work_email, email_valid,
           company_name, company_domain, company_industry, company_headcount,
           company_arr, company_stage, in_target_geo, qualification_label,
           personalization_first_line
```

## Verification (before declaring the deliverable done)

- Row count of `lead_list.csv` equals row count of the person-grain master at step 1.
- Every row has a non-empty value in the person-key column.
- No column contains a semicolon/comma-joined list of multiple people.
- No `contact_1_*`, `contact_2_*`, ... column families.
- No JSON blobs of person records buried in a `people` / `contacts` / `notes` column.
- Columns are flat scalars (one acceptable exception: a single `recent_posts_json`-style column for genuinely list-shaped enrichment).
- File lives under `<play_root>/outputs/`, and the absolute path was printed to the user.

## Troubleshooting

| Symptom | First Check |
| --- | --- |
| Final CSV has way more rows than the person count | Join with `how="left"` on the master and `how="inner"` on the right — duplicates on the right exploded the master. Deduplicate the right side by the join key first. |
| Final CSV has fewer rows than the person count | A non-left join dropped unmatched rows. Use `how="left"` from the master side. |
| Some enrichment columns are empty for many rows | The enrichment was keyed differently (e.g. by `email` while master is keyed by `linkedin_profile_url`). Add the second identifier to the master, re-join. |
| User got 3 CSVs and asked "which one do I upload?" | Rule 1 violation. Left-join the per-step artifacts onto the master and re-deliver one file. |
| A row holds `"Alice; Bob; Carol"` in one cell | Rule 2 violation. Explode to one row per person before writing. |

## Related References

| If You Are Doing This | Read Next |
| --- | --- |
| Mechanics of local CSV joins, `RowSet` shaping, deterministic transforms | `row_operations.md` |
| Choosing / running enrichment capabilities | `enrichment.md` |
| Acquiring the rows you'll be joining | `audience_discovery.md`, `crm.md`, `clay.md` |
| Staging the final CSV into a sequence (publish/send gates still apply) | `sequences.md` |
| Rendering a Play UI/report instead of (or alongside) the CSV | `workflow_reports.md`, `dashboard.md` |
| Saving the file inside the durable Play root and away from wipe-prone paths | This guide's canonical-master and final-output path rules above. |
