> ## 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.

# Contacts and National IDs

> After this ships, a company can store its customers — people and companies alike — with a national ID that is actually validated for its country, and the ID is masked from everyone who has no business seeing it.

## Context

The contact is the entity the whole suite is about. Two decisions make ours different from the
tools built elsewhere.

**A contact can be a company** (DEC-A1). In Latin America a loyalty program or a CRM routinely
holds businesses as customers — a distributor, a clinic, a franchise — and modelling a customer as
a person with a birthday breaks the moment a company appears. The `contact_kind` split runs through
validation, date triggers and required fields.

**The national ID is a first-class, validated, protected field.** It is how a company actually
recognises its customer across systems, so it must be normalized and check-digit validated per type
(DEC-A3). It is also the most sensitive field in the platform, so it is masked by default and its
full value sits behind a dedicated permission with every read audited (DEC-A4).

Uniqueness is per tenant, never global: the same person is legitimately a customer of two different
companies, and those two records must never see each other.

## Scope *(normative)*

* `core.contacts` with `contact_kind` person or company.
* National ID: normalization, format validation, check-digit validation where an algorithm exists.
* Partial unique index per (tenant, type, id).
* Masking by default, full read behind `core.contacts.read_national_id`, every full read audited.
* Tenant configuration making the national ID required.
* Base fields per kind: person gets `first_name`, `last_name`, `birth_date`; company gets
  `legal_name` and no birth date.
* Management CRUD.

## Non-scope *(normative)*

* Identity resolution and merging — FS-CORE-0003.
* Custom typed attributes — FS-CORE-0011.
* Consent — FS-CORE-0004. A contact existing is not a contact you may write to.
* Bulk import — FS-CORE-0013.

## Behaviour *(normative)*

1. `contact_kind` is `person | company` and is **immutable after creation**. Changing it would
   invalidate the fields and triggers that depend on it; the correct operation is a new contact.
2. A company contact has `legal_name` and **no `birth_date`**. Date-property triggers apply per
   kind: a person has a birthday, a company has an incorporation anniversary.
3. The national ID is **normalized before persisting** — a per-type normalizer strips separators
   and canonicalizes case, so `12.345.678-5` and `123456785` are the same value.
4. Format is validated against the type's regex, **always**. A value that does not match is
   rejected, never stored "for later cleaning" — DEC-A3 exists because dirty identity data is
   unrecoverable at scale.
5. Where a check-digit algorithm exists it runs, and `dv_validated` records whether it passed.
   Where none exists, format validation alone applies and the flag is false.
6. Uniqueness is a **partial unique index on (tenant\_id, national\_id\_type, national\_id) WHERE
   national\_id IS NOT NULL**. The same ID across different tenants is always allowed.
7. Direct creation with a duplicate ID is **rejected**. Bulk import routes duplicates to a merge
   review queue instead (DEC-A6, FS-CORE-0013).
8. The national ID is **masked in every UI and API response by default**. The full value requires
   `core.contacts.read_national_id`, and **every full read writes to `core.audit_log`** — including
   exports, which are logged as bulk access.
9. The national ID is optional by default and can be made required by tenant configuration
   (DEC-A5).
10. FORBIDDEN: logging a full national ID · returning it unmasked without the permission ·
    storing it unnormalized.

## Data *(normative)*

| Table           | Key invariants                                                                                                                                                                                                                                                                                                               |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.contacts` | `tenant_id` + `cell_id`, RLS mandatory; `contact_kind` immutable; `national_id` + `national_id_type` FK, both nullable together; `dv_validated` BOOL; partial unique index as above; `first_name`/`last_name`/`birth_date` for persons, `legal_name` for companies; `email`, `phone`, `locale`, `timezone`; soft-delete only |

Not partitioned — contacts are a working set, read constantly and bounded by tenant size.

Data classification: the national ID is `sensitive`; names, email and phone are `personal`. The
strictest applicable law across supported countries governs (DEC-A4, floor: Ley 21.719 + LGPD).

## API *(normative)*

| Endpoint                                 | Class      | Permission                       | Budget                       |
| ---------------------------------------- | ---------- | -------------------------------- | ---------------------------- |
| `POST /v1/core/contacts`                 | Management | `core.contacts.create`           | p95 \<1 s                    |
| `GET /v1/core/contacts`                  | Management | `core.contacts.read`             | p95 \<1 s, cursor pagination |
| `GET /v1/core/contacts/{id}`             | Management | `core.contacts.read`             | p95 \<1 s                    |
| `PATCH /v1/core/contacts/{id}`           | Management | `core.contacts.update`           | p95 \<1 s                    |
| `GET /v1/core/contacts/{id}/national-id` | Management | `core.contacts.read_national_id` | p95 \<1 s, audited           |

The full national ID has its **own endpoint** rather than a query flag. A separate path makes the
permission check, the audit entry and the rate limit unmistakable, and makes accidental exposure
through a shared serializer impossible.

## Events *(normative)*

`core.contact.created` and `core.contact.updated`, both available as outgoing webhooks. Payloads
carry `contact_id` and changed field names — **never the national ID**, regardless of the
endpoint's PII configuration.

## Acceptance criteria *(normative)*

1. A Chilean RUT with a wrong check digit is rejected; the same RUT with the correct one is stored
   normalized, with `dv_validated = true`.
2. A Brazilian CPF and an Argentine CUIT each validate against their own algorithm, proven with
   known-valid and known-invalid fixtures.
3. `12.345.678-5` and `123456785` collide on the unique index — they are the same value after
   normalization.
4. The same national ID under two different tenants is accepted, and neither tenant can read the
   other's row.
5. A company contact rejects `birth_date`; a person contact rejects `legal_name`.
6. `contact_kind` cannot be changed after creation.
7. Reading a contact without `core.contacts.read_national_id` returns a masked value in every
   response shape the module exposes.
8. Every call to the full-value endpoint produces exactly one `core.audit_log` row naming the
   actor.
9. **Negative:** no log line, event payload or error message anywhere contains a full national ID —
   verified by a scan over a seeded request set.

## Execution

Single slice, synchronous command. Schema and normalizers in TS-001; endpoints in the module's
first API slice. Normalizers and check-digit algorithms live in `packages/core` as pure functions
with exhaustive fixture tests — they are the kind of code that is written once and trusted for
years, so it is tested accordingly.

## Open questions

| # | Question                                                                                                      | Decides | By              |
| - | ------------------------------------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Is `email` unique per tenant, or can two contacts share one? Households and shared business inboxes are real. | Daniel  | before approval |
| 2 | Do we store a normalized phone (E.164) and reject anything else, mirroring the national ID discipline?        | Daniel  | 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.*
