> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CSV Import and Merge Review Queue

> After this ships, a company can upload years of customer history on day one, see exactly what happened to every row, and review the duplicates a human should decide on.

## Context

Importing existing customers is the first thing a new tenant does and the moment the product either
earns trust or loses it. It is also the direct consequence of our pricing position: ADR-014 makes
stored history free precisely so that importing everything is the obvious move. If the import is
then painful, we have priced for an action we made unpleasant.

DEC-A6 sets the safety rule. Direct creation of a duplicate national ID is rejected outright, but a
bulk import cannot fail 40 000 rows because 12 of them look like duplicates. Those 12 go to a
**merge review queue** for a human, because an automatic merge on weak evidence is the one error
that cannot be undone.

## Scope *(normative)*

* `core.imports`: an import job with its file, mapping and status.
* `core.import_rows`: per-row outcome — created, updated, suggested, rejected — with the reason.
* Column mapping onto contact fields and custom attributes.
* Dry-run producing the full outcome report **without writing anything**.
* `core.merge_suggestions`: duplicates queued for review with their evidence.
* Resumable processing in chunks.
* An error report downloadable as CSV.

## Non-scope *(normative)*

* Importing events. Historic event backfill is a separate, larger problem.
* Continuous sync from an external system — that is an integration, not an import.
* The upload and review UI — `frontend/console`.
* Automatic merging. This feature **suggests**; FS-CORE-0003 executes, and only on a human decision.

## Behaviour *(normative)*

1. **Dry-run first, always available.** It produces the complete outcome report and writes nothing.
   A tenant should be able to see what an import will do before it does it.
2. Processing is chunked and **resumable**. A 500 000-row file that fails at row 300 000 resumes
   there; it does not start over and it does not duplicate the first 300 000.
3. Every row gets a recorded outcome and, when it is not a plain success, a reason. A row that
   silently vanished is the failure mode that destroys trust in an import.
4. A row matching an existing contact on a **verified** identifier updates it. A row matching on a
   weaker signal creates a **merge suggestion** and imports the contact separately — never a merge.
5. Duplicate `(national_id_type, national_id)` within the file itself is detected and queued, not
   silently collapsed.
6. Validation is per row: an invalid national ID rejects that row, not the file.
7. Imported contacts arrive with **no consent** unless the file supplies consent evidence with a
   captured timestamp. Importing a contact list is not importing permission to email it, and this
   is where that mistake would otherwise be made.
8. An import is **never partially rolled back**. Successful rows stay; the report says exactly what
   happened. Undoing an import is a restore, not a button.
9. Import rate is bounded per tenant so a bulk load cannot degrade Runtime latency for anyone.

## Data *(normative)*

| Table                    | Key invariants                                                                                                                                        |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.imports`           | `tenant_id`; `file_ref`; `mapping` JSONB; `status` pending\|dry\_run\|processing\|completed\|failed; counters; `started_at`, `finished_at`            |
| `core.import_rows`       | FK import; `row_number`; `outcome` created\|updated\|suggested\|rejected; `reason`; `contact_id` nullable; append-only; partition candidate at volume |
| `core.merge_suggestions` | `tenant_id`; `existing_contact_id`, `incoming_contact_id`; `evidence` JSONB; `status` pending\|merged\|dismissed; `reviewed_by`, `reviewed_at`        |

## API *(normative)*

| Endpoint                                       | Class      | Permission            | Budget            |
| ---------------------------------------------- | ---------- | --------------------- | ----------------- |
| `POST /v1/core/imports`                        | Management | `core.imports.create` | p95 \<1 s (async) |
| `GET /v1/core/imports/{id}`                    | Management | `core.imports.read`   | p95 \<1 s         |
| `GET /v1/core/imports/{id}/errors`             | Management | `core.imports.read`   | CSV download      |
| `GET /v1/core/merge-suggestions`               | Management | `core.contacts.merge` | p95 \<1 s         |
| `POST /v1/core/merge-suggestions/{id}/resolve` | Management | `core.contacts.merge` | p95 \<1 s         |

## Events *(normative)*

`core.import.completed` on the outbox, so a tenant can trigger their own follow-up. Individual
contact creations emit `core.contact.created` as usual — an import is not a special case for
consumers.

## Acceptance criteria *(normative)*

1. A dry-run over 100 000 rows produces the full report and creates zero contacts.
2. An import failing at row 300 000 resumes there, with no duplicates among the first 300 000.
3. Every row has an outcome; the counts sum to the file's row count exactly.
4. A row matching a verified identifier updates; a row matching a weaker signal creates a suggestion
   and imports separately.
5. Duplicates within the file itself are queued, not collapsed.
6. Imported contacts have no consent unless the file supplied evidence with a timestamp.
7. The error CSV round-trips: correcting it and re-importing resolves exactly those rows.
8. **Negative:** no import path performs an automatic merge on non-verified evidence.

## Execution

Asynchronous pipeline. Upload to Storage, chunked processing in `backend/workers`, idempotent per
chunk via `core.processed_jobs`.

## Open questions

| # | Question                                                                                                            | Decides          | By              |
| - | ------------------------------------------------------------------------------------------------------------------- | ---------------- | --------------- |
| 1 | Maximum file size and row count per import — where do we cap it, and is it plan-gated?                              | Daniel           | before approval |
| 2 | Do we accept a consent column at all, given the liability, or require consent to be captured through our own flows? | Daniel + abogado | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
