Primer commit - Sprint 1

This commit is contained in:
markosbenitez
2026-05-31 04:33:07 -03:00
commit ef9e4bf998
22 changed files with 2515 additions and 0 deletions

152
DATA_MODEL.md Normal file
View File

@@ -0,0 +1,152 @@
# DATA_MODEL.md — Contrato del modelo de datos
> Este es el **contrato** que Claude Code implementa como migraciones de Supabase + policies de RLS
> en la Etapa 1. El DDL de abajo es **ilustrativo del contrato** (columnas, tipos, intención), no la
> migración final: el agente escribe las migraciones reales, constraints, índices y RLS. No agregar
> tablas que no estén acá sin aprobación (Nivel 3).
Convención: snake_case, claves `uuid` (default `gen_random_uuid()`), timestamps `timestamptz`.
`UMBRAL_K` se referencia como constante de proyecto (default 5).
---
## Diagrama lógico
```
organizations 1──┐
surveys ─────────┤ (controller_role, is_anonymous, sensibilidad, seguridad)
│ │
├─ questions ─┤ (type, is_sensitive auto, conditional_rules)
│ │
responses ───────┘ (company_id, respondent_id NULLABLE, ip_hash, consent_record_id)
├─ answers (value JSONB)
consent_versions 1──< consent_records (snapshot inmutable por respuesta)
```
---
## Tablas
### `organizations` — empresas cliente
```sql
id uuid pk
name text not null
sector text -- para agrupar/benchmark sectorial (Nivel 2)
size_bucket text -- rango de tamaño, NO headcount exacto (minimización)
created_at timestamptz default now()
```
### `surveys` — definición de cada estudio
```sql
id uuid pk
org_id uuid fk -> organizations(id) -- empresa para la que corre
title text not null
status text default 'draft' -- draft | live | closed
-- Eje 1: anonimato (propiedad técnica, irreversible una vez live)
is_anonymous boolean not null -- true en las 4 de v1
-- Eje 2a: sensibilidad legal (alimenta consentimiento)
data_sensitivity text not null -- 'comun' | 'sensible' | 'anonimizado'
-- Eje 2b: seguridad (alimenta RLS/cifrado) — ORTOGONAL al anterior
security_class text not null -- 'publico' | 'interno' | 'confidencial'
-- Rol de responsable
controller_role text not null default 'A' -- 'A' = nosotros | 'B' = empresa (no usado en v1)
-- Consentimiento vigente al publicar
consent_version_id uuid fk -> consent_versions(id)
-- Marca / look & feel
brand_config jsonb -- { logo_url, palette, ... }
k_threshold int not null default 5 -- umbral de k-anonimato para esta encuesta
created_at timestamptz default now()
published_at timestamptz
```
> El agente NO calcula `data_sensitivity` a partir de texto libre del creador: ciertos `questions.type`
> (salud/discapacidad) **fuerzan** `is_sensitive = true` a nivel pregunta y elevan la encuesta a
> `sensible`. La herramienta constriñe, no solo permite (ver ADR-001).
### `questions`
```sql
id uuid pk
survey_id uuid fk -> surveys(id)
code text -- 'B1','C1','H1'... (del mockup)
type text not null -- text_short|text_long|single_choice|multiple_choice|
-- rating|nps|matrix|ranking|date|slider|yes_no|section
prompt text not null
position int not null
is_sensitive boolean not null default false -- auto-true para tipos de salud/discapacidad
max_select int -- p/ multiple_choice con límite (H1 = 3)
options jsonb -- [{value,label}, ...]
conditional_rules jsonb -- [{ if_question, op, value, action: show|hide|skip, target }]
required boolean default false
```
### `responses` — una submission (anónima en v1)
```sql
id uuid pk
survey_id uuid fk -> surveys(id)
company_id uuid fk -> organizations(id) -- SE CONSERVA (habilita Nivel 2)
respondent_id uuid -- RESERVADO, NULL en encuestas anónimas
ip_hash text -- SHA-256 + salt, dedup sin identidad
consent_record_id uuid fk -> consent_records(id) not null
submitted_at timestamptz default now()
-- cuasi-identificadores denormalizados para segmentación eficiente (también viven en answers)
qi_age_bucket text -- rango, no edad exacta (minimización)
qi_sector text
qi_zone text
```
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
> minimizar re-identificación.
### `answers`
```sql
id uuid pk
response_id uuid fk -> responses(id)
question_id uuid fk -> questions(id)
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
```
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
### `consent_versions` — textos de consentimiento versionados
```sql
id uuid pk
version text not null -- 'v1', 'v2'...
body text not null -- texto completo presentado al titular
scopes jsonb not null -- catálogo de scopes que este texto ofrece
-- ej: ["operativo","macro_n1"] (v1 NO incluye macro_n3)
created_at timestamptz default now()
```
### `consent_records` — qué consintió cada respuesta (INMUTABLE)
```sql
id uuid pk
consent_version_id uuid fk -> consent_versions(id) not null
granted_scopes jsonb not null -- subconjunto efectivamente aceptado por el titular
-- ej: {"operativo":true,"macro_n1":true}
granted_at timestamptz not null default now()
-- inmutable: sin UPDATE/DELETE; se inserta una vez junto con la response
```
> En encuesta anónima NO hay revocación posible (no se puede ubicar la respuesta). El texto de
> consentimiento debe declararlo explícitamente al titular antes de responder (ADR-003).
---
## Intención de RLS (Claude Code escribe las policies)
- Una `organization` solo puede leer **agregados** de sus propias `responses`, nunca filas crudas, y
solo por encima de `k_threshold`. No existe policy que devuelva una fila individual a una empresa.
- El rol de **análisis macro** (interno, responsable = nosotros) puede leer filas para análisis
propio, pero la **capa de reportes/exportación** aplica el umbral de k-anonimato y excluye texto
libre crudo antes de exponer cualquier cosa.
- `consent_records` y `consent_versions`: solo INSERT y SELECT. Sin UPDATE/DELETE (inmutabilidad).
---
## Vistas/funciones helper esperadas (Etapa 1)
- `agg_segment(survey_id, dimensions[])` → agregación que **suprime** automáticamente celdas con
`count < k_threshold` (devuelve "n insuficiente" en lugar del valor).
- Un trigger/constraint que rechaza `respondent_id` no nulo cuando `is_anonymous = true`.