Skip to main content

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)

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)

  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

Changelog

Delivery record

Not implemented yet.