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)
- 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.
- 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.
- 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.
- 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.
- Duplicate
(national_id_type, national_id)within the file itself is detected and queued, not silently collapsed. - Validation is per row: an invalid national ID rejects that row, not the file.
- 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.
- 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.
- Import rate is bounded per tenant so a bulk load cannot degrade Runtime latency for anyone.
Data (normative)
API (normative)
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)
- A dry-run over 100 000 rows produces the full report and creates zero contacts.
- An import failing at row 300 000 resumes there, with no duplicates among the first 300 000.
- Every row has an outcome; the counts sum to the file’s row count exactly.
- A row matching a verified identifier updates; a row matching a weaker signal creates a suggestion and imports separately.
- Duplicates within the file itself are queued, not collapsed.
- Imported contacts have no consent unless the file supplied evidence with a timestamp.
- The error CSV round-trips: correcting it and re-importing resolves exactly those rows.
- Negative: no import path performs an automatic merge on non-verified evidence.
Execution
Asynchronous pipeline. Upload to Storage, chunked processing inbackend/workers, idempotent per
chunk via core.processed_jobs.