Module 7: Shipping Ai Safely
Privacy and data when shipping: what gets logged, and what didn't need to
Description
Every lesson in this module, so far, has generated logs: recs-v1's and recs-v2's outputs in shadow (lessons 3 and 4), an incident's repeated calls (lesson 5). At no point yet did anyone ask how detailed those logs should be. This lesson answers that question: when shadow logging, a canary, or a postmortem need to save buyer data to work, the correct discipline isn't "save everything, in case it's needed later" — it's data minimization: log only what's necessary for the specific purpose, nothing more. This lesson builds redactPII(), the function that applies that discipline to a real log record.
Connection to the module. Lesson 3's shadow mode and lesson 4's shadowCompare() need to log the two models' outputs to be able to compare them — that already implies saving something about each request. This lesson doesn't change that need; it puts a limit on it. redactPII() is the function lesson 8's project reuses, unchanged, to define exactly which fields of a shadow logging record end up persisted and which don't.
An analogy: the ID copy, over the counter
Imagine an employee at a customer service counter who, to verify each person's identity, asks for their ID and photocopies it — "just in case we need it later." Over time, that counter accumulates a stack of photocopies with full names, addresses, ID numbers, of hundreds of customers who never gave explicit consent for their ID to be kept beyond the moment of verification. None of those photocopies was necessary for resolving each customer's original inquiry — verifying that the person at the counter is who they say they are doesn't require keeping a permanent copy of their identity document.
A system that logs "the entire request" in a shadow logging log, without thinking about which fields are actually needed, is that counter. The buyer's full name, their shipping address, their phone number — fields that probably arrived in the original request for some operational reason — end up copied into a log that exists to compare two models' recommendations, a purpose that doesn't need to know who the person is, only what the models decided about their case. Data minimization is the discipline of not making that photocopy — of asking yourself, before saving any data, "does this specific field belong for this record's specific purpose?", and saving only what the answer confirms.
Worked example: redactPII() on a shadow logging record
An engineer, setting up shadow-mode logging for recs-v2, assembles a record with everything available in the request — without yet thinking about what's actually needed to compare recommendations. Let's build redactPII(), the function that cleans that record before it gets persisted:
// pseudonymizeId: turns a real userId into a stable but not trivially
// reversible identifier -- the same deterministic-hash style you already saw
// in isEnabled() (module 2), applied here to minimize log data.
function pseudonymizeId(id) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 100000;
return 'usr-' + String(hash).padStart(5, '0');
}
// redactPII: applies data minimization to a log record.
// - FULLY_REMOVE: fields this log's purpose does NOT need get deleted.
// - MASK: fields sometimes partially needed (e.g. support), get masked.
// - userId: gets pseudonymized -- still useful for debugging (same id always
// produces the same pseudonym), but no longer directly identifies the person.
function redactPII(record) {
const FULLY_REMOVE = ['fullName', 'shippingAddress', 'phoneNumber'];
const MASK = ['email'];
const clean = { ...record };
FULLY_REMOVE.forEach((field) => { delete clean[field]; });
MASK.forEach((field) => {
if (clean[field]) {
const [localPart, domain] = clean[field].split('@');
clean[field] = localPart.slice(0, 2) + '***@' + domain;
}
});
if (clean.userId) clean.userId = pseudonymizeId(clean.userId);
return clean;
}
const rawShadowLogRecord = {
requestId: 'req-cold-07',
userId: 'buyer-84213',
fullName: 'Marta Gonzalez Ibarra',
email: 'marta.gonzalez84@example.com',
shippingAddress: 'Av. Insurgentes Sur 1457, CDMX',
phoneNumber: '+52-55-1234-5678',
region: 'south',
oldTopRec: 'trending-general',
newTopRec: 'home',
modelVersions: { old: 'recs-v1', new: 'recs-v2' },
timestamp: '2026-07-22T14:03:11Z',
};
console.log('=== redactPII on a raw log record ===\n');
console.log('--- BEFORE (what someone wanted to log) ---');
console.log(JSON.stringify(rawShadowLogRecord, null, 2));
console.log('\n--- AFTER (what redactPII lets through) ---');
console.log(JSON.stringify(redactPII(rawShadowLogRecord), null, 2));
What to expect. Running the file with Node, the output is exactly this:
=== redactPII on a raw log record ===
--- BEFORE (what someone wanted to log) ---
{
"requestId": "req-cold-07",
"userId": "buyer-84213",
"fullName": "Marta Gonzalez Ibarra",
"email": "marta.gonzalez84@example.com",
"shippingAddress": "Av. Insurgentes Sur 1457, CDMX",
"phoneNumber": "+52-55-1234-5678",
"region": "south",
"oldTopRec": "trending-general",
"newTopRec": "home",
"modelVersions": {
"old": "recs-v1",
"new": "recs-v2"
},
"timestamp": "2026-07-22T14:03:11Z"
}
--- AFTER (what redactPII lets through) ---
{
"requestId": "req-cold-07",
"userId": "usr-19934",
"email": "ma***@example.com",
"region": "south",
"oldTopRec": "trending-general",
"newTopRec": "home",
"modelVersions": {
"old": "recs-v1",
"new": "recs-v2"
},
"timestamp": "2026-07-22T14:03:11Z"
}
Compare the two records carefully. fullName, shippingAddress, and phoneNumber disappeared entirely — none of the three is needed to compare what recs-v1 recommended against what recs-v2 recommended, so redactPII() doesn't mask or shorten them: it removes them. email is kept, but masked (ma***@example.com) — a reasonable compromise if the support team ever needs to correlate a record with a ticket from a buyer who wrote in from that address, without exposing the full email in every log. And userId — the field that's actually needed to be able to say "these two records are from the same buyer" when analyzing patterns — doesn't disappear, but it also doesn't stay as buyer-84213: it becomes usr-19934, a stable pseudonym (the same real userId always produces the same pseudonym, so it still serves the analysis) but one that doesn't reveal, to whoever only sees the log, who the person behind that record is.
redactPII()'s three moves, and why they're different
It's worth naming precisely the three moves this function makes, because they aren't interchangeable — each answers a different question about the data.
Fully removing (fullName, shippingAddress, phoneNumber) is the correct answer when the log's purpose — comparing recommendations between two models — doesn't need the field under any reasonable circumstance. This is the heart of data minimization: it isn't "hiding sensitive data," it's not collecting it for this purpose in the first place. The question that decides this isn't "is this field sensitive?" — it is, almost any personal data is, to some degree — but "does this record's specific purpose need it?"
Partially masking (email) is the answer when there's a legitimate operational reason to keep part of the data — correlating with support, in this example — but not all of it. Masking isn't the same as fully minimizing: it's still a conscious decision about exactly how much information to keep, not a blanket permission to save the whole field "in case it's useful for something later."
Pseudonymizing (userId) is the answer when the log's purpose does need to be able to tell "these records belong to the same person" without needing to know who that person is. It's the difference between identification and correlation: usr-19934 lets you group all of one buyer's records together for analysis, but doesn't tell you their name or how to contact them without access to a separate mapping table, stored apart with its own access controls.
These three moves — remove, mask, pseudonymize — aren't interchangeable with each other: applying pseudonymization to fullName instead of removing it would leave a piece of data nobody needs, just a little more disguised. Data minimization discipline always starts with the question of whether the field is needed at all — and only for the ones that are, it decides between keeping it whole, masking it, or pseudonymizing it.
Common mistakes
Logging the full request "just in case" it's needed later. What happens: an engineer, without thinking too much, serializes the entire original request object — with all its fields, including ones that arrived there for reasons completely unrelated to shadow logging — directly into the model comparison log. Why it happens: saving everything is simpler to code than deciding field by field what's needed, and "just in case we need it" feels like a reasonable precaution, not a risk. How to spot it: if nobody can justify, field by field, what each piece of data saved in a specific log is for, there's probably more than needed. How to fix it: as in this lesson's example, explicitly define which fields are needed for the log's purpose — comparing recommendations, in this case — and remove the rest, instead of saving everything by default and deciding later.
Assuming pseudonymizing the userId solves the entire privacy problem, without reviewing the other fields. What happens: the team applies careful pseudonymization to the main identifier, feels satisfied with that improvement, and doesn't check whether the rest of the record — name, address, phone — is still traveling in plain text alongside that same pseudonym. Why it happens: pseudonymizing the main identifier feels like "solving privacy" in a single move, and it's easy to miss that other fields in the same record can re-identify the person anyway. How to spot it: if a record has a pseudonymized userId but still carries a full name or an address alongside it, pseudonymizing the identifier didn't do much good — anyone with access to the log can identify the person through those other fields. How to fix it: review each field of the record separately, as redactPII() does in the example — pseudonymizing one field doesn't exempt you from applying the right discipline to the rest.
Treating the buyer's consent for the service as implicit consent for any use of their data. What happens: the team assumes that, since the buyer accepted Mercado's general terms when signing up, that automatically covers any new use of their data — like saving it in a model comparison log that didn't even exist when they signed up. Why it happens: a general consent, given once at the start of the relationship with the buyer, feels like a broad authorization covering whatever comes later, even though the privacy principles behind regulations like GDPR distinguish between the original purpose of collection and new uses not contemplated at that time. How to spot it: if a new, non-obvious use of buyer data — like this shadow log — can't be explained in terms of the purpose the buyer originally agreed to, that consent probably doesn't cover it. How to fix it: any new, non-obvious use of personal data deserves its own review of whether existing consent covers it, instead of assuming a generic consent already settled the question — a matter that, legally and at the policy level, typically involves the company's legal or privacy team, not just engineering.
Exercises
Exercise 1 — Classify a new field. Mercado's shadow logging record sometimes also includes lastFourCardDigits (the last four digits of the card used in the buyer's most recent purchase). Using this lesson's criteria, should redactPII() remove it, mask it, or let it through unchanged, given that the log's purpose is comparing recommendations between models?
See solution
It should be fully removed — it belongs to FULLY_REMOVE, alongside fullName, shippingAddress, and phoneNumber. The log's purpose is comparing what recs-v1 recommended against what recs-v2 recommended; no aspect of that comparison needs to know anything about the buyer's payment method, not even partially. This is a good example of the lesson's central principle: the data's sensitivity isn't what decides the answer — the last four digits of a card look, at first glance, "already fairly masked" — what decides is whether the log's specific purpose needs it, and in this case, it doesn't need it at all.
Exercise 2 — Find the design problem. A teammate proposes a version of redactPII() that pseudonymizes fullName the same way as userId (with a deterministic hash), instead of removing it. Why doesn't this solve the problem the right way, even though it technically "hides" the name?
See solution
Pseudonymizing fullName would still save data the log's purpose — comparing recommendations — doesn't need at all; it would just disguise it with a different value, without removing the underlying question: why are we saving this? Unlike userId, which does serve a legitimate purpose in the log (grouping the same buyer's records for analysis), fullName has no use within this specific log's purpose, pseudonymized or not. Pseudonymization is the right tool for data that's needed but doesn't need to directly identify the person — it's not a generic substitute for data minimization's more basic question: is this field needed, to begin with?
Exercise 3 — The support team's case. Mercado's support team argues it needs the full, unmasked email in shadow logs to help a buyer who writes in a ticket faster. How would you reconcile that real need with this lesson's data minimization principle, without simply denying them access?
See solution
A reasonable reconciliation isn't "everyone sees the full email in every log" or "nobody ever sees it" — it's separating access by purpose: the shadow logging log the engineering team uses to compare models can stay masked (as in this lesson's example, ma***@example.com is enough to confirm which address it is without exposing it fully), while the support team, when it genuinely needs to resolve a specific ticket, accesses the full email through a separate system with its own permissions — the ticketing system, not the model comparison log — where that full access does have a clear, auditable purpose. Data minimization's solution isn't denying the data to whoever genuinely needs it; it's not exposing it, unnecessarily, to whoever doesn't need it for their specific purpose.
Summary and next step
In this lesson you built redactPII() and ran it on a real shadow logging record: it fully removed three fields the log's purpose didn't need (fullName, shippingAddress, phoneNumber), partially masked one that did have a legitimate operational use (email), and pseudonymized the identifier that needed keeping for the analysis (userId, from buyer-84213 to usr-19934). You confirmed data minimization's central idea: the right question isn't "is this data sensitive?" but "does this record's specific purpose need it?"
Before moving on you should be able to: distinguish between removing, masking, and pseudonymizing, and explain when each applies; apply the "does the purpose need it?" criterion to a new field you didn't see in the example; and explain why a general consent doesn't automatically cover any new use of personal data.
Lesson 7 brings together everything this module has built — shadow mode, agreement rate, probabilistic postmortems, data minimization — into a complete migration process, start to finish, with a decision function that checks shadowCompare()'s result against explicit thresholds before authorizing a canary.
Resources
- General Data Protection Regulation (GDPR), Article 5 — gdpr-info.eu/art-5-gdpr. The legal text that defines the data minimization principle: personal data must be "adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed" — the formal definition of the criterion
redactPII()applies in code. In English. - OWASP, "Logging Cheat Sheet" — cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html. A practical engineering guide on what to avoid logging and how to handle sensitive data in application logs — the implementation-level detail that complements GDPR's legal principle. In English.
- California Attorney General's Office, "California Consumer Privacy Act (CCPA)" — oag.ca.gov/privacy/ccpa. The U.S. equivalent to GDPR regarding consumer rights over their personal data — useful for contrasting how two different regulatory frameworks arrive at similar practical principles. In English.