Skip to main content
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:
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)

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

3. Lo que NO es columna base

  • is_active no es base. Es dominio: significa “existe pero no se puede usar”, que es distinto de “borrado”. Va solo donde una spec lo justifica —programas, monedas de puntos, catálogos— y nunca como sustituto de deleted_at.
  • 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