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. Thecontact_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.contactswithcontact_kindperson 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 getslegal_nameand 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)
contact_kindisperson | companyand is immutable after creation. Changing it would invalidate the fields and triggers that depend on it; the correct operation is a new contact.- A company contact has
legal_nameand nobirth_date. Date-property triggers apply per kind: a person has a birthday, a company has an incorporation anniversary. - The national ID is normalized before persisting — a per-type normalizer strips separators
and canonicalizes case, so
12.345.678-5and123456785are the same value. - 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.
- Where a check-digit algorithm exists it runs, and
dv_validatedrecords whether it passed. Where none exists, format validation alone applies and the flag is false. - 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.
- Direct creation with a duplicate ID is rejected. Bulk import routes duplicates to a merge review queue instead (DEC-A6, FS-CORE-0013).
- 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 tocore.audit_log— including exports, which are logged as bulk access. - The national ID is optional by default and can be made required by tenant configuration (DEC-A5).
- FORBIDDEN: logging a full national ID · returning it unmasked without the permission · storing it unnormalized.
Data (normative)
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)
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)
- A Chilean RUT with a wrong check digit is rejected; the same RUT with the correct one is stored
normalized, with
dv_validated = true. - A Brazilian CPF and an Argentine CUIT each validate against their own algorithm, proven with known-valid and known-invalid fixtures.
12.345.678-5and123456785collide on the unique index — they are the same value after normalization.- The same national ID under two different tenants is accepted, and neither tenant can read the other’s row.
- A company contact rejects
birth_date; a person contact rejectslegal_name. contact_kindcannot be changed after creation.- Reading a contact without
core.contacts.read_national_idreturns a masked value in every response shape the module exposes. - Every call to the full-value endpoint produces exactly one
core.audit_logrow naming the actor. - 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 inpackages/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.