# The `.deeta` file standard — merge files, format major 1

This is the reference for a `.deeta` file that Deeta's **Import & Merge** reads. It is the source for the public page at `https://docs.deeta.app/deeta-file-standard`. The same file format carries a full backup; this document covers the merge use, where a file adds people and changes only the records it names.

| Artifact | What it is |
|---|---|
| `deeta-import-v1.md` | This reference. |
| `deeta-import-v1.schema.json` | JSON Schema for a merge file. |
| `deeta-import-v1.report.schema.json` | JSON Schema for the error report Deeta produces. |
| `deeta-import-v1.kit.md` | The generation kit: a prompt for an AI agent or script that writes a merge file. |
| `examples/minimal.deeta` | The smallest valid file: one new person. |
| `examples/enrich.deeta` | Every row class and mode in one file. |
| `examples/failing.deeta` + `examples/error-report.json` | A file with five defects and the report Deeta produces for it. |
| `examples/kit-round-trip/` | An export, and the two files an AI agent wrote from it with the generation kit for one request. |

The schemas describe each row's shape. Rules that span rows (for example "every `new:` key is declared once") are listed under [Structural rules](#structural-rules); a file must satisfy both.

## 1. How a merge works

1. You pick a file in **Settings → Import → Import & Merge…**.
2. Deeta checks the whole file. One defect anywhere rejects the whole file, and nothing is changed. Deeta lists every defect it found in an [error report](#9-the-error-report) you can save.
3. Deeta shows a preview of every change. Nothing is saved until you confirm.
4. The import applies all at once, or not at all.
5. The summary offers **Undo this import** until you tap Done.

A merge can never delete a person, a tag, a group or a deet set, and never changes a person's name.

## 2. The envelope

```json
{
  "format": "deeta-data-export-v2",
  "formatVersion": "1.4",
  "generator": "your-tool/1.0",
  "generatedAt": "2026-10-13T12:00:00Z",
  "contacts": []
}
```

| Field | Required | Rule |
|---|---|---|
| `format` | yes | Exactly `deeta-data-export-v2`. Backups carry the same value. |
| `formatVersion` | yes | `major.minor`. See [Versioning](#10-versioning). Write `1.4`. |
| `generator` | no | Free text naming the tool that wrote the file, at most 200 characters. Never used to decide anything. |
| `generatedAt` | no | ISO-8601 timestamp. |
| `contacts` | yes | The people in the file. May be empty when the file only changes your own data. |

### Sections

| Section | Shape | Read by a merge |
|---|---|---|
| `contacts` | array of person rows | yes |
| `deets` | array of deet rows | yes |
| `tags` | array of tag rows | yes |
| `contact_tag_ids` | object: person id → array of tag row ids | yes |
| `groups` | array of group rows | yes |
| `group_members` | object: group row id → array of person ids | yes |
| `self_profile` | one row, or absent | yes |
| `self_deets` | array of your own deet rows | yes |
| `deet_sets` | array of deet set rows | yes |
| `relationships` | must be absent or empty | a file with relationships is refused |
| `ai_preferences`, `deletion_log`, `core_field_writes`, `core_field_set_members`, `live_sync`, `settings`, `contact_apple_links`, `contact_sync_state`, `accountMarker` | anything | accepted and ignored |

The ignored sections are what a backup carries. They are accepted so that an export you have edited still imports.

A file in which no person row has a `"new"` id or an `importType` is a plain backup. Import & Merge refuses it and points you to **Restore Backup…**.

## 3. Identity: the four row classes

Every person row has an `id`. It is one of two things:

- the literal `"new"`, or `"new:<key>"` where `<key>` is a name other rows in the same file use to refer to this person (`[A-Za-z0-9_-]`, 1 to 64 characters); or
- the id of a person already in Deeta, copied exactly from an export. Such a row must also carry `importType`.

Deeta creates every new id itself. An id in a file is never used to create a person.

| `id` | `importType` | Found in Deeta? | Result |
|---|---|---|---|
| `"new"` / `"new:<key>"` | must be absent | — | **Create**: a new person. |
| an existing id | `"additive"` | yes | **Additive**: adds what is missing. |
| an existing id | `"update"` | yes | **Update**: changes what is already there. |
| an existing id | `"overwrite"` | yes | **Overwrite**: the person's deets become exactly the file's. |
| an existing id | any | no, or the person is a different type | The row is rejected and reported. It never becomes a new person. |
| missing, malformed, an existing id with no `importType`, or `"new"` with an `importType` | | | The whole file is rejected. |

### Which mode when

| You want to | Use |
|---|---|
| Put everything back from a backup | Not this import. Use **Restore Backup…**. |
| Export, add to it, bring it back | `additive` |
| Correct what is already there (a number changed) | `update` |
| Start one person's deets over | `overwrite`, on that person only |

One person can appear once in a file, with one mode. To both add a new email and correct an existing number for the same person, use two files.

### What each mode does

| | Create | `additive` | `update` | `overwrite` |
|---|---|---|---|---|
| Name | set | never changed | never changed | never changed |
| `organization`, `notes` | set | filled if empty | changed; `""` clears it | not touched |
| Phone, email, URL, address | set | an entry under a label the person does not have is added | the entry under a matching label is changed | not touched |
| Birthday | set | set if the person has none | changed | not touched |
| Deets | added | added; nothing changed or removed | values of existing deets changed | replaced by the file's deets |
| Tags and groups | attached | attached if not already | not allowed in the file | attached if not already |

No mode removes a phone, email, URL or address entry, detaches a tag, or takes someone out of a group. The only values a file can clear are text fields, with `""` under `update` (see below); the preview lists each one as a removal.

**Additive, in detail.** The file's deets for the person are matched against the person's existing deets level by level, by `key` under the same parent. Keys are matched ignoring case, in every mode.

- A deet with the same key exists: its children are added under it.
- No deet with that key exists: it is created, with its children.
- Same key and same value already there: skipped, reported `already_present`.
- Same key, different value, no children: added beside it.
- More than one existing deet with that key at that level: the row is rejected, reported `ambiguous_match`.
- A phone, email, URL or address entry whose label the person already has is skipped and reported `already_present`, even when the value differs.

**Update, in detail.** Nothing is created.

- A deet found by its key path has its value changed. The same value is a no-op.
- A deet not found is skipped, reported `not_present`.
- More than one match is rejected, reported `ambiguous_match`.
- A phone, email, URL or address entry is matched by its `label`, ignoring case. No match: skipped, `not_present`. Two existing entries under that label: rejected, `ambiguous_match`.
- An address is replaced whole.

**Overwrite, in detail.** The deet rows in the file for that person are the person's complete deets afterwards. Deets the file leaves out are removed, and the preview lists every one. An overwrite with no deet rows removes all of the person's deets.

### Values someone shares with you live

When a value on a person is kept current by a live share from someone else, an import does not change it:

- an `update` to that value is added beside it as a second entry (a second phone number under the same label, or a second deet with the same key);
- an `overwrite` keeps that deet even when the file leaves it out; when the file gives it a different value, the deet is kept and the file's value is added beside it.

The preview says so for each one. A generator should not retry such a change.

### Leaving a field out, `""` and `null`

Every optional text field follows one rule: `organization`, `notes`, a new person's `firstName` and `lastName`, the address parts, your `bio` and `organization`, and a deet set's `description`.

| The file writes | Meaning |
|---|---|
| no such field | Leave the value as it is. |
| `""` | Clear the value. Under `update` an existing value is removed; under `additive` there is nothing to fill, so nothing changes; on a new row the field is empty. |
| `"text"` | Set the value to that text. `"null"` in quotes is text like any other. |
| `null` | Refused (`null_value`). Leave the field out to keep the value, or write `""` to clear it. |

`null` is refused because JSON libraries write it for "no value": a generator filling rows from a table with blank cells would otherwise send it for everyone, and it could not mean both "leave it" and "clear it". A Deeta export never writes `null` in a text field: a field with no value is left out.

`null` is still how a row says it has no link: `parentDeetId` and `parentGroupId` on a top-level row, `self_profile` when there is none, a deet's `relationshipId`, and a birthday's `year`.

## 4. People — `contacts`

| Field | Create row | Existing row | Rule |
|---|---|---|---|
| `id` | required | required | See section 3. |
| `importType` | forbidden | required | `additive`, `update` or `overwrite`. |
| `contactType` | required | optional | `person` or `organization`. On an existing row it must match the person in Deeta. |
| `firstName`, `lastName` | optional | accepted and ignored | Up to 150 characters each. |
| `organization` | optional | optional | Up to 100 characters. |
| `notes` | optional | optional | Up to 10,000 characters. Line breaks allowed. |
| `phoneNumbers` | optional | optional | Array of `{ "label", "value" }`. |
| `emailAddresses` | optional | optional | Array of `{ "label", "value" }`. |
| `urlAddresses` | optional | optional | Array of `{ "label", "value" }`. |
| `physicalAddresses` | optional | optional | Array of address entries. |
| `birthday` | optional | optional | `{ "month", "day", "year" }`; `year` may be left out. |
| `isFavorite`, `createdAt`, `updatedAt` | accepted and ignored | accepted and ignored | |

### Phone, email, URL, address, birthday

These are written the same way on every row, the way an export writes them.

```json
"phoneNumbers":      [{ "label": "mobile", "value": "+44 7700 900456" }],
"emailAddresses":    [{ "label": "work", "value": "priya.patel@example.org" }],
"urlAddresses":      [{ "label": "homepage", "value": "https://example.org" }],
"physicalAddresses": [{ "label": "home", "street": "1 Example Street", "city": "London",
                        "postalCode": "N1 9GU", "country": "United Kingdom",
                        "isoCountryCode": "GB" }],
"birthday":          { "month": 3, "day": 14, "year": 1990 }
```

| Field | Rule |
|---|---|
| `label` | Required on every entry, 1 to 50 characters. Common labels are `mobile`, `home`, `work`, `main`, `other` and `homepage`; any text is allowed. |
| Phone `value` | Up to 100 characters. The international form (`+44 7700 900456`) is recommended, not required. |
| Email `value` | Up to 254 characters. |
| URL `value` | Up to 2,048 characters. A full address with `https://` is recommended, not required. |
| Address | Each part optional; a part left out or `""` is empty, since an address is written whole. `street` up to 500 characters and may have several lines; `city`, `state`, `postalCode`, `country` up to 200 each; `isoCountryCode` up to 12. |
| Birthday | `month` 1–12, `day` 1–31, `year` optional; when given, 1900–9999. |
| Entries per field | At most 50 phone, 50 email, 50 URL and 50 address entries on one person. |

Phone, email, URL and address values are checked only for length and for the characters in section 8's not-allowed list. Their content is not checked, so any value Apple Contacts holds can be exported, edited and imported again.

Two entries with the same field and label for one person in one file reject the file.

## 5. Deets — `deets`

```json
{ "id": "new:jonas-kid1", "contactId": "9d4e1f70-2b3c-4a5d-9e6f-1b2c3d4e5f60",
  "key": "Mila", "value": "Born 2021", "parentDeetId": "new:jonas-kids" }
```

| Field | Required | Rule |
|---|---|---|
| `id` | yes | A label for this row inside the file: an id as an export wrote it, or `"new:<key>"`. It is used only so other rows can name this one. Deeta never looks it up and never stores it. |
| `contactId` | yes | The person the deet belongs to: an existing id, or the `"new:<key>"` of a person created in this file. That person must have a row in `contacts`. |
| `key` | yes | 1 to 50 characters. |
| `value` | yes | Up to 280 characters. May be empty. |
| `parentDeetId` | no | The `id` of another deet row in this file for the same person, or `null` for a top-level deet. |
| `sourceLinkId` | no | Optional. If Deeta already holds a deet from that source, the row is skipped. It is never saved. |
| `relationshipId` | no | Must be absent or `null`. |
| `source`, `createdAt`, `updatedAt`, `archivedAt` | no | Accepted and ignored. |

Deets nest at most 7 levels deep. An existing deet is found by where it sits and its key (for example Jonas → Kids → Mila), never by its id.

Every deet an import adds or changes is marked **imported** in Deeta.

## 6. Tags and groups

```json
"tags": [{ "id": "new:climbing", "name": "climbing" }],
"contact_tag_ids": { "9d4e1f70-2b3c-4a5d-9e6f-1b2c3d4e5f60": ["new:climbing"] },
"groups": [{ "id": "new:work", "name": "Work", "parentGroupId": null }],
"group_members": { "new:work": ["c7a1e2d3-4b5c-4d6e-8f70-a1b2c3d4e5f6", "new:lena"] }
```

| Field | Rule |
|---|---|
| Tag `id`, group `id` | A label for the row inside the file, as for a deet. |
| `name` | 1 to 50 characters. |
| `parentGroupId` | The `id` of another group row in this file, or `null`. |
| `contact_tag_ids` | Each key is a person id in this file's `contacts`; each value lists tag row ids from this file's `tags`. At most 30 tags per person. |
| `group_members` | Each key is a group row id from this file's `groups`; each value lists person ids from this file's `contacts`. |

A tag is matched to one you already have by name, ignoring case; a group by name, ignoring case, under the same parent. If none exists it is created. If you have two groups with that name under that parent, the row is rejected (`ambiguous_match`). A person is attached to a tag or group they are not already in; nothing is ever detached.

A person whose row says `update` must not appear in `contact_tag_ids` or `group_members`.

## 7. Your own data — `self_profile`, `self_deets`, `deet_sets`

Leave these sections out and none of your own data changes.

```json
"self_profile": { "importType": "additive" },
"self_deets": [{ "id": "new:me-diet", "key": "Diet", "value": "Vegetarian",
                 "parentDeetId": null, "visibility": "ask" }],
"deet_sets": [{ "id": "new:dinner", "name": "Dinner guests",
                "liveSyncDefault": "ask", "memberSelfDeetIds": ["new:me-diet"] }]
```

**`self_profile`** carries `importType`, and that mode applies to the file's `self_deets` exactly as it would to a person's deets.

| Field | Rule |
|---|---|
| `importType` | Required. |
| `organization` | Up to 100 characters. Filled if empty (`additive`) or changed (`update`); `""` under `update` clears it. |
| `bio` | Up to 500 characters. Same rule. |
| `firstName`, `lastName`, `id`, `contactId`, `createdAt`, `updatedAt` | Accepted and ignored. Your name is never changed. |

**`self_deets`** rows have `id`, `key`, `value` and `parentDeetId` as in section 5, plus:

| Field | Rule |
|---|---|
| `visibility` | Optional: `always`, `ask` or `never`. Used for a deet the import creates; without it the deet gets Deeta's default. |
| `version`, `notifySubscribers`, `deetSource`, `createdAt`, `updatedAt` | Accepted and ignored. |

A file with `self_deets` rows and no `self_profile` row is rejected.

**`deet_sets`** rows follow the same identity rule as people: `"new"` / `"new:<key>"` with no `importType`, or the id of a set you have with an `importType`.

| Field | Rule |
|---|---|
| `name` | 1 to 50 characters. Required on a new set. |
| `description` | Up to 140 characters. `""` under `update` clears it. |
| `liveSyncDefault` | `always`, `ask` or `never`. |
| `cardVisibilityConfig` | `{ "show_set_name", "show_deet_keys", "show_deet_values" }`, each `true` or `false`. |
| `memberSelfDeetIds` | Row ids from this file's `self_deets`. At most 200. |
| `isDefault`, `defaultKind`, `createdAt`, `updatedAt` | Accepted and ignored. |

| Mode on a set you have | Effect |
|---|---|
| `additive` | Adds the listed deets to the set. |
| `update` | Adds the listed deets and changes the `name`, `description`, `liveSyncDefault` and `cardVisibilityConfig` the row states. |
| `overwrite` | The set's members become exactly the listed deets. |

A file cannot delete a set.

Changing your own deets or a set is the same as editing them in the app: people you share with live receive the change. The preview says so.

## 8. Limits

Lengths count Unicode scalar values. An emoji made of several joined parts counts each part, so a family emoji can use seven or more of a deet value's 280.

### Per file

| Limit | Value |
|---|---|
| File size | 32 MB |
| People in `contacts` | 10,000 |
| Rows in `deets` | 100,000 |
| Deet nesting depth | 7 |

### Text

| Field | Limit |
|---|---|
| Deet `key` | 50 |
| Deet `value` | 280 |
| Tag, group and deet set `name`; any `label` | 50 |
| `firstName`, `lastName` | 150 each |
| `organization` | 100 |
| `notes` | 10,000 |
| `bio` | 500 |
| Deet set `description` | 140 |
| Phone | 100 |
| Email | 254 |
| URL | 2,048 |
| Address `street` | 500 |
| Other address parts | 200 each |
| Address `isoCountryCode` | 12 |

### What your account may hold afterwards

Deeta also works out what your account would hold after the import. If that passes any of these, the whole file is refused; it is never trimmed to fit.

| Limit | Value |
|---|---|
| People | 25,000 |
| Deets on your account, people's and your own together | 50,000 |
| Deets on one person | 500 |
| Your own deets | 500 |
| Tags on one person | 30 |
| Tags in total | 500 |
| Top-level groups | 500 |
| Sub-groups under one group | 200 |
| Deet sets | 50 |
| Deets in one set | 200 |

### Characters

- The file is JSON in valid UTF-8.
- Not allowed in any text: control characters (U+0000–U+001F, U+007F–U+009F), the direction-override characters U+202A–U+202E and U+2066–U+2069, and Unicode noncharacters. `notes` and an address `street` may contain line breaks and tabs.
- Emoji are allowed, including joined sequences and skin tones.
- Text is trimmed, and runs of spaces inside it become one space.

## Structural rules

A file that breaks any of these is rejected whole.

1. Every `"new:<key>"` on a person row is unique among people, and every `"new:<key>"` that a row refers to is declared by a row of the right section.
2. An existing person's id appears on at most one row in `contacts`.
3. Every deet row's `contactId` names a person with a row in `contacts`.
4. Row ids are unique within `deets`, within `tags`, within `groups`, within `self_deets` and within `deet_sets`.
5. A `parentDeetId` names a row in the same section, for the same person, and the chain has no loop and is at most 7 deep.
6. One person has at most one entry per field and label.
7. A person whose row says `update` does not appear in `contact_tag_ids` or `group_members`.
8. `self_deets` rows need a `self_profile` row.
9. No field appears that this document does not list (see [Versioning](#10-versioning)).
10. The file changes something: at least one person row, or a `self_profile` row.

## 9. The error report

When a file is rejected, and when an import leaves rows out, Deeta offers **Save error report**: a JSON file (`deeta-import-report.json`) matching `deeta-import-v1.report.schema.json`. Give it back to whatever wrote the file.

```json
{
  "reportVersion": "1",
  "reportId": "RPT-7F3K2A9Q",
  "formatVersion": "1.4",
  "totals": { "file_fatal": 5, "row_rejected": 0, "row_skipped": 0 },
  "truncated": false,
  "entries": [
    { "code": "missing_import_type", "severity": "file_fatal", "collection": "contacts",
      "rowIndex": 0, "rowId": "3f2b8c1e-5a7d-4e9b-8c21-0a4d6f7b9e10", "field": "importType",
      "message": "A row for an existing person needs importType." }
  ]
}
```

| Field | Meaning |
|---|---|
| `reportId` | A random id made for this report, shown on the screen beside the failure. It is not derived from the file. |
| `totals` | How many entries of each severity Deeta found, including any past the cap. |
| `truncated` | `true` when Deeta found more than the 500 entries a report holds. |
| `collection`, `rowIndex` | The section and the zero-based position of the row in it. `null` for a problem with the file as a whole. |
| `rowId` | The row's own `id` exactly as the file gave it, or `null`. |
| `field` | The field at fault, or `null`. |
| `message` | A sentence for a person. |

| `severity` | Meaning |
|---|---|
| `file_fatal` | The file was rejected. Nothing was imported. |
| `row_rejected` | That row could not be matched in Deeta. The rest of the file was still offered. |
| `row_skipped` | That row was left out because it was already there, not there, or could not be saved. |

**The report holds locations, never values.** It never contains a deet value, a phone number, a name or anything from your Deeta data. The only text taken from the file is a row's own id or `new:` key and the name of a field it wrote, so the report says nothing the file's author did not already write.

### Codes

| Code | Severity | Meaning |
|---|---|---|
| `invalid_json` | file | Not valid JSON, or not UTF-8. |
| `not_a_deeta_file` | file | `format` is missing or is not a Deeta format. |
| `unsupported_format` | file | A Deeta format other than `deeta-data-export-v2`. |
| `missing_format_version`, `invalid_format_version` | file | `formatVersion` is absent or does not parse. |
| `newer_format_version` | file | Written for a newer Deeta. Update the app. |
| `older_format_version` | file | Too old for this Deeta to read. |
| `file_too_large` | file | Over 32 MB. |
| `is_backup_file` | file | A plain backup. Use Restore Backup. |
| `relationships_not_supported` | file | The file has relationships. |
| `no_rows` | file | Nothing in the file to import. |
| `unknown_field` | file | A field this document does not list. |
| `missing_field` | file | A required field is absent. |
| `wrong_type` | file | A value of the wrong JSON type. |
| `null_value` | file | `null` in a text field. Leave the field out to keep the value, or write `""` to clear it. |
| `invalid_value` | file | A value outside the allowed set. |
| `invalid_id` | file | An `id` or reference that is neither an id nor `new` / `new:<key>`. |
| `missing_import_type` | file | An existing record's row has no `importType`. |
| `import_type_on_new_row` | file | A `new` row carries `importType`. |
| `unknown_import_type` | file | `importType` is not one of the three. |
| `duplicate_local_key` | file | The same `new:<key>` is declared twice. |
| `undeclared_local_key` | file | A `new:<key>` is referred to and declared nowhere. |
| `duplicate_row_id` | file | Two rows in one section share an `id`. |
| `duplicate_target` | file | Two rows target the same existing record. |
| `orphan_row` | file | A row belongs to a person with no row in `contacts`. |
| `parent_not_found` | file | `parentDeetId` or `parentGroupId` names no row. |
| `parent_cycle` | file | A parent chain loops. |
| `nesting_too_deep` | file | Deets nested more than 7 deep. |
| `duplicate_core_field_label` | file | Two entries with the same field and label for one person. |
| `membership_under_update` | file | A tag or group membership for a person in `update` mode. |
| `self_rows_without_profile` | file | `self_deets` rows with no `self_profile`. |
| `string_empty`, `string_too_long` | file | Text shorter or longer than allowed. |
| `forbidden_character` | file | A character from the not-allowed list. |
| `invalid_birthday` | file | The birthday does not meet its rule in section 4. |
| `over_file_cap` | file | More rows than a file may hold. |
| `over_account_cap` | file | The import would take the account past a limit. `field` names it: `contacts`, `account_deets`, `deets_per_contact`, `self_deets`, `tags_per_contact`, `tags`, `top_level_groups`, `subgroups_per_group`, `deet_sets` or `members_per_set`. |
| `uuid_not_found` | row rejected | No such record in Deeta. Deleted since the export, or mistyped. |
| `type_mismatch` | row rejected | The record in Deeta is a different type. |
| `ambiguous_match` | row rejected | More than one existing deet or entry matches. |
| `already_present` | row skipped | Already there. Use `update` to change it. |
| `not_present` | row skipped | Not there. Use `additive` to add it. |
| `late_constraint` | row skipped | The row could not be saved. |

### Correcting a file from a report

Correct exactly the rows the report lists, found by `collection`, `rowIndex` and `rowId`, and change nothing else.

After an import succeeds, do not send its `"new"` rows again. A new person is created every time a `"new"` row is imported, so sending one twice makes a duplicate.

## 10. Versioning

`formatVersion` is `major.minor`.

- Deeta reads the current major and the one before it. A newer major is refused with a message to update the app.
- A field Deeta does not know is an error when the file's version is one Deeta fully knows.
- When the file declares a newer minor of a major Deeta reads, unknown fields are skipped.
- A change that removes or redefines a field raises the major, and is announced on the public page before it ships.

## 11. Platforms

There is one format for every Deeta app. The schemas in this folder are the contract; each app implements them. Where an app cannot store a kind of field, it leaves those entries out and reports them as `row_skipped`. Today the iOS app is the only one that imports.
