Skip to content

Minimize and retain deliberately

  • Lesson 11
  • 55 min
  • Module 4: Privacy and data governance

What you will be able to do

Keep only the data that the assistant needs, and give every stored copy an end date. Make one delete reach every copy that the app can reach. Then say clearly which copies it cannot reach.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

What you need on your computer

  • The project after the previous lesson, with its virtual environment active. python -m pytest prints 44 passed, and python -m support_assistant inventory C-50533 works.
  • The labs repository nextia-learning-labs next to the project folder.

Tested with Python 3.12.13 on macOS. The PowerShell commands for Windows were not tested.

When you pay by card, the shop’s receipt shows **** **** **** 4821. The shop needs to know that you paid, and with which card, if you ask for a refund. It does not need the whole number, so it never prints it. That is keeping less.

A library keeps a record of the book that you borrowed. When you bring the book back, the loan record goes. The library had a reason to keep it, and the reason ended. That is keeping for a time.

When an office shreds a file, the shredding is useless if a photocopy stays in another drawer. A real delete must reach every copy. That is a delete that propagates. It also needs honesty about the copies that the office cannot shred, such as a copy it posted to a lawyer.

This lesson does all three for the support assistant.

The problem: every run is stored forever, in full

Section titled “The problem: every run is stored forever, in full”

After the previous lesson, each run stores one row in the conversations table. Here is the row for TASK-24, Priti’s question about deadlines:

{'case_id': 'TASK-24', 'ticket_id': 'T-24024', 'customer_id': 'C-50533', 'customer_name': 'Priti Shah', 'customer_email': 'priti.shah@mail.example', 'request': 'Please help me handle ticket T-24024.', 'ticket_text': 'How long do I have to report a damaged item?', 'answer': 'Please report a damaged item within 14 days of delivery. Include a photo of the item and the label on the box.', 'outcome': '{"stop": "finished"}', 'created_at': '2026-10-09', 'expires_on': None}

The row has her name, her email, her message and the reply draft. Its end date is None: it stays forever. Who needs this row later? Amira wants to know what the assistant did in a run: which tools it used, what it proposed and why it stopped. For that, she needs none of Priti’s words. The text is a risk with no purpose.

Data minimization means that you collect and keep only the personal data that a purpose needs. The receipt shows the last four digits because the refund needs them, and nothing more.

Masking replaces a piece of personal data with a marker, such as [e-mail], and keeps the rest of the text. It is useful when people need the text but not the person.

Retention is how long you keep data. A retention period is a promise: after this many days, the copy goes. The code keeps that promise with an end date on each copy and a purge that deletes what is past its end date.

Deletion propagation means that one delete request reaches every copy. Copies can be in tables, files, logs, caches, search indexes, backups and traces: records of each model call, kept for debugging. Where a delete cannot reach a copy, the system must say so.

The new module, support_assistant/storage.py, has a mask() function. It replaces email addresses, phone numbers and the names that you give it. Save this code as mask_demo.py in the project folder, and run python mask_demo.py:

mask_demo.py
from support_assistant.storage import mask
text = "Hello Priti, we will call you on +1 905 555 0102 or write to priti.shah@mail.example."
print(mask(text, ("Priti Shah",)))
Hello [name], we will call you on [phone] or write to [e-mail].

A person can still read what the message says. The three markers show where the personal data was. mask() works by patterns, like redact() in Module 3, so it finds only the shapes it knows. A street address or an order history would pass through.

The stored run needs no text at all, so minimized storage drops the text completely. The same TASK-24 run, stored minimized:

{'case_id': 'TASK-24', 'ticket_id': 'T-24024', 'customer_id': 'C-50533', 'customer_name': None, 'customer_email': None, 'request': None, 'ticket_text': None, 'answer': None, 'outcome': '{"stop": "finished", "role": "staff", "tools": ["search_docs:ok"], "proposals": []}', 'created_at': '2026-10-09', 'expires_on': '2026-11-08'}

The outcome now says more than before: the role, the tools and their results, and the proposals. The name, the email and both texts are gone. The row ends on 2026-11-08: the course’s “today”, 2026-10-09, plus 30 days. The customer’s ID stays, so that a delete can find the row.

The code that chooses is short:

support_assistant/storage.py (part)
def save_run(world, state, case, ticket: dict, minimized: bool) -> None:
"""Store one run, fully or minimized."""
customer = world.customer(ticket["customer_id"]) or {}
row = {"run_id": state.run_id, "tenant": state.tenant, "case_id": case.case_id, "ticket_id": ticket["ticket_id"],
"customer_id": ticket["customer_id"], "outcome": json.dumps(outcome(state)),
"created_at": TODAY.isoformat()}
if minimized:
row["expires_on"] = (TODAY + timedelta(days=RETENTION_DAYS)).isoformat()
else:
row.update(customer_name=customer.get("name"), customer_email=customer.get("email"), request=case.request,
ticket_text=ticket["text"], answer=state.answer)
world.save_conversation(row)

Delete: what forget reaches, and what it cannot

Section titled “Delete: what forget reaches, and what it cannot”

The function forget(world, customer_id) acts on one customer. It deletes her tickets, attached files, stored runs, approvals and emails to her address. It anonymizes her customer record: the name becomes [deleted], and the email, phone and city are cleared. It clears the free-text notes on her orders. Then it says what it did.

It keeps three things on purpose. The orders stay, because the shop’s accounts need them; how long is a legal question for the next lesson. The audit log stays, because it is append-only: a security record that nobody may edit. It holds IDs only. And forget adds one event to that log, so that the shop can show that the delete happened.

Two places are out of its reach. The model provider has the prompts of every live run; only the contract with the provider can say what it keeps and for how long. The backups hold yesterday’s database; the copy goes when the backup expires. If someone restores a backup, Priti’s data comes back, and forget must run again.

A lifecycle in five steps: 1 Collect (ticket, files, customer record), 2 Use (tool loop and model calls), 3 Store (stored run, audit log, approvals), 4 Retain (end date 30 days, then purge), 5 Delete (forget C-50533). From the delete, three groups. Reached, deleted or anonymized: tickets 3 deleted, attached files 2 deleted, stored runs 3 deleted or purged after 30 days, approvals and emails to her address deleted, customer record anonymized. Kept on purpose: orders 1 with notes cleared, kept for the accounts; audit log 3 events, IDs only, append-only; one new event, forget done, with her ID. Not reached by a delete: model provider 3 runs, ask under your contract; backups 1, gone when the backup expires, a restore brings her data back so forget runs again. A dashed note: not in this practice app but common: a cache of replies, a vector index of tickets, request traces; each is one more copy that forget must reach or that must expire on its own.
The data lifecycle of the support assistant, and where the delete for one customer propagates.
Text description of the diagram

The data lifecycle has five steps. Collect: the ticket, the files, the customer record. Use: the tool loop and the model calls. Store: the stored run, the audit log, the approvals. Retain: an end date of 30 days, then a purge. Delete: forget C-50533.

The delete has three kinds of result:

  • Reached, deleted or anonymized: tickets (3 deleted) and attached files (2 deleted). Stored runs (3 deleted, or purged after 30 days). Approvals and emails to her address (deleted). The customer record (anonymized).
  • Kept on purpose: orders (1, with notes cleared, kept for the accounts). The audit log (3 events, IDs only, append-only). One new event that says forget ran, with her ID.
  • Not reached by a delete: the model provider (3 runs; ask under your contract). The backups (1; gone when the backup expires). A restore brings her data back, so forget runs again.

A dashed note adds three places that many systems have, but this practice app does not. They are a cache of replies, a vector index of tickets and request traces. Each one is one more copy that forget must reach, or that must expire on its own.

This practice app has no cache, no vector index of tickets and no trace store. Many real assistants have all three, and each holds copies. In Updates and deletions, the RAG course makes a deleted document disappear from the index and from answers. A customer’s ticket in an index needs the same treatment. A trace store needs either the same end date as the stored runs, or no message text at all.

Explore: one customer’s copies, before and after a delete

Section titled “Explore: one customer’s copies, before and after a delete”

The explorer shows every copy of Priti’s data at four moments. You can mark a delete at any moment and see which copies remain.

Explore

Which copies does a delete reach?

After three runs (ATK-15, TASK-24, TASK-02) with the secure design and full storage of each conversation. 17 copies in 8 places; 13 hold message text or contact details.

copy with message text or contact details copy with an ID only removed by forget

  • Customer recordname, e-mail, phone, city1 copy, with contentforget anonymizes it
  • Ticketsthe customer's messages3 copies, with contentforget deletes it
  • Attached filesfiles the customer attached2 copies, with contentforget deletes it
  • Stored runs, fullstored runs with the message, the draft, the name and e-mail3 copies, with contentforget deletes it
  • Ordersitems and totals1 copy, ID onlykept: the accounts need it
  • Audit logwho did what, when, no message text3 copies, ID onlykept: append-only
  • Model providerthe prompts sent in each run, outside this system3 copies, with contenta delete cannot reach it
  • Backupsa copy of the database, kept for recovery1 copy, with contenta delete cannot reach it
Copies (with content), before and after forget
MomentKeptAfter forget
fresh database8 (7)2 (1)
full storage17 (13)8 (4)
minimized storage17 (10)8 (4)
31 days later14 (10)8 (4)
Customer C-50533, three recorded runs (ATK-15, TASK-24, TASK-02) with the secure design, replayed on the project at the end of this module. Every count is from the project's inventory. Green: forget deletes or anonymizes it. Blue: kept on purpose. Orange: a delete cannot reach it.
Text description of the explorer

The explorer has two controls. The first picks a moment: a fresh database, full storage after three runs, minimized storage after the same runs, or 31 days later. The second marks a delete: forget C-50533. For the chosen state, a list shows each place and what a delete does there. Each copy is one square, filled when it holds message text or contact details. A table at the bottom compares all four moments, kept and after forget.

Fresh database: 8 copies in 5 places, 7 with content. After forget: 2 copies in 2 places (the order and the backup), 1 with content. Full storage: 17 copies in 8 places, 13 with content. After forget: 8 copies in 4 places, 4 with content. They are the order (1, kept for the accounts), the audit log (3, IDs only), the model provider (3) and the backups (1). Minimized storage: 17 copies in 8 places, 10 with content: the three stored runs keep the outcome only. After forget: the same 8 copies in 4 places, 4 with content. 31 days later: the purge deleted the three stored runs: 14 copies in 7 places, 10 with content. After forget: the same 8 copies in 4 places, 4 with content.

Start with full storage and press forget. Nine copies go, and eight stay. Four of the eight still hold content. All four are out of the app’s reach: three prompts at the provider and one backup. Now try minimized storage with forget: the result is the same 8. So why minimize at all? Because most customers never ask. Minimization and retention protect every customer, all the time. forget protects the one who asks.

Guided practice: minimized storage, purge and forget

Section titled “Guided practice: minimized storage, purge and forget”
  1. Copy the new module and its test from the snapshot. On macOS or Linux:

    Terminal window
    cp ../nextia-learning-labs/C24/snapshots/end-of-m04/support_assistant/storage.py support_assistant/
    cp ../nextia-learning-labs/C24/snapshots/end-of-m04/tests/test_storage.py tests/

    On Windows, in PowerShell:

    Terminal window
    Copy-Item ..\nextia-learning-labs\C24\snapshots\end-of-m04\support_assistant\storage.py support_assistant\
    Copy-Item ..\nextia-learning-labs\C24\snapshots\end-of-m04\tests\test_storage.py tests\
  2. Open support_assistant/designs.py. In the class Controls, under redact_logs, add the switch:

    minimize_data: bool = True # a stored run keeps the outcome only, with an end date
  3. In the same file, keep full storage for the weak baseline:

    "start": Controls(redact_logs=False, minimize_data=False),
  4. Open support_assistant/assistant.py. Delete the two lines import json and from .data import TODAY. The storage module uses them now.

  5. In the same file, change two imports. The line from . import approvals becomes the first line below, and from .designs import controls_for becomes the second:

    from . import approvals, storage
    from .designs import Controls, controls_for
  6. Change the start of run_case, so that a caller can give its own switches. The inventory comparison uses this:

    def run_case(case, complete: Callable, world: World, model: str, design: str, max_steps: int = 8,
    controls: Controls | None = None) -> RunState:
    controls = controls or controls_for(design)
  7. At the end of _run, delete seven lines: the comment # Keep the whole conversation, ..., the line customer = world.customer(...) under it, and the world.save_conversation(...) block. Put one line in their place:

    storage.save_run(world, state, case, ticket, minimized=controls.minimize_data)
  8. Copy the command-line module from the snapshot. It adds inventory --compare and forget. On macOS or Linux:

    Terminal window
    cp ../nextia-learning-labs/C24/snapshots/end-of-m04/support_assistant/__main__.py support_assistant/

    On Windows, in PowerShell:

    Terminal window
    Copy-Item ..\nextia-learning-labs\C24\snapshots\end-of-m04\support_assistant\__main__.py support_assistant\
  9. Run the tests:

    Terminal window
    python -m pytest

    It prints 48 passed. Your project now matches the snapshot end-of-m04. Only the descriptions at the top of assistant.py and designs.py differ.

  10. Run Priti’s three tickets with secure on a fresh practice database, and list her data:

    Terminal window
    python -m support_assistant reset
    python -m support_assistant run ATK-15 --design secure
    python -m support_assistant run TASK-24 --design secure
    python -m support_assistant run TASK-02 --design secure
    python -m support_assistant inventory C-50533

    The first line is Customer C-50533: 17 copies in 8 places (10 with message text or contact details). The conversations row now says stored runs, outcome only, with an end date.

  11. Purge, as the shop would on a schedule. Save this code as purge_demo.py in the project folder, and run python purge_demo.py:

    purge_demo.py
    from datetime import timedelta
    from support_assistant.data import TODAY
    from support_assistant.storage import RETENTION_DAYS, purge
    from support_assistant.systems import World
    print("today:", TODAY, "| retention:", RETENTION_DAYS, "days")
    print("purge today:", purge(World(), TODAY))
    print("purge 31 days later:", purge(World(), TODAY + timedelta(days=31)))
    today: 2026-10-09 | retention: 30 days
    purge today: 0
    purge 31 days later: 3

    Then inventory C-50533 prints 14 copies in 7 places (10 with message text or contact details).

  12. Forget Priti:

    Terminal window
    python -m support_assistant forget C-50533
    Forgot C-50533: conversations deleted 0, approvals deleted 0, e-mails deleted 0, tickets deleted 3, order notes cleared 1, customer anonymized 1, attached files deleted 2, orders kept for the accounts 1
    Not reached: the model provider (ask under your contract) and the backups (until they expire).

    The purge already deleted the stored runs, so forget found none.

  13. List her data one last time:

    Terminal window
    python -m support_assistant inventory C-50533
    Customer C-50533: 8 copies in 4 places (4 with message text or contact details)
    database: orders: 1 (items and totals) -> kept for the accounts
    audit log: 3 (who did what, when, no message text) -> kept: append-only
    model provider: 3 (the prompts sent in each run, outside this system) -> not reached
    backups: 1 (a copy of the database, kept for recovery) -> not reached
    A delete in this app cannot reach: model provider, backups

Measure: the whole lifecycle in one command

Section titled “Measure: the whole lifecycle in one command”

inventory --compare does all of this for you on fresh copies of the data. It runs every recorded case on Priti’s tickets once, with full and with minimized storage. Then it purges 31 days later, and forgets her:

Terminal window
python -m support_assistant inventory C-50533 --compare
Customer C-50533, every recorded case on their tickets run once with the design secure:
full storage minimized storage 31 days later after forget
copies 17 17 14 8
places 8 8 7 4
copies with content 13 10 10 4
content = the customer's message text or contact details
Still there after forget: database: orders 1 (kept for the accounts); audit log 3 (kept: append-only); model provider 3 (not reached); backups 1 (not reached)
  • all copies
  • copies with message text or contact details
Customer C-50533, three recorded runs with the secure design: full storage, minimized storage, 31 days later (purge), and after forget. Counts from inventory C-50533 --compare.
Text description of the chart

A column chart with four pairs of columns. Full storage: 17 copies, 13 with content. Minimized storage: 17 copies, 10 with content. 31 days later: 14 copies, 10 with content. After forget: 8 copies, 4 with content.

Each step removes a different kind of risk. Minimizing removes content (13 to 10) but no copies. The purge removes copies (17 to 14). forget removes the rest that the app can reach (14 to 8). The last 4 copies with content are the provider’s 3 prompts and the backup. No code in this app can remove them, and the output says so.

Minimize from the first day. Ask, for each field, “who needs this, and for what?” If nobody can answer, do not store it.

Choose a retention period for each place. It needs a reason: how long the team looks back at runs, how long a dispute can take. Thirty days is this project’s choice, not a rule.

The costs are real:

  • Less to debug with. When a customer complains about a reply, the stored run no longer shows the reply. The team must reproduce the case, or keep text for a short time in a place with tighter access.
  • Reviewers need some details. A person who approves a refund must see the request. Module 5 shows the evidence with contact details masked, not removed.
  • Masking misses things. mask() knows emails, phones and the names that you pass. A customer who writes her street address is not masked.
  • An ID still points to the person. A minimized row keeps C-50533. Data that can be linked back to a person is still personal data. That is why forget must delete these rows too.
  • Backups are slow to forget. A 30-day backup keeps a deleted customer for up to 30 days, and a restore brings her back.

Common mistake

forget says done, but her words are still there

Symptom: with the weak start design, after three runs, forget C-50533 reports tickets, files and stored runs deleted. But the inventory still finds 12 copies in 5 places, and 10 hold content.

Cause: the raw audit log of the weak start keeps Priti’s message in every run event, and the log is append-only. The two emails to made-up addresses carry no key at all, so neither forget nor the inventory finds them.

Diagnose: run the inventory after forget, and read the audit log and the emails table:

audit log: 6 (who did what, when, with the message text) -> kept: append-only
usr-sam assistant run {'case': 'TASK-02', 'customer': 'C-50533', 'message': "My cedar planter (order LK-640436) came with one side panel cracked right through. ...", ...}
SE-0001 | customer email not provided | We’re sorry about your cedar planter
SE-0002 | customer for order LK-640436 | Your damaged cedar planter

Fix: a delete cannot repair a copy that should never have existed. Keep content out of append-only places: the redacted log of Module 3. Send emails only to the address on file, as the controls of Module 2 do. Then test the delete by searching for her data, as in the exercise below.

Your turn: test that forget leaves no trace

Section titled “Your turn: test that forget leaves no trace”

test_storage.py checks that some places are gone after forget. It does not search for Priti’s words. Write a test file, tests/test_forget_leaves_nothing.py. It runs TASK-02 with a scripted reply that says her name, then calls forget. Then it searches every table for four traces: Priti, her email, her phone number and the words side panel cracked. Check two things: secure leaves nothing, and the weak start leaves her message in the audit log.

Show an example answer
import sqlite3
from support_assistant.assistant import run_case
from support_assistant.data import load_tasks
from support_assistant.designs import controls_for
from support_assistant.runner import fresh_world
from support_assistant.storage import forget
from tests.scripted import Scripted
TRACES = ("Priti", "priti.shah@mail.example", "905 555 0102", "side panel cracked")
def traces_after_forget(design):
world = fresh_world(redact_log=controls_for(design).redact_logs)
reply = Scripted([("Hello Priti, sorry about the side panel.", [])])
run_case(load_tasks()["TASK-02"], reply.complete, world, "scripted", design)
forget(world, "C-50533")
found = set()
with sqlite3.connect(world.db) as con:
tables = [r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
for row in con.execute(f"SELECT * FROM {table}"):
text = " ".join(str(v) for v in row)
found |= {(table, t) for t in TRACES if t in text}
return sorted(found)
def test_forget_leaves_no_trace_with_the_secure_design():
assert traces_after_forget("secure") == []
def test_the_raw_log_of_the_weak_start_keeps_the_message():
assert ("audit_log", "side panel cracked") in traces_after_forget("start")

python -m pytest tests/test_forget_leaves_nothing.py prints 2 passed, and the whole suite prints 50 passed. The test searches by content, so it also finds copies that carry no key. It searches only the database: the files, the provider and the backups need their own checks. The snapshots do not include this test.

Check your understanding

Minimized storage and full storage both give 8 copies after forget. Tomás says minimization was useless. Explain why he is wrong.

Show the answer

forget runs only when a customer asks, and most customers never ask. Until then, minimization keeps fewer copies with content: 10 instead of 13 for Priti. The purge removes every customer’s stored runs after 30 days. If the database leaks before anyone asks, there is less to leak. Minimization protects everyone; forget protects the one who asks.

Check your understanding

After forget, 4 copies with content remain. Where are they, and what is the honest answer to Priti about each?

Show the answer

Three prompts at the model provider: the app cannot delete them. What the provider keeps, and for how long, depends on the contract, and the shop must ask. One backup: it holds a copy of the database until the backup expires. If the shop restores it, it must run forget again. The honest answer names both places, instead of saying “everything is deleted”.

Check your understanding

Mei wants a vector index of past tickets, so the assistant can find similar cases. What must she add so that a delete still propagates?

Show the answer

She must add a row in the inventory for the index, and key each entry by the customer’s ID or ticket ID. forget needs a step that deletes the customer’s entries and checks that a search no longer returns them. The index also needs a retention period. If the entries cannot be found by customer, the index must hold no personal data, or be rebuilt on a schedule.

Recap

  • Data minimization: keep only what a purpose needs. A minimized stored run keeps the outcome and the customer’s ID, with no text.
  • Masking replaces personal data with markers; it finds only the shapes it knows.
  • Retention: each stored run ends after 30 days, and the purge deletes it: 17 copies become 14.
  • Deletion propagation: forget reaches tickets, files, stored runs, approvals, emails and the customer record: 8 copies in 4 places remain.
  • The model provider (3) and the backups (1) are not reached by a delete. Say so, and handle them by contract and by expiry.
  • The project matches end-of-m04: 48 passed.

Next: Residency and contractual questions