
Comply with Mexico's new LFPDPPP 2025 in your SaaS: hash-chained audit log, ARCO as a state machine, and verifiable deletion (an engineer's guide, with code)
The new LFPDPPP (DOF March 20, 2025; in force March 21) moved data-protection enforcement from INAI to the Secretaría de Anticorrupción y Buen Gobierno. As an engineer, ship three artifacts: a hash-chained append-only audit log, an ARCO state machine honoring the 20-business-day SLA, and verifiable deletion with mandatory bloqueo. There is no data-localization mandate. Confirm exact obligations with counsel.
Not another law-firm summary: what actually changed in the 2025 LFPDPPP (and who enforces it now)
To comply with the 2025 LFPDPPP in a SaaS, an engineer needs three artifacts: a hash-chained append-only audit log, an ARCO request modeled as a state machine that meets the 20-business-day SLA, and verifiable deletion that blocks (bloqueo) data before erasing it. Enforcement moved from the INAI to the Secretaría de Anticorrupción y Buen Gobierno, and there is no data-localization mandate. Confirm exact obligations with counsel.
The search results for “LFPDPPP 2025” are wall-to-wall law firms and zero running code. This post is the engineering scaffolding those posts skip. The new Ley Federal de Protección de Datos Personales en Posesión de los Particulares (LFPDPPP) was published in the DOF on March 20, 2025 and took effect March 21, 2025 — here is how you build against it.
What changed, in plain terms: new law, new authority (INAI is gone), 20-business-day ARCO, bloqueo-before-deletion
Four facts drive every design decision below:
- New law, new date. DOF March 20, 2025; in force March 21, 2025.
- New authority. The functions the INAI used to perform are now assumed by the Secretaría de Anticorrupción y Buen Gobierno. The INAI was wound down in the 2025 reform.
- ARCO has a hard clock. You must respond to an Acceso, Rectificación, Cancelación, or Oposición request within 20 días hábiles (business days). When the right must then be made effective, a further period applies (commonly cited as 15 días hábiles — confirm against the current law).
- Bloqueo before deletion. Personal data must be blocked before it is suppressed. Deletion is never immediate; a mandatory blocking state comes first, after the retention period and once the data is no longer needed.
The 2025 LFPDPPP for engineers, in five facts
First, kill the false premise: the LFPDPPP does NOT require you to keep data in Mexico
Before you build anything, delete a myth from your backlog. The LFPDPPP does not require personal data to live inside Mexico. There is no residency or localization mandate. If someone on your team wants to migrate your database to a Mexican region “for LFPDPPP,” stop them — you’d be spending sprint budget on a requirement that doesn’t exist.
What the law does care about is that transfers (including cross-border ones) are covered by your aviso de privacidad and consent, and that you can prove what you did with the data. That’s a provenance problem, not a geography problem — and provenance is exactly what the audit log below solves. If cross-border transfer terms matter to your flow, that’s a conversation for counsel, not a datacenter migration.
If you’re also moving Mexican PII toward language models, the correct pattern isn’t locking data inside Mexico — it’s redacting it before it leaves. I cover the exact patterns in redact Mexican PII before the LLM.
Artifact 1 — A tamper-evident audit log: the hash-chained, append-only row for consent, access, and ARCO events
You can’t prove compliance with a mutable updated_at column. What you need is an append-only ledger where every consent grant, data access, and ARCO action is a row you can never silently rewrite. The trick borrowed from blockchains without any blockchain: each row commits to the hash of the row before it.
Each entry stores the previous row’s hash plus its own hash, where hash = H(prev_hash || payload). Change any historical payload and every downstream hash breaks — tampering becomes detectable, not invisible.
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;
Note that payload is TEXT, not JSONB, on purpose: we store the exact canonical bytes we hashed, so verification reproduces the hash byte-for-byte. A JSONB column would re-serialize and reorder keys, silently breaking the chain. And do the REVOKE — if your app role can’t UPDATE or DELETE, a compromised service account can’t quietly edit history either.
Appending and verifying is a few lines. The rule: canonicalize the payload once (stable key order, no whitespace), store that exact string, and hash prev_hash + that_string. Verification reads the stored string back verbatim and recomputes — any mismatch pinpoints the exact row that was altered.
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
Keep raw PII (CURP, RFC, CLABE) out of the payload — store references or hashes instead. Run verify on a nightly cron and in CI; a clean “chain intact through row N” is, in practice, your proof that nobody rewrote the consent and access record.
Artifact 2 — Model an ARCO request as a state machine: received → validated → in_progress → resolved/effectuated
An ARCO request is not a support ticket you close whenever. It’s a regulated workflow with a clock. Model it as an explicit state machine so every transition is logged, every deadline is computed, and nothing rots in an inbox.
The ARCO request as a state machine
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;
}
Every transition writes an arco.* event to the audit log from Artifact 1. When an auditor asks “what happened to request X,” you replay the chain — no reconstruction from memory, and no way to backdate a transition without breaking it.
The 20-day SLA is días hábiles, not calendar days. Get this wrong and you’ll report compliance while actually blowing deadlines. Business-day math must skip weekends and the official Mexican holidays. Hardcode nothing you can’t cite — pull the holiday list from an authoritative calendar and confirm it each year.
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
Run a daily job that flags any ARCO request whose arco_deadline is within, say, three business days and still not in resolved. That’s your early-warning system — the timer should page someone, not sit silent.
Artifact 3 — Verifiable deletion with the mandatory bloqueo: soft-block → retain for the legal period → hard-delete with a tombstone
Here’s the mistake engineers make: a “cancelación” request comes in and someone runs DELETE FROM users WHERE id = .... Wrong. The law requires bloqueo first. The data gets blocked — marked, isolated, no longer used for its stated purposes — then retained through the applicable conservation period, and only then suppressed.
Verifiable deletion with the mandatory bloqueo
- Soft-block (bloqueo)Mark the record blocked; freeze it from all normal use immediately. Emit an arco.blocked event.
- Retain for the legal periodHold through the applicable conservation window, no longer used for the original purposes.
- Hard-delete + tombstoneWhen the period ends, erase the row and emit a tombstone proof that outlives the deleted row.
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;
Blocked records must stop appearing in your normal queries — add WHERE status = 'active' to the paths that serve business features, or route reads through a view that filters them out. purge_after encodes when the hard delete becomes legal. Blocked means blocked: no reports, no features, no third-party exports.
When the retention period ends, you hard-delete the row — but you keep a tombstone: a small proof, written to the hash-chained log, that this record existed and was suppressed on this date under this request. The tombstone survives the data it describes. That’s what makes deletion verifiable rather than just silent.
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,))
The guards are if ... raise, not assert — assertions vanish when Python runs with -O, and you never want the mandatory bloqueo or the conservation period silently skipped. Now verify from Artifact 1 proves the whole story end to end: consent granted, ARCO received, right effectuated, data suppressed — an unbroken, tamper-evident chain.
What NOT to build: skip data residency, skip over-engineering — the law doesn’t ask for it
Resist scope creep dressed up as compliance:
Where to spend the engineering effort
Build this (the law backs it)
- Hash-chained append-only audit log
- ARCO state machine with business-day timers
- Bloqueo-then-delete with a tombstone proof
- Periodic verification of the chain
- Redaction of CURP/RFC/CLABE before any LLM
Don't over-build (the law doesn't ask)
- Migrating your DB to a Mexican region (no localization mandate)
- Data residency treated as a legal requirement
- A private blockchain (a SHA-256 chain in Postgres is enough)
- Instant deletion on request (bloqueo comes first)
- Homegrown crypto — use the standard library
No localization requirement means no residency migration. No blockchain requirement means a SHA-256 chain in your existing Postgres is plenty. And “delete now” is actually non-compliant — bloqueo has to come first. Build the three artifacts, wire in redaction, and stop.
Wiring it into a real SaaS: where the audit log, ARCO worker, and deletion job actually live
Concretely, in a typical stack:
- Audit log — one append-only table, writes behind a single
append_event()helper called from your consent flow, your data-access middleware, and every ARCO transition. Runverifyin CI and on a nightly cron; alert on any break. - ARCO worker — a small service (or queue consumer) that owns the state machine, computes
arco_deadlineonreceived, and escalates when a request nears the 20-business-day line. - Deletion job — a daily batch that finds rows past
purge_afterwithstatus = 'blocked', writes the tombstone, then hard-deletes. Idempotent, logged, boring.
This slots naturally beside the other MX-specific controls in your payments stack. If you’re already handling disputes and fraud, you’ve seen this pattern — durable, auditable event trails — in win chargebacks in Mexico and Stripe disputes, chargebacks & Radar. It’s the same discipline applied to personal data, and the same “correct-the-myth” nuance shows up in 3DS2 in Mexico is not PSD2. For the broader regional picture, the LATAM payments hub collects the rest.
I’m an engineer, not your lawyer: confirm obligations, deadlines, and your aviso de privacidad with counsel
This is the engineering scaffolding to support compliance — it is not legal advice. The 20-business-day response window, the further period to make a right effective, the exact retention periods, and the wording of your aviso de privacidad all depend on your specific processing, and on the current LFPDPPP plus its reglamento. Verify every obligation, deadline, and threshold against the law and with qualified counsel before you ship. The code here gives you a tamper-evident audit trail, a deadline-aware ARCO workflow, and provable deletion — the parts an engineer can actually own — but the what and the when are legal calls, not architecture decisions.
Sources: LFPDPPP publication, Diario Oficial de la Federación, March 20, 2025 · Ley Federal de Protección de Datos Personales en Posesión de los Particulares (Cámara de Diputados) · Secretaría de Anticorrupción y Buen Gobierno (gob.mx)