Skip to main content

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)

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)

  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

Changelog

Delivery record

Not implemented yet.