GDPR Assistant: Searching the Regulation, a Processing Agreement and a Breach Tracker
In an organisation you have to answer GDPR questions, prepare agreements with processors and, if an incident happens, watch the notification deadline. We build a preparation aid on your own machine: it searches the official text and returns verbatim quotes, assembles an agreement draft from the Regulation's exact clauses, and keeps count of the hours to the Article 33 deadline. Decisions stay with people and the lawyer.
01What you will learn
- How to load the official Bulgarian text of the GDPR, split it into articles and check that all 99 are there.
- How to search by meaning and get verbatim text with a similarity score — or a refusal when nothing close exists.
- How to use a local model only for explanation and check each of its quotes.
- How to assemble a processing agreement draft with clauses taken verbatim from Art. 28.
- How to track the 72-hour window under Art. 33 and prepare the content of the notification — without the code filing anything.
- Where the machine ends and the lawyer begins.
02Before you start
- A machine of the NVIDIA GB10 class (for example ASUS Ascent GX10 or DGX Spark) with Ollama — see the n8n on GX10 lesson if you do not have it yet.
- Python 3.10 or newer and PostgreSQL with the pgvector extension (
CREATE EXTENSION vectorper the pgvector documentation; how to install it for your Postgres version — see there ⚠️ we have not run it). - The official Bulgarian text of Regulation (EU) 2016/679, saved by hand from EUR-Lex as an HTML file
gdpr_bg.html🌐. We took ours from the Publications Office of the EU (OJ L 119, 4.5.2016, p. 1). The EUR-Lex page sometimes asks for a browser check, so do not download it with a script. The code parses the Bulgarian text (headings start with "Член"); the English quotes below are for you to read. - A fictional organisation for trials. Real incident data does not go into trials.
- A lawyer or data protection officer to confirm the conclusions. Without one, the tool is just reading matter.
What we use. Checked as of 01.10.2026
| Part | Version / size | Note |
|---|---|---|
| psycopg | 3.3.6 | LGPL-3.0 licence. Installed as psycopg[binary] |
| httpx | 0.28.1 | Requests to Ollama |
| nomic-embed-text 🔒 | 274 MB, 2K context | Embedding model. The vector size is not stated on its page — so the code measures it |
| llama3.1:70b 🔒 | 43 GB | For explanation only; its official language list does not include Bulgarian — accuracy on Bulgarian is unverified ⚠️ |
| pgvector | Postgres extension | Operator <=> = cosine distance (pgvector README); similarity = 1 - distance |
03Steps
-
What we build and why this way
Four small files. The rule of the lesson: legal words are taken from the official text and quoted verbatim, not "written" by the model. A model can paraphrase, drop a clause or invent one. So search returns the text, the agreement is assembled from copied clauses, and the deadline is computed by a formula.
File Does gdpr_loader.pyreads gdpr_bg.html, splits it into articles and passages, checks the count (99)gdpr_rag.pypgvector index, search with a threshold, explanation with quote verification dpa_draft.pyagreement draft with verbatim clauses under Art. 28(3) breach_tracker.pytracker of the Art. 33 window, action log, draft of the notification content -
The official text and what we use of it
These provisions are cited in the lesson. The text is from the official English version of the Regulation (OJ L 119, 4.5.2016, p. 1), checked against the official Bulgarian text on 01.10.2026. ⚠️ We did not check whether later corrections of the text (corrigenda) affect these articles — see the list of versions on EUR-Lex.
📜Art. 4(12) — "personal data breach"‘personal data breach’ means a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorised disclosure of, or access to, personal data transmitted, stored or otherwise processed;📜Art. 28 — Processor (paragraphs 2, 3, 4 and 9)2. The processor shall not engage another processor without prior specific or general written authorisation of the controller. In the case of general written authorisation, the processor shall inform the controller of any intended changes concerning the addition or replacement of other processors, thereby giving the controller the opportunity to object to such changes.
3. Processing by a processor shall be governed by a contract or other legal act under Union or Member State law, that is binding on the processor with regard to the controller and that sets out the subject-matter and duration of the processing, the nature and purpose of the processing, the type of personal data and categories of data subjects and the obligations and rights of the controller. That contract or other legal act shall stipulate, in particular, that the processor:
(a) processes the personal data only on documented instructions from the controller, including with regard to transfers of personal data to a third country or an international organisation, unless required to do so by Union or Member State law to which the processor is subject; in such a case, the processor shall inform the controller of that legal requirement before processing, unless that law prohibits such information on important grounds of public interest;
(b) ensures that persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality;
(c) takes all measures required pursuant to Article 32;
(d) respects the conditions referred to in paragraphs 2 and 4 for engaging another processor;
(e) taking into account the nature of the processing, assists the controller by appropriate technical and organisational measures, insofar as this is possible, for the fulfilment of the controller's obligation to respond to requests for exercising the data subject's rights laid down in Chapter III;
(f) assists the controller in ensuring compliance with the obligations pursuant to Articles 32 to 36 taking into account the nature of processing and the information available to the processor;
(g) at the choice of the controller, deletes or returns all the personal data to the controller after the end of the provision of services relating to processing, and deletes existing copies unless Union or Member State law requires storage of the personal data;
(h) makes available to the controller all information necessary to demonstrate compliance with the obligations laid down in this Article and allow for and contribute to audits, including inspections, conducted by the controller or another auditor mandated by the controller.
With regard to point (h) of the first subparagraph, the processor shall immediately inform the controller if, in its opinion, an instruction infringes this Regulation or other Union or Member State data protection provisions.
4. Where a processor engages another processor for carrying out specific processing activities on behalf of the controller, the same data protection obligations as set out in the contract or other legal act between the controller and the processor as referred to in paragraph 3 shall be imposed on that other processor by way of a contract or other legal act under Union or Member State law, in particular providing sufficient guarantees to implement appropriate technical and organisational measures in such a manner that the processing will meet the requirements of this Regulation. Where that other processor fails to fulfil its data protection obligations, the initial processor shall remain fully liable to the controller for the performance of that other processor's obligations.
9. The contract or the other legal act referred to in paragraphs 3 and 4 shall be in writing, including in electronic form.📜Art. 33 — Notification of a personal data breach to the supervisory authority1. In the case of a personal data breach, the controller shall without undue delay and, where feasible, not later than 72 hours after having become aware of it, notify the personal data breach to the supervisory authority competent in accordance with Article 55, unless the personal data breach is unlikely to result in a risk to the rights and freedoms of natural persons. Where the notification to the supervisory authority is not made within 72 hours, it shall be accompanied by reasons for the delay.
2. The processor shall notify the controller without undue delay after becoming aware of a personal data breach.
3. The notification referred to in paragraph 1 shall at least:
(a) describe the nature of the personal data breach including where possible, the categories and approximate number of data subjects concerned and the categories and approximate number of personal data records concerned;
(b) communicate the name and contact details of the data protection officer or other contact point where more information can be obtained;
(c) describe the likely consequences of the personal data breach;
(d) describe the measures taken or proposed to be taken by the controller to address the personal data breach, including, where appropriate, measures to mitigate its possible adverse effects.
4. Where, and in so far as, it is not possible to provide the information at the same time, the information may be provided in phases without undue further delay.
5. The controller shall document any personal data breaches, comprising the facts relating to the personal data breach, its effects and the remedial action taken. That documentation shall enable the supervisory authority to verify compliance with this Article.📜Art. 34(1) — Communication of a personal data breach to the data subject1. When the personal data breach is likely to result in a high risk to the rights and freedoms of natural persons, the controller shall communicate the personal data breach to the data subject without undue delay.Three things stand out in the text. First: the 72 hours are the controller's and run from the moment it "has become aware" — that is not a technical date but a legal judgement, so the tracker asks a person for it. Second: for the processor there is no figure of 72 — "without undue delay" (paragraph 2). Third: paragraph 5 requires every breach to be documented, including when no notification is made — that is why the tracker keeps a log.
-
Loading the Regulation
Save the official text as
gdpr_bg.htmlnext to the scripts. The code turns it into text, finds the "Член N" headings (Bulgarian for "Article N"), takes each article's title and body, and stops with an error if there are not exactly 99 articles. The body is cut before chapter or section headings, which would otherwise stick to the end of the previous article. Theparagraphfunction returns a paragraph verbatim — we need it for the agreement.python · gdpr_loader.pyimport re from html.parser import HTMLParser BLOCK = {"p", "div", "tr", "li", "h1", "h2", "h3", "br", "table"} class _Text(HTMLParser): def __init__(self): super().__init__() self.out = [] def handle_starttag(self, tag, attrs): if tag in BLOCK: self.out.append("\n") def handle_endtag(self, tag): if tag in BLOCK: self.out.append("\n") def handle_data(self, data): self.out.append(data) def html_to_text(path: str) -> str: p = _Text() with open(path, encoding="utf-8") as f: p.feed(f.read()) t = "".join(p.out).replace("\xa0", " ") t = re.sub(r"[ \t]+", " ", t) return re.sub(r"\n\s*\n+", "\n", t) def load_articles(path: str) -> dict: """{number: {"title": ..., "body": ...}} from the official Bulgarian text (headings start with "Член").""" t = html_to_text(path) heads = list(re.finditer(r"^\s*Член (\d+)\s*$", t, flags=re.M)) articles = {} for i, m in enumerate(heads): end = heads[i + 1].start() if i + 1 < len(heads) else len(t) lines = [l.strip() for l in t[m.end():end].split("\n") if l.strip()] title, body = lines[0], [] for l in lines[1:]: if re.match(r"^(ГЛАВА [IVX]+|Раздел \d+)$", l) or l.startswith("Настоящият регламент е задължителен"): break body.append(l) articles[int(m.group(1))] = {"title": title, "body": "\n".join(body)} if len(articles) != 99: raise ValueError(f"Expected 99 articles, found {len(articles)} - check the file.") return articles def chunks(body: str, limit: int = 1200): """Split the body at paragraph/point boundaries so each chunk fits the embedding model context.""" cur = "" for line in body.split("\n"): if cur and len(cur) + len(line) > limit and re.match(r"^(\d+[.)]|[a-zа-я]\))", line): yield cur cur = line else: cur = (cur + "\n" + line) if cur else line if cur: yield cur def paragraph(body: str, n: int) -> str: """Paragraph n of an article - verbatim, with its lettered points.""" m = re.search(rf"^{n}\. .*?(?=^{n + 1}\. |\Z)", body, flags=re.M | re.S) return m.group(0).strip() if m else ""The passages are about 1750 characters at most. That matters:
nomic-embed-texthas a 2K context, and Ollama by default truncates a long input silently — so below we call/api/embedwith"truncate": falseand get an error if something does not fit. -
Index and search: a quote or a refusal
First the passages are embedded (Ollama's
/api/embedaccepts a list). We measure the vector size with one test call and create the column with it — we do not guess. In search we sort by<=>(cosine distance); similarity is1 - distance. Below theMIN_SIMthreshold the system refuses. The value 0.5 is only a starting point with no proven basis: change it after trying 20 questions of your own.The model does not answer "from its head". It receives only the retrieved passages and returns an explanation plus quotes. Every quote is searched for in the passages; one that is not found is marked
found_in_text: falseand is not shown as a quote.python · gdpr_rag.pyimport json import re import httpx import psycopg from gdpr_loader import load_articles, chunks OLLAMA = "http://localhost:11434" EMBED_MODEL = "nomic-embed-text" CHAT_MODEL = "llama3.1:70b" DSN = "dbname=gdpr_lab" MIN_SIM = 0.5 # STARTING value with no proven basis: calibrate it with your own questions def embed(texts: list[str]) -> list[list[float]]: r = httpx.post(f"{OLLAMA}/api/embed", timeout=300, json={"model": EMBED_MODEL, "input": texts, "truncate": False}) r.raise_for_status() return r.json()["embeddings"] def vec(v: list[float]) -> str: return "[" + ",".join(map(str, v)) + "]" def build_index(path: str) -> None: arts = load_articles(path) dim = len(embed(["test"])[0]) # measure the dimension, do not guess it with psycopg.connect(DSN) as conn: conn.execute("CREATE EXTENSION IF NOT EXISTS vector") conn.execute(f"""CREATE TABLE IF NOT EXISTS gdpr_chunks( id bigserial PRIMARY KEY, article int NOT NULL, title text NOT NULL, body text NOT NULL, embedding vector({dim}) NOT NULL)""") if conn.execute("SELECT count(*) FROM gdpr_chunks").fetchone()[0]: return # already indexed rows = [(n, a["title"], c) for n, a in arts.items() for c in chunks(a["body"])] for i in range(0, len(rows), 16): batch = rows[i:i + 16] embs = embed([f"Член {n}. {t}\n{c}" for n, t, c in batch]) for (n, t, c), e in zip(batch, embs): conn.execute("INSERT INTO gdpr_chunks(article, title, body, embedding) VALUES (%s,%s,%s,%s::vector)", (n, t, c, vec(e))) def search(question: str, k: int = 4) -> list[dict]: q = vec(embed([question])[0]) with psycopg.connect(DSN) as conn: rows = conn.execute( "SELECT article, title, body, 1 - (embedding <=> %s::vector) AS sim " "FROM gdpr_chunks ORDER BY embedding <=> %s::vector LIMIT %s", (q, q, k)).fetchall() return [{"article": r[0], "title": r[1], "body": r[2], "sim": round(float(r[3]), 3)} for r in rows if r[3] >= MIN_SIM] def _norm(s: str) -> str: return re.sub(r"\s+", " ", s).strip().lower() def explain(question: str, hits: list[dict]) -> dict: """A short explanation ONLY from the retrieved passages. Every quote is checked against them.""" if not hits: return {"answer": "The index holds no sufficiently close text. No answer.", "quotes": []} context = "\n\n".join(f"[Член {h['article']}]\n{h['body']}" for h in hits) system = ("You answer in English ONLY from the given passages of Regulation (EU) 2016/679 (Bulgarian text). " "You use no other knowledge. You give no legal advice. If the passages do not answer the question, " 'say so. Return JSON only: {"answer": "...", "quotes": [{"article": 33, "quote": "verbatim quote in Bulgarian"}]}') r = httpx.post(f"{OLLAMA}/api/chat", timeout=600, json={ "model": CHAT_MODEL, "stream": False, "format": "json", "options": {"temperature": 0.1, "num_ctx": 16384}, "messages": [{"role": "system", "content": system}, {"role": "user", "content": f"Question: {question}\n\nPassages:\n{context}"}]}) r.raise_for_status() result = json.loads(r.json()["message"]["content"]) pool = _norm(context) for q in result.get("quotes", []): q["found_in_text"] = _norm(q.get("quote", "")) in pool return result if __name__ == "__main__": import sys if sys.argv[1] == "index": build_index(sys.argv[2]) else: question = " ".join(sys.argv[2:]) hits = search(question) for h in hits: print(f"Art. {h['article']} ({h['title']}) · similarity {h['sim']}\n{h['body']}\n") print(json.dumps(explain(question, hits), ensure_ascii=False, indent=2))bashpip install "psycopg[binary]" httpx ollama pull nomic-embed-text ollama pull llama3.1:70b createdb gdpr_lab python gdpr_rag.py index gdpr_bg.html python gdpr_rag.py ask "What must a notification of a personal data breach contain?"You expect to see Art. 33 among the retrieved passages. ⚠️ We have not run it — if it does not appear, that is a reason to change the passage size or the threshold, not to "fix" the answer by hand. Note that the index holds the Bulgarian text, so a question in Bulgarian will match best; test an English question on your own setup.
💡Model languageLlama 3.1's official language list does not include Bulgarian: explanations may be weak. So the real result is the passage; the explanation is a helper and is always checked. -
A processing agreement draft — without the model
Art. 28(3) says what the agreement must stipulate. So the clauses are not generated: the code extracts paragraph 3 verbatim from the loaded text (with points (a) to (h) and the final subparagraph) and inserts it into the draft. Next to it come five fields that a person fills in (subject matter and duration, nature and purpose, type of data, categories of data subjects, the controller's rights and obligations), and placeholders for sub-processors (paragraphs 2 and 4) and form (paragraph 9). Anything missing is printed as
[TO FILL IN]. This is a v0.1 draft for the lawyer, not a contract. Note that the clause block is in Bulgarian, as in the source text it comes from.python · dpa_draft.pyfrom gdpr_loader import load_articles, paragraph FIELDS = ["subject matter and duration of the processing", "nature and purpose of the processing", "type of personal data", "categories of data subjects", "rights and obligations of the controller"] def build_dpa(controller: str, processor: str, values: dict, articles: dict) -> str: """Draft processing agreement. The mandatory clauses are VERBATIM from Art. 28(3) (no model involved).""" missing = [f for f in FIELDS if not values.get(f)] art28 = articles[28]["body"] out = [f"# DRAFT - data processing agreement (v0.1, not reviewed by a lawyer)", f"Controller: {controller}", f"Processor: {processor}", ""] for i, f in enumerate(FIELDS, 1): out.append(f"{i}. {f.capitalize()}: {values.get(f) or '[TO FILL IN]'}") out += ["", "## Clauses under Art. 28(3) of Regulation (EU) 2016/679 (verbatim, official Bulgarian text)", paragraph(art28, 3), "", "## Sub-processors - Art. 28(2) and (4): [TO BE SETTLED BY THE LAWYER]", "## Form - Art. 28(9): in writing, including in electronic form."] if missing: out.append(f"\nUNFILLED FIELDS: {', '.join(missing)}") return "\n".join(out) if __name__ == "__main__": a = load_articles("gdpr_bg.html") print(build_dpa('"Alpha Example" Ltd', '"Beta Example" Ltd', {"type of personal data": "names, e-mails"}, a)) -
The breach tracker: the deadline is a formula, the decisions are human
The tracker records a breach with the moment
aware_at, entered by a person (when the controller became aware), and computesdeadline = aware_at + 72 hours— by the letter of Art. 33(1), which speaks of hours; we see no weekend exception in the article itself, but how to count in your case is for the lawyer to say. The risk assessment ("risk unlikely", "risk likely", "high risk likely" — this ties to Art. 33(1) and Art. 34(1)) is also human and is stored with a reason. Every action goes into a log — that matches the documentation duty of Art. 33(5).The notification draft follows the content of Art. 33(3), points (a) to (d), and shows what is still missing. If the deadline has passed, it adds a line for the reasons for the delay (paragraph 1, last sentence). Information that is not yet available may be provided in phases (paragraph 4). The code sends nothing — filing is a human act and is recorded with
mark_notified. The database file is owner-only, because it holds descriptions of incidents.python · breach_tracker.pyimport json import os import sqlite3 import uuid from datetime import datetime, timedelta, timezone DB = "breaches.db" RISK = {"unlikely": "risk unlikely", "risk": "risk likely", "high": "high risk likely"} def _conn(): new = not os.path.exists(DB) c = sqlite3.connect(DB) c.row_factory = sqlite3.Row if new: os.chmod(DB, 0o600) # owner-only access c.executescript(""" CREATE TABLE IF NOT EXISTS breach( id TEXT PRIMARY KEY, aware_at TEXT NOT NULL, deadline TEXT NOT NULL, description TEXT NOT NULL, categories TEXT, approx_subjects TEXT, approx_records TEXT, consequences TEXT, measures TEXT, contact TEXT, risk TEXT, risk_reason TEXT, notified_at TEXT, delay_reason TEXT); CREATE TABLE IF NOT EXISTS action( id INTEGER PRIMARY KEY AUTOINCREMENT, breach_id TEXT NOT NULL, at TEXT NOT NULL, text TEXT NOT NULL); """) return c def now() -> datetime: return datetime.now(timezone.utc) def log(c, breach_id: str, text: str) -> None: c.execute("INSERT INTO action(breach_id, at, text) VALUES (?,?,?)", (breach_id, now().isoformat(), text)) c.commit() def register(description: str, aware_at: datetime | None = None, **fields) -> str: """Record a breach. aware_at is entered by a HUMAN: when the controller became aware of it.""" aware_at = aware_at or now() bid = uuid.uuid4().hex[:8] c = _conn() c.execute( "INSERT INTO breach(id, aware_at, deadline, description, categories, approx_subjects, approx_records," " consequences, measures, contact) VALUES (?,?,?,?,?,?,?,?,?,?)", (bid, aware_at.isoformat(), (aware_at + timedelta(hours=72)).isoformat(), description, fields.get("categories"), fields.get("approx_subjects"), fields.get("approx_records"), fields.get("consequences"), fields.get("measures"), fields.get("contact"))) c.commit() log(c, bid, "Breach recorded") return bid def decide(bid: str, risk: str, reason: str) -> None: """The risk assessment is made by a HUMAN (with the lawyer / DPO) and stored with a reason. The system does not decide.""" if risk not in RISK: raise ValueError(f"risk must be one of {list(RISK)}") c = _conn() c.execute("UPDATE breach SET risk=?, risk_reason=? WHERE id=?", (risk, reason, bid)) c.commit() log(c, bid, f"Assessment: {RISK[risk]}. Reason: {reason}") def status(bid: str) -> dict: c = _conn() b = dict(c.execute("SELECT * FROM breach WHERE id=?", (bid,)).fetchone()) left = (datetime.fromisoformat(b["deadline"]) - now()).total_seconds() / 3600 b["hours_left"] = round(left, 1) b["overdue"] = left < 0 and not b["notified_at"] return b def draft_notification(bid: str) -> dict: """Draft following the content of Art. 33(3), points (a) to (d). The code never sends it.""" b = status(bid) d = { "a_nature_categories_numbers": f"{b['description']} | categories: {b['categories'] or '[TO FILL IN]'}" f" | approximate number of data subjects: {b['approx_subjects'] or '[TO FILL IN]'}" f" | approximate number of records: {b['approx_records'] or '[TO FILL IN]'}", "b_contact": b["contact"] or "[TO FILL IN: DPO or other contact point]", "c_consequences": b["consequences"] or "[TO FILL IN]", "d_measures": b["measures"] or "[TO FILL IN]", } if b["overdue"]: d["reasons_for_delay"] = "[TO FILL IN: the 72-hour period has passed]" return d def mark_notified(bid: str, delay_reason: str | None = None) -> None: c = _conn() c.execute("UPDATE breach SET notified_at=?, delay_reason=? WHERE id=?", (now().isoformat(), delay_reason, bid)) c.commit() log(c, bid, "Notification filed by a person" + (f"; reasons for delay: {delay_reason}" if delay_reason else "")) if __name__ == "__main__": bid = register("FICTIONAL EXAMPLE: a lost unencrypted portable drive holding a customer list", categories="names, e-mails", approx_subjects="~200", approx_records="~200") decide(bid, "risk", "data not encrypted; the LAWYER confirms the assessment") print(json.dumps(status(bid), ensure_ascii=False, indent=2)) print(json.dumps(draft_notification(bid), ensure_ascii=False, indent=2))⚠️If you are a processor, not a controllerArt. 33(2) obliges you to notify the controller "without undue delay" after becoming aware. The tracker above is for the controller; as a processor, record in the log when you became aware and when you notified the controller. -
The ZZLD and the KZLD: what we know and what we could not check
Art. 33 speaks of "the supervisory authority competent in accordance with Article 55". In Bulgaria this is the Commission for Personal Data Protection (KZLD, in Bulgarian КЗЛД). Here are the provisions of the Personal Data Protection Act (ZZLD, ЗЗЛД) we rely on — verbatim from the consolidated text on lex.bg (an unofficial source; last amendment there: State Gazette No. 69 of 31.07.2026), checked on 01.10.2026. Only the Bulgarian text has legal force; the English rendering is ours.
📜ZZLD, Art. 6(1) — who the supervisory authority is„(1) (Изм. - ДВ, бр. 17 от 2019 г.) Комисията за защита на личните данни, наричана по-нататък "комисията", е постоянно действащ независим надзорен орган, който осъществява защитата на лицата при обработването на техните лични данни и при осъществяването на достъпа до тези данни, както и контрола по спазването на Регламент (ЕС) 2016/679 и на този закон.“Unofficial English rendering: "(1) (Amended, State Gazette No. 17 of 2019) The Commission for Personal Data Protection, hereinafter 'the Commission', is a permanent independent supervisory authority that protects individuals in the processing of their personal data and in access to such data, and supervises compliance with Regulation (EU) 2016/679 and with this Act."📜ZZLD, Art. 15(2) — the KZLD's non-public registers„(2) Комисията води следните регистри, които не са публични:
1. регистър на нарушенията на Регламент (ЕС) 2016/679 и на този закон, както и на предприетите мерки в съответствие с упражняването на правомощията по чл. 58, параграф 2 от Регламент (ЕС) 2016/679;
2. регистър на уведомленията за нарушения на сигурността на личните данни по чл. 33 от Регламент (ЕС) 2016/679 и по чл. 67.“Unofficial English rendering: "(2) The Commission keeps the following registers, which are not public: 1. a register of infringements of Regulation (EU) 2016/679 and of this Act, and of the measures taken in exercising the powers under Art. 58(2) of Regulation (EU) 2016/679; 2. a register of personal data breach notifications under Art. 33 of Regulation (EU) 2016/679 and under Art. 67."So: a notification under Art. 33 goes to the KZLD and is entered in its non-public register. Exception: where data are processed by a court acting in its judicial capacity, or by the prosecution and investigating bodies for criminal-justice purposes, supervision lies with the Inspectorate to the Supreme Judicial Council (ZZLD, Art. 17(1)) — if you work in such an organisation, ask your lawyer ⚠️. We did not check the current way of filing a breach notification — a form, a portal or e-mail. On 01.10.2026 only the home page of the KZLD website opened, not a page with the procedure, so we do not give you an address or a form "from memory" ⚠️.
- Open the KZLD website 🌐 and find the current way to notify a breach.
- Check in the official State Gazette whether the ZZLD has been amended after 31.07.2026.
- Fill in the draft from step 6 so that its content matches Art. 33(3), and have yourself or your lawyer file it through the current channel.
This is guidance, not legal advice. It is confirmed by the lawyer or data protection officer in your organisation.
-
A trial with a fictional case
- Index the Regulation. Ask five questions — for example about the content of the notification, the deadline, the agreement with a processor, communicating to data subjects. Note: does the right article come up? Is there a question the system should refuse, and does it?
- Run
python dpa_draft.pyand check that clauses (a) to (h) match the quote above (in Bulgarian). - Run
python breach_tracker.py— the fictional example of a lost drive. Look at the deadline, the log and the draft with its blanks. - Read everything together with a lawyer and write down what they changed. That is your quality assessment — not figures we did not measure.
✅Practical minimumNothing goes to an external service. Incident records have restricted access and do not go into application logs. A real incident is handled under the organisation's own procedure, not through a trial of this lesson.
04Check
- The "not legal advice" notice is visible and a lawyer confirms the conclusions.
load_articlesreturns exactly 99 articles for your file.- The index is built; the measured vector size matches the column.
- On five questions the right article comes up or the system refuses; the threshold is calibrated.
- Every quote in an explanation has
found_in_text: true. - The agreement draft contains Art. 28(3) verbatim and lists the unfilled fields.
- In the tracker, "when we became aware" and the risk assessment are entered by a person with a reason; the log is kept; the file is owner-only.
- The current channel for filing with the KZLD was checked by a person on its website.
Quiz
1. What time limit does a processor have under Art. 33(2) to notify the controller of a breach?
2. Who decides whether a breach is likely to result in a risk?
3. Why are the Art. 28(3) clauses in the draft copied verbatim and not written by a model?
4. What does the system do if the closest passage is below the similarity threshold?
05What next
06Sources
- Regulation (EU) 2016/679 on EUR-Lex (official English text, OJ L 119, 4.5.2016, p. 1) · official Bulgarian text 🌐 global — Art. 4(12), 28, 33, 34 (full text quoted in step 2).
- pgvector 🔒 local —
CREATE EXTENSION vector, operator<=>(cosine distance). - Ollama: API (
/api/embed,truncate) · nomic-embed-text · llama3.1 🔒 local — sizes and context. - psycopg · httpx — versions and licences.
- Personal Data Protection Act (ZZLD) on lex.bg — Art. 6(1), 15(2) and 17(1); consolidated text (unofficial; last amendment there State Gazette No. 69 of 31.07.2026), checked on 01.10.2026. The official source is the State Gazette.
- Commission for Personal Data Protection (KZLD) 🌐 global — current way of notifying (⚠️ not checked by us: on 01.10.2026 only the home page opened).