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

# ADR-024 — Nombre de tabla en singular y estructura base obligatoria

> Toda tabla se llama en singular y arranca desde el mismo conjunto de columnas. Dos reglas de cimiento: la primera hace que el nombre de la tabla y el prefijo de sus FK sean el mismo token; la segunda impide que una tabla nazca sin lo que el resto del sistema da por hecho.

Estado: Aceptado · Fecha: 2026-08-18 · Refs: DEC-A\*, DEC-B\*, ADR-017, ADR-018, ADR-022, `standards/data.md`

## Contexto

El repositorio tiene 107 tablas declaradas en las specs de los cinco módulos y **ninguna regla
escrita sobre cómo se llaman ni sobre qué columnas debe tener cualquiera de ellas**. El resultado ya
es visible: 103 en plural, 4 en singular, y cada spec resolviendo por su cuenta si lleva
`deleted_at`, si lleva `updated_at`, o si el identificador es `id` o `{entidad}_id`.

Eso se puede arreglar hoy, cuando no existe ni una migración. Después de TS-001 cada corrección es
una migración con datos vivos, y a partir de cierto punto simplemente no se corrige: se convive.

Hay dos problemas distintos y conviene no mezclarlos.

**El nombre.** Con tablas en plural, el nombre de la tabla y el prefijo de sus claves foráneas son
tokens distintos: la tabla es `contacts` pero la columna es `contact_id`, la tabla es
`point_currencies` pero la columna es `point_currency_id`. Cada FK obliga a despluralizar
mentalmente, y en inglés eso no es una regla sino un diccionario: `currencies → currency`,
`taxonomies → taxonomy`, `identities → identity`, `merges → merge`. Un generador, un agente o una
persona cansada se equivoca ahí, y el error aparece como una columna mal nombrada que después nadie
renombra.

**La estructura.** Toda tabla necesita las mismas cosas —identidad, tenancy, tiempo— y la mitad de
esas cosas están hoy en reglas dispersas: `tenant_id` y `cell_id` vienen de R6, RLS de R7, la
auditoría de R15 y ADR-017. Ninguna spec las repite completas, así que cada tabla nueva depende de
que quien la escriba recuerde las cinco reglas al mismo tiempo.

## Decisión

### 1. El nombre de la tabla va en singular

**Toda tabla se nombra en singular, snake\_case, en inglés.** `contact`, no `contacts`.
`point_currency`, no `point_currencies`. `ledger_transaction`, no `ledger_transactions`.

La tabla nombra **una fila**, que es lo que el modelo describe: una fila es un contacto, no varios.
Y la consecuencia práctica es la que importa:

```
contact          →  contact_id          el nombre de la tabla ES el prefijo de la FK
point_currency   →  point_currency_id
ledger_transaction → ledger_transaction_id
```

Reglas derivadas, todas verificables:

* **FK**: la columna que referencia a `X` se llama `{X}_id`. Sin excepción y sin diccionario.
  Cuando hay dos FK a la misma tabla, el rol va delante: `parent_redemption_id`,
  `source_contact_id`.
* **Tabla intermedia N-M**: `{a}_{b}`, ambos en singular y en orden alfabético salvo que una
  dirección sea claramente la dominante — `role_permission`, `contact_segment`. Una tabla
  intermedia **es una tabla** y tiene su propia página de schema, su propósito escrito y su
  registro de cambios, igual que cualquier otra.
* **Catálogo paramétrico**: también singular — `ledger_transaction_type`, no
  `ledger_transaction_types`. Esto corrige de paso la redacción de `standards/data.md` §2, que
  usaba `{schema}.{entidad}_types`.
* **Índices y restricciones**: `idx_{tabla}_{columnas}`, `uq_{tabla}_{columnas}`,
  `fk_{tabla}_{tabla_referenciada}`, `ck_{tabla}_{regla}`.
* **Nunca se pluraliza en la base.** El plural existe en la API (`/v1/loyalty/redemptions` es una
  colección y así se queda) y en la prosa. La colección es un concepto de transporte; la tabla no.

### 2. Toda tabla arranca desde la misma base

Tres formas, y toda tabla es exactamente una de ellas.

#### Forma A — tabla de dominio con alcance de tenant (el caso por defecto)

| Columna      | Tipo                 | Regla                                                                                                       |
| ------------ | -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `id`         | UUID PK              | `uuidv7`, generado por la aplicación. Ordenable por tiempo, sin exponer un contador                         |
| `tenant_id`  | UUID NOT NULL        | R6. FK a la tabla de tenant de la suite                                                                     |
| `cell_id`    | UUID NOT NULL        | R6. Costura de particionamiento regional; presente desde la primera migración y sin usar hasta multi-región |
| `created_at` | TIMESTAMPTZ NOT NULL | `DEFAULT now()`, nunca se actualiza                                                                         |
| `updated_at` | TIMESTAMPTZ NOT NULL | `DEFAULT now()`, la escribe el command handler en cada update                                               |
| `deleted_at` | TIMESTAMPTZ NULL     | Borrado lógico. `NULL` significa vivo — no hay booleano acompañante                                         |

Además, obligatorio y no negociable:

* **RLS activa** (R7), con la política de tenant escrita en la misma migración que crea la tabla.
  Una tabla con alcance de tenant sin RLS es un defecto que bloquea el merge.
* **Toda restricción de unicidad incluye `tenant_id`** (`standards/data.md` §2b).
* **Ningún actor por fila.** No se agregan `created_by` ni `updated_by`: quién hizo qué vive en
  `core.audit_log` con el actor tipado y el diff completo (R15, ADR-017). Dos lugares para el mismo
  hecho es la trampa que este repositorio ya prohíbe para los saldos.

#### Forma B — tabla append-only

Ledger, eventos, auditoría, medición, outbox. Se declaran explícitamente como append-only en su
spec y en su página de schema.

Llevan `id`, `tenant_id`, `cell_id` y **una columna de tiempo del dominio** —`occurred_at`,
`sent_at`— que es además la clave de partición cuando la tabla está particionada (ADR-018).

**No llevan `updated_at` ni `deleted_at`**, porque no se actualizan ni se borran. Ponerlas
invitaría a usarlas. La corrección de una fila append-only es otra fila que la compensa, y la
supresión es la anonimización que ya define `standards/data.md` §5.

Las excepciones son las que ya están escritas y no se amplían sin ADR: el paso `pending → available`
del ledger y el tombstone de anonimización.

#### Forma C — catálogo paramétrico

Sin cambios respecto de `standards/data.md` §2, salvo el nombre en singular: `code` TEXT PK,
`label`, `is_system`, `is_active`, `sort_order`, `metadata`. **No** llevan `id`, ni `tenant_id`
cuando son de plataforma, ni `deleted_at` — un catálogo no se borra, se desactiva.

### 2b. `deleted_at` habilita dos APIs de eliminación

De `deleted_at` salen dos endpoints, y todo recurso de forma A expone los dos:
`DELETE /{id}` escribe `deleted_at` y es reversible con `POST /{id}/restore`;
`DELETE /{id}/purge` borra la fila y no lo es. Son **permisos distintos** —`delete` y `purge`— y
purgar exige haber eliminado antes, para que la destrucción sea deliberada y en dos pasos. Las
tablas de forma B no exponen ninguna de las dos: se compensan.

La marca de tiempo **es** el flag. `deleted_at IS NOT NULL` responde lo mismo que respondería un
`is_deleted`, y además responde *cuándo*. Agregar el booleano al lado daría dos representaciones del
mismo hecho, que es la razón exacta por la que este repositorio prohíbe una columna de saldo
mutable. El contrato completo está en `standards/data.md` §1b.

### 3. Lo que NO es columna base

* **`is_active`** no es base. Es dominio, y responde otra pregunta que `deleted_at`: significa
  "existe y es válido, pero no se ofrece para uso nuevo". Un registro inactivo **sí aparece en el
  mantenedor** —es donde se reactiva— y **nunca aparece en un selector**. Uno eliminado no aparece
  en ninguno de los dos. Va solo donde el dominio tiene ese estado intermedio: programas, monedas de
  puntos, catálogos.
* **`metadata` JSONB** no es base. Un campo libre en toda tabla es un modelo de datos que se escapa
  del control en seis meses. Va donde una spec lo pide y con su contenido documentado.
* **`version` / `lock_version`** no es base. La concurrencia se resuelve en la transacción del
  command handler (ADR-017); un contador optimista se agrega donde se demuestre que hace falta.

## Consecuencias

**+** El nombre de la tabla y el prefijo de sus FK son el mismo token. Se acaba la despluralización
y la clase de error que produce.
**+** Una tabla nueva no puede nacer incompleta: la forma que declara determina sus columnas, y eso
es verificable por CI y por revisión.
**+** El modelamiento en Drizzle parte de tres factories —dominio, append-only, catálogo— en vez de
copiar y pegar la tabla anterior.
**+** Las páginas de schema quedan comparables entre sí, porque todas describen la misma base más
lo propio.

**−** Renombrar 103 tablas toca casi todos los documentos del repositorio. Es barato hoy y caro
después de la primera migración; ese es exactamente el argumento para hacerlo ahora.
**−** El singular contradice la convención más extendida en Rails y en buena parte del ecosistema
Postgres. Es una divergencia consciente: la coherencia interna entre tabla y FK pesa más que la
familiaridad externa, y el equipo que lo lee es este.
**−** `deleted_at` sin booleano obliga a que toda consulta de lectura filtre `deleted_at IS NULL`.
Se resuelve en el repositorio de acceso, no recordándolo en cada query, y se prueba.

## Trabajo de seguimiento

* `standards/data.md`: sección de nombres y las tres formas base, con anti-ejemplos.
* Renombrar las 103 tablas en plural en specs de módulo, feature specs, frontmatter `tables:`,
  ADRs, estándares y runbooks.
* Validador: rechazar un nombre de tabla en plural y exigir que toda tabla declarada tenga página de
  schema.
* Sección **Schemas**: una página por tabla con sus atributos, referencias, restricciones y su
  propio registro de cambios.
* TS-001 implementa las tres factories antes de la primera tabla.

## Alternativas consideradas

**Plural, la convención de Rails.** Es la más común y `SELECT * FROM contacts` se lee bien. Perdió
porque el argumento de lectura se aplica a una consulta y el costo se paga en cada FK, en cada
generador y en cada tabla intermedia. Con 107 tablas y creciendo, la coherencia gana.

**Singular sin contrato de columnas base.** Habría resuelto el nombre y dejado el problema real:
tablas que nacen sin `cell_id` o sin RLS porque nadie recordó la regla. Las dos reglas se decidieron
juntas porque las dos responden a la misma pregunta: *¿desde qué parte toda tabla?*

**`is_deleted` booleano junto a `deleted_at`**, como en otros proyectos de la casa. Perdió por la
misma razón por la que está prohibida una columna de saldo mutable: dos representaciones del mismo
hecho terminan discrepando, y entonces no hay forma de saber cuál manda.

## Changelog

| Versión | Fecha      | Cambio                                                                                                               | Por qué                                                                                                                                 | Autor                  |
| ------- | ---------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| 1.1.0   | 2026-08-18 | Se agrega §2b con el contrato de las dos APIs de eliminación, y se precisa qué distingue `is_active` de `deleted_at` | La regla decía qué columnas hay pero no qué se puede hacer con ellas; sin eso, cada módulo iba a inventar su propio endpoint de borrado | daniel + claude-opus-5 |
| 1.0.0   | 2026-08-18 | Decisión inicial                                                                                                     | 107 tablas declaradas sin regla de nombre ni contrato de columnas, y la ventana para corregirlo se cierra en TS-001                     | daniel + claude-opus-5 |
