
Cumple la nueva LFPDPPP 2025 en tu SaaS: audit log encadenado, ARCO como máquina de estados y borrado verificable (guía de ingeniero, con código)
La nueva LFPDPPP (DOF 20 marzo 2025; vigente el 21) pasó la aplicación de datos personales del INAI a la Secretaría de Anticorrupción y Buen Gobierno. Como ingeniero, entrega tres artefactos: un audit log append-only con hash encadenado, una máquina de estados ARCO que respete el SLA de 20 días hábiles y borrado verificable con bloqueo obligatorio. No hay mandato de localización. Confirma tus obligaciones con un abogado.
No es otro resumen de despacho: qué cambió de verdad en la LFPDPPP 2025 (y quién la aplica ahora)
Para cumplir la LFPDPPP 2025 en un SaaS, como ingeniero necesitas tres artefactos: un audit log append-only con hash encadenado, una solicitud ARCO modelada como máquina de estados que cumpla el SLA de 20 días hábiles, y borrado verificable que bloquee (bloqueo) los datos antes de eliminarlos. La aplicación pasó del INAI a la Secretaría de Anticorrupción y Buen Gobierno, y no hay mandato de localización. Confirma tus obligaciones exactas con un abogado.
El SERP de este tema está 100% lleno de posts de despachos: puro resumen legal, cero código. Este post es lo contrario. La nueva Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP) se publicó en el DOF el 20 de marzo de 2025 y entró en vigor el 21 de marzo de 2025 — aquí te doy el andamiaje de ingeniería que lo sostiene y que puedes montar en producción esta semana. La ley es de tu abogado; los tres artefactos son míos.
Lo que cambió, sin rodeos: ley nueva, autoridad nueva (adiós INAI), ARCO en 20 días hábiles, bloqueo antes de borrar
Cuatro cosas que importan para el diseño de tu sistema, y una que no aplica:
- Ley nueva. La Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP) se publicó en el DOF el 20 de marzo de 2025 y entró en vigor el 21 de marzo de 2025.
- Autoridad nueva. Las funciones de protección de datos que antes ejercía el INAI las asume ahora la Secretaría de Anticorrupción y Buen Gobierno (el INAI se extinguió en la reforma de 2025).
- ARCO en 20 días hábiles. Ante una solicitud de derechos ARCO (Acceso, Rectificación, Cancelación, Oposición), el responsable debe responder en 20 días hábiles. Si procede hacer efectivo el derecho, suele citarse un plazo adicional (comúnmente 15 días hábiles — confírmalo contra la ley vigente).
- Bloqueo antes de borrar. Los datos deben bloquearse (bloqueo) antes de suprimirse, una vez concluido el periodo de conservación y cuando ya no son necesarios para las finalidades. La supresión no es inmediata: primero hay un estado de bloqueo obligatorio.
- Lo que NO cambió. No hay mandato de localización. La ley no exige guardar los datos dentro de México.
La LFPDPPP 2025 para ingenieros, en cinco datos
Primero, tira la premisa falsa: la LFPDPPP NO te obliga a guardar los datos en México
Antes de escribir una línea, mata el mito que hace que los equipos sobre-inviertan: la LFPDPPP no impone residencia de datos. No hay artículo que te obligue a que la base de datos viva en un centro de datos mexicano. Si un proveedor te está vendiendo “cumplimiento LFPDPPP” a punta de migrar todo a una región mx-central, te está vendiendo humo.
Esto importa porque cada hora que gastas en re-alojar datos es una hora que no gastas en lo que la ley sí pide: transparencia, control del titular y un rastro auditable. La transferencia internacional tiene sus propias reglas (aviso de privacidad, consentimiento donde aplique), pero eso no es lo mismo que un mandato de localización. Diséñalo como un problema de gobernanza y evidencia, no de geografía.
Si además mueves PII mexicana hacia modelos de lenguaje, el patrón correcto no es encerrar los datos en México sino redactarlos antes de que salgan: cubro ese flujo en ocultar PII mexicano (CURP/RFC/CLABE) antes del LLM.
Artefacto 1 — Un audit log a prueba de manipulación: la fila append-only con hash encadenado para consentimiento, acceso y acciones ARCO
Lo que la autoridad y un titular molesto van a pedirte no es “confía en nosotros”: es evidencia. Necesitas un registro append-only donde quede asentado cada evento sensible — otorgamiento y revocación de consentimiento, cada acceso a datos personales, y cada acción de una solicitud ARCO.
El truco de ingeniería es hacerlo tamper-evident: cada fila encadena su hash con el de la anterior, igual que un bloque enlaza al previo. Si alguien edita o borra una fila del pasado, la cadena se rompe y la verificación lo delata.
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
actor TEXT NOT NULL, -- who/what performed the action
subject_id TEXT NOT NULL, -- the data subject (titular)
event_type TEXT NOT NULL, -- consent.granted | data.access | arco.received ...
payload TEXT NOT NULL, -- EXACT canonical JSON that was hashed (no raw PII)
prev_hash BYTEA NOT NULL, -- hash of the immediately preceding row
hash BYTEA NOT NULL -- H(prev_hash || payload)
);
-- Enforce append-only at the DB layer, not just in the app.
REVOKE UPDATE, DELETE ON audit_log FROM app_role;
Ojo con que payload es TEXT, no JSONB, a propósito: guardamos los bytes canónicos exactos que hasheamos, para que la verificación reproduzca el hash byte por byte. Una columna JSONB reordenaría las llaves al re-serializar y rompería la cadena en silencio. Y haz el REVOKE: si el rol de la app no puede UPDATE ni DELETE, una cuenta de servicio comprometida tampoco puede editar la historia a escondidas.
Insertar y verificar son unas pocas líneas. La regla: canoniza el payload una sola vez (llaves ordenadas, sin espacios), guarda esa cadena exacta y hashea prev_hash + esa_cadena. La verificación lee la cadena guardada tal cual y recomputa — cualquier discrepancia señala la fila exacta que se alteró.
import hashlib, json
GENESIS = b"\x00" * 32 # prev_hash of the first row
def canonical(payload: dict) -> str:
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
def row_hash(prev_hash: bytes, payload_str: str) -> bytes:
return hashlib.sha256(prev_hash + payload_str.encode()).digest()
def append_event(cur, actor, subject_id, event_type, payload):
cur.execute("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1")
row = cur.fetchone()
prev = row[0] if row else GENESIS
body = canonical(payload) # store the EXACT bytes we hash
h = row_hash(prev, body)
cur.execute(
"INSERT INTO audit_log(actor, subject_id, event_type, payload, prev_hash, hash)"
" VALUES (%s,%s,%s,%s,%s,%s)",
(actor, subject_id, event_type, body, prev, h),
)
def verify(cur) -> tuple[bool, int | None]:
cur.execute("SELECT id, payload, prev_hash, hash FROM audit_log ORDER BY id ASC")
prev = GENESIS
for row_id, payload_str, prev_hash, stored in cur.fetchall():
if bytes(prev_hash) != prev:
return False, row_id # chain break here
if row_hash(prev, payload_str) != bytes(stored):
return False, row_id # payload tampered here
prev = bytes(stored)
return True, None
Mantén la PII cruda (CURP, RFC, CLABE) fuera del payload — guarda referencias o hashes. Corre verify en un cron nocturno y en CI: ese “cadena íntegra hasta la fila N” es, en la práctica, tu prueba de que nadie reescribió el registro de consentimientos y accesos.
Artefacto 2 — Modela una solicitud ARCO como máquina de estados: recibida → validada → en_proceso → resuelta/efectuada
Una solicitud ARCO no es un ticket de soporte cualquiera: tiene un reloj legal corriendo. La forma limpia de manejarlo es una máquina de estados explícita, con transiciones válidas y un timestamp por cada salto. Así nunca “pierdes” una solicitud ni descubres tarde que ya se te pasó el plazo.
La solicitud ARCO como máquina de estados
type ArcoState =
| "received" // request logged; 20-business-day clock starts
| "validated" // titular identity confirmed
| "in_progress" // executing access/rectification/cancellation/opposition
| "resolved" // response delivered to the titular (within 20 days)
| "effectuated"; // right made effective (further period may apply)
const TRANSITIONS: Record<ArcoState, ArcoState[]> = {
received: ["validated"],
validated: ["in_progress"],
in_progress: ["resolved"],
resolved: ["effectuated"],
effectuated: [],
};
function transition(req: { id: string; state: ArcoState }, next: ArcoState) {
if (!TRANSITIONS[req.state].includes(next)) {
throw new Error(`illegal ARCO transition ${req.state} -> ${next}`);
}
// append to the hash-chained audit_log (Artifact 1)
appendEvent(cur, "arco_worker", req.id, "arco." + next, {
requestId: req.id,
at: new Date().toISOString(),
});
req.state = next;
}
Cada transición escribe un evento arco.* en el audit log del Artefacto 1. Cuando un auditor pregunta “qué pasó con la solicitud X”, reproduces la cadena: nada de reconstruir de memoria, y no hay forma de “adelantar” o “atrasar” una fecha sin romperla.
El SLA es de 20 días hábiles, no naturales. Si cuentas días corridos vas a reportar mal el vencimiento — y en la dirección peligrosa. Necesitas un cálculo que excluya sábados, domingos y los días festivos oficiales de México. No hardcodees nada que no puedas citar: carga la lista de festivos de una fuente autorizada y confírmala cada año.
from datetime import date, timedelta
# Confirm the current year against the official DOF/gobierno calendar every year.
MX_HOLIDAYS_2025 = {
date(2025, 1, 1), date(2025, 2, 3), date(2025, 3, 17),
date(2025, 5, 1), date(2025, 9, 16), date(2025, 11, 17),
date(2025, 12, 25),
}
def add_business_days(start: date, n: int, holidays=MX_HOLIDAYS_2025) -> date:
d, added = start, 0
while added < n:
d += timedelta(days=1)
if d.weekday() < 5 and d not in holidays: # Mon-Fri, not a holiday
added += 1
return d
def arco_deadline(received_on: date) -> date:
return add_business_days(received_on, 20) # respond within 20 business days
Corre un job diario que marque cualquier solicitud ARCO cuyo arco_deadline esté, digamos, a tres días hábiles de vencer y siga sin llegar a resolved. Ese es tu sistema de alerta temprana: el reloj debe avisarle a alguien, no quedarse callado.
Artefacto 3 — Borrado verificable con el bloqueo obligatorio: bloqueo suave → conservar el periodo legal → borrado duro con tombstone
Aquí está el error más común de ingeniería: llega una “cancelación” y alguien corre DELETE FROM users WHERE id = .... Mal. La ley pide lo contrario: bloqueo primero. El dato se bloquea — se marca, se aísla, deja de usarse para sus finalidades — luego se conserva durante el periodo de conservación aplicable, y solo después se suprime.
El ciclo de supresión con bloqueo obligatorio
- Bloqueo suave (bloqueo)El registro se marca bloqueado: inaccesible para uso, pero no borrado. Se emite un evento arco.blocked.
- Conservar el periodo legalSe retiene durante el periodo de conservación aplicable, ya sin usarse para las finalidades originales.
- Borrado duro con tombstoneAl vencer el periodo, se suprime la fila y se emite un tombstone de prueba que sobrevive a la fila borrada.
ALTER TABLE users ADD COLUMN status TEXT; -- 'active' | 'blocked'
ALTER TABLE users ADD COLUMN blocked_at TIMESTAMPTZ; -- bloqueo timestamp
ALTER TABLE users ADD COLUMN purge_after DATE; -- end of retention period
-- Step 1: soft-block on an ARCO cancelación (NOT a delete).
UPDATE users
SET status = 'blocked',
blocked_at = now(),
purge_after = (now() + interval '5 years')::date -- confirm the real period w/ counsel
WHERE id = :subject_id;
Los registros bloqueados deben dejar de aparecer en tus consultas normales — agrega WHERE status = 'active' a las rutas que sirven features de negocio, o enruta las lecturas por una vista que los filtre. purge_after codifica cuándo es legal el borrado duro. Bloqueado es bloqueado: sin reportes, sin features, sin exportaciones a terceros.
Al vencer el periodo de conservación borras la fila en duro — pero guardas un tombstone: una prueba pequeña, escrita en el log encadenado, de que este registro existió y se suprimió en esta fecha bajo esta solicitud. El tombstone sobrevive al dato que describe. Eso es lo que hace la supresión verificable y no solo silenciosa.
import hashlib, os
from datetime import date
SALT = os.environ["AUDIT_SALT"] # secret defined outside this snippet
def hard_delete(cur, subject_id: str, arco_request_id: str):
cur.execute("SELECT status, purge_after FROM users WHERE id = %s", (subject_id,))
row = cur.fetchone()
if row is None:
raise RuntimeError("no such subject")
status, purge_after = row
if status != "blocked":
raise RuntimeError("only previously blocked records may be suppressed")
if purge_after is None or purge_after > date.today():
raise RuntimeError("still within the conservation period — cannot suppress yet")
# Tombstone references, never raw PII: a salted hash links the proof, not the identity.
tombstone = {
"subject_ref": hashlib.sha256((SALT + subject_id).encode()).hexdigest(),
"arco_request_id": arco_request_id,
"suppressed_on": date.today().isoformat(),
"basis": "arco.cancelacion_post_bloqueo",
}
append_event(cur, "purge_job", subject_id, "deletion.effected", tombstone)
cur.execute("DELETE FROM users WHERE id = %s", (subject_id,))
Las guardas son if ... raise, no assert: los assert desaparecen si corres Python con -O, y nunca quieres que el bloqueo obligatorio o el periodo de conservación se salten en silencio. Ahora verify del Artefacto 1 prueba la historia completa de punta a punta: consentimiento otorgado, ARCO recibida, derecho efectuado, dato suprimido — una cadena íntegra y a prueba de manipulación.
Qué NO construir: nada de residencia de datos, nada de sobre-ingeniería — la ley no lo pide
Tan importante como los tres artefactos es la lista de lo que no debes construir, porque es donde los equipos queman presupuesto sin ganar cumplimiento:
Dónde poner el esfuerzo de ingeniería
Sí construye (la ley sí lo respalda)
- Audit log append-only con hash encadenado
- Máquina de estados ARCO con timer de 20 días hábiles
- Bloqueo previo + borrado duro con tombstone
- Verificación periódica de la cadena
- Redacción de CURP/RFC/CLABE antes de cualquier LLM
No sobre-construyas (la ley no lo pide)
- Migrar la base de datos a una región mexicana (sin mandato de localización)
- Residencia de datos como requisito legal
- Un blockchain privado (una cadena SHA-256 en Postgres basta)
- Borrado inmediato a solicitud (el bloqueo va primero)
- Cripto casera — usa la biblioteca estándar
Sin mandato de localización, no hay migración por residencia. Sin requisito de blockchain, una cadena SHA-256 en tu Postgres actual sobra. Y “borra ahora” es de hecho incumplimiento — el bloqueo va primero. Construye los tres artefactos, cablea la redacción y párale.
Cómo cablearlo en un SaaS real: dónde viven de verdad el audit log, el worker de ARCO y el job de borrado
En un SaaS de producción los tres artefactos no son un módulo monolítico — son tres piezas con dueños claros:
- Audit log — una tabla append-only, escrita detrás de un único helper
append_event()que llamas desde tu flujo de consentimiento, tu middleware de acceso a datos y cada transición ARCO. Correverifyen CI y en un cron nocturno; alerta ante cualquier ruptura. - Worker de ARCO — un servicio pequeño (o consumidor de cola) que es dueño de la máquina de estados, calcula
arco_deadlinealreceivedy escala cuando una solicitud se acerca a la línea de los 20 días hábiles. - Job de borrado — un batch diario que encuentra filas pasadas de
purge_afterconstatus = 'blocked', escribe el tombstone y luego borra en duro. Idempotente, con bitácora, aburrido.
Este patrón encaja bien con el resto de tu stack de pagos y datos LATAM — es el mismo instinto de “emite evidencia verificable” que aplicas en los flujos de disputas y contracargos, como en ganar contracargos en México y disputas y contracargos con Stripe y Radar. Es la misma disciplina aplicada a datos personales, y el mismo matiz de “corrige el mito” aparece en 3DS2 en México no es PSD2. Para el panorama regional completo, el stack de pagos LATAM reúne el resto.
Soy ingeniero, no tu abogado: confirma obligaciones, plazos y tu aviso de privacidad con un abogado
Que quede claro: esto es el andamiaje de ingeniería para sostener el cumplimiento, no asesoría legal. Los tres artefactos te dan evidencia verificable, control del titular y un rastro a prueba de manipulación — pero tus obligaciones exactas, los plazos aplicables a tu caso (el de respuesta ARCO y cualquier plazo adicional para hacer efectivo el derecho), el periodo de conservación que aplica a cada tipo de dato antes del bloqueo, y el contenido de tu aviso de privacidad los defines con un abogado y contra la LFPDPPP vigente y su reglamento. Yo pongo el código en producción; tu abogado pone el criterio legal.
Fuentes oficiales (verifica siempre contra la versión vigente): Diario Oficial de la Federación (DOF) — publicación de la LFPDPPP del 20 de marzo de 2025 · Cámara de Diputados — leyes federales vigentes — texto de la LFPDPPP y su reglamento · Secretaría de Anticorrupción y Buen Gobierno (gob.mx) — autoridad que asume las funciones de protección de datos tras la extinción del INAI.