Appearance
Privacy model
An endpoint DLP agent is, mechanically, surveillance software. The difference between a security control and a surveillance tool is what it is capable of, not what it is configured to do. This document records where those limits are and why.
1. The governing rule
Matched sensitive values never leave the endpoint, with one exception an organisation has to choose twice.
Redaction happens inside the agent, at the point of detection, before an event is serialised. No event, log, preview or report carries a raw match.
The exception is a copy of the whole file (§39, ADR-0038, DLP-1190). It leaves the endpoint only when both are true:
- the organisation's Owner has switched full-file evidence on, with where copies go;
- a published policy asks for the file.
A copy carries whatever the document holds, matched values included. So it never reaches the MyDLP database or a log. It is sealed before it is written to the organisation's own bucket, and opened only by a justified, audited download. This sentence used to say the server "never receives" a raw match, which stopped being true when that exception was built.
What travels:
| Sent | Not sent |
|---|---|
| Detector id, version, category | The matched value |
| Match count, distinct count | File content, unless the organisation chose to keep copies and a policy asks (§39) |
| Confidence, validation status | Surrounding text (unless a policy explicitly enables snippets) |
Redacted preview (••••••••1111) | Screenshots |
| File path, name, size, MIME, BLAKE3 hash | Keystrokes |
| Process name, username, department | Browsing history |
| Policy decision and its explanation | Anything the policy did not ask for |
The endpoint may use a username or UPN while evaluating local policy, but the server is the final storage boundary. When privacy.collect_usernames is false, ingestion stores neither field and removes them from rejected-event diagnostics too. File-owner SID and name follow the same switch, and so does the SID a process runs as (process.user_sid, DLP-1487). That SID is not a column on an event row; it reached only the rejected-event diagnostic, which keeps the whole event and kept it until this change. The process's name, pid, integrity level and signature stay in that diagnostic, because they describe the program, not the person. Department and elevation remain because they are policy context rather than a person's login identity.
The console writes a stored username with its domain hidden, ***\ahmad, on every page, and shows the full name on hover (DLP-1768). That is a display convention, not a privacy control: the API, exports and the hover carry the full name to anyone who may read the page. What limits who is named is privacy.collect_usernames above, and for Behaviour's tables the per-person exclusion from naming (DLP-350). The console's mask does not pseudonymise a person. The server does, when an Owner turns it on: see §63.
2. Redaction, concretely
dlp_contracts::redaction guarantees:
- Credentials reveal zero characters. Every credential detector is pinned to
RedactionMode::Full; a test walks the catalogue and fails the build if one is not. - Partial modes reveal at most
keepcharacters, and fall back to full masking when the value is short enough thatkeepwould expose most of it. - E-mail keeps the domain, masks the local part - the domain is the investigative signal, the local part is the person.
- Previews are length-capped, so a preview cannot be used as an exfiltration channel by a hostile policy.
- An analyst's free text holds no detected value (
DLP-1974). A comment, a resolution, a status or link note, an unlink or test-incident reason and an evidence justification are refused with 422 when a shipped detector finds a value in them; the refusal names the field and the kind (financial, ...), never the value. Text a shipped detector cannot recognise is not caught. Asserted bytests/incident_free_text.sh. - The server checks the endpoint's masking (
DLP-1975). Ingest cuts every preview to the 128 charactersredactcan produce. If a shipped detector still finds a value in a preview, the preview is replaced with[withheld by the server: not masked]before it is stored, and the replacement is counted on the metrics port (mydlp_ingest_previews_withheld_total). A faulty or tampered agent build therefore cannot put a raw card or IBAN into evidence. What a shipped detector cannot recognise, for example a tenant's own pattern, is not caught here. Asserted bytests/evidence_previews.sh§3, and by a unit test that no preview the engine makes from the shipped corpus is withheld.
A character-class silhouette (AAA+999+) is available for triage: it answers "is this shaped like a key?" while revealing nothing.
A file name shown on a screen is masked too (DLP-454, DLP-491). A file name is routinely the sensitive value - Resad_Mammadov_FIN_5AB1C2D.pdf - and it had been exempt from this rule by accident rather than by decision. Two rules apply where a name is displayed, and neither changes what is recorded: a token that looks like an identifier is masked, and so is any token the tenant's own detectors match, run locally over the name at the moment of rendering.
The second rule collects nothing, and that is why it is the one that was chosen. The alternative - counting how often a name appears across a fleet, so that a rare name could be masked and a common one left alone - is the more effective control and would require the server to keep a corpus of what people call their files. That would be a new collection needing its own row here. docs/adr/0024-a-file-name-is-masked-by-what-the-tenant-calls-sensitive.md weighs the four options and says why this one.
What follows from it, plainly: a personal name in a file name is masked if and only if the tenant ships a detector that matches one. Without such a detector it is displayed, as it always was. The unmasked name still reaches the incident, where access is controlled - §8 of CLAUDE.md is explicit that file names are recorded deliberately, because an incident naming no file is one nobody can act on.
3. What the agent will not do
Refused by design, not merely unconfigured:
| Not implemented | Why |
|---|---|
| Keylogging | Captures credentials and private conversation. No DLP question requires it |
| Password or credential collection | The agent detects credentials in files; it never harvests them |
| Credential interception | Same |
| Covert screenshots | Captures whatever is on screen, including personal and third-party content. Its collection scope cannot be limited to a policy |
| Hidden persistence | An agent the user cannot see or remove is indistinguishable from malware |
| Security-tool bypass | Evasion capability makes the agent a better attack tool |
| Browsing history (the pages a person visited), webcam, microphone, location | Not data loss prevention. Time in the foreground, per application and browser host, is a different thing, decided on 2026-09-28 and off until a tenant turns it on: §47 and ADR-0043 |
An agent with these capabilities is one configuration change away from being the threat it detects.
4. Data minimisation in practice
Metadata always; previews by choice. A file's name, path, size and hash are always kept, because an incident naming no file is one nobody can act on; the policy's metadata switch is not read and the editor no longer offers it. Redacted previews are kept unless the policy says redacted_previews: false, and then the server clears them from the event and its incident at ingest, keeping each match's position (DLP-1301). No context snippet is collected anywhere: context_snippets names a collection that was never built, the editor no longer offers it, and a snippet around a match could carry other people's sensitive data. full_file defaults off, and the policy API rejects a policy that sets it, naming the field, unless the organisation's Owner has switched full-file evidence on, with where copies go. A copy is then kept as ADR-0038 decided, and §39 is its row (DLP-1188, DLP-1189, DLP-1190). There is no separate evidence grant. This paragraph used to say full capture "requires a separate, auditable evidence grant", which described a mechanism that was never built.
Path redaction. An Owner may choose, with a reason, to hash file names (POST /settings/privacy/file-paths, audited as settings.file_path_redaction_changed). The directory is kept and the file name is replaced with a hash, in both the event's path and its file name, and the same hash in each, so two events on one document still read as one. The directory is what investigation needs (/home/x/Downloads vs /srv/payroll); the file name is what identifies a person's document. A rejected event's diagnostic excerpt is hashed the same way. Choosing to keep names asks for a recent sign-in; choosing to hash them does not.
A tenant that has never chosen follows the server's DLP_REDACT_FILE_PATHS, and GET /settings says which of the two decided. Until DLP-1302 the tenant's own redact_file_paths was reported and never read: the server-wide variable alone decided, and even then only the path was hashed while the file name was stored in the clear beside it.
The choice applies from the moment it is made. Events already stored keep what they were stored with: a hashed name cannot be recovered, and a stored name is not rewritten by the switch. It covers endpoint events and the incidents built from them, and, since DLP-1304, the findings of server-side discovery (a mounted share, a bucket, a SharePoint or OneDrive drive) and the cloud incidents raised from them, including the incident's title. It is the same hash, so a finding and an endpoint event on one name still read as one. Nothing acts on a finding's path: remediation records a state, and every repository discovery reads is read-only, so hashing it costs no function. A policy is still evaluated against the real name, in memory, and only what is written down is hashed.
The agent does not scan itself. Its own state directory, wherever the configuration puts it, is excluded from every watched path and every discovery walk, and so are /proc, /sys, /dev and /run - otherwise the agent would scan its own event queue and report its own findings back to itself. Until DLP-558 this sentence was true only of a list nothing applied, which named the Linux state directory and not the Windows one.
Caches, build outputs, .git and node_modules are not excluded by default, although that list once said they were. An exclusion matches a name anywhere beneath a watched root, and each of those is a folder anybody can create, so a default that skipped them would be a way past the monitor. Noise is excluded by a policy, where the exclusion is visible and scoped.
Unchanged files are not rescanned. A privacy benefit as well as a performance one: less reading of user files.
The scan cache remembers what was found, never what it looked like. So that deleting a file known to contain something sensitive can be reported (#364), the endpoint's cache keeps the detector id, the data category and the match count per file. It keeps no preview and no content hash of the match. A preview is redacted from content; once the file is gone there is nothing left to redact from, so a preview kept there would be the one copy of matched data that outlived the file it came from. A test asserts the serialised cache contains no preview.
That cache is on the endpoint's disk, in the agent's state directory, whose permissions are checked at every start-up and reported to the console when they are wrong. Somebody who can read it learns which files had findings and of what kind - which is why it holds categories rather than content.
Asking for an exception, and being answered (DLP-460, DLP-461). A person facing a refusal can ask for it to be lifted. What travels is the rule's display name, the content hash, the masked file name, the destination, a category, and the free text they typed. Not the file, not the matched value, not the detector that fired. The hash rather than the bytes because an approver decides whether this content may go to that destination and does not need the content to do it; the masked name because the request lands in a second store with a different access path, and the unmasked name is already on the incident where access is controlled.
The free text is personal data: it is somebody describing their own work, in their own words, and it is the one field an approver actually reads. It is held in exception_requests under the tenant's ordinary retention and is visible to the roles that hold exceptions:read.
Before the server acknowledges delivery, the endpoint also keeps the masked request body in exception-requests-pending.json, under the agent state directory's ACL. This includes the reason, masked file name and destination, content hash, category, reference and subject; it contains no file content or unmasked file name. It is retried after an agent restart and removed from this queue after the signed POST succeeds. A crash after server acceptance but before that removal can resend the same reference; the server accepts that retry rather than risk losing the request.
A withdrawal (DLP-820) adds the withdrawing approver's address and their reason to the same row. That reason reaches the person's endpoint as the decision's note, so it follows the rule every user-facing string follows. The audit entry exception.withdrawn records the reason and not the request's free text.
It is in a subject-access answer (DLP-494). §6 assembles that from events, incidents, the audit log and now this table, keyed on the subject the endpoint recorded: the words somebody typed about their own work are theirs, and an Article 15 answer that omitted the one field an approver actually reads would be answering a narrower question than was asked.
Two decisions travel with it. The approver's name is withheld, exactly as the name of whoever looked at somebody's data is withheld: they are a second person, Article 15(4) says one person's copy must not adversely affect another's rights, and the requester's interest is in the decision and its basis rather than in who signed it. The controller holds the name in the audit log and can answer for it there.
And a request the endpoint could not attribute - no signed-in user to record, or a row predating the field - belongs to nobody and reaches nobody, including the person who may well have written it. Handing it over would disclose it to somebody who may not have. That omission is named in the answer's not_included list rather than left silent, because "there is nothing" and "there is something we cannot attribute" are different answers and only one of them is true.
The answer travels back signed with the tenant's policy-bundle key and carries the approver's name, their note, and the four things the approval is bound to - person, rule, content hash, destination - plus its expiry. It carries no matched value and no detector id, for the same reason the request does not.
A file that stops the reader is remembered by a hash, not a name (DLP-1695). reads-in-flight.jsonl and poison-files.json, in the agent's state directory, record which file a read is in flight on and which files the reader has died on, so that a file which crashes it twice is not read for ever. Each holds a short hash of the path, its size and its modification time, never the path and never content: a file named for a card number is exactly the file whose name must not sit in a state file. A test asserts neither file names the file it is about.
The endpoint keeps its own copy of what it asked, and deliberately less of it. exception-ledger.json, in the agent's state directory under the same ACL as the device key, holds the reference, the rule's name, the masked file name, the destination, when it was asked and what was decided. It does not hold the reason the person typed - that is already in the approver's queue where access is controlled, and the person who wrote it does not need it read back to them off a file on their own disk. It holds at most fifty requests and forgets a decided one after thirty days. A test asserts the serialised ledger contains neither the reason nor the category.
5. Exact Data Match
EDM lets an endpoint recognise a specific customer's account number without holding the customer database.
The server normalises each authorised field, computes HMAC-SHA256(key, field || 0x1f || value), truncates it, and ships only those tags. The HMAC key travels separately over the authenticated channel.
Where the key is, now that it travels (DLP-267). Not in the bundle: a bundle is signed rather than encrypted and the agent caches it on disk, so the key comes over the agent's own authenticated request - GET /api/v1/agent/edm-keys, signed the way every other agent call is - and is stored beside the device key under the same protection: DPAPI machine scope on Windows, a 0600 file on Unix. The same class of secret and the same mechanism rather than a second one. Only the datasets a published policy references travel, and only their keys, so a tenant with no EDM policy is handed nothing. A policy references a dataset by naming its detector, or through a classification or a data category that reaches the detector (DLP-969).
An import never repeats a source value (DLP-875). A refused import names the importer's field names, column positions and counts, and nothing from the file. The first row is not repeated even when it is a header, because in a file with no header row it is a customer record, and it used to come back in the error, the API log and the uploader's screen (DLP-876). tests/edm_delivery.sh §7c plants a canary through a refused import and an accepted one. It searches the responses, the server log at the server's built-in debug filter and a pg_dump of the database, after first finding the canary in the source files. The agent leak test holds a dataset record and a fingerprinted passage among its planted secrets (DLP-877). Deployments log at info (DLP-878).
Where the source file goes, and when it is dropped (DLP-1040, DLP-1041). The console sends the chosen file as the body of POST /api/v1/edm-datasets/import-file. The browser streams it from disk and does not keep it as text in the page. Before sending, the page reads the file in pieces for two things only: the number of data rows, for the size projection, and the header's column names, which are compared with the fields the importer named and never displayed, because in a file with no header row they are its first record. When the server answers, the page drops its reference to the file and empties the picker.
On the server the body is held in memory for the length of the request, up to DLP_EDM_MAX_UPLOAD_BYTES. It is fingerprinted and dropped there. It is not written to disk, the database or a log. A reverse proxy in front of the API may buffer a request body to a temporary file, which is how nginx treats large bodies by default. That buffering is the proxy's behaviour and belongs to the deployment, not to this code. An operator who needs the source never to touch the proxy host's disk sets proxy_request_buffering off on this path.
The JSON import (POST /api/v1/edm-datasets with csv) still exists for API clients, and it is dropped the same way.
Rotation uses a protected overlap (ADR-0071). The source file is not available to re-key later, so POST /api/v1/edm/rotate-key creates a new key without removing the old key or tags. Each source is imported again under the replacement key. Only after every dataset has a replacement version and every active endpoint has accepted the resulting bundle and confirmed the separately delivered replacement key may the operator complete the rotation; completion retires old tags and keys in one audited transaction. The agent then removes keys its accepted bundle no longer names. The server cannot re-fingerprint automatically without retaining the source values this model deliberately excludes.
Honest limits, because "privacy-preserving" is often overclaimed:
- Truncation trades collision probability for bundle size, and at the sizes this product accepts it trades against nothing: measured at the enforced 8-byte floor, a scan testing 200,000 distinct values against a 300,000-tag set produced no false positives at all (SPIKE-005).
edm::collision_probabilityis exposed and the import shows it, but the number an operator should act on is the per-field entropy the import also reports: acountrycolumn matches every document mentioning a country, at any tag length. - The dataset size is bounded by what an endpoint can hold, not by privacy. 250,000 records across three fields is 13.5 MiB of bundle and 34 MiB resident; ten million records would be 544 MiB and 1.3 GiB. A design for larger datasets - a Bloom filter with a confirmation pass - would make the endpoint ask the server about candidates, and that is why it is not the default. If it is ever adopted, the endpoint sends the keyed tag, never the candidate value: the tag is not reversible and the server already holds a set of them, so the confirmation discloses nothing it did not already know.
- An attacker holding both the fingerprints and the HMAC key can brute-force low-entropy fields - dates of birth, small ID spaces. This is inherent to any hash-based EDM, not specific to this implementation. The key is the control, and since DLP-267 an enrolled endpoint holds both - it must, or it cannot compare anything. So the exposure is now "whoever takes an endpoint can test guesses against the dataset", which is a real and accepted cost of doing EDM anywhere but on a server. What it is not is disclosure of the dataset: the endpoint holds truncated keyed hashes and no source value, so a guess can be tested and the file cannot be read. Requiring mTLS narrows who can reach the key endpoint at all and is recommended; gating the feature on it would make EDM unusable for almost every deployment, which is not a security improvement. Imports therefore record fields whose easiest value has an estimated search space below 48 bits, and the API refuses an EDM detector that lets one of those fields establish a record by itself (ADR-0070). It must co-occur with another distinct field. This limits what a cheap guess can make the product assert; it does not claim to prevent a privileged attacker from testing membership guesses against the datasets delivered to that endpoint.
- Field names are bound into the tag, so a value cannot be confused across fields, but the set of field names is visible in the bundle.
5A. Protected-document fingerprints and their lifecycle (DLP-588)
The server receives document bytes long enough to extract and fingerprint them. It does not persist those bytes or extracted text: only fixed-size shingle hashes are written, and the API has no source-document read path. A hash corpus still lets its holder test a paragraph they already possess; that is the feature's purpose and why corpus read/build permissions remain separate.
Lifecycle visibility adds metadata, not content. A corpus row records its owner, version, document and shingle counts, building/ready/failed processing state, timestamps, and the bundle version and inclusion time that carried it. Bundle inclusion is not reported as endpoint deployment; live acknowledgement counts determine that state. Failed processing stores a closed code such as no_fingerprintable_documents, never the extractor message, file name or source. Duplicate detection stores one SHA-256 digest over the ordered shingle hashes; that digest is not returned by list or detail APIs and introduces no source value beyond the fingerprints already held by the server.
Deployment counts are derived from active endpoints that reported in the last ten minutes and their acknowledged policy-bundle version. They reveal fleet posture, not document activity. A tenant with no reporting endpoint is called pending, not deployed, and dependency responses are tenant-scoped. Build, failure and withdrawal audit events carry corpus/version coordinates, counts, closed error codes and the administrator's reason; they never carry document bytes, extracted text or shingle values.
6. Who can see what
| Role | Dashboard counts | Incidents | Redacted previews | Audit log | Export |
|---|---|---|---|---|---|
| Owner | ✅ | ✅ | ✅ | ✅ | ✅ |
| Admin | ✅ | ✅ | ✅ | ✅ | ✅ |
| Security analyst | ✅ | ✅ | ✅ | ❌ | ✅ |
| Auditor | ✅ | ✅ | ✅ | ✅ | ❌ |
| Viewer | ✅ | ❌ | ❌ | ❌ | ❌ |
A Viewer does not read incidents (DLP-542). Until then it did: every role held IncidentRead, nothing filters incidents below the tenant, and an incident row carries username, department, hostname, file_name and destination_target. So the least-privileged account in a tenant could read, about every colleague, the sentence this person moved this file to this destination - with only the redacted preview withheld, which is the corroboration and not the sentence.
Decided against the Viewer, and why it is not "no incidents":
- What a Viewer is for is the dashboard read to somebody else - a manager, a board, a works council. That needs how many, how severe, of what kind and through which channel. It does not need who, and a role that could answer who for everybody is a surveillance grant with a harmless name.
- So the dashboard is its own permission,
OverviewRead, held by every role. The overview, AI exposure and destination-trust aggregates are gated on it. The rankings that can name somebody - highest-risk people, people by volume, highest-risk endpoints, and the three lists of verbatim destinations - are served only to a caller that also holdsIncidentRead, and to anyone else are emptied withnames_withheldordestinations_withheldset, so the console says not named for your role rather than no activity. Every count still includes everyone. - Endpoints go with people, because a hostname is how most fleets name a person's machine; verbatim destinations go too, because an email recipient is a person's address.
- What a Viewer still sees, and why that is accepted: departments (a group, never one person); the AI-exposure tools list, which holds only destinations containing a pattern from a rule the tenant labelled
AI tools- verbatim, so a path can follow the service's host. (The destination-trust panel's unclassified and non-business lists are withheld:host_ofreadsa.person@gmail.comasgmail.com, so a mailbox lands on them verbatim.) The endpoint list stays readable (AgentRead) with each device's last user and incident count withheld andpeople_withheldset, and?last_user=refused: a hostname with its user and its incident count is the highest-risk-endpoints ranking by another route. Discovery results stay readable (DiscoveryRead): they describe where data rests, not what a person did. - API tokens minted before the split with
incidents:readstill read the dashboard;overview:readalone never reaches an incident row. - Not done: row-level filtering between the roles that do hold
IncidentRead. An Auditor still sees every incident an Owner does.
Exception requests are a separate pair of permissions (DLP-461), because reading what somebody wrote about their own work and lifting a control for them are different acts:
| Role | Read the queue | Decide a request |
|---|---|---|
| Owner | ✅ | ✅ |
| Admin | ✅ | ✅ |
| Security analyst | ✅ | ✅ |
| Auditor | ✅ | ❌ |
| Viewer | ❌ | ❌ |
An auditor's job is to find out that a control was lifted, not to lift it. A security analyst holds both, because the request names a file, a destination and a rule, and deciding it is the same judgement as triaging the incident it would otherwise become.
Behaviour analysis is a separate column again, and deliberately narrower - see §9:
| Role | Behaviour analysis | Edit categorisation rules |
|---|---|---|
| Owner | ✅ | ✅ |
| Admin | ✅ | ✅ |
| Security analyst | ❌ | ❌ |
| Auditor | ❌ | ❌ |
| Viewer | ❌ | ❌ |
The incident detail carries no match preview, for any caller (DLP-651). Each detector summary says how many previews exist (preview_count) and why they are not there (previews_withheld_reason): open_through_evidence for a holder of EvidenceRead, evidence_read_required for one without it, such as a token scoped to incidents:read alone. The absence is visible, not silent.
Previews are seen only through GET /incidents/{id}/evidence, which refuses a justification shorter than five characters. Each successful call writes one row to evidence_access_log and one evidence.accessed audit entry, naming the person and carrying the justification; a refused call writes neither. The console asks for the reason in a dialog before it makes the call, and renders only what that call returned. The justification is a real control: it makes casual browsing of other people's findings a recorded act.
Asserted by tests/evidence_access.sh: no preview in the detail for Owner, Admin, Analyst or Auditor, and one audit entry per justified look, none per refusal. Also asserted in apps/dashboard/src/pages/evidence-access.test.tsx: no request before the reason is given, and no preview rendered from the detail even when a server sends one.
Refusals are counted, without their content (DLP-753). The audit log records only the views that happened. So the operational port counts every request to that route, by tenant and outcome, in mydlp_evidence_views_total. The outcome is one of opened, refused_reason, refused_permission or not_found. The counts are kept in the process, since it started. The justification and the previews are never a label, and the tenant is held by id and named only when the metrics are read. tests/evidence_access.sh makes one call of each kind and checks each count. It also searches the scrape for every justification it typed and for the planted previews, and finds none.
7. Retention
Per-tenant, stored in tenant settings, defaulting to: events 90 days, incidents 365 days, audit 730 days. Evidence retention is set per policy and is independent of incident retention, so previews can expire while the incident record remains. Each incident's previews go on the window of the policy version that raised it; one no policy version names, or whose version names no positive window, goes on 30 days (DLP-1305). Until then the shortest window of any published policy governed every incident, so one 30-day rule cut every other rule's evidence to 30.
Enforced (DLP-2). A background pass deletes aged events, discovery findings and poison events, and closed incidents past their window. A closed incident's window is counted from the latest of its closure, its last event and its creation, so one raised long ago and closed yesterday keeps its full window (DLP-1978; before that it was counted from creation). Evidence previews are stripped from the incident rather than the incident being deleted: the redacted preview goes and the record that something happened remains, which is what lets evidence retention be shorter than incident retention. Each stripped summary is marked previews_expired, so their absence is visible rather than looking like a detection that never had any.
Each pass's retention.enforced audit entry names the incidents it deleted by reference, the first 1,000 of them, and counts the rest (incidents_deleted_references_omitted) (DLP-1980). GET /api/v1/audit?reference= finds that entry, so the disappearance of an incident has an answer after the fact. A sealed evidence copy outlives the incident it was kept for: its row is the only record of the object in the tenant's bucket, so deleting the incident clears the copy's incident_id (foreign key ON DELETE SET NULL, migration 0163) and the copy is deleted on its own expiry. The key was added NOT VALID, so copies written before it are not checked. Those that name a missing incident are listed by SELECT id, event_id, incident_id FROM evidence_files f WHERE incident_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM incidents i WHERE i.id = f.incident_id AND i.tenant_id = f.tenant_id), and VALIDATE CONSTRAINT is run once that list is understood.
The previews are stored twice: on the incident's summaries, and on each event's matches. Both copies go on the evidence window (DLP-1979). Each event's copy goes on the window of the policy version that decided it, or the tenant default where it names none, and its matches keep their positions. Until then the event's copy stayed for the whole event window, sixty days past the incident's under the defaults. An event under a legal hold keeps both, because a hold preserves.
An event is kept past the event window while its incident is open, and for the event window after the incident closed, so an investigation does not lose the events it is being investigated from (DLP-1977). An event kept that way loses its match previews by the event window at the latest, and by its evidence window where that is sooner; its positions, file name and decision stay. An event with no incident ages as before.
It never touches an open incident and never touches the audit log: the first is live casework, the second is append-only by database trigger.
Three things about it a privacy assessment will ask, answered here rather than found out later:
- It runs on an interval, not on a clock. "Deleted within 90 days" is accurate to that interval, not to the hour.
- A job that has stopped and a job with nothing to delete both report zero deletions. Only the timestamp separates them, so
GET /api/v1/system/healthcarriesretention.staleandretention.never_run, and the console shows both. A tenant whose data is never purged is a compliance finding that produces no symptom until somebody asks (DLP-56). - A large backlog is drained over several passes, and
more_remainingsays so, rather than one pass holding locks on a table the product is still ingesting into.
Covered by tests/retention.sh, tests/retention_metrics.sh and, for events kept for an open incident, tests/evidence_retention.sh.
The endpoint keeps two records of its own, and both are bounded separately.
The agent writes a local log - five megabytes a file, four files, deleted after fourteen days (DLP-259) - under the same ACL as the device key. It carries what an event carries: paths, detector ids, decisions and event ids. A matched value is never written to it, which tests/agent_logging.sh asserts against a real scan rather than a promise. docs/windows-agent/PRIVACY_MODEL.md §6 has the detail, and it applies on every platform.
It also holds an undelivered-event queue, and this is the one place in the product where data sits outside the retention rules above: the job on this page governs what the server keeps, and an event that never arrived is governed by nothing. So the queue has its own cap - 180 days, whatever the event's severity (DLP-272) - and both the row cap and the age cap count what they drop into the endpoint's health report rather than discarding it silently.
A subject-access request does not reach that queue, and this is the reason it is acceptable. An Article 15 answer is assembled from what the server holds; a laptop in a drawer cannot be asked. Reaching into endpoint queues to answer a request would mean the server pulling arbitrary local records from machines on demand - a larger power than the request needs, and one whose absence is itself a privacy property. What bounds the exposure instead is the cap: anything the queue holds reaches the server and becomes answerable, or it is deleted within six months. Before DLP-272 neither was guaranteed.
8. Transparency obligations
The product supports, and a lawful deployment generally requires:
- Telling users which directories are monitored. The monitored-path list is policy data, not hidden configuration.
- End-user notification on warn and justify actions, with a message the operator writes.
- Not deploying the agent on personal devices without informed consent.
- Nothing starts at sign-in without being listed. On Windows the per-person session agent - clipboard reports, justification prompts, notifications - is started by an
HKLM\…\CurrentVersion\Runvalue named MyDLP Session, which Task Manager shows under Startup apps anduninstallremoves (DLP-1126). It observes nothing the session agent did not already observe when started by hand. What it changes is that, on a machine installed by the setup program or the MSI, it runs for everybody who signs in, which is why the entry is named and visible rather than a hidden task.
The product cannot enforce these - they are deployment decisions. It is designed so that a lawful deployment is the straightforward one: no covert capability exists to switch on.
An indication that the machine is monitored is put up by default (DLP-1878). The cyberkap icon is in the notification area of every signed-in person's session, as the owner's design draws it, and a tenant may turn it off (tray_icon: "off" in the signed bundle). It is not continuously visible on Windows 11, which puts a new icon in the hidden-icons overflow behind the "^" until the person, or an administrator, chooses to show it (DLP-1958; seen on YUSIF, 2026-10-07). The agent does not promote itself: where an icon sits is the person's own shell setting. AGENT_INSTALL says how to show it. SPIKE-018 had decided off by default; the owner's design decided otherwise, and the spike says so. The icon is the same whatever rules are armed. Its card shows the agent's own state - protected, offline, policy out of date, needs attention, paused or unknown, the last policy sync, the policy version and the computer's name - read from the summary the service publishes for the About page (DLP-1316), and decided by the same function the About page uses (DLP-1877). For "needs attention" that summary carries the service's health word (healthy, degraded or impaired) and never the warnings behind it, which can name paths. Since DLP-2044 it also carries which areas that word is about, from a fixed list: file monitoring, sending events, the agent's own files, detection rules, kept copies, policy updates. The card names them, so "needs attention" says what needs it. An area is a word from that list and never a path, a file or a count. It shows no file names and no policy content. It shows no counts from that summary either, because the summary is readable by every user of the machine and its counts are the machine's, so one person would learn another's requests. The Pending requests and Temporary exceptions badges are the person's own. The tray asks the service for them over the session channel, and the service counts only the ledger entries raised from the account Windows names on the pipe (DLP-1946). Nothing on it pauses monitoring, uninstalls or reconfigures anything, and ending its process changes no protection. This paragraph said the indicator was not built until DLP-1878 (DLP-1211 caught an earlier version that claimed it was).
9. Activity categorisation, and what it is not
The product can categorise observed activity as business, non-business, suspicious or uncategorised (#476). This section states plainly what that is built on, because in several of our target markets an employee has a legal right to ask.
It categorises events the product already records - file operations, uploads, prints, copies to removable storage - by the process that performed them and the destination they went to. It does not observe anything new.
It is not a browsing history. A destination is recorded only when a monitored data operation targeted it. Browsing that moves no data is invisible to this product, and §3 refuses browsing-history collection outright.
Its figures here count operations, not time. DLP-320 decided that this product would never measure time: an application in the background for six hours is not six hours of use, and the only way to tell the difference is to watch what the person is doing. Every figure in this section is a count of observed operations, and the API carries unit in every behaviour payload so that a console cannot render one as a duration by accident.
On 2026-09-28 the owner reversed that for one capability. The owner asked for the reference console this section used to cite as the example of what the product could not measure ("4 h 55 min in Gaming" for a named employee), and accepted the legal responsibility that comes with measuring it. Active time is §47. It is decided, it is off until a tenant turns it on, and ADR-0043 sets its limits. Nothing below about operations changes.
Two reads exist that a reader will find in the source and should not mistake for that telemetry.
One window-focus read. When a clipboard copy is reported, the session agent asks which process owns the clipboard - the process the copy came out of (DLP-181) - and, as a fallback, which window had focus at that instant (DLP-222). Both are a single question asked at the moment of a monitored data operation, and both produce one process. Nothing is sampled, nothing is accumulated, no window title is read, and no timer runs: there is no code path that could say what was on screen a minute ago or for how long.
One idle-time read, which never leaves the endpoint. The session agent reports how long the console has been idle to the service on the same machine (DLP-301), and the service uses it to decide whether a discovery scan may run now or should wait (DLP-168). It is scheduling input, not an observation about a person: no contract carries it, no event carries it, and the server never learns it.
The rules are the tenant's, and so are the groupings. Categorisation is a per-tenant table of patterns, not compiled-in logic. One company's non-business is another's core tool. Groupings such as "Social media" are tenant-defined text and can be removed entirely - including the ones a vendor might be tempted to ship, because a column named for a protected activity turns a security control into a performance review.
The shipped catalogue names four groupings an investigation starts from - Social media, Gaming, Job search and AI tools (DLP-322) - and each is a seeded row a tenant edits or deletes like any other. linkedin.com is deliberately not among them. It is a professional network before it is a job board, and recruiting, sales and hiring are done there as work; shipping it under Job search would file a salesperson's ordinary Tuesday as looking for a way out. The consequence of being wrong about that label is a conversation about somebody's loyalty, so the shipped list carries only sites whose whole purpose is the search, and a tenant that wants more adds the rule and owns the judgement.
The dashboard's destination panels count, and never name. Two overview aggregates are built on these events - file operations to AI tools (DLP-340) and the trust split with its unclassified destinations (DLP-337). Both name destinations and report how many distinct people were involved; neither returns a username, and neither has a parameter that would make it. Naming a person stays where it was put: /behaviour/people, behind the tenant switch and a recorded justification. Nothing new is observed for either panel - both read events the product already stored.
Nothing ships as suspicious. suspicious is the one category with a human consequence - it is what gets somebody a conversation with their manager - so the product supplies no such rule. A tenant that wants one writes it, and the API refuses to store it without a stated reason. Every categorisation reports the rule that produced it.
Uncategorised is an outcome. A scheme that forces every observation into a bucket produces confident nonsense, and the confident nonsense is about a person.
Who can see it. Behaviour analysis is gated on its own permission, behaviour:read, held by Owner and Admin only - not by Security Analyst. Investigating an incident is asking what happened to some data; this is asking what a person did, and the product does not treat them as the same question. Editing the rules requires behaviour:manage, and every change is audited with what the rule said before.
10. Scoring a person
Behaviour analysis can also carry a risk score per person (#479), and this is the most consequential thing in the product for the people it covers. What governs it:
Only confirmed incidents count. An incident somebody has contained, escalated or resolved, and never one closed as a false positive. An open incident is a judgement nobody has made yet; counting one would let a noisy rule build somebody a record they never earned.
A mistake stops following somebody. Every incident decays: it counts half as much after the tenant's half-life, ninety days by default. That half-life is a tenant setting, bounded between one week and four years, because how long a mistake counts against a person has employment-law consequences and is not a question a vendor answers on a customer's behalf. Changing it requires a stated reason and is audited with the old and new values - shortening it erases history that was counting against somebody, and lengthening it makes an old mistake count again.
The review table beside the score (DLP-1565). For the people the same justified, audited call already names, the list also carries their department, the endpoint and the time of their newest operation, and their score as it stood a week ago. Nothing there is new about them: each is already on their incidents and events. The Behavior Analytics overview's figures (high-risk users, anomalies, linked incidents, baseline coverage, the trend and the drivers) name nobody, and the two that come from scoring people are given only while naming individuals is switched on.
One number is never presented as meaning one thing. The score always travels with its shape: how much came from going past a control that was shown, how much from a long tail of small repeated incidents, and how much from a sudden rise against that person's own recent history. Those want different responses - a conversation, training, or checking whether an account is compromised - and when no component dominates the product says so rather than naming the largest.
Continuing past a warning keeps counting, and is labelled as what it is (DLP-2026). Overrides of a warning or block, and refused bypass codes, count towards a person's score (DLP-1960): one is routine, and many is a pattern an organisation is entitled to notice. Decided again here, and kept. What changed is how it is said. The label is Repeatedly continued past warnings, with the number of incidents, and the factor says the count and that continuing is allowed and recorded. Neither says the person made "a decision, not a mistake" any more; a count is not a finding about why, and the label is read out in a dispute. The person is told at the time: the justify card's footnote says the justification and decision are recorded and may be reviewed (aec7311c).
Right of reply. GET /api/v1/user-risk/{username} returns every incident that contributed, its age, what it was worth when new and what it is worth now. Assume any score is disclosed in an employment dispute, because it will be, and a score somebody cannot see the inputs to is one they cannot answer.
For somebody excluded from being named under §11 that endpoint refuses, and the refusal says why (DLP-354). This is not the right of reply being withdrawn from the people most likely to want it, and the reasoning has to hold or the refusal is the thing that is wrong: no score is computed for them by name on any surface, so there is no ranking, no list position and no number about them for anybody - an analyst, a manager or a tribunal - to be shown. There is nothing to answer because nothing is asserted.
What does exist for them, and is the thing to ask for, is the audit entry recording that they were excluded, by whom, when and with what reason. Their incidents are unchanged and still name them, so an incident of theirs is answerable exactly as anybody else's is.
Access and audit. Behind behaviour:read - Owner and Admin, not Security Analyst - and behind the same per-tenant switch as the behaviour table, which is off by default. Every view requires a justification and writes an audit entry naming who looked, when, at whom, and why.
Comparison with a person's own past (DLP-94, DLP-1794). When an incident names a person, the server can compare it with that person's last 30 days: destinations, applications, kinds of data, working hours and their busiest day. The result is the incident's baseline_deviation risk factor and Behaviour's anomaly count. That comparison is analysis of a named individual, so it runs only while the tenant's named-individuals switch is on. Until DLP-1794 it ran on every named incident whatever the switch said; while the switch is off the factor says it was not compared because named-individual analysis is off, naming the setting (DLP-1832), and contributes zero, and the overview's anomalies and drivers count nothing new. Incidents raised before the switch was turned off keep the factor they were given. A discovery scan is never compared and never counted in a person's normal, because it is the agent reading files at rest, not anything the person did (DLP-1806). The API reports a comparison that did not run as an explicit factor contributing zero rather than omitting it: an input that is absent looks like an input that was zero, and that is how a score gets over-trusted.
11. Keeping one person out of an aggregate (DLP-341)
A works council or a data-protection officer eventually asks whether a named individual can be kept out of the numbers. The reference console's answer is that they cannot, "without revoking their Safetica license entirely". This section is ours, and it separates two things that question runs together.
What is true today (DLP-350). A tenant can name people who are never named in an aggregate. Nobody can be excluded from being counted in one.
Every enrolled endpoint's events are still collected, every policy is still enforced, and every total still contains everybody. What an exclusion removes is a person's name from the lists that rank people.
- A DLP control that skips a person is a control with a documented hole, and anybody who learns who is on the list knows which laptop to send the file from. So events keep being collected and policies keep being enforced for everybody, with no exceptions and nothing to discover. The suppression happens after every figure is computed, so an excluded person's operations are in the totals the suppressed list sits beneath.
- Naming is separable and is where the objection actually lands. Five surfaces name somebody in an aggregate - the dashboard's highest-risk users, the behaviour table's per-person rows, the per-person risk score, the incident queue's repeated-incidents groups, which order people by how many incidents they have (
DLP-1903), and the bursts strip above the queue, which ranks one person's runs of incidents by size (DLP-1972) - and the flag suppresses all five while every count stays whole; the groups list and the bursts strip say how many they withheld. The dashboard's two rankings of people also need what the per-person risk score needs, the tenant's person-analysis switch andbehaviour:read, and say which is missing when they are empty; its highest-risk list ranks confirmed incidents only, so a noisy rule's false positives cannot put a colleague at the top of it (DLP-1973). Two further paths name one person directly: a person's behaviour page and their risk detail. Both refuse for an excluded person and say why, because a suppression one URL away from being undone is not one. - It does not reach incident response. An analyst opening a specific incident still sees the username on it: that is the record of one event, not an aggregate, and suppressing it would mean an incident nobody can act on (§8 says the same about file names). Anybody told "this person is excluded" has to be told that too, or they have been given a promise the product does not keep.
- A suppressed aggregate says that it is suppressed, with a count and never a name: "3 people are excluded from this list by tenant configuration." Silently shortening a list is how somebody concludes the console is broken, or worse, that the list is complete.
- Setting or clearing the flag is a tenant decision with a stated reason and an audit entry, like the behaviour switch in §6 - and in both directions, unlike that switch. "Why was the finance director taken off the risk list in March" is exactly the question this log exists to answer, and restoring a restriction needs no reason only when the restriction is about the product rather than about one person. The names are in the audit entry, which is the one place this decision has to be answerable by name; the aggregates it affects never see them.
The list is matched case-insensitively, because a username arrives in whatever case the endpoint reported and an exclusion that failed on capitalisation would fail silently for the one person it was written for - which is the worst way for a privacy control to be wrong, because it looks applied.
12. Retiring a device, and what retiring does not erase (DLP-375)
A device leaves the fleet - it is replaced, the person left, the laptop was lost. The reference console calls this the Trash, and states that a device moved there disappears from the user tree and its records disappear from every dashboard. Ours does the first and deliberately not the second, and calls the list "Revoked & uninstalled" rather than a bin (DLP-1257).
What is true today. Revoking or uninstalling an agent takes the device out of every coverage figure: agents_active, the protection counts, the health rows on the dashboard all read status = 'active' and stop counting it the moment it is retired. That is right - a machine nobody is protecting should not be in the number that says how much is protected.
Its incidents stay. The aggregates over incidents and events do not join the agents table at all, so what a device did while it was enrolled is still counted after it is retired. This is a decision, not an oversight:
- An incident is a record of something that happened. It did not stop having happened because the laptop was later replaced.
- A figure that shrinks when a device is retired is a figure somebody can move by retiring devices. "Our incident count halved this quarter" should never be answerable with "we trashed forty laptops".
- The retention rules in §7 are what remove old records, on a schedule that is stated and audited. A second, silent path that erases history as a side effect of tidying an inventory would undo that.
Where the device's own identity goes. Revocation destroys the device key and the agent stops enforcing at its next check-in, which is the part that must happen immediately. The hostname and the events already stored remain until retention removes them.
What an operator is told. The Revoked & uninstalled tab says what moving a device there does and does not do, in those words, because "trash" is a word that promises deletion and this one does not perform it.
13. Regulatory notes
Not legal advice; the engineering facts a privacy assessment will need.
GDPR. Processing employee data requires a lawful basis and usually a DPIA. Relevant technical measures present: data minimisation (metadata and redaction by default), purpose limitation (only what a published policy describes), access control with audit, encryption in transit, and automated retention enforcement (§7, DLP-2).
Subject access has an endpoint and a page (DLP-429, DLP-938). POST /api/v1/subject-access/{username}, with the justification in the body (DLP-949), returns what the system holds that identifies one person: their events, the incidents those raised, the devices they used, and a record that somebody looked at their data. It refuses without a justification and records the request as a disclosure, audited as subject_access.exported with "GDPR Article 15" and the justification on the entry. It needs its own permission, subject_access:export (Owner, Admin, or a DPO's custom role), and it is not behind the behaviour-analysis consent switch (DLP-766). Until then it was, so a tenant that did not analyse behaviour, which is the default, could not answer an Article 15 request from the product. The right of access does not depend on whether the controller analyses behaviour, and the records it covers exist either way. The Subject access panel under Settings → Obligations asks for the username and justification and saves the answer as a JSON file in the browser; nothing of it is kept by the console.
What it deliberately leaves out, and tells the subject it left out. The export carries a not_included list in the reply, in words a subject can read: no file content, no matched values, no content hashes, no names of the people who looked at their data, and no data about anybody else. One exception is called out rather than hidden - a machine name is part of the subject's record, and an organisation that names machines after people has put somebody else's name in it.
tests/subject_access.sh asserts the restraint rather than the feature, which is the harder half: that the export cannot be produced without a reason, that it does not contain a colleague, and that it does not quietly omit something the subject could then not ask about.
It is an export, not a workflow, and correcting the old sentence must not overshoot into claiming otherwise. There is no request register, no clock against Article 15's one-month deadline, no verification that the requester is the subject, and no erasure or rectification path. Those remain the controller's process, and docs/KNOWN_LIMITATIONS.md carries the row.
This paragraph replaced one claiming no such endpoint existed - a document that says a control is missing when it is present is as wrong as one that claims a control it lacks, and it is worse here, because the old text named itself as the single unsupported sentence in the register and invited a reader to trust everything else on that basis.
Azerbaijan, Law on Personal Data. Cross-border transfer restrictions make the on-premises deployment the expected mode. The architecture supports it: the agent needs only the management server, and the optional Tika/Presidio/Tesseract services all run locally. No customer document is sent to a third-party service unless an operator explicitly enables one.
PCI-DSS. Payment card previews reveal at most the last four digits, which is the standard's own allowance. Full PANs are never stored.
Türkiye and the Gulf (DLP-2034)
What follows is in two parts for each law. Product facts are what the product does, each traceable to a section of this register. Recommendations are what a controller deploying it will usually need to do. They are the review's reading, not legal advice, and each should be confirmed by counsel in the jurisdiction before it is relied on. The duties these laws place on an employer monitoring staff are the controller's; the product's part is to make the lawful deployment the straightforward one, and to say plainly where it does not help.
The facts that hold under every law below:
- Where data lives. On-premises is the expected mode. The agent needs only the management server, the optional extraction services run locally, and no document goes to a third party unless an operator enables one (see the Azerbaijan note above). A sealed copy of a matched file, if an Owner opts in, goes only to the tenant's own bucket (§39).
- What is kept. Metadata and redacted previews by default, never a matched value in a log, a queued event or the local history (§1, §2). A policy can keep no preview at all. Retention is enforced automatically (§7).
- Who sees it. Role-based access with audit (§6). People can be named by a stable pseudonym throughout the console (§63). Behaviour analysis is off until a tenant switches it on (§10).
- Telling people. The tray icon is shown by default in every session (§8). Warn and justify actions show the person a message the operator writes. The monitored folders are policy data, not hidden configuration.
- A person's access to their record.
POST /api/v1/subject-access/{username}exports what identifies one person, with a justification, as an export and not a workflow (above). - A gap. Every endpoint warning is shown in English. That is the owner's decision of 2026-10-07 (
DLP-1897,AGENT_UI_LANGUAGE). While it holds, the tenant's fallback warning language (English, Azerbaijani, Turkish or Russian,NOTICE_LANGUAGES) changes nothing the endpoint shows. No endpoint warning can be in Arabic, so in the Gulf the notice a person sees at the moment of a warning is in English. This weakens the in-the-moment notice in the Gulf jurisdictions below. The owner confirmed English only on 2026-10-10 andDLP-2077(Arabic notices) was closed as decided. Closing the gap needs that decision lifted, Arabic strings and right-to-left cards.
Türkiye, Law No. 6698 on the Protection of Personal Data (KVKK).
- Product facts. Endpoint warnings are English, as above, though Turkish remains a fallback language the server accepts. A Turkish identity number (
tr.tckn) and a tax number (tr.vkn) are detected and, like every value, shown only redacted. On-premises deployment keeps data in the country unless the controller sends it out. - Recommendations, not legal advice. Give employees the information notice (aydınlatma metni) the law requires before monitoring starts. It names the controller, the purposes, what is collected (file names, paths, the user and device, redacted previews), who receives it and how long it is kept; the product's own retention windows are the figures to quote. Choose the lawful basis deliberately. Explicit consent (açık rıza) is hard to rely on from an employee, so the other grounds in the law, such as a legitimate interest that does not override the employee's rights, are usually examined first. Register in VERBİS if the controller meets the thresholds. If any data leaves Türkiye, including to a hosted server or to a SIEM, check the cross-border transfer rules as amended in 2024 (adequacy, appropriate safeguards and the standard contracts regime) before it does. Keep the subject-access export as part of answering a data subject's application, within the law's time limit.
Saudi Arabia, Personal Data Protection Law (PDPL) and its implementing and transfer regulations, overseen by SDAIA.
- Product facts. A Saudi national ID or Iqama number (
sa.national_id,DLP-2033) is detected and shown only redacted. On-premises deployment keeps data in the Kingdom. - Recommendations, not legal advice. Publish a privacy notice to employees before monitoring begins, covering the purpose, the lawful basis, what is collected, retention and their rights. Assess the lawful basis; the law, as amended, allows legitimate interest alongside consent in defined cases, and the regulations' conditions for it should be met and recorded. Treat any transfer outside the Kingdom, including a hosted server, as restricted until the transfer regulations' conditions are met. The redacted-by-default evidence and short retention windows are the minimisation measures to cite. Note the gap above: endpoint warnings cannot yet be shown in Arabic.
United Arab Emirates, Federal Decree-Law No. 45 of 2021 on the Protection of Personal Data (PDPL).
- Product facts. An Emirates ID (
ae.emirates_id,DLP-2033) is detected and shown only redacted. On-premises deployment keeps data in the country. - Recommendations, not legal advice. Check which regime applies first: entities in the DIFC and ADGM free zones are under those zones' own data protection laws, not the federal decree-law. Under the federal law, tell employees before processing and record the lawful basis; confirm with counsel which provisions are in force, since parts depend on executive regulations. Treat cross-border transfer as restricted unless the destination or the safeguards meet the law's conditions. The same Arabic notice gap applies.
Qatar, Law No. 13 of 2016 on Personal Data Privacy Protection.
- Product facts. A Qatari ID (
qa.qid) is detected beside its label and shown only redacted. - Recommendations, not legal advice. Inform employees of the processing and its purpose before it starts, and process only for that lawful purpose. The product's purpose limitation (only what a published policy describes) and minimisation support this. Review the guidance of the competent authority on employee monitoring and transfers.
Kuwait. A civil ID (kw.civil_id) is detected beside its label. Kuwait has no general data protection statute comparable to those above at the time of writing; sector rules, such as the communications regulator's data-privacy regulation, may apply to some controllers. This is a recommendation to check with counsel, not a statement of the law.
14. What a score records about a document (DLP-422)
A finding carries the reasons for its confidence - validation passed, a supporting term was nearby, a negative indicator was nearby. Those reasons travel with the event, so they are part of what this product records about a customer's file, and DLP-422 changed what they say.
They now name the term that fired, and the term is always ours. The vocabulary is the one written into the detector definition - VÖEN, AZN, elan edilir - which an operator can already read in the console. What a signal never contains is a word taken from the document: not the matched value, not the text around it, not the heading it sat under. The distinction is the whole of this section, and it is asserted rather than promised: agent/core/tests/nothing_sensitive_leaks.rs plants a tender reference and a bid price, runs a real scan with the procurement pack loaded, and searches every log, queue, history row and support bundle for both.
What this does record, and did not before, is that a document contained one of a fixed list of words within a few hundred characters of a match. That is a small increase and a deliberate one: a signal that says only how many matches had an indicator nearby is a signal nobody can act on, and an analyst deciding whether to open a file needs to know whether it was the prices or the word "placeholder" that moved the number. At most three terms are named, so a detector with forty of them cannot put forty into every event.
A suppressed match survives the scan and does not survive the endpoint. Where a negative indicator ends a match - a published tender notice carrying the same reference as the bids that answer it - the result stays in the scan report, with no locations, no value and a count of zero, saying only that something matched and a rule decided not to report it. That is what the console's test bench renders when an operator runs a rule against text they pasted, and it is the only place a negative list can be audited.
It goes no further. The agent filters to hits before anything is queued (agent/core/src/runner.rs, enforcer.rs, detector_runtime.rs), and the server drops non-hits again when it condenses results into an incident (domain::incidents::summarise). So a suppression is never queued, never stored and never attached to an incident, and a_match_a_negative_indicator_ended_is_not_attached_to_an_incident asserts the second of those.
That division is the point rather than an accident. Auditing a negative list is something an operator does to a rule, against text they chose. An incident is a record about a person's document, and "we looked at this and decided not to tell you" is a record of a document nobody was supposed to be recording. The control stays auditable; the person does not acquire a file.
15. Diagnostics collected from an endpoint (DLP-371)
Supporting a device that is misbehaving means seeing what it sees. An operator can now ask an endpoint for a diagnostic bundle; the endpoint builds it at its next check-in and uploads it, and the console offers it for download. This is the row that capability needs before it ships.
Sent by the person at the machine (DLP-1879). The server also accepts the same bundle when the person reports a problem, named by the reference they were shown rather than by a console command, at most three a day from one device. It is marked as theirs in the console and kept, like every bundle, for thirty days. Its contents and omissions are exactly those below. On the endpoint it is the tray card's "Report a problem" row. The agent service builds the ordinary bundle, without log contents, and sends it with the reference, and the card shows that reference to quote. The bundle's generated_by names the account that asked, as Windows names it on the session channel, where a bundle built from the command line names the account that ran it. The events the device sends about that person already carry it.
A crash dump is not a bundle, and can hold more (DLP-1952). When the agent's process crashes on a machine with Windows Error Reporting's local dumps switched on, as some machines have it, Windows writes a dump of its memory. That is whatever the agent held at that moment: the policy bundle, monitored paths and, while a file was being scanned, fragments of its text. On one physical PC nine such dumps held a document fragment and no matched value that could be searched for; nothing guarantees the second half. Windows wrote them, ten at a time, into the system profile's CrashDumps folder, outside the agent's state directory and outside what uninstall removes. The agent now sets its own policy, a per-application key WER reads before the machine's setting: a minidump that leaves out the heap and writes each thread's stack as only the pointers a stack walk needs (MiniDumpFilterMemory, DLP-1957), which Microsoft documents as leaving out what a thread held on its stack, such as text being scanned; the newest two only, in crash inside the state directory, which only SYSTEM and Administrators can read and which uninstall empties. What a dump can still carry is the threads' registers, a few bytes each. Uninstall also removes the policy and the dumps of the agent Windows wrote before it had one. Nothing sends a dump anywhere. That the filter does this is Microsoft's documentation, not yet an observation here. Under Wine a dump without it was seen to carry text a process held on its stack, but Wine does not implement the filter, so the test that would show the filtered dump without the text runs only on a Windows host and has not been run there. Where a Windows build ignored the flag, the dump would be the plain minidump it was before.
What the bundle contains. Agent version and architecture, device and tenant identifiers, policy bundle version and a summary of it, queue depth and what the queue has dropped or expired, the health report, the capability matrix, the state directory path and any finding about its permissions, whether the executable is the one that was installed, configuration field names with hashed values, and a list of the agent's log files with their sizes and modification times.
What it does not contain, and this is the part that matters. Except inside a troubleshooting session (§16), which is the one case where the log files themselves travel and says so in the bundle. Otherwise: no secrets - the device private key is never collected, in any form. No log file contents: the logs are described, not included. No local history, because every entry in it names a file. No configuration value that could identify anything: values are hashed so two endpoints can be compared without disclosing either. Nothing a detector matched, in any form, redacted or otherwise.
The omissions travel with the bundle. The agent declares them in the bundle itself, the server stores that declaration beside the body, and the console shows it on the row above the download button. A promise kept in this document is one nobody reads at the moment it matters.
A bundle that does not declare its omissions is refused. The server rejects it rather than storing it. That rule protects nothing unless the agent actually declares them, and the two live in different crates, so agent/core/tests/nothing_sensitive_leaks.rs asserts the agent's side against the exact payload that is uploaded - not against something built the same way.
A bundle nobody asked for is refused. The upload must name the request id of a command actually issued to that device. Without that, a device - or somebody holding its key - could push whatever it liked into the database and the console would present it as something an operator had requested.
Who may read one. Listing bundles and reading what they withhold needs device read; opening one needs device management, and every download is audited. A bundle is the most detailed view this product offers of one person's machine, and it is attached to a named endpoint, which in most tenants means a named person. Reading it is an act somebody answers for.
A request can be withdrawn. An operator who asks for a bundle and then resolves the problem can cancel the request while it is still waiting, and the device never collects it. Nothing is gathered, so nothing has to be deleted - which is the better outcome than a bundle collected, stored and then removed. A request the device has already collected cannot be recalled, and the console says so rather than pretending otherwise.
How long one is kept. Thirty days from collection, then the retention pass deletes it. A bundle answers one support conversation; keeping a year of them would build, by accumulation, the per-device archive §7 has decided not to hold. The audit entry survives the bundle, and carries the omissions - so what was collected remains answerable after the body is gone.
16. Detailed logging, for a while, in the open (DLP-372)
Supporting a fault that only happens on one machine means seeing what that machine saw while it happened. An operator can put an endpoint into a troubleshooting session: the agent raises its own log level, the operator reproduces the problem, and the session ends by collecting what it produced or by collecting nothing at all.
This is the one capability that carries log file contents off an endpoint. support::OMITTED has always said "log file contents: described below, not included - send them deliberately or not at all". A session is that deliberate act, and the bundle's omissions list changes to say so: a bundle that carried logs while still claiming it omits them would be worse than either choice made openly.
What the logs contain, and what they do not. They name files the agent handled - §8 above: file names are recorded and are supposed to be, because an incident naming no file is one nobody can act on. They never carry what was in one. agent/core/tests/nothing_sensitive_leaks.rs runs a real scan of a file holding every planted secret, at TRACE, into the agent's own rotating log, and then asserts the troubleshooting bundle carries none of them. Planting a secret in the log excerpt makes it fail; that was checked.
It is time-boxed, on the endpoint's own clock. A session is asked for in hours and may not exceed a day. The deadline travels with the command, the agent clamps it again on arrival, and the agent stops when it passes - whether or not it can reach the server, and including across a restart, because the session is on disk with its deadline. A device that never checks in again is exactly the device that must not keep verbose logging running. The server marks the row expired separately, so the console does not show a session as running for ever because somebody went home.
It is not covert. The session appears in the Troubleshooting column of the fleet view, not only on the screen of whoever started it, and the agent reports it in health so the endpoint can say so locally. It is deliberately not coloured as a fault: detailed logging is something somebody turned on for a reason, and colouring it red teaches operators to clear it rather than to ask whose session it is.
One live session per device, enforced in the schema. Two operators troubleshooting different problems would each end the other's session without knowing, and the bundle would answer the wrong question.
Cancelling collects nothing. It is a separate instruction rather than a stop followed by somebody discarding the result: not gathering is a better outcome than gathering and deleting. An expired session collects nothing either - nobody came back for it.
Who can do it. Starting and ending a session is device management, and each transition is audited with the reason it was given.
17. Which instruments a tenant says govern their material (DLP-426)
A document's sensitivity, its category and the regimes that govern it are three independent facts. The third now has somewhere to live: a classification can carry compliance tags - personal data under the Azerbaijani law, official use, procurement or contract confidentiality, retention or legal hold, PCI DSS, GDPR - each with a citation the tenant writes.
The product records a determination and never makes one. Which law applies to which material is a legal judgement about a tenant's own work. Every regime arrives disabled; enabling one is a separate act with its own stated basis, and until then it is not offered as a tag at all. An organisation that has never taken a card payment should not find PCI DSS in a list, because seeing it there is how somebody ticks it.
A tag is about a class of document, never about a person. It travels with an incident because the incident concerns material of that class - not because of who touched it, and it adds nothing to what is recorded about them.
Tags drive reporting and retention, not enforcement. What is blocked stays a policy decision; a regime is what the organisation must be able to say about the material afterwards.
They are snapshotted onto the incident. A report says what was believed to govern this material when it left. A determination made or withdrawn next year does not rewrite last year's report, and cannot erase why something was escalated.
Withdrawing one means withdrawing it where it was made. A regime still recorded on a classification cannot be switched off; the determinations are removed first, each with a reason. Otherwise "enabled" stops being one truth and the screen and the reports disagree.
Every tag added or removed, and every regime enabled or disabled, is an immutable audit entry with who, when and the reason given.
18. Correcting a label, and the line that is not crossed (DLP-412)
Discovery reveals files whose metadata is missing or wrong. This product will correct one thing: the alternate stream it owns, on an endpoint, through the agent.
It will not move, delete or re-permission a customer's file. DLP-234 drew that boundary and the S3 connector states the same principle in its own words: the way to be sure a feature cannot write is not to implement writing. Material on a share or in a bucket therefore appears in a remediation plan as unsupported, with the reason, rather than being silently absent.
A customer's own labels are inputs. Microsoft sensitivity labels and anything else the organisation maintains are read, never rewritten. Where the agent's precedence rule declines to replace a label already on a file, the plan records conflicting and leaves it for a person: two systems the customer runs disagreeing is not something a DLP tool resolves by writing its own answer over theirs.
Nothing runs unapproved, nothing runs past the ceiling the operator set, and a retry cannot double-apply. Each transition is audited with counts and a reason - never a file path or a label value, because the audit log is long-lived and this is a record about files rather than a copy of them.
What the agent reports back is metadata. Which item, what happened, what was there before, and an error string about the file system when something failed. No file content, at any point.
19. Retention proposals, and the engine this does not become (DLP-415)
A classification service that can dispose of records is a different product with different liabilities. This one produces a proposal and hands it to whatever already owns disposition.
Nothing here deletes, moves or changes access to anything. The schema has no value that says a file was disposed of, and the database refuses one.
Legal hold wins first, always. Before the schedule, before the timestamp, before anything.
A classification that was not evaluated is never a basis. "We could not tell what this is" must not become "it is not the protected class": that is the path from a coverage gap to a deletion.
A clock nobody vouches for is not a basis. A file's modified time is as trustworthy as whatever wrote it. The product's own first-observation is trusted because we wrote it; a repository's timestamps are accepted only where the tenant has said so, per rule, defaulting to no.
What is left out is stated. Ineligible material appears with its reason, because a proposal that silently omits material reads as a proposal that considered it and found it not due.
The audit entry is about the decision, not the material. Counts and the reason given; no path, no timestamp, no label value.
20. A restricted legal status is not a label (DLP-427)
§17 records that a tenant may tag a classification with official use (xidməti istifadə) as a regime - a statement about which instrument governs the material, made by the organisation, with a citation they write.
That is deliberately not the same thing as the national restricted-information status itself. Applying, changing and removing that status are authorised acts performed by a named person under a named instrument, with handling obligations attaching to the carrier rather than to a row. A value in a dropdown can be applied, edited and removed by anybody who can edit the dropdown, which is the opposite of what the status means.
ADR-0023 fixes the design: an append-only register of authorised acts, its own role with separation of duties from whoever authored the classification, declassification as a separate act that no edit can cause as a side effect, and carrier handling rules that default to refusal - the inversion of this product's general policy behaviour, because here the cost of a false refusal is not an interrupted transfer.
One half of it is implemented and the other is not, and the half that is tells you which. The label vocabulary now refuses a label whose id, display name or visual marking names one of the statutory phrases, with a message naming ADR-0023 (DLP-490, packages/contracts/src/label.rs). That needed no legal answer, because refusing is correct under every answer: if such material is not processed here the label is misleading, and if it is, this is exactly the path ADR-0023 §1 says must be closed. The guard deliberately does not reach the ambiguous single words - məxfi is also the ordinary word for "confidential" - and a test asserts that under-reach so it is not quietly widened.
The register itself is not implemented: no authorised role, no acts, no carrier handling rules. This product will never determine what is restricted; it records a determination somebody authorised made. Implementation waits on three answers only the organisation's legal and security owners can give - whether such material is processed in this system at all, who holds the authorising role, and the instrument relied on. None is assumed here, and the organisation's status as a critical-information-infrastructure subject is recorded as unconfirmed rather than inferred.
21. Whether a machine is waiting for a restart (DLP-370)
A new observation, and a small one, recorded here because §8 of CLAUDE.md says a capability that observes something gets a row before it ships rather than after.
What is observed. Whether this machine has an unfinished install. Two sources: a marker the MyDLP installer wrote when it replaced an agent image that was still loaded, and the three registry locations in which Windows records that something is waiting for a reboot - Component Based Servicing, Windows Update, and PendingFileRenameOperations.
What reaches the server. One of three words - agent_replaced, driver_changed, elsewhere - the time the endpoint first noticed, and a fixed sentence naming which of the three registry locations answered. Nothing else.
What deliberately does not. PendingFileRenameOperations holds a list of file paths from this machine, which is a partial inventory of what somebody installed and where. The agent asks the registry for the value's size and never reads it: the question is "is this list empty", and the contents are a thing this product has no reason to hold. That is a direct application of §4 - the field that would have been convenient is the field not collected.
What it is used for. One decision: whether the console may offer to restart this device. It is not an inventory signal, it is not retained beyond the health record it arrives in, and it says nothing about a person - a pending reboot is a fact about a machine, and the row exists so that nobody has to take that on trust from reading the code.
What the person at the machine sees. The restart itself is announced on their screen with five minutes' notice and a message that names MyDLP and says why. This is the one action in the product that interrupts somebody directly, and it is not permitted to be quiet.
22. What an update reports, and the log it leaves behind (DLP-369)
What reaches the server. The version the endpoint was told to install, the version it was running, one of six outcome words, and one sentence for a person. Nothing else.
What deliberately does not. Windows Installer writes a verbose log - this product asks for one, with /l*v - and that log names every file the installer touched, every path it probed and a good deal of what else is installed on the machine. It stays on the endpoint, under the state directory's ACL, and it is not part of what is reported and not part of a support bundle. Somebody diagnosing a failed install on a specific machine reads it there, which is a deliberate act on one endpoint rather than a fleet-wide collection nobody asked for.
The detail field is written by this product, from a closed set of sentences plus an installer exit code. It is never the log's contents and never a path from a user's directory.
Where the artefact is staged. Under the state directory, not %TEMP%. That is a security decision rather than a privacy one - it is a file the machine executes as SYSTEM, and in %TEMP% it would be writable by whoever is logged in - but it has the privacy consequence that the staging directory inherits the ACL that keeps ordinary users out of the endpoint's own files.
23. Paths and application names as classification inputs (DLP-384)
A classification can now be written about where a file is and what touched it, which makes two things that were already collected into inputs a rule depends on. The ticket asks for the register row before it ships, so here it is.
What is observed. The source path, and the executable name of the process that acted. Both were already on an event - source_path and application have been in ClassificationEvaluationContext since the model landed - and neither is new collection. What is new is that a rule can turn on them.
Why it is worth a row anyway. A path names a person more often than it looks: C:\Users\aliyeva\Desktop\ərizə.docx carries a name, a role and sometimes a health or legal matter in a folder name. The application names what somebody was doing. §4's rule applies unchanged - these are recorded because an incident that cannot say where a file was is an incident nobody can act on - but a rule about them makes the console show them to whoever can author rules, which is a wider audience than the incident queue.
What canonicalisation changes, and what it does not. paths::canonical lowercases and resolves a path so a rule cannot be stepped around. It is comparison only: the original path is what is stored and shown, because a lowercased path in an incident is a path that no longer matches what the person sees on their own machine.
Application categories are a naming convention, not an identity.applications.rs maps executable names to groups. A renamed binary is not caught by it, and the module says so rather than implying a boundary it does not enforce - authenticode is where that question belongs. This matters here because an operator reading "Browsers" could otherwise believe the control is stronger than it is.
Retention and subject access. Neither field changes either: they travel on the event, live under the incident's retention, and appear in a subject access export exactly as they already did. No new store, no new index, nothing kept for longer because a rule now reads it.
24. A tag a person applied is a record of a decision they made (DLP-387)
What is stored. A tag code on a file, in MyDLP's own alternate data stream, and - when the endpoint reports it - who applied it and when. Nothing else: no file content, no reason, no free text.
Why it is different from everything else in this register. The rest of this product observes. A tag records a decision somebody made, deliberately, about a document they were working on. That makes it evidence about a person's judgement rather than about their activity, and two things follow.
It must be attributable and visible. An applied tag that nobody could trace would be a mark on a file with no author, and an applied tag the person could not see would be this product speaking in their name.
And it must never be inferred. Nothing in this product applies a user tag automatically, because a tag that the system applied and attributed to a person is a statement they did not make. Automatic classification has its own vocabulary - that is what a classification rule is - and the two are kept apart.
The customer's own labels are untouched. ADR-0020 again: a user tag lives beside a Purview label and never in it. The Windows fixtures for that are DLP-446's.
Retention and subject access. The tag travels on the event and lives under the incident's retention, like every other field. It is included in a subject access export - it is a statement by the data subject in the common case, and withholding somebody's own annotation would be the odd choice.
Withdrawal is not erasure. Retiring a tag from the catalogue does not remove it from files that carry it, and does not remove it from incidents that name it. An incident from March that says a person marked a document restricted stays true after restricted is withdrawn; the catalogue row is kept precisely so that record still means something.
69. A label's marking, written into the document (DLP-1057)
Numbered §69 since DLP-1948: it was given 25 when provenance already had it, and keeps its place here. Every citation of §25 means provenance.
When the Windows agent labels a Word document automatically and the label carries a marking, it writes the marking into that document's footer. That is the only time this product changes a document's content, and it writes only what the tenant's template asks for, from a closed set of three values:
- the label's name;
- the file owner's name, when the template contains
{owner}; - the date. Nothing matched in the document, no path and no policy detail can appear, because the template validator refuses any other placeholder.
{owner} is the one that puts a person's name on every page of a document, where anybody the document is shared with will read it. That is the tenant's choice in its template, and a tenant that does not want it leaves the token out. What the agent records about the act is the template id, its version and the outcome, never the footer text or anything from the document.
Audit entries leave the database, and where they go (DLP-55)
The audit log records who looked at what: every evidence view, every subject access export, every behaviour query naming a person. It is therefore personal data about the analyst as well as a control over them, and a log that grows for ever is a retention position nobody chose.
retention_days.audit now does something. Aged entries are exported to DLP_AUDIT_ARCHIVE_DIR, verified there, and only then removed from the database. Three consequences worth stating plainly:
- The archive contains everything the log contained. Actor, action, target, source IP and metadata, unchanged - it is the same rows, moved. Whoever can read that directory can read the audit history, so it belongs on storage with the same access controls as the database, not on a convenient share.
- Removing entries is not erasure. A subject access request covering an analyst's own activity has to reach the archive as well, and this product does not read it back. That is recorded in
docs/KNOWN_LIMITATIONS.md. - Nothing is removed without a verified copy, so the shortest honest description of audit retention is "moved after N days", not "deleted after N days". A customer whose policy requires deletion after a period needs a deletion policy on the archive storage; the product does not delete archives.
A digest leaves as well, where anchoring is configured (DLP-1185). Each stamp sends the configured timestamp authority one SHA-256 digest of a checkpoint statement, and a random nonce. No audit entry, actor, tenant name or any other content is sent, and a digest cannot be reversed into any of them. The authority learns only that this server asked for a stamp, and when.
The floor is thirty days. A shorter window is clamped to it rather than honoured: below that the log stops being able to answer the question it exists for, and the effective window is recorded in the audit.archived entry so the clamp is visible rather than silent.
25. A durable record of where a file came from (DLP-485)
The first store in this product that outlives the event it came from, which is why §23 said no new store had been added and why this section exists. Everything else in this register travels on an event and lives under the incident's retention. This sits on the endpoint and answers a question about a file that may never produce an incident at all.
What is stored. One row per content hash, in the scan-skip cache the agent already keeps: the hash, the host the file came from, which kind of approved telemetry said so, and when. Four fields.
A host, never a URL. A download URL carries a query string, and a query string carries session tokens, search terms and record ids - the search someone ran, the case number they opened. The host answers the question a rule actually asks, which is did this come from outside the organisation, and the rest answers nothing extra while putting a credential in a durable store. The URL path is not shortened or hashed; it is not collected.
No origin is ever inferred. A file in C:\Users\x\Downloads\ was very probably downloaded, and "very probably" is not what provenance means. There is no variant of ProvenanceSource for a path, so a path-derived origin is unrepresentable rather than refused - DLP-485 asks for the refusal and making it impossible to express is the version that cannot be forgotten at four o'clock.
Identity is content, so an edit ends it. A downloaded document somebody opens and saves has different bytes, a different hash and no origin. That is correct - it is no longer the file that was downloaded - and it is written here rather than discovered, because it will be reported as a bug.
Three states that are not "no origin". Content below 512 bytes, or shared with more than three unrelated paths on the endpoint, reports InsufficientProvenance; an origin past its validity reports Expired. None of them may satisfy a rule. Collapsing any into Unknown would turn an absence of evidence into evidence, and SPIKE-013 is the measurement the two thresholds came from - including the finding that size is the weaker of the two controls.
Retention. Thirty days from capture, matching §17 so an organisation has one number rather than two. Expiry is not deletion: the record still says this expired, because a rule that silently stopped matching reads as a broken rule. The row is also evicted with the cache entry it lives in, so an origin cannot outlive every record of the file it belongs to.
Subject access. The store is keyed by content hash and holds no identifier of a person. It is reachable from a subject's files by path through the same cache, and is exported with them: where a document a person holds came from is information about that document, and an export that omitted it would be answering a narrower question than was asked.
What is not built. The capture itself. DLP-485 requires an origin to come only from approved browser or download telemetry, and the download side of that does not exist - SPIKE-010 covers upload interception. Nothing described here produces an origin today; it decides what may be believed about one when it does. Recorded in the present tense only for the parts that exist: the store, the four states, and the two thresholds.
26. A classification choice waiting to be written (DLP-492)
The second durable endpoint store, after §25, and for the same reason it gets its own row: it outlives the moment it was made.
What is stored. One row per deferred choice: the file's path, the label id the person chose, the content hash the choice was made about, when they chose, and why it could not be written. Five fields, asserted as a closed set - the test compares the serialised key list by equality rather than searching for suspicious names, so adding a sixth field is a failure somebody has to argue past rather than a review somebody has to catch.
Why a path, when §25 deliberately stores no path. Provenance answers a question about content and travels with it; this has to find one specific file again when it becomes writable, and a hash cannot do that. It is the same rule as everywhere else in this register: file names and paths are recorded because an action nobody can locate is an action nobody can take, and file contents are not. Nothing here carries what was in the document.
Why the content hash is here too. A person classified that document. If the bytes changed before the queue drained they classified something that no longer exists, and applying their label to what replaced it would put their name to a decision they did not make. The choice is dropped and they are told - not applied, and not discarded silently.
What is refused rather than queued. A format that cannot carry a classification, and read-only media. Neither becomes writable by waiting, so queueing would leave a person believing a document is classified when it is not - and finding out, if ever, from an incident about the document they thought they had marked. They are told at the moment they choose.
Read-only is asked of the volume (GetVolumeInformationW on Windows, statvfs elsewhere), because the error cannot say: a file another program holds open and a file on a read-only volume both arrive as permission denied, and one is a wait while the other is not (DLP-561). Until then the volume was not asked and both were deferred, so a choice on read-only media sat in the queue for its seven days before being reported. A volume that will not answer is still treated as writable, which defers - the direction whose mistake is recoverable. The check reads a flags word about the volume and nothing about the file or its contents.
Retention. Seven days from the choice, and the reason is not storage: a choice somebody made a week ago, about a file that has been unavailable since, is one they have stopped thinking about. The queue is also bounded at 256 entries, oldest evicted first, and an eviction is reported rather than silent - an evicted choice is somebody's decision thrown away.
Subject access. The queue holds the paths of files a person was working on and the labels they chose, so it is that person's own record of their own decisions and is exported with their data.
What is not built. Every surface. DLP-446 needs a code-signing certificate, a licensed Office installation and a Microsoft tenant, none of which this project has, and classification_prompt::surface_support still reports every surface as absent with its reason. Nothing described here is reachable by a person yet; it is the answer waiting for the surfaces that will ask it.
27. API tokens and a refused one's address (DLP-598, DLP-600)
What is stored about a token. Its name, its public id, its scopes, who created it and when, when it expires, when it was last used, when its secret was last replaced, and -- once revoked -- by whom and why. last_used_at is a time and nothing else: not an address, not a route, not a count.
What is never stored. The secret. It is returned once, in the answer that created or rotated it, and only its SHA-256 is kept. tests/api_tokens.sh searches the server's log, every audit entry and every token row for each secret it mints and finds none.
A refused token's address. When a presented token is refused for any reason but unknown, the server's log records the token's public id, the reason class and the source address of the request. It is logged because it is the one fact an operator responding to a leaked credential needs that nothing else records (the request log does not carry addresses), and because revoked means somebody still holds a credential that was taken away. unknown refusals are not logged: that is what scanners produce, and logging their addresses would be collecting the internet's for no one's benefit. Retention is the log's own; it is not copied into the database.
What is counted. Refusals per reason class, in the process, since it started (mydlp_api_token_refusals_total). No token, address or tenant is a label.
Confirmations (DLP-616). How each governed action's request for a recent confirmation came out, per action and outcome, since the process started (mydlp_step_up_total). Also, per tenant and action over the last day, how many confirmations were used or expired unused, and the longest wait between confirming and using one. That second group is read from the grants table, which holds a session id and no person. No person, session or address is a label. Who confirmed what, and when, is in the audit log (step_up.*), whose reads are themselves audited. It is not repeated here. tests/step_up.sh asserts that no series names a person, a session or a tenant id.
Search (DLP-612). Per kind of object: queries run, queries skipped for want of permission, and rows returned; and how long searches take. No query text, person, tenant or result is a label. What somebody searched for is recorded nowhere: not in metrics, and not in the log at any level, because the request span records a path without its query string (tests/search.sh §7 checks this at debug).
28. Recurring report execution metadata (DLP-584)
What is stored. A schedule keeps the saved-report id and display name, its IANA time zone and cadence, enabled state, next/last/success times, a bounded failure code, and the id/type/display attribution of the user or API token that asked for it. An occurrence keeps those object names and principal attribution, occurrence time, at most three attempt states, a bounded error code and the resulting row count. Names survive object deletion so an audit event remains intelligible; an API-token label is its non-secret database id, never its token value.
What is not stored. No report row, incident field, evidence, saved filter, CSV or delivery payload is copied into either table, an audit event or a log. The worker asks the incident query for the current result and immediately reduces it to a count. Delivery is separate work and cannot treat this metadata as a document waiting to be sent.
Authority is not retained. The schedule records which principal to resolve, not what that principal was allowed to do when it was created. Every occurrence re-reads the user status and role or token revocation, expiry and scopes, then runs through the incident query's authorization and tenant boundary. Permanent loss of authority pauses the schedule rather than retrying a forbidden read.
Retention and deletion. Removing a report atomically removes every active schedule. Existing occurrence rows retain only historical metadata and lose their executable schedule reference. Audit events are immutable. A separate retention period for occurrence metadata is not introduced by this task; it follows the tenant's audit/operational retention until delivery and reporting requirements define a stricter period.
29. Scheduled report delivery (DLP-585)
What leaves the product. When a schedule names an approved destination, the same CSV an authorised person could export interactively is sent over verified HTTPS. It contains no matched value or detector preview, because the shared IncidentRow export type carries neither. What else it carries depends on the tenant's delivery mode (DLP-2082, privacy.report_delivery), set by the Owner with a reason and audited:
- full: the incident-list fields, such as reference, title, status, user, department, endpoint, policy, file name and destinations. Names are pseudonyms where the tenant pseudonymises people.
- minimised: the same columns, with title, user, department, endpoint, file name and destinations left empty. A row is its reference, status, severity, channel, policy, counts and times, and the rest is read in the console, where access is checked and audited.
The default while the Owner has not chosen is minimised where the tenant pseudonymises people, and full where it does not. A tenant that hides names would otherwise still send file names and hostnames, which can name people too. Choosing full asks for a fresh step-up (people_names). The mode is separate from the Behaviour naming switch, and it governs scheduled delivery only: an interactive export is a person reading under their own export permission, and keeps every column. A private receiver CA may be trusted; certificate verification cannot be disabled. The request is HMAC-signed over the exact bytes and carries a run-scoped idempotency key.
What is stored. The destination keeps its display name, enabled/health state, HTTPS configuration and a write-only secret reference. A run keeps the destination id/name, csv format and SHA-256 of the first rendered body. Each attempt appends ids, object names, outcome/error class, row and byte counts, duration and time. Database triggers refuse changes or deletion of those attempt rows. The audit log records configuration and final outcomes using the same bounded metadata.
What is not stored. No CSV, report row, saved filter, URL, receiver response body, HMAC secret, matched value or evidence preview enters delivery history, audit, telemetry or logs. The body exists in process memory only for rendering, hashing and the HTTPS request. If current data would render different bytes on a retry, the digest mismatch stops the send and pauses the schedule; one idempotency key is never used for two reports.
What is counted. The operational metrics endpoint exposes attempt outcomes, bounded error classes and acknowledged-send duration for the last hour, grouped only by tenant and the applicable bounded value. It deliberately has no destination, report, principal, URL or free-text error label: those customer-chosen values both leak configuration and create unbounded cardinality. Object-level diagnosis stays in the tenant-scoped delivery-history API.
Authority and failure. Every attempt resolves the current user/token and rechecks schedule, read and export permissions. A disabled destination or lost permission sends nothing. A run is successful only after transport acknowledgement; connection and receiver failures are not reported as report success. Destination configuration is separate from incident delivery, so approving a report receiver does not subscribe it to incident alerts.
30. Testing a discovery target's connection (DLP-595)
What is observed. Whether a location answers and whether this server may read it: for a bucket, one ListObjectsV2 asking for a single key; for a mounted share, a stat and an open of its top directory. The listing that comes back is discarded without being parsed, and no directory entry is read, so no object or file name is learned. Nothing is written to the location.
What is stored. On the target: passed or failed, when, the outcome class (denied, timeout, …) and a sentence this server wrote for that class. The audit entry holds the kind, the outcome class and the duration. The store's own error text is never stored or shown, because it is the one part of the answer this product did not write.
Credentials. A target names a server environment variable whose name begins DLP_DISCOVERY_; the value never enters the database, a response, the audit log or the server log, which tests/discovery_target_validation.sh asserts with planted secrets. The console's selector lists those variables by name and whether each has a value. Its access key id is sent to the store, because SigV4 requires it; the secret half never is, since it is used only to compute the signature.
31. Directory group membership, for evaluation only (DLP-669)
What is observed. The Windows agent (DLP-670, agent/core/src/user_groups.rs) reads the security groups of the user signed in at the console from their logon token: each enabled group's SID, and its DOMAIN\Name once looked up, as of their last logon. It reads no other user's groups, and when more than one person is signed in to the machine it supplies none. It does not query the directory for membership, and it holds no credential. Naming a SID uses the same bounded LookupAccountSidW resolver as a file's owner (agent/core/src/file_owner.rs, DLP-169), which may ask a domain controller for the name.
What it is used for. Only for deciding whether a policy or exception scoped to a group applies to an event on that endpoint, in memory (EvalContext.user_groups).
What is not stored or sent. Membership is not put into an event, an incident, a log line at any level, the local history or a support bundle, and no server stores it. An incident names the policy that matched, and the policy's scope says which group it was for. The only thing reported is that the agent supplies the attribute (capabilities.scope_attributes), not what it is. agent/core/tests/nothing_sensitive_leaks.rs (group_membership_decides_the_policy_and_reaches_no_artefact) asserts it. It plants a distinctive group, shows that it decides a group-scoped policy for a member and not for an outsider, and then finds it in no file the agent wrote, at TRACE, or in the troubleshooting bundle.
Why the whole token and not only the groups policies name. The groups the policies name are not known to the token reader, so the reader resolves all of them. The comparison then happens in memory, and the list is discarded.
Known limit. A group change takes effect at the user's next logon, as it does for Windows access control, so a removed member can match a group-scoped policy until then.
Department is not collected. It needs a directory source, which is not decided. Department scopes stay warned about (DLP-667).
Interactive account SID (DLP-1437). The Windows implementation also reads TokenUser from the same interactive token as the groups. It is used only in local EvaluationContext.user_sid, never as an event username or in queued events, history, telemetry, logs, audit metadata or support bundles. The console session/logon is checked before and after the read; ambiguous or changed sessions supply no identity, and a channel naming another actor gets neither console SID nor groups. The existing ten-second cache bounds session freshness, not directory membership freshness. Legacy event usernames retain their existing privacy setting. The planted positive/negative real-scan test an_interactive_user_sid_is_used_locally_and_reaches_no_artefact checks every written artefact at TRACE and the support bundle. This does not execute WTS on Windows: native acceptance remains outstanding and no user-SID capability or typed directory-user targeting is enabled by this implementation.
32. Legal holds (DLP-703, ADR-0031)
What is stored (legal_holds, migration 0069). For each hold: its scope (an incident, a person or a discovery finding), the target (an incident or finding id, or a username stored trimmed and in lower case), a matter reference of up to 120 characters, the reason, and who placed and released it, and when. A person hold's username and matter reference together can say that a named person is party to litigation or an investigation. That is personal data about the held person, and the placer's identity is personal data about the placer. Placing and releasing are also written to the audit log (hold.placed, hold.released), with the same scope, target, matter and reason.
Who reads it. GET /legal-holds needs legal_hold:write or audit:read. Anybody who can read a held incident is told under_legal_hold: true, and not the matter or the reason; tests/legal_holds.sh asserts that the incident response carries neither. In the console (DLP-704) the incident page shows "Under legal hold" to every reader, and the matter, reason and placer only to register readers, whose browser is the only one that asks for them (pages/legal-holds.test.tsx asserts that an analyst's page sends no request to the register). The register itself is the Legal holds panel in Settings.
What it changes. Held material is kept past its retention window, so a hold lengthens how long personal data about the held person is kept, for as long as the hold stands. That is its purpose. It changes nothing about what is collected.
What it does not do. A hold never hides data from a subject access export (domain/subject_access.rs, GDPR Art. 15), and the export does not disclose the hold itself. Whether a hold outweighs an erasure request is the tenant's decision; the product records the matter and reason and makes no such judgement.
33. A digest of what the label simulator was given (DLP-864)
What is kept. Each shadow-rollout sample the simulator records (label_rollout_simulations) carries input_digest: HMAC-SHA256 over the simulated text with its whitespace collapsed, the replayed detector results, the existing MyDLP label and whether an external label was present. The text itself is still never stored, and the digest is not written to the audit entry for the sample (tests/classification_crud.sh §9b asserts both).
Why. The promotion gate for automatic labelling counts distinct inputs. Counting rows let one person holding policies:read meet it by running two texts twenty times. Without a digest, repeats cannot be told apart from distinct inputs, because nothing else about a sample identifies what was simulated.
What it is not. The key is per tenant, held in the same database (label_rollout_digest_keys), so the digest is not anonymisation. Anyone who can read both tables can test a guessed input against a sample's digest, including a short guess such as a card number somebody pasted into the simulator. What it prevents is reading the input back, and comparing inputs across tenants.
How long. The promotion gate reads the last thirty days. Housekeeping deletes each sample, with its digest, once it is older than that. Housekeeping runs every five minutes, so a digest outlives the window by at most one interval (DLP-1090). Both read one constant, routes::labels::EVIDENCE_WINDOW_DAYS, so the prune cannot delete evidence the gate still counts. Each sample's audit entry stays: it carries no content, and it is the record that the sample was taken. tests/classification_crud.sh §9b ages one sample past the window and one to just inside it. After a pass, only the first is gone, and the second still counts toward the gate.
Who reads it. Nobody through the API. The evidence route returns counts of distinct digests, never the digests themselves.
34. Test cases kept with a classification or detector (DLP-1050)
What is kept. A test case is a short text an administrator writes, with the outcome they expect it to produce (match, no match or not evaluated). It is attached to one classification or one detector, and stored with its author and time so that a change can be checked against it before it deploys. This is the one place in the product where text somebody typed as a sample is stored, and it is stored because a test that forgets its input cannot be run again.
The rule for what goes in one. Test cases are for synthetic text: made-up names, published test card numbers, invented project names. The console says so where one is written. The product cannot tell a real record from an invented one in general, so it enforces the cases it can tell:
- A sample that one of this tenant's EDM datasets or fingerprint sets recognises is refused. Recognition means the text holds a real record from the dataset, or a passage of a protected document, and storing it would keep in clear exactly what those features promise never to keep (§5, §5A). An EDM or fingerprint detector is tested on the detector bench, which stores nothing, and a negative case for it (a record that is not in the dataset) can be kept.
- Each case is capped at 4 KiB, and an object at 50 cases. A test case is a sentence or a paragraph. A document is not a test case.
Where it never goes. Test case text is never in a policy bundle, so no endpoint receives it. It is not in a support bundle, the server log or an audit entry: the audit records the case's name, its expected outcome and the counts of a run, never its text. A run's result carries outcomes and reason codes, not matched values.
Who reads it. The text is returned to accounts that may change the object it belongs to (detectors:write) and to auditors (audit:read), who review what a change was tested against. Other readers see the case's name and expected outcome, and the result of the last run, not the text.
How long. A test case is kept as long as the object it belongs to, and deleted with its author's own action or when the detector it belongs to is deleted. Retiring a classification keeps its cases with it, as it keeps its revisions.
35. When a console session was last used (DLP-997)
What is kept. sessions.last_activity_at: when a person last used a console session. Use means a request that changes something, or the activity heartbeat the console sends while someone types, clicks, scrolls or moves the pointer. It is written at most once a minute, and records no page, action, key or pointer position. The heartbeat carries nothing but the session.
Why. The idle timeout ends a session nobody has used for the organisation's setting, so a console left open at an unattended desk does not stay signed in for the whole session lifetime.
How long. With the session: housekeeping deletes session rows seven days after they expire, and an idle or revoked session expires like any other.
Who reads it. The server decides idleness. GET /users/sessions also returns the last activity time, the account, source IP and browser metadata to an unscoped users:write holder for the tenant's active-session inventory. The existing per-person route lists issue and expiry times instead. Neither route returns session tokens or hashes. Last activity is not in the audit log.
36. Which file server, or which Remote Desktop client, a file went to (DLP-227, DLP-1145)
What is kept. On a file event written to a network share, the event's destination names the share the way its users write it: \\server\share, server:/export for NFS, or the drive letter when a mapping's target could not be read. On Windows, a Remote Desktop client drive is named \\tsclient\C. That records that the file went to the disk of the machine somebody was connecting from. It is kept as its own kind (rdp_client_drive) rather than as a file server. Nothing about the share is read beyond its name: not its other files, its permissions or who else uses it.
Why. A copy to a file server, or to a home PC over Remote Desktop, is an ordinary way data leaves a managed endpoint. Without the name, an incident says "a network share", and nobody can act on it.
What is not seen. A write by another client to the same share, and on Windows a person's own mapped drive letter: a mapping belongs to their logon session, which the service cannot see. The channel covers monitored roots written as UNC paths, and letters mapped for the service itself. KNOWN_LIMITATIONS.md says so.
How long, and who reads it. With the event and the incident, under the same retention and access as any destination (§6, §7).
This row was missing when DLP-227 shipped the Linux half. It was added with the Windows half, which also names Remote Desktop client drives.
37. Where an endpoint is on the network (DLP-1147)
What is kept. One word in an endpoint's health at each check-in: corporate, vpn, off_network or unknown. It is decided on the endpoint from each connected network's category as Windows records it (public, private, domain-authenticated), and from each adapter's state, whether it is a tunnel, and its connection-specific DNS suffix. These are compared with the indicators the tenant set. Not read: network names or SSIDs, addresses, gateways, DNS servers, or any traffic. Nothing about which networks were seen leaves the endpoint, only the one word.
Why. A rule may reasonably differ in the office and on an airport network, and online (whether the server answered) is not that signal.
What it is not. It is not a location in the physical sense, and it is not evidence that traffic went over the corporate VPN. It says unknown rather than guessing, and unknown is never treated as corporate.
How long, and who reads it. With the device's latest health, replaced at each check-in. Anyone with agents:read can read it in a device's detail (GET /agents/{id}, which returns the whole health), not in the fleet list. No history of it is kept.
38. The language a person reads warnings in (DLP-1135)
Since DLP-1897 the agent's UI is English, by the owner's decision of 2026-10-07: every warning, question and agent page is English, and neither the person's language below nor the tenant's fallback changes what is shown. The session agent still reports the language and the service still holds it in memory exactly as described here, for the day that decision is lifted (one switch, localisation::AGENT_UI_LANGUAGE).
What is kept. When the session agent connects to the service on the same machine, it says the signed-in person's language, as a tag such as ru-RU. The service holds it in memory while that session is connected, and uses it only to choose the language of a warning or a question shown to that person.
What is not. It is not sent to the server, not written to disk, not put on an event, and not logged. It is not a claim about who the person is or where they are from: it says which of four string tables to use.
The tenant's part. The fallback, for a person whose language the product does not speak, is a tenant setting (POST /settings/notice-language), audited with its reason, and it travels in the signed bundle.
A file warning on a machine with one person signed in (DLP-1125). A file-system event names no session. When a warning is due, the service asks Windows which sessions have somebody signed in: their numbers, whether each is active, and only whether a user name is present, never which one. With exactly one, and that one active, the warning goes to that session and no other. With two or more, or one disconnected, it goes nowhere and the event records not_delivered. The list is read at that moment and kept nowhere.
The local route (DLP-1127). On Windows, the service also reads the numeric session identifier from the named-pipe client's kernel handle. It is held only in memory: the connection map until disconnect, an in-flight report until processing or discard, a pending prompt/receipt until its bounded wait ends, and warning-suppression keys until the next prune or process exit. It selects the one session that may receive a clipboard notice or prompt and is never converted to a person, written to disk, logged, placed on an event, or sent to the server. An event with no OS-attributed session is delivered to nobody, apart from a file warning on a machine with exactly one person signed in (above, DLP-1125); the service does not infer a recipient from the active console.
39. A copy of a matched file (DLP-1157, ADR-0038)
Built, and off twice by default. This row was written before any of it, as CLAUDE.md §8 requires, from ADR-0038:
- the server half that holds a copy (
DLP-1188); - the Owner's switch, the
evidence:filepermission and the justified download (DLP-1189); - the endpoint half (
DLP-1190).
No copy is kept unless both are true:
- the organisation's Owner has switched it on, with where copies go;
- a published policy asks for the file.
The policy API refuses the second without the first (DLP-1158). tests/evidence_files.sh asserts it end to end with a real agent and a real object store.
What would be kept. The whole file, as it was when it matched, for a policy that sets evidence.full_file on a tenant whose Owner has turned full-file evidence on. That is the file's content, including whatever else it holds: other people's data that matched nothing, and personal material mixed into a work document. That is why it is off twice by default, and why nothing else in this register keeps content.
Where, and how.
- In the tenant's own S3-compatible bucket, never in the MyDLP database.
- Sealed with AES-256-GCM under a key per tenant, so reading the bucket does not read the file.
- Capped at 10 MB by default and at most 50 MB, refused above the cap.
- Under a credential reference the operator assigned to that tenant, never a key stored by MyDLP.
- On the endpoint, the copy is taken when the file matched and only if its bytes still hash to what the scan reported. It waits in the agent's own state directory, named for the event and never for the file, until the server confirms it, in a bounded space (256 MiB). When that space is full the oldest copy is dropped. A copy the server has not taken in a week is given up.
- Every copy the policy asked for and did not get is said, with its reason: in the endpoint's health, and on the incident's list of copies as not kept. The reasons are: over the cap, changed, unreadable, spool full, spool unwritable, given up, and refused by the server.
Who reads it (DLP-1189).
- Only a holder of
evidence:file, which among the built-in roles is the Owner's alone. An Admin does not hold it. - Only through a justified download: a reason of five characters or more, in the request body, and a recent sign-in.
- Each download writes an
evidence.file_downloadedaudit entry naming the reader, the incident, the copy and the reason, and a row in the evidence access log (kindfull_file). It is counted asfile_downloaded, and a refusal writes neither. - The console saves the file and never renders it.
- An incident outside the reader's group scope (ADR-0035) is not found.
How long. The policy's evidence.retention_days, after which the expiry pass deletes the object. A legal hold (ADR-0031) preserves it. A deletion the store refuses is recorded as delete_failed with the store's answer, retried by each later pass, and counted in health (retention.evidence_file_delete_failures) rather than recorded as done. The incident's list of copies shows a deleted one as deleted, with the date, and it cannot be downloaded.
What the person is told. Whenever an endpoint notice is shown for a policy that keeps a copy, it says a copy is kept as evidence, that only people the organisation names can open it, and that each time is recorded. It says so in English: every endpoint surface is English while the owner's decision DLP-1897 holds, whatever the person's language. An audit-only policy shows no notice, so it says nothing, which is the same as for every other thing it records. This row is written for the tenant's own privacy notice and DPIA. Turning the switch on means keeping document copies about named people, and the console says so beside the switch.
Decided against. The database as the store, storage MyDLP hosts, the agent uploading straight to the bucket, fetching a file from an endpoint on request after the fact, and Analyst access by default. The reasons are in the ADR.
40. That an image was copied to the clipboard (DLP-1168, SPIKE-016 §4)
What is kept. On Windows, when an image is copied to the clipboard, one clipboard event with destination.kind = "clipboard_image". It records:
- when it happened;
- the signed-in person (by the same rule as any clipboard event);
- the application the copy came out of, as the agent verified it;
- the policy that asked for it.
What is not read. The image. Not its pixels, its size, its dimensions or its format: the reader asks Windows only whether a bitmap format is offered, and never fetches it. The event does not call it a screenshot, because a copied photograph and a copied chart look the same from here.
Why. A screenshot cannot be prevented (SPIKE-016). The honest answer that remains is a record that an image was captured and from where, instead of no record at all. This records a person's action that matched nothing, which is why it is narrow.
When. Only while clipboard reporting runs, which is only when a policy names the clipboard channel. An event is kept only when the policy that decides it names the clipboard channel itself: a policy scoped to every channel does not turn every screenshot into an event. At most one a minute, so a burst of captures is recorded once. It is always an audit event: nobody is shown a notice and nothing is prevented, whatever the policy asked. The decision records what was asked beside what was done.
How long, and who reads it. As any event: the tenant's event retention, read by whoever can read events. It carries a policy decision, so it becomes an incident like any other. It is folded with that person's other clipboard events under the same policy on the same device in the correlation window, unless the policy raises every match (domain/incidents/correlation.rs).
Asserted by agent/core/tests/nothing_sensitive_leaks.rs (a_copied_image_is_recorded_as_a_fact_only_where_a_policy_names_the_clipboard): one event with no file and no detector results, audit where warn was asked; none for a second copy inside the minute; none under a policy that does not name the clipboard, beside a card number that the same policy does record. Not verified on a Windows desktop: the bitmap-format check (clipboard/windows_impl.rs) runs only there.
41. A bypass code and what it relaxed (DLP-1154)
What is kept. On the server, one row per code issued: the endpoint, the scope, when it was issued and until when, who issued it and the reason they typed. The code itself is not stored. On the endpoint, the signed code while it is in force. On an event that a code relaxed, one line naming the code and what the policy asked for. When a person hands a code to the agent service because their account cannot keep it (DLP-1156), the agent's own log records the account the operating system named and the code id, or the reason it was refused. It never records the code, which is a credential while it lasts.
What is not. Nothing about the person the code was for, beyond the endpoint it names. The reason is what the issuer wrote. It is kept with the record and the audit entry, as any other administrative reason is.
How long, and who reads it. The record, as long as the endpoint's record. The audit entry, under the audit retention. The events, under event retention. Issuing needs exceptions:decide; reading the list needs agents:read.
42. An incident posted to Slack (DLP-1179, ADR-0040)
What leaves the product. For each incident, one message to the Slack channel an administrator configured. It names the incident's reference, title, severity, channel, policy, endpoint, user, risk score and band, and detector ids, and links back to the console when a console URL is set. These are the fields a webhook delivery already carries. No matched value and no preview are included, because the message is built from the same IncidentPayload, which carries neither. The recipient is Slack, a third party with its own retention, and everybody who can read that channel. That audience is usually wider than the security team, so the administrator chooses the channel on that basis.
What is stored. The destination's name, the URL's host, the optional console URL and CA certificate, and the URL itself as a secret reference. Delivery rows are the same as for every kind: the incident, attempts, the last error and when.
What is not. The URL does not appear in the listing, the audit entry, a delivery error or the console. Anybody holding it can post into the channel, so each of those records its host instead. No signing secret is kept, since Slack verifies none.
Who decides. Configuring a destination needs integrations:write and the incident_delivery step-up, as for every delivery kind.
43. Accounts the identity provider pushes (DLP-1187, ADR-0041)
What arrives. Over SCIM, the organisation's identity provider sends each person it provisions: a user name, usually the work address, a display name or given and family names, e-mail addresses, an external id and whether the account is active. It also sends groups, with their names, external ids and members.
What is kept. On the account: the address, one display name, the external id, and the provider's active flag. The groups: their name and external id, and who is in each, because group membership decides the role. A role and a status are derived from them.
What is not kept. Everything else a provider sends is accepted and discarded: titles, phone numbers, managers, departments, the enterprise extension. The given and family names are combined into the display name and not kept apart.
How long. An account the provider deletes is disabled and kept, like any disabled account, because the audit log and incident history refer to it. Its group memberships are removed at once. A group the provider deletes is removed.
Who decides. Whoever holds sso:write creates the token, after a step-up. The provider's administrators decide who is pushed.
44. Which sync service a file went to, and whether its account is sanctioned (DLP-189, DLP-1210)
What is observed. When a file is written inside a folder a sync client keeps, the endpoint reads where the client says its folders are:
- on Windows, OneDrive's registry accounts and Dropbox's
info.json; - on any platform, the folders an administrator declared.
From a OneDrive business account it also reads the Microsoft tenant id OneDrive recorded (ConfiguredTenantId). It reads no account name, e-mail address or display name.
What leaves the endpoint. On the event: the provider (onedrive, dropbox, a declared name), the account kind where the provider publishes one (personal or business), and whether the destination is trusted (true, false or unset). The tenant id does not leave the endpoint. It is compared on the endpoint with sanctioned_cloud_tenants in agent.toml, and only the answer is sent.
In the health report, since DLP-1523: each synced library the agent watches, as its provider, how it was found, and its folder with the profile folder written as %USERPROFILE%. The console's endpoint page lists it, so an administrator can see that a person's OneDrive is read. The account name in the profile path does not leave the endpoint this way; a library outside a profile is sent as its path.
Why. An incident needs to say that a file went into a sync folder and whether that was the organisation's own library. Without the tenant, a business account could only be reported as undecided, and "probably the employer's" is not a basis for trusted: it may be a contractor's company or a second tenant nobody sanctioned.
How long. Nothing is stored on the endpoint beyond the process's own memory; the sync folders are read again at each start. On the server, the provider, kind and answer are part of the event and follow event retention (§7). The watched libraries in health are replaced by each check-in, as the rest of the health report is.
Who decides. The organisation, by writing the list into the agent's configuration. With no list, no business account is decided either way.
45. Files read from a SharePoint site (DLP-1194, DLP-1195, ADR-0017)
What is read. Every file in the document library of a site the customer granted to the app under Sites.Selected, and the sharing permissions of each file that matched a detector. The content goes through the same engine as a file on a share, in memory, under the same caps.
What is kept. For a finding only: the file's web URL (its name and where it is), size, last-modified time, content hash, data categories, match counts, and how it is shared, in words. Sharing names no person: it records "a guest from outside the organisation", not which guest. Nothing is kept about a file that matched nothing.
What is not kept. File content, matched values and previews, as for every discovery finding. The permissions list itself is not kept either, since it names people, and nor is the file's modifier. The file's creator is kept only on an incident, as below, never on a finding.
Where it goes. Nowhere. Nothing is written back to the site, and no write scope is requested.
The credential. The app registration's secret is a server variable, never stored in the database or shown in the console. It is sent only to the Microsoft sign-in service the operator configured.
An incident from a cloud file (DLP-1198). When a published policy selects a finding, the incident records the file's URL, name, size and type, the policy, and the detector counts, as for any incident. No device is recorded. Since DLP-1988 the person is the file's creator, by the address Graph gives (createdBy.user.email, else its userPrincipalName, never a display name), so the incident reaches assignment rules, person filters and the person's risk as an endpoint incident does. It is not recorded where the tenant has said not to collect usernames, and where people are named by pseudonym it is replaced like any other name. A scoped role sees cloud incidents only when its scope names cloud storage (ADR-0035 addendum). An endpoint incident that a cloud finding joins gains a list of the places the same content was found.
Incremental runs (DLP-1201). To read only what changed, a finding also keeps the Graph ids of its drive and item, and each drive keeps Graph's delta link. Neither names a person or says anything about content. A finding carried forward is a copy of one already kept, not a new reading.
Change notifications (DLP-1196). A notification from Graph names a subscription and says something changed. It carries no file content and no name, and nothing in it is kept beyond when the last one arrived. What follows is an ordinary scan of the site.
Every person's OneDrive (DLP-1197). With the tenant's OneDrive switch on, the same reading covers each person's own drive: their files are scanned, and a finding's web URL names whose drive it is (/personal/<name>/). That is more personal than a team site, and the product treats it that way:
- Off by default. Switching it on is an Owner's decision, with a reason and a fresh proof of identity, and it is audited. Switching it off stops the next run of every OneDrive scan.
- Everybody or nobody. There is no target for one person's drive, so discovery cannot be pointed at a chosen employee.
- Only who has a drive. The list of people is read only to find their drives, and it is not kept.
46. Where and how much an API token is used (DLP-1236)
What is kept. For each API token: the address its last request came from (api_tokens.last_used_ip), and a count of its requests per day (api_token_usage). The address is the one the API records for every request, which believes X-Forwarded-For only from a trusted proxy (DLP-1237). A token restricted to source addresses also has each refused use audited with the address it came from (api_token.refused_source).
What is not kept. Anything about what a request asked for or returned. The counts are numbers per day. The audit log already records every write a token makes, as it does for a person.
Whose data it is. A token acts as the account that made it, and is usually run by a machine: a SIEM, a script. Where it is run from a person's workstation, the address is that workstation's.
How long, and who reads it. Counts are kept 90 days and deleted by housekeeping. The last address is replaced by the next request and goes with the token when it is deleted. Both are read by whoever manages tokens (tokens:write), in the console's token detail and through GET /api-tokens/{id}/usage.
47. Active time, per hour, by application and host (DLP-1241, ADR-0043)
Status: built end to end, off by default, and not yet run on a Windows host. Nothing is collected unless an Owner switches it on (DLP-1243).
- Server (
DLP-1243):- the Owner's switch, which is off, audited and asks for fresh proof;
- the signed-bundle setting, which tells endpoints whether to sample;
- the ingest route with every refusal below, and the tables;
- the Behaviour figures in seconds.
tests/active_time.shasserts all of it with a signed test device.
- Endpoint (
DLP-1245,DLP-1250):- Only while the signed bundle carries the setting, the service asks each signed-in session's agent, at its welcome, to sample every 5 seconds. The foreground process's base name and seconds since the last input cross the session pipe.
- The service stamps each sample with the account Windows names for the pipe, never the session's claim.
- It keeps an application only if it is a lower-cased base name: a path, a title-like string or a control character is dropped and counts as idle.
- It folds samples into hour totals, keeps them in
active-time.jsonunder the state directory's ACL, and sends them every 5 minutes. - Switching the setting off, or the server saying the tenant does not measure, drops what is kept and deletes the file.
- Visible to the person: the agent's status page (
dlp-agent ui) says whether active time is measured, and what it never reads. So does the session agent's own window when run by hand. - The browser host (
DLP-1246, joined on Windows byDLP-1256):- The extension reports the focused tab's host only while the agent's
Readysays active time is measured (a literaltrue; an older agent asks for nothing). It sendsURL.hostnameand nothing else. An incognito tab, a browser page, a local file or a browser behind another application reports no host. It stops the moment the agent disconnects, and reports again only when a new agent asks. - The agent checks the host again and refuses, never trims, anything that is not a DNS name: a path, a query, a port or credentials.
- The handoff to the session agent is one file in the person's own profile,
%LOCALAPPDATA%\MyDLP\active-host.json. It holds the current host, the browser and the time, and each report overwrites it. There is no list. It is read tolerantly (missing or broken is no host), never through a link, and used only when that same browser is in front and the record is at most 15 seconds old. - The Windows native-messaging host writes that record only after the service says active-time measurement is enabled. The session agent reads it for each sample (
DLP-1250). This joined path is implemented and cross-compiled, but has not yet been observed in a managed browser on a physical Windows host.
- The extension reports the focused tab's host only while the agent's
- Not yet shown on a real machine: that samples flow on a Windows host (
DLP-1250's acceptance, on the first physical PC).
Why it exists. The owner decided on 2026-09-28 that the Behaviour page measures active time per person and category, as the reference console does. The owner accepted the legal responsibility. §10 records the decision this reverses. A deploying organisation still owes its own assessment and notice: typically a DPIA in the EU, and in several countries a works council's agreement.
What will be observed. Every 5 seconds, in the signed-in person's own session: the foreground window's process base name, whether input arrived within the idle threshold (5 minutes by default, 1 to 30), and, for a browser, the focused tab's host, when the extension reports one. Locked, disconnected and screen-saver time is idle.
What will never be observed or kept. Window titles. URL paths, queries and fragments. Keystrokes, and any input timing beyond "was there input". Screen contents. Clipboard contents. Document contents. A private browser window gives its browser's name and no host.
What will leave the endpoint. Totals per UTC hour, per (application, host), in active seconds, plus one idle total per hour. Never a sequence of windows. Minute buckets were decided against because they amount to a timeline.
Controls.
- Off by default. An Owner turns it on, with a recent sign-in, and the change is audited. While it is off, nothing is sampled.
- Working hours only by default (
BusinessHours,DLP-348). An Owner can choosealways. Out-of-scope hours are dropped on receipt and counted. - The fleet figures name nobody. A named person's time stays behind the per-user switch, a justification, an audit entry and the exclusion list (
DLP-350), exactly as their operations do. - Kept as long as events, and removed by the same retention pass.
What the person will see. While measurement is on, the agent's status page (dlp-agent ui) says so, and says what is not read. It will also appear on the tray presence once that exists (DLP-1169).
48. Detectors that matched where no policy acted (DLP-1272)
What is observed. When a watched file, or a browser upload, matches a detector and no policy acts on it, the agent counts it against that detector: how many files, and when it last happened. At most 64 detectors are held; the one seen longest ago is dropped first, and one not seen for 90 days is dropped.
What leaves the endpoint. The detector's id, the retained count and time, plus 1-to-90-day aggregate counts and their snapshot time, in the health report the agent already sends. Not the file's name, path, owner or hash, not a preview, and not the matched value. Asserted by agent/core/tests/nothing_sensitive_leaks.rs (a_detector_that_matched_where_no_policy_acted_is_counted_by_name_and_never_by_file_or_value), which plants a secret and searches the report, and the state file below, for the file's name and every planted value.
Names were left out on purpose. An incident names its file because an incident naming no file is one nobody can act on. Here no policy asked, so there is nothing to act on, and a file name would be a record about a person's files that no rule called for.
Who sees it. GET /detectors/unpoliced needs both detectors:read and agents:read, because it names endpoints. It sums active endpoints only, filtered by the latest match over 1 to 90 days (7 by default). The optional window count is exact as of each endpoint's check-in only when every included endpoint reports it; an old or overflowed ledger contributes null, not an invented zero. The Policies page shows it as "detectors matched where no policy acts". Classification health links a no-policy row to the ordinary policy draft form with that exact classification selected. The link grants no authority: the form opens only for policies:write, and the result remains an unpublished draft subject to the normal release rules.
Kept. On the endpoint, in unpoliced.json in the agent's state directory, so a restart or self-update does not empty it (DLP-1314): per detector, the time it last matched and a 16-character hash of the path of each file it matched in (at most 10,000), which is how the same file read again is counted once. New ledgers also retain each hash's last match time locally; pre-existing ledgers cannot be dated retroactively, and a window remains unknown until its whole duration has been observed. A detector that exceeds the 10,000-hash bound likewise reports an unknown affected window. The hashes and individual times never leave the endpoint; only aggregate numbers do. Since DLP-1388 each detector's entry also carries the digest of the definition that counted it (a hash of the detector's rules, nothing about a file or a person), and an entry counted by a definition the endpoint no longer runs is dropped when a bundle is adopted. It identifies a file only to somebody who already has its path, and the agent's scan cache beside it already holds the paths themselves. Until then the counts restarted with the agent, and on the first physical host twenty self-updates meant the console never saw the detection that prompted this. On the server, in the endpoint's health row, which each check-in replaces. Nothing is stored beyond the latest report.
49. Which removable device, by name (DLP-1267)
What is observed. When a file goes to removable storage, the endpoint already sent the device's serial, which is what an approval list is keyed on. It now also sends the device's vendor and model as its firmware reports them, for example "JetFlash" and "Transcend 16GB". They are stored on the event, and the incident's Device row shows them.
What it is not. They describe a product, not a person, and not the device's contents. They are shown only for the destination of a file that something was already recorded about. Where the firmware reports nothing, nothing is sent, and the console falls back to what an administrator typed on the approved list.
Kept. With the event, and removed by the same retention pass.
50. Notices sent by mail (DLP-1240)
What leaves the server. When a mail relay is configured (DLP_SMTP_URL), the server sends an API token's creator a message 14 and 3 days before the token expires. The relay receives the creator's address, the token's name, its public id and its expiry date. Never the token or its hash, and never any incident, event or matched value.
Who is written to. Only the user who created the token, while their account is active. A disabled account is not written to.
Kept. A row per token and threshold, recording that the notice was sent (so it is sent once), and notification.sent or notification.failed in the audit log, with the address. What the relay and the mailbox keep is outside this product.
Off by default. With no relay configured nothing is sent, and the server says so once at start.
51. Files the endpoint could not inspect (DLP-1270, DLP-1279, DLP-1281, DLP-1525, DLP-1529)
What is observed. A file the agent could not fully read, for example one over the size limit, an encrypted archive, an image with no OCR, a format it does not read, or a folder behind a junction it does not follow. The agent records three things:
- a count since it started;
- the latest such file, with its name and the reason in words;
- a count per UTC day and per cause for the last seven days, from a closed set such as
size_limit,encryptedorlink_not_followed.
For a link, the reason names the link's path and its target's path, because "a link was not followed" without saying where it points sends nobody anywhere.
What leaves the endpoint. Those three things, in the health report it already sends. Never content, and never a matched value: the reason is written by the agent, not taken from the file. nothing_sensitive_leaks.rs plants a card behind a padded file and behind a link, and asserts it appears in neither reason. The week holds causes and numbers only, no file names, so a week of files is not a list of a person's documents.
Who sees it. Readers of the endpoint list see the row's chip and week. Readers of the Overview see the fleet's files, endpoints and causes, which name no file and no endpoint.
Kept. The latest file, and the week, in the endpoint's health row, which each check-in replaces. On the endpoint, the week lives in not-inspected.json in the state directory, which is protected as the rest of it is (SYSTEM and Administrators). It survives restarts and updates, and each day drops out after seven. An empty week leaves no file. Since DLP-1374 it holds, per day and cause, a 16-character hash of each file's path (at most 10,000), the same hash unpoliced.json keeps, so a file a walk reports again is counted once. The hashes never leave the endpoint; the report carries the number.
Which formats (DLP-1776). For the files counted as unsupported_format, the report also carries how many had each extension over the same seven days, the fifteen most common, as the lower-case extension and a number (dwg, 2,100). An extension is part of a file name, which is recorded on purpose, and it says nothing of what is in the file; no name or path goes with it. On the endpoint, not-inspected.json keeps per day and extension the same 16-character path hashes as above, so a file met twice is counted once. The Overview sums the fleet's extensions and names no file and no endpoint.
Gone before it was read (DLP-1529). A file that was created or changed and deleted before it could be read is recorded the same way, with its name and how long it lived. It is recorded under its own cause, and nothing of its content is known.
Beside a finding (DLP-1525). A file in which something was found and part of which was not read, such as a ZIP with an encrypted entry beside a plain one or a large file read only at its two ends, is counted the same way. Its event also carries the cause and the agent's reason as file.not_read, so the incident can say what was not read. The reason can name an entry inside an archive, which is a file name. Ingest therefore keeps the reason only where the tenant does not redact file paths. Where it does, ingest keeps the cause alone. Both are kept on the event row for as long as the event is (file_not_read_cause, file_not_read, at most 400 characters), and readers of the incident see them.
52. Directory identities searched for policy targeting (DLP-1435, DLP-1436)
What is observed. Only after an Owner registers and enables an operator-assigned AD source, a caller holding both policies:write and directory:read can search a limited directory base for users or security groups. Each page is bounded to 100 objects: source/domain SID, object SID, object GUID, display label, enabled state and observation time. The same attributes can resolve a previously selected SID after its name changes. No email, phone, address, member, memberOf, tokenGroups, password or group-membership list is requested. These are administrative identity lookups, not browsing history, endpoint user collection, or a central membership sync.
Who sees it. Owner/Admin by default, and a consciously granted custom policy-author role with directory read access. A policy author without that second permission, Auditor, Viewer, another tenant and a role limited to incidents in some endpoint groups are refused by the backend. Menu hiding is not this boundary. Token callers need both scopes as well. AD user metadata is explicitly unselectable while the endpoint cannot evaluate stable user SIDs; the lookup does not misrepresent its data as enforcement support.
Kept. The database holds only the source's ID/name, credential-reference name, pinned domain identity, enabled state, timestamps and fixed health failure code. It stores no lookup result or membership catalogue. A selected identity may subsequently be stored as a policy's typed scope object under that workflow's authorization/versioning; this connector itself does not write policies. LDAP continuation cookies are encrypted, expire in five minutes and are actor/tenant/source/revision/search-bound; the key is process-local and discarded on restart. Bind credentials remain solely in the protected operator environment, referenced through a tenant-qualified name. Base64 there is encoding, not encryption.
Logs and audit. Search is POST, keeping personal queries out of access-log URLs. Source configuration/switch/health transitions are audited without search terms, returned identities, bind DN or secret reference/value. Owner business reasons are retained; they must never contain credentials. LDAP library events that could contain a cancelled raw response are filtered even under trace logging, while sanitized connector errors remain visible. Bad UTF-8 entries are rejected without invoking the library's panic-producing entry constructor. Test evidence is a recording LDAPS peer plus real API/PostgreSQL, not a statement that a customer's AD ACLs were inspected.
Entra metadata (DLP-1436). The same source registry and backend permissions also accept an explicitly configured Microsoft tenant/app. The operator assigns a tenant-qualified secret reference; source rows pin the Microsoft tenant UUID and store no app credential. A query requires a token with that tenant and the relevant customer-consented read-only role: User.Read.All or GroupMember.Read.All. These are broader than per-object access; the customer must approve them separately from SharePoint access. The adapter requests only user ID/display name/account-enabled state, or security-group ID/display name/security-enabled/group-type metadata. It requests neither members nor membership rules, UPN/email, files, or profile attributes. Dynamic groups are labelled; all Entra objects stay unselectable until endpoint matching is verified. Unknown account state remains null.
Each response is limited to 100 objects and 256 KiB. Token acquisition and Graph lookup share a ten-second deadline. Dropping the future cancels it. Only operator-controlled Graph/login endpoints receive credentials/tokens; redirects are refused, and continuation URLs remain inside the exact Graph origin/collection with the same minimal fields and bounded page size. Raw nextLink is held only in the encrypted five-minute cursor, never in source rows, audit or logs. Observation time is not a freshness guarantee for Microsoft's eventually consistent directory or endpoint membership. Configuration/switch/health events keep fixed failure classes without search terms, returned identities or secrets. A recording Graph peer and real API/PostgreSQL exercise credential/consent/throttle failures and trace-log privacy; that evidence cannot certify customer consent or Windows support.
53. Directory computers without an agent (DLP-1502, DLP-1503)
What is observed. Only when an Owner has registered and enabled a directory source, and only when somebody holding agents:read asks for the unmanaged computers. From Active Directory: the enabled computer objects under the source's search base, with their common name, DNS host name and operating system, and two fields that decide whether they count (userAccountControl, which says disabled, and lastLogonTimestamp, which says stale). No owner, managedBy, description, location, group or membership is requested. From Entra ID, with the customer's separate consent to the application permission Device.Read.All: each device's ID, display name, operating system, accountEnabled and approximateLastSignInDateTime. No registered owner or user, compliance or management state is requested, and a device answer carrying any field not asked for is refused.
Who sees it. Anyone with agents:read, as for the endpoint list: the name and operating system of each enabled computer that logged on within 90 days and has no active agent, and counts of the stale ones. A role scoped to some endpoint groups is refused by the backend: these computers belong to no group.
Kept. Nothing in the database. The directory's answer is held in the server's memory for five minutes per tenant, and is gone on restart. The last-logon and last-sign-in times are never returned or stored; a computer is only said to be stale. The source's health columns record whether the last contact succeeded and its failure class, as for the identity lookup (§52).
54. The NTFS change journal (DLP-167, DLP-1539)
What is observed. On Windows, the volume's change journal. It records every file created, changed, renamed or deleted on the volume, not only in the monitored paths. The agent reads it twice: at service start, for what changed in the monitored paths while it was stopped (DLP-167), and while it runs, for why a path the watcher reported removed is gone (DLP-1539). For each record it opens the record's parent folder by its file reference number and asks whether that folder is under a monitored path. Only then is the record's file name decoded. For a renamed file's new location outside the monitored paths it keeps one of two facts, a Recycle Bin or elsewhere. It keeps neither the folder's name nor the file's.
What leaves the endpoint. For a removal, one of deleted_permanently, recycled or moved_out on the event about a file in a monitored path, in place of the watcher's deleted. Never the name or folder of where a file went outside the monitored paths.
Who sees it. Readers of the incident, as the location's state: deleted, in the Recycle Bin, moved out of watched folders, or no longer here.
Kept. On the endpoint, usn-checkpoints.json holds a journal id and a number per volume and no names. The removal lookup keeps, in memory only, where its last read stopped per volume. On the server, the reason is a column of the event row (file_path_state), kept as long as the event is.
55. A sensitive value in a file or folder name (DLP-1559)
What is observed. File and folder names, as everywhere in this register: an incident naming no file is one nobody can act on. Names are sometimes the data: refund-<card>.txt, a scan named after an identity number, a folder per customer account.
What leaves the endpoint. Since DLP-1559, the agent runs its detectors over each event's path, file name, other paths where the content was seen, destination and not-read sentence, and over the latest-not-inspected file name in its health report. It masks every validated value before the event is queued: a Luhn-valid card, an IBAN, a checksummed national identifier. Each letter and digit becomes * except the last four, so refund-************5559.txt still tells an analyst which file is meant. The policy decides on the real path first. A value only a pattern matches is not masked, so a folder called Confidential or a ten-digit order number is kept as written. A public test card number on the detectors' allowlist is not masked either.
Who sees it. Readers of incidents, events, exports and forwarded events see the masked name.
Kept. Since DLP-1563 the server applies the same mask at ingest, by the same code, so an agent from before DLP-1559 cannot store a clear name either. Since DLP-1566 so do cloud and file-share discovery, for a finding's path and for an incident raised from a cloud file, after the policy has seen the real name. Since DLP-1569 the same mask covers what a person types: the justification on an event, and an exception request's reason and destination, masked by the agent before they leave and again by the server on arrival. Events stored before then, and incidents opened from them, keep the clear names they were sent with until an operator runs dlp-management-api --mask-stored-names --apply. That masks them with the same code and records one audit entry per tenant with the counts. Without --apply it is a dry run that changes nothing, and neither prints a value. On production it was run on 2026-10-05 with the owner's approval: 12 events and 4 incidents in one organisation were masked, one audit entry was recorded, and a second dry run found nothing left. A self-hosted installation runs it itself.
The agent's own log (DLP-1561). Since then, the log lines that name a file or folder carry the same mask, and so do the error and not-inspected sentences written beside them. That covers a match, a folder that arrived, a deletion or move away, a file gone before it could be read, a scan failure, a discovery walk, the change-journal catch-up and a deferred classification. The mask runs only for a line that is written, so a debug line nobody enabled costs no scan. Lines written by an earlier agent stay in the rotated log files as they were until rotation removes them, and a troubleshooting session can still send those. Paths of the agent's own state, of configured monitored roots and of updates are logged as written: they come from configuration, not from a person's files.
56. A file whose permissions exclude the agent (DLP-1560)
What is observed. The content of a file under a monitored path whose owner removed the service's access, or denied it. Until DLP-1560 such a file was not read and was counted as not inspected. Since then the Windows service reads it with SeBackupPrivilege and backup semantics, read only, as backup software does (ADR-0046). Nothing about the file's permissions is changed or recorded.
What leaves the endpoint. The same as for any other file read: findings, never content. Where reading past the permissions is not possible, the file is counted as not inspected under its own cause, access_denied, with its name.
Who sees it. As for any incident or not-inspected count.
Kept. Nothing new.
57. Bypass code attempts (DLP-1562)
What is observed. Each bypass code entered on an endpoint through the agent service: when, whether it was accepted, the refusal sentence the person was shown, the code id when it was accepted, and the account the operating system named for the person entering it.
What leaves the endpoint. Those fields, never the code, which is a credential for as long as it lasts. A refusal sentence names the check that failed and at most a key id or an expiry from the code.
Who sees it. Holders of audit:read, on the endpoint's page and in the audit log.
What else it is used for (DLP-1833, DLP-1907). A refused code counts toward the incident risk factor "attempts to get past a control" for the person who entered it, in the 30 days before an event of theirs: matched by the whole account name, or by its account part on that event's own endpoint. Holders of incidents:read see the count on the incident, not the attempts. An accepted code is not counted.
Kept. On the endpoint, until the server acknowledges them, at most 50 (the rest counted). On the server, as audit entries, for the audit log's retention.
58. A file's Mark-of-the-Web (DLP-1684)
What is observed. When the agent reads a file on Windows, it also reads the file's Zone.Identifier stream, if there is one: the stream a browser, Outlook or a download tool writes beside a file it saves. It reads at most 4 KiB, and keeps three fields: the zone number (0 this computer to 4 restricted sites), the host of the download address (HostUrl) and the host of the page that linked to it (ReferrerUrl).
What is not collected. The rest of either address. A download URL's path and query can carry a session token, a share link's secret, a search or a person's name. A host answers what an investigator asks, did this come from outside, and from which site. The path is not shortened or hashed; it never leaves the stream. User information in an address (user:password@) and a port are dropped too.
What leaves the endpoint. The three fields on the file's event. The server masks a validated value in a host as it masks one in a file name (§55).
Not provenance. Any program can write the stream, so the console words it as what the mark says ("Marked as from the web") and explains that it is not proof. It never feeds the provenance store of §25, which takes only approved telemetry, and no policy condition reads it.
Who sees it. Holders of incident:read, on the incident's events.
Kept. On the event, for the event's retention. Nothing is kept on the endpoint.
59. Reviewers' verdicts on a detector's match (DLP-1678, DLP-1696)
What is recorded. When a reviewer judges one detector's contribution to an incident: the detector and its version, the verdict (correct, false_positive, unknown), for a false positive the reason code, the reviewer and the time. Every change is kept.
Free text. Only with the reason other: a note of at most 200 characters. The server refuses a note holding four or more digits in a row or anything a shipped detector reports (a card, an IBAN, a credential), and stores nothing when it does. A note in words can still name a person or a place; that is the reviewer's text, as a comment on an incident is.
What is not recorded. The matched value, the file's content or name, and the note in the audit log. The audit entry has the reason code and whether a note exists.
Who sees it. Holders of incidents:read see the latest verdict, reason and note on the incident. Holders of detectors:read see counts per detector version and reason, never a note.
Kept. With the tenant. Deleting the incident keeps its verdicts, with the incident reference cleared, so the quality figures do not change retroactively. Deleting the tenant deletes them.
60. An incident posted to Teams or Splunk, or written as CEF (DLP-1773)
What leaves the product. For Microsoft Teams, one Adaptive Card per incident to the channel an administrator configured, with the same fields as the Slack message in §42: reference, title, severity, channel, risk, policy, endpoint, user and detector ids, and a link back when a console URL is set. For a Splunk HTTP Event Collector, one event per incident whose body is the webhook payload itself, sent to the Splunk deployment the administrator named. For syslog in CEF, the same fields as the RFC 5424 rendering, keyed for a SIEM. All three are built from IncidentPayload, which carries no matched value and no preview, so none of them can include one. tests/integration_kinds.sh plants a card number's last digits in an incident's detector preview and finds them in none of the three deliveries. The recipients are Microsoft (Teams), whoever operates the Splunk deployment, and the SIEM, each with its own retention. A Teams channel's audience is usually wider than the security team, as a Slack channel's is.
What is stored. For Teams, as for Slack: the name, the URL's host, the optional console URL and CA certificate, and the URL as a secret reference. For Splunk: the URL in the open and the HEC token as a secret reference. For CEF: one format value in the syslog configuration.
What is not. A Teams URL is never listed, audited or named in an error; its host is. The HEC token is never listed or audited. Neither kind keeps a signing secret, and neither is listed as signed, because neither receiver verifies a body signature.
Who decides. As for every delivery kind: integrations:write and the incident_delivery step-up.
61. A person's department, from the tenant's directory (DLP-1772, ADR-0048)
What is read. For each person with an open incident and no department, and only in a tenant whose Owner enabled a directory source: their department, from that directory. AD is asked for one exact account and six attributes: the four that prove one enabled user, the UPN, and department. Entra is asked for one user by UPN, with id, userPrincipalName, department and accountEnabled. A reply carrying any other field is refused. No manager, title, phone, mail or group membership is asked for or kept.
Who is looked up. Only people who already raised an open incident. The directory is not enumerated, and nobody else in the organisation is read.
What is kept. One row per tenant and username: the department or none, which source answered, the outcome and when. On the incident: the department and department_source = 'directory'. The username is already on the incident; nothing new identifies the person.
What is not changed. A closed incident keeps what it said when it was closed. A department an endpoint reported is never overwritten.
How long. A row is refreshed after 24 hours, or 15 minutes after a failed lookup, and replaced in place. It is deleted with the tenant, or its source reference is cleared when the source is deleted. On an incident, the department lives as long as the incident.
Who sees it. Holders of incidents:read, on the incident, marked as from the directory. The cache itself has no route, but its outcome does reach the incident (DLP-1838): when the department is empty, the incident says why - not found in the directory, more than one person by that name, the directory could not be reached, or no source is set up. So a reader can learn that a person is not in the directory, which the department alone would not have said.
Who decides. The Owner, by enabling a directory source (tenant:write). The operator provisions the credential, read-only, outside the product.
62. A database column, sampled by discovery (DLP-1778, ADR-0050)
What is read. For a PostgreSQL target the operator provided, each text column of each table in the chosen schemas: at most 1,000 non-null values and 1 MB, read over a read-only connection, with verified TLS when the database is on another host.
What is kept. A finding per column whose sample matched: the column's locator (postgres://host:port/database/schema.table.column), the data categories, the classifications that matched, the match count and severity. Plus the scan's counts of columns read and columns it could not read.
What is not. No value, no row, no preview and no row identifier. The sample is scanned in memory and dropped. The connection URL, which holds a password, is the operator's server variable. It is never stored on a target, scan or finding, or written to a log or a refusal.
Who decides. The operator decides which databases a tenant may reach, by assigning variables. A tenant administrator (discovery:write) decides which schemas are read and when.
Kept. As every discovery finding: with the tenant, under its retention.
63. People named by a stable pseudonym (DLP-1786, ADR-0051)
Off until an Owner turns it on. privacy.pseudonymise_people is changed with a reason, audited. Turning names back on asks for a fresh step-up, because every name returns to every screen at once.
What a person is called. With it on, every response, export and delivery names a person as "Person " and eight hex characters. It is an HMAC of the lower-cased name under a key derived for the tenant from a server secret (pseudonym_key in server_secrets, or DLP_PSEUDONYM_KEY). It is the same for the same person everywhere, and cannot be computed or reversed without the key. Rotating the secret renames everybody.
Where it applies. A layer on /api/v1 rewrites every JSON, CSV and printed response for the account reading it:
- the value under a person field (username, UPN, file owner);
- a ranking's label that is a known person;
- a known person's
DOMAIN\nameorname@domainanywhere in a string; - the folder after
Usersorhomein a file path. Scheduled report deliveries and incident deliveries (SIEM, webhooks, chat) carry pseudonyms too, and so does a ticket an analyst escalates to Jira or ServiceNow, in its body and in any field mapped to the person (DLP-1969; before it, an escalation sent the name, and tickets raised then keep it). A pseudonym sent back in a filter, a path or a body reaches the route as the name, so the console works with what it shows. Signing in, the console's own accounts and the reveal itself are outside it.
What is kept to do it. tenant_people: each name the tenant's records already hold, once, as first spelled, filled by triggers. Nothing that is not already stored elsewhere. Deleted with the tenant.
Revealing a name. POST /people/reveal with the pseudonym and a justification of at least five characters, by a role holding identity:reveal: the built-in Owner only, or a custom role given it on purpose. The reveal is written to the audit log, with the pseudonym and the reason but not the name, and committed before the name is returned. The console shows a revealed name in that tab until it is reloaded.
Not covered, said. Device names (AHMAD-LAPTOP). Text a person wrote, such as a comment or a justification, shown as written. A bare name inside a sentence: a short name would match ordinary words, so only qualified forms are replaced inside text.
With collect_usernames = false nothing is stored, so there is nothing to pseudonymise and nothing to reveal.
Proof. tests/pseudonyms.sh seeds a person with a unique name everywhere a name is stored, turns pseudonyms on, and crawls every parameterless GET route in the router, plus the person's incident pages, the exports and a printed report, as the Owner and as an analyst. The name appears in none of them. tests/tracker.sh escalates to a stub Jira and ServiceNow with the person mapped into a field, before and after the switch.
64. A person's score against their department (DLP-1781, ADR-0052)
What is shown. On the per-person risk view, which already needs the per-user switch, a justification and an audit entry, the person's department and four figures about it: how many people are in it, the median and upper-decile score, and the share of the others the person scores above.
What is not. No colleague's name, score or count of incidents. Each colleague's score is computed in memory for the figures and dropped. Nothing is stored. A department below five people, or a person with no department, gets no figures, so a "median" is never one colleague's score.
Who is in a group. Everyone whose newest event in the window named that department, except people the tenant excludes from being named, who are left out of every group.
Who decides. The same gates as the score itself.
65. Files a policy acted on, for the classification overview (DLP-1602)
What is kept. A daily row per tenant, detector, endpoint and canonical file path, with the latest event time and the number of source events. The path follows the event's existing case rule: lower-cased unless the endpoint marked its directory case-sensitive. A narrow reference connects each source event and detector to that aggregate; it contains no file path.
Why. The classification overview needs distinct files per detector in a rolling 1–90 day window. It reads the daily aggregate and the exact last-event time, rather than expanding every source event's detector JSON on each page load. This does not add content, a matched value, a hash, a user identity or a new event stream.
Masking and retention. The stored path is the same masked path already on the event. The existing name-masking operation updates the aggregate through the event trigger. Deleting a source event removes its reference; the aggregate row disappears when its last source event is deleted. Therefore the same event retention and legal-hold rules apply, including held events kept past the ordinary window. Deleting the tenant removes all three tables' rows.
Backfill. A deploy does not scan historical events. An operator first runs dlp-management-api --backfill-classification-rollups to report counts, then explicitly applies with --apply-classification-rollups. Until a tenant's transaction commits, its overview continues to use the original event query. The operation is idempotent, outputs counts only, and stores no file path in its report.
Who sees it. No new route or permission: the aggregate is used only by the existing classification overview, behind its existing detector and agent read permissions. The aggregate and event-reference tables have no direct API.
66. What the endpoint's dialogs show, and the reference a person can quote (DLP-1861, DLP-1862, DLP-1865, DLP-1875, DLP-1876, DLP-1877, DLP-1888)
The cyberkap agent UI (epic DLP-1847) shows more on the endpoint's own screen than the notice used to. This section registers each addition. It says only what is built: two fields the design draws are not, and they are named at the end.
On the notice, shown and not recorded (DLP-1861). These are the program, by a recognisable name ("Google Chrome", or the executable's base name, never its folder), whether the destination is one the organisation approved, the matched classification's display name and the content's sensitivity label name. They are shown to the signed-in person on their own screen, in the notice. The notice adds no field to the event: each value is derived from what the agent already holds for the decision. The classification and label names are the tenant's own prose and pass the same fitness check as the policy's sentence, so a classification named after a detector is not put on the screen. A file name in the notice is masked by the shape rule and the tenant's detectors (DLP-454). The program, which is new beside it, carries only the base name. Asserted by nothing_sensitive_leaks, which plants a card number in the file name, the destination and the program's folder, renders every locale, and finds it nowhere while every other field is present.
The reference (DLP-1862). This is six characters from an alphabet without look-alike letters, generated when a decision is shown to a person, and never made for an audit, which shows nobody anything. It is the same string on the prompt, on the notice and on the event, so what the person quotes to support finds the incident. It is a label, not a secret, and is derived from nothing about the person or the file. The server keeps it as events.user_reference only when it has exactly that shape; anything else is dropped, not stored. It is kept for as long as the event is, under the tenant's event retention (§7). Readers of the incident see it as "Shown as …", and can find the incident by it.
Justification rules (DLP-1865, DLP-1871). The reasons a policy offers, whether it asks for a sentence, and the least length are the tenant's configuration in the signed bundle. Configuring them observes nothing. What a person types is the existing user_justification, masked by the agent before it leaves and again by the server on arrival (§55). The Windows session draws the cyberkap screens for any prompt the service sends it (DLP-1863). They are built from what the notice already shows: the file name masked by the service, the folder rather than the path, the rule, the reference and the policy's reasons. A file event names no Windows session, and the service never guesses one (DLP-1127), so a file is not asked about there. A browser upload under a rule that asks is: the extension keeps it held, and the person is asked in the browser's own session, named by the pipe (DLP-1864). A justification lets it through; a refusal, a request for an exception, no answer or the agent lost leaves it blocked. A refused justification creates an exception request only when the person chooses "Request exception": typing a reason alone is not consent to send it to approvers (DLP-1871). No browser on a managed Windows endpoint has run this yet, because that needs a signed extension id configured (DLP-1203).
The protection state (DLP-1877). The tray icon and the About page read one summary the service writes for every user of the machine. It gains the service's health as one word (healthy, degraded or impaired), never the warnings behind it, which can name paths (§8). Since DLP-2044 it also names the areas that word is about, each one of six fixed words (file monitoring, sending events, the agent's own files, detection rules, kept copies, policy updates). Every user of the machine can read them; none names a path, a file or a count.
Screen 03, asked for over the session channel (DLP-2050). After a request is sent, and from the tray's Pending requests row, the session agent asks the service for the person's own recent requests and draws one. The service answers from its exception ledger with that person's rows only, as Windows names the account on the pipe, at most twenty. The rows hold what the request card shows: the reference, the masked file name, the destination, the times, the state and the approver's label. Nothing is written on the session side, and another account on the machine is never given them. Withdraw on the card goes through the service, as DLP-1873's command does.
Who a request is with (DLP-1875). The server returns the tenant's approver label with each request and each decision poll. The agent keeps it on the request's row in its local exception ledger and shows it to the person on the About page and the request status card. It is the tenant's own name for its queue, at most 60 characters, or "Your security team" when none is set. It is an individual's name only if an administrator typed one. It sits outside the signed decision and decides nothing.
Being told of a decision (DLP-1876). When an approval, a refusal, an expiry or a withdrawal reaches the endpoint, the person signed in is told with a notice in their session, naming the state, the request's reference, the rule's display name and, for an approval, until when. Only when they are the account the request was raised from: on a shared machine the next person to sign in is told nothing about somebody else's request. Nothing new is recorded.
Withdrawing one's own request (DLP-1873). The person who asked can withdraw a request still pending, with dlp-agent requests --withdraw <reference>. It goes through the agent service, which allows it only for the account the request was raised from, as Windows names the caller. For that check, the endpoint's local exception ledger now keeps that account on the request's row, as the request itself already carried it. A request that never left the machine is dropped there. One that did is withdrawn on the server, which records it as the requester's (API.md). Nothing new is sent to the server: the reference only. §4 covers withdrawal of an approval by an approver.
The tray's counts (DLP-1946). The tray card's Pending requests and Temporary exceptions badges count only the signed-in person's own requests: those still waiting, and approvals still in force. The service counts ledger entries whose recorded account is the one Windows names on the session channel, and answers that session alone. It sends counts, never names, and writes nothing into the summary every user of the machine can read. An older service gives no answer, and the rows then show no badge rather than a guessed zero.
67. A file's owner, on an incident (DLP-169, DLP-1928)
What is observed. For a file the agent reads on Windows, the security identifier (SID) of the account that owns it, and, when the endpoint can look it up in time, that account's name (agent/core/src/file_owner.rs). A failed or slow lookup sends the SID with no name rather than a guess.
What it is for. Whose file it is changes what a finding means: the same spreadsheet in the finance director's folder and in a contractor's are different events. ADR-0053 relies on it: an incident at rest names the owner an analyst can contact.
Who sees it. Holders of incidents:read, on the incident and its events. In a tenant that names people by pseudonym, the owner's name is a pseudonym like every other person field, and the SID is withheld (null in every response, blank in an export) because it cannot be said as a pseudonym and resolves to the account (DLP-1928). The reveal, with its recorded reason, returns the SIDs recorded as the person's beside the name.
Kept. On the event and the incident, for as long as they are kept.
68. Incidents suggested as related (DLP-1834)
What is inferred. For an incident, up to ten others that are probably about the same thing: an event in each holds the same file content (by hash, on any endpoint and of any person), or the same person and policy within 14 days, or the same endpoint within ten minutes. This links people through the files they handled, which no single incident says.
Who sees it. Holders of incidents:read, within their incident scope, on the incident page. A suggestion is never linked by itself: two incidents are related when a person with incidents:write relates them, or when the same activity recurs after an incident was closed and correlation relates the new incident to the closed one (DLP-1905); each is recorded on both incidents' history.
Kept. Nothing. The suggestions are computed when the page asks and are not stored.
70. Connection policies on the endpoint (DLP-2072, ADR-0057)
What is observed. The Windows agent installs filters that refuse an application's connection to a destination. For each connection one of those filters refused, it records an event (channel network_connection), at most one per application, destination and policy a minute:
- the application's file name and path, as Windows names it;
- the remote address and port;
- the policy, the time, and the user the agent puts on its other events, which is the active session's user and not necessarily the account the application ran as.
Never a URL, a hostname or anything sent, and nothing about a connection that was allowed. The sole signed-in person is shown a notice naming the application and the address. With two people signed in, or none, nobody is told and the event is the record.
Health carries counts: filters wanted, filters in force, and why they are not in force.
Kept. The event, as events are kept. The counts, in health.
71. A print job's counts and the program behind an event (DLP-1987)
What is observed. The agent has long reported both (DLP-1141, DLP-222). Until migration 0164 the server kept only the process name. It now keeps, on the event:
- The print job: the queue's name, its port, the machine it was sent from (when that is a print server's client), and the pages, copies and duplex the spooler reported. Never the document's content, which the agent never reads; the document's name is the event's file name, as before.
- The program: the executable's path, its BLAKE3 hash, the publisher of a signature that verified, the signature state (
signed,unsigned,invalid,undetermined) and the Windows integrity level.
Not kept, deliberately:
- the pid, the parent pid and the process start time, which identify a moment rather than a program and mean nothing afterwards;
- the account SID the process ran as, which the event's user already says;
- the command line, which the wire format has never carried, because command lines carry credentials.
Why it is acceptable. It is what distinguishes a person printing forty pages, or an unsigned program uploading a file, from ordinary work. It is the investigative question the incident is for.
The person in it. An executable path often runs through the person's profile folder (C:\Users\<name>\...). Where the tenant redacts file paths, the executable path is not stored at all; the process name still says which program it was. Where people are named by pseudonym, the response layer replaces a known person's name wherever it appears, as it does in a file path. Every name is masked like a file name (DLP-1563) before it is stored, so a card number used as a queue name is not kept.
Kept. As the event is kept: on the events window, deleted with it.
Asserted by tests/evidence_previews.sh section 4 (what is kept, and that the pid and SID are not) and by the unit tests in domain/ingest.rs (the redacted-path case, masking, and values outside the vocabulary).
72. Notices to the people working incidents (DLP-2001)
What is sent. A person is told when an incident is assigned to them by somebody else, and when an incident they hold, or an unassigned one if they are an Owner or Admin, misses its response or resolution target. Each person's notices go as one digest, at most every fifteen minutes, by mail when a relay is configured, and to any Slack or Teams destination whose Owner asked it to carry people's notices.
What a notice says: the incident's reference, its severity, what happened (assigned, or which target was missed), and, for an assignment, who made it: an analyst's address or an assignment rule's name. In a chat channel, also whose digest it is, by address.
Not in it, deliberately: the incident's title, the person it is about, the file, the destination, the detector, any matched value. A mailbox and a chat channel are outside every retention rule this product enforces, and the reference is all an analyst needs to open the incident, where access is checked as always.
Who it is about. The analysts receiving it, not the people the incidents are about. A chat channel shows who was given which incident to everybody in the channel, which is why a destination carries these only when asked, and the switch is audited (integration.people_notices_changed).
Control. Each person can mute assignment notices for themselves. A missed target cannot be muted. Each digest is audited as notification.sent or notification.failed, with the address and the channels, never its content.
Kept. The notice rows, which name the incident, the person and the kind, live as long as the incident and the person do, and are deleted with either.
Asserted by tests/incident_notices.sh (what is told and to whom, that no notice carries the title or the person, the mute, the chat opt-in) and by the unit tests in domain/notices.rs.
73. A question to the person, and their answer (DLP-1997)
What is observed. An analyst moving an incident to awaiting_user may ask the person a question. It is sent to the incident's endpoint as a command and shown only in the named person's own session. If they answer, the answer comes back signed by that endpoint.
What is kept. On the server, in incident_questions: the question, who asked it and when, the endpoint and the person it went to, when it lapses (three days), and whether it was answered, lapsed or withdrawn. The answer itself is a comment in the incident's discussion, labelled as the person's ("answered on their endpoint"). Both the question and the answer are refused if a shipped detector finds a value in them (DLP-1974), so neither can carry a card number. The audit log records that a question was asked and answered, with their lengths and never their words.
On the endpoint the agent service keeps the open question in its state folder, which only the service and administrators can read, until it is answered or lapses. An answer waits in the service's memory until it is posted. If the service restarts first, the answer is lost, but the question is still open, so the person is asked again. Neither the question nor the answer is written to the agent's log.
Who sees it. Whoever may read the incident, as for any comment. A role scoped away from the incident sees neither (DLP-1001).
Not observed. Nothing about the person's session beyond showing the card and posting the answer: whether they opened it, how long they took, or a draft they did not send. Leaving it unanswered is a legitimate choice; the incident is then worked without it, and the history says it lapsed.
Kept for. As the incident: a question and its answer go with the incident when retention deletes it.
Status. The server half is built: the command, the signed answer route, the discussion, the lapse. The card that shows the question on Windows is the agent's (in progress, Windows lane). Until it ships, a question reaches the endpoint, is logged there as one this agent cannot show, and lapses unanswered.
74. How often a person runs and exports reports (DLP-2088)
What is observed. Console users, not the people incidents are about. Each admitted report run or export by a user is a row in report_activity: the user, whether it was a run or an export, when, and, for an export, how many rows it took. The first refusal by the rate limit in a window is a row too.
Not kept, deliberately: which report, its filters, the view, or anything in the rows. The budget needs a count and the volume signal needs a sum, and neither needs to know what was read. An export is already on the record with what it was of, in its own incident.exported audit entry.
What it is used for. Refusing a run or an export over the person's budget (60 runs a minute, 30 exports an hour by default), and flagging the export that takes a person's last 24 hours over the row threshold (50,000 by default). The first refusal in a window is audited as report.rate_limited, and the crossing as report.export_volume_unusual. Both entries carry counts and the budget, never the report. System health shows how many people were refused in the last hour and how many were flagged in the last 24 hours, without names. Who it was is in the audit log, for somebody who may read it.
Kept for. 25 hours. Older rows are deleted by housekeeping, which runs every five minutes, and as the same user's next ones arrive. Rows are also deleted with the user or the organisation. This is a budget, not a run history: interactive runs keep none (DLP-2095).
Asserted by tests/report_rate_limits.sh and the unit tests in domain/report_budget.rs.
75. Export approvals and the mark on an export (DLP-2087, ADR-0060)
What is kept. Where a tenant requires approval above a row count, each request is a row in report_export_requests, which holds:
- who asked, for which saved report, and when;
- the view they had narrowed it to, and the format;
- how many rows it was;
- their reason;
- who decided, when, and the note they wrote;
- when the approval was used.
Why the view is kept. It is what the approver is approving. The view can hold a value a person typed into a filter, such as a hostname or a username.
Not kept, deliberately: the rows themselves. The request says how many rows there were, never what they were.
The mark. Every export, approved or not, carries a reference. On every CSV row and on every printed page, the reference sits beside the organisation's slug. The incident.exported audit entry records the same reference, so a file found later leads back to who took it. The mark names the organisation and the export, not the person. The person is in the audit entry, for somebody who may read it.
Who sees it. Anybody in the organisation who may export lists every request, because each of them may be the second person for somebody else's.
Kept for. Requests are deleted by housekeeping ninety days after they were made. Each request, decision, withdrawal and approved export is also an audit entry, kept for as long as the audit log is kept.
Asserted by tests/report_export_approval.sh and the unit tests in domain/report_export_requests.rs and routes/reports.rs (watermark_tests).
76. Audit events forwarded to a SIEM (DLP-2205)
A syslog destination may select audit categories in addition to incidents. Each selected row is queued for that destination when it is written.
Sent. The tenant, the category, the audit sequence, the action name, the actor, the actor's type and the target, as the audit row recorded them. The actor is a pseudonym where the tenant's person-pseudonym rule applies to a user; the target is sent as recorded, so a name the action recorded, such as a policy's, is sent with it.
A CEF destination sends the same fields as CEF keys (act, cs1 to cs4, cn1, suser), and no others.
Not sent. The audit row's metadata, its source address and its hash-chain fields. The forwarded structure has no field for any of them.
Kept for. Until delivery, a queued row holds the same fields the collector receives. A delivered row keeps them for seven days and is then removed by housekeeping, which deletes at most a thousand rows per pass. Removing the destination removes its queue.
Asserted by tests/syslog_categories.sh (sections 5 and 6: another tenant's rows never arrive, and the structured data holds the contracted fields and no invitee address, source address or hash-chain field; section 8: a CEF destination receives its selected category and not the others) and by the unit tests a_forwarded_event_carries_exactly_the_fields_the_contract_names and a_cef_event_is_a_record_under_the_keys_a_siem_knows_in_the_same_frame. The pseudonymisation of the actor is not asserted by either: it reuses the incident path's rule in domain/pseudonyms.rs, and nothing here tests it.
77. Resource samples and their history (DLP-2182)
A sampler writes one row per metric on each tick, a minute apart by default, to system_samples. The host rows are CPU use, memory use, the percentage used of the volume named by DLP_DATA_VOLUME, and the size of the database. The per-organisation rows are the number of pending correlation events and the age of the oldest one. Each row holds the metric, its figure or the reason no figure was taken, and the time it was taken.
Read. The sampler reads the event table's organisation, correlation state and arrival time, and nothing else from it. No event content, file name, matched value, user, device or address is read or stored.
Read by. Any holder of system:read. Queue figures are returned for the caller's organisation alone. The host figures and the database size are one figure for the whole installation, so they are the same for every organisation on it. The database size reveals how large the installation is, not what any organisation holds. That is recorded as a decision: per-organisation sizes would need a measurement that does not exist, and this figure is kept for capacity. If an installation must hide its size from its organisations, this figure is the one to remove.
Kept for. Seven days. Each tick removes rows older than that, at most 640,000 of them, and a tick that cannot finish leaves the rest to the next.
Asserted by tests/health_metrics.sh: a queue figure stored for one organisation appears in none of another's points (section 5), and a row eight days old is removed while a day-old row is kept (section 6). Also by the unit tests in domain/system_metrics.rs. Not asserted: that two replicas write one row per tick, which the advisory writer lock is meant to ensure and which needs two servers on one database to test.
78. The agent's own service, driver and tamper observations (DLP-2186)
An agent may report three observations about itself in its health document: whether its service is running, the state of its driver, and whether a tamper check saw something. Each is a status, plus for the driver and the tamper check a short code and the time it was first seen. The contract is in place; no agent sends the block yet. The Windows service and driver collection is the rest of DLP-2186.
Read. Only what the agent itself knows about its own process and driver. The code is a fixed word chosen by the agent, such as a load failure. It is not an error message, a path, a file name or a process name, and the agent must not put one there.
Read by. Holders of health:read (ADR-0066; system:read implies it), the permission that gates the fleet health routes that return the health document. No server route names the block. It is stored with the rest of the health document.
Kept for. As long as the health document is: each check-in replaces it, so the record is the latest report and not a history. Interruption history lives in its own table.
Absent means unknown. An agent that does not send the block, or a field inside it, is read as unknown. It is never read as running, loaded or clear.
Asserted by the unit tests in packages/contracts/src/agent.rs: an old health document carries no observations and the JSON does not gain the key; a block with only a version reads every state as unknown; a driver failure keeps its code and time, and a loaded driver writes no code. Not asserted: that the server stores and returns a block sent in a real check-in, which needs a device credential and is not yet in a shell suite; and anything on Windows.
79. Licence and certificate expiry in the health components (DLP-2187)
The health components read three things that are not event data: the signed licence file the server is given (DLP_LICENSE_FILE), the expiry dates of the device CA, the active signing key and the agent certificates still in use, and the public certificate chain an operator copies for the TLS terminator (DLP_TLS_CERTIFICATE_FILE).
Read. From the licence file: its signature, its claims, and the expiry and seat limits. From the database: the three expiry dates and the count of endpoints, data sources and users already used by the licence. From the chain: the expiry and whether it parses. Its subject, issuer and serial are parsed in memory and discarded, never stored or returned. The licence file is read in full for its signature, and its claims are never returned in the answer.
Read by. Any holder of system:read. The answer carries a status, a reason code and the time of the check: licence, certificates and tls_terminator each name the window or condition, such as expires_within_14_days, and never the organisation's name, a key, a serial number or a subject. The organisation's name is returned only by the licence status route, which already requires system:read and is unchanged.
Kept for. Not kept. The answer is cached in memory for ten seconds per organisation and is not written anywhere.
Asserted by tests/health_components.sh section 1 (the terminator reads as not supplied, and the licence reads with no reason), section 5 (a licence signed for organisation A is refused as invalid for organisation B) and section 5b (a licence altered in place is refused once the cache lapses). Also by the unit tests in domain/components.rs and routes/license.rs. Not asserted: a production licence file, a real terminator serving the chain, and the behaviour when the agent certificate renews late, which the 30-day agent warning may flag.
80. Health events: faults from first reading to recovery (DLP-2190)
A health event is one fault the health page reported, kept from the reading that first showed it to the reading that stopped showing it. The worker takes the same judgement the page shows, for each organisation on every pass; a pass runs every 60 seconds unless DLP_HEALTH_EVENTS_INTERVAL_SECS says otherwise.
Written. The fault's code (a fixed word from the evaluator), its severity, its state (open, acknowledged or resolved), the part it concerns (one of the fixed targets such as backup or delivery), the owner the organisation chose if any, and the times it was opened and resolved. Nothing else about the fault. No file name, matched value, integration name, host name, error text or subject is written, because the evaluator's output does not carry one.
Read by. Any holder of system:read sees the events of their own organisation. Only tenant:write can acknowledge an event or name its owner, and each such change is audited (health_event.updated) with the state and owner it moved from and to. The owner is a user identifier from the same organisation, shown as an identifier and not as a name.
Kept for. A resolved fault for 90 days after it resolved (DLP-2246). An open or acknowledged fault is kept however old it is. The 90 days is fixed, not a tenant setting, and the hourly retention pass removes it. A resolved fault is kept for as long as any notice still refers to it, so it goes only after the last of its notices does.
Not written. A reading that could not be taken. An unreadable probe does not resolve a fault, so an event never records recovery that was not observed.
The recorder's heartbeat (DLP-2247). Each pass writes one row per organisation to health_recorder_passes: the time the last pass recorded every reading it took, and the time the last pass failed. Those two timestamps, and the organisation they belong to, are all it holds. The row is overwritten on every pass and removed with its organisation (ON DELETE CASCADE). The error that failed a pass is written to the server log only. The health page reads the heartbeat and raises health_history_stopped when the last success is older than three intervals (never less than three minutes), or when the last failure is newer than the last success. That reading is shown to holders of HealthRead, like the rest of the page, and is never recorded as a fault, because the recorder cannot see its own stop.
Asserted by the unit tests in domain/health_events.rs (one event per fault, resolution once, severity change in place, resource part of the identity, the stop reading never recorded, the stale window), and by tests/health_events.sh. The stop reading is asserted by the four a_recorder_... tests in domain/health_issues.rs. A live server was run by hand against a scratch database with the heartbeat set by hand through healthy, stale, past-window, failing and recovered states, and each was read through /system/health. That run is not committed. Not asserted: the recorder's own writes to the heartbeat (NOTE_RECORDED, NOTE_FAILED) are checked by reading the SQL and by hand against a scratch database, not by a suite, so a change to them is not caught by tests/ until DLP-2289 is done. Also not asserted: the outcome of SMTP delivery failures, which is not yet one of the recorded faults (DLP-2244).
81. Health alert rules and their notices (DLP-2192)
A health alert rule says that a fault, or any fault of a severity, is worth telling somebody about, and where. The worker looks at the open health events (section 80) on the same interval, and queues one notice per rule per event, then no other notice for that pair until the cooldown has passed. A fault that recovers and recurs is a new event, so it is notified again at once.
Written. For each rule: the fault code it names (a fixed word, or nothing for any fault), the severity floor, the channel, the cooldown and whether it is enabled. For a rule that emails, the address as the owner entered it. For a rule that posts to a webhook or syslog, a reference to the integration and nothing of its configuration. For each notice: the rule, the event, its status (pending, delivered or failed), the number of attempts, the times it was queued and delivered, and the error the channel returned, cut to 300 characters. The error is what the mail relay, webhook or syslog collector reported; it is stored as they reported it, and this section does not claim that none of them echoes an address back.
Sent. The notice carries the fault code, its severity, the part of the product it concerns, the event number and the times it opened and resolved. It carries no file name, matched value, endpoint name, policy text or subject, because the event it is built from has none of those. The webhook body adds the tenant and rule identifiers and the schema name mydlp.health_alert.v1.
A test notice (POST .../test, DLP-2193) has a fixed subject and text saying that no health fault has occurred. Its webhook body carries the same schema and identifiers with "test": true and "event": null, and nothing from an event.
Read by. Only tenant:write (the Owner) sees rules, their last error and their failure count. Creating a rule is audited (health_alert_rule.created) with the fault code, severity, channel, cooldown and enabled state, and whether an address or an integration was set; the address itself is not recorded. Changing a rule is audited (health_alert_rule.updated) with the fault code, severity, cooldown and enabled state, before and after; the channel and target cannot change.
Sending a test notice is audited as health_alert_rule.tested, with the channel and whether it accepted the notice, and nothing else. The channel's answer is returned to the Owner who asked, and for a failure it is the channel's own error text cut to 300 characters; it is not stored, and a test writes no notice row.
Kept for. A finished notice for 90 days after it was queued (DLP-2246), except the newest notice of each rule, which is kept until a newer notice for that rule replaces it. A notice still pending is kept until it is sent or fails. Removing an old finished notice lowers the failure count the Owner sees on the rule, which counts only the notices still kept.
Not written. The text of a notice once it is sent; only its outcome is kept.
Asserted by the unit tests in domain/health_alerts.rs (the cooldown boundary, the severity floor, the condition match, the target and address checks, and that a notice names no content), and by tests/health_alerts.sh (refusals, one notice per cooldown, a failed channel shown on the rule with the event still open, recurrence, audit, and a viewer refused; and its section 7 for the test notice: a failed channel reported as failed and audited as failed, a disabled integration refused, and no notice row written). HealthAlertRules.test.tsx asserts that a failed test is never worded as sent. Not asserted: delivery to a real mail relay, a successful webhook or syslog delivery, and a receiver's own behaviour; the suite runs with no relay and no receiver.
82. A support bundle for the Health page (DLP-2197)
An Owner can ask for a support bundle: a zip the Health page makes for whoever is supporting the platform. It is built from typed values only. It holds four files: manifest.json (the format, the times, the files, and what it withholds), components.json (each part's verdict, latency, check time and fixed error code), metrics.json (resource samples over the last 30 minutes, section 77) and health_events.json (the newest 500 health events of the tenant: code, severity, state, and the opened and resolved times). None of those fields is free text. Resource names, owner identities, log lines, credentials, keys and policy content are not read into the bundle at all, and the manifest says so.
Kept. The archive is stored as bytes in the support_bundles row for an hour, in the same database as the rest of the tenant's records; it is not encrypted separately and is not written to disk. The row keeps the id, the times, the size, the SHA-256 digest and the person who made it. When a bundle expires, its bytes are cleared the next time any bundle is made, and the row stays as the record.
Handed over. The first download returns the bytes and clears them, in the same transaction that writes the audit row, so a bundle is handed over once. A later download, or one after expiry, is refused with 409 and says why.
Read by. Only tenant:write (the Owner) makes or takes a bundle. Another organisation cannot see it: its id answers 404. Each creation is audited as support_bundle.created with the size and digest; each hand-over as support_bundle.downloaded with the size. Neither records the bundle's content.
Not included. Log lines. The health-event codes are the fault record this product keeps, and this section does not claim that a log extract is included. Endpoint diagnostics (section 15) are a separate collection and are not in this bundle.
Asserted. tests/support_bundle.sh makes a bundle as an Owner, refuses a viewer, checks the zip's four names, its digest and a credential-pattern scan of every file, refuses the second hand-over, a foreign tenant and an unknown id, refuses an expired bundle and clears its bytes at the next creation, and checks the audit counts. Not asserted: how long a build takes on a large tenant, and a bundle read by a person outside the product.
83. The audit chain checked on a schedule (DLP-2199)
Each tenant's audit chain is verified on a schedule (DLP_AUDIT_CHAIN_CHECK_SECONDS, one hour by default), so a rewrite is found without someone opening the Audit log. A check reads at most DLP_AUDIT_CHAIN_CHECK_LIMIT entries (100,000 by default) and continues from where the last one stopped; a sweep from the start of the chain runs again 24 intervals after the last one reached the end (DLP-2279). What a check reads is the audit log's own columns: sequence, hashes and the recorded actor and action. It does not read a metadata value into anything it keeps.
Kept. One row per tenant in audit_chain_checks: when a check last finished, whether the chain verified, the first entry that did not, how many entries the current sweep has read and whether it reached the end, the sequence and entry hash the last read reached, when a sweep last reached the end, a fixed sentence describing the failure, and the text of the last check that could not run. The Health page reads it. The entry hash is what the next check compares with the entry at that sequence before it reads on, so a removed or replaced entry is noticed (DLP-2279). A failure is also written once to the audit log as audit.chain_broken, with the sequence, the count and the same fixed sentence. Nothing else is written.
Held in memory. When the store refuses the write, the result of that check (the same fields, the read position, and the text of a check that could not run) is kept in this server's memory, not in a file or a second store, until a later write succeeds. The position is held too, so a refused write does not make the next check read the same entries again. The Health page shows it as not yet in the audit log. It is lost when the server restarts; the next check derives it again from the chain, and until then the stale warning shows that the schedule's writes are not landing. The decision against persisting it elsewhere: a second store would be a new place holding audit-derived results, and the chain itself is the source the next check reads.
Not sent. The check does not leave the server. It does not contact the timestamp authority, whose digest is described under the audit entries section (DLP-55, the anchoring paragraph).
Bounded. A check reads at most DLP_AUDIT_CHAIN_CHECK_LIMIT entries. A read that continues from the stored position does not see a change to an entry an earlier check already passed; only a sweep from the start does, so such a change is found within one sweep cycle, which is about the number of checks needed to read the chain once plus 24 intervals. The Health page says how far the current sweep has got, rather than implying the whole chain was read.
Removed. The stored position's entry must still have the hash the check recorded for it before anything is read. An entry removed after it was verified, or replaced by an append that takes its sequence, is reported and stays reported: nothing in the product clears that yet. The audit.chain_broken row is the record, and the position check is what keeps the Health page from reading the chain as intact again.
Asserted. tests/audit_chain_check.sh runs the real scheduler at a one-second period and asserts a clean chain, a rewritten entry found and named, one audit.chain_broken row rather than one per check, the chain intact again once restored, and, while writes are refused, a broken verdict held in memory and the stale warning raised, with no second audit.chain_broken row. When writes are allowed again the held verdict is written, once, and the warning clears. The merge of a held result with the stored one is asserted in audit_chain_check::tests; the Health page's severity and wording are asserted in health_issues::tests and outbound-health.test.tsx.
84. Sequence gaps and the reasons given for accepting them (DLP-2194, DLP-2276)
When an agent's event sequence skips numbers, the server records the missing range as a gap (agent_sequence_gaps). An Owner can accept a gap as a loss, with a reason, so that it stops counting against the fleet. This section was missed when gap detection shipped and again with DLP-2194.
Recorded. Per gap: the device id, the missing sequence range, the number of events missing, and the time the gap was seen. The hostname is not stored with the gap; it is read from the agent's own record when the gap is shown. Once accepted, the gap also records the time, the accepting actor, and the reason.
The accepting actor is the signed-in user's email, or, for an API call, the token or service account id (api_token:<id>, service_account:<id>), as the audit log records it.
The reason is free text that the accepting administrator types. It is required, trimmed, and limited to 500 characters. Nothing in the product reads it for personal data: no detector or classifier runs over it, and nothing checks it beyond being non-blank and within the length limit. Write the reason as the decision, not as a description of a person.
Who can read the gap and the reason. GET /api/v1/system/sequence-gaps requires system:read. That permission is held by every built-in role: Owner, Admin, Security Analyst, Auditor and Viewer. So the accepted reason is visible to all of them, next to the hostname.
Who can accept. TenantManage, which is Owner only among the built-in roles. Each acceptance writes sequence_gap.accepted to the audit log with the device, the missing range, the event count and the reason in its metadata.
Where the reason goes. The reason is in the audit entry's metadata, so it is kept wherever audit entries are kept: the audit log, and the archive under DLP_AUDIT_ARCHIVE_DIR when audit retention is on (see Audit entries leave the database). Who can read the audit log is audit:read: Owner, Admin and Auditor. It is not sent to a SIEM. Section 76 sends a fixed set of audit fields, which do not include metadata.
Removing it is not erasure. An archived copy of the entry keeps the reason. A subject access request has to reach the archive as well, as recorded for audit entries in docs/KNOWN_LIMITATIONS.md.
85. The classification queue on the Health page (DLP-2200, DLP-2281)
A choice waiting to be written (section 26) is on the endpoint's disk until the file becomes writable. The Health page needs to know whether that queue is growing, and nothing about each choice is needed to know it.
Sent. Each endpoint's health report carries the numbers for its queue: how many choices are waiting, the age in seconds of the oldest, and how long each of the last 256 deferred choices it wrote waited, in seconds (DLP-2281). No path, label, content hash or reason is in the report. The field is absent from endpoints that predate it, which is not the same as an empty queue.
Served. GET /api/v1/system/health/classification and the classification_queue part of system/health/components return tenant-wide totals: how many endpoints reported in the last hour, the total waiting, and the age of the oldest. No hostname and no device id are returned, so the page answers "is it growing" and not "whose decision is it". The route also returns the median and 95th-percentile wait pooled across those endpoints, with the time they were observed; the waits themselves are not returned. health:read is required.
Stored. The server keeps nothing new. The figures are part of the endpoint's health record, which is overwritten at each check-in as the rest of that report is.
Measured, for deferred choices only. A choice that was written at once is not timed, and nor is an OCR read: OCR runs inside the scan and is not queued, so it has no backlog to record. The response says processing_measured: false with null percentiles until an endpoint has written a deferred choice, rather than leaving the reader to read the absence as a fast queue.
86. Periodic jobs record when each pass started and finished (DLP-2222)
A job that never started and a job that started and hung both looked like silence on the Health page. Each periodic job (the housekeeping loop, and audit anchoring where a timestamp authority is configured) now writes two timestamps: when its pass started, and when that pass finished.
Sent. Nothing. The rows stay on the server.
Stored. One row per job in job_heartbeats: the job's name, the time its last pass started, and the time that pass finished, empty while it is running. Each pass overwrites the row, and replicas share it, as worker_heartbeats does. It holds no organisation, no event and no content, and nothing removes it when an organisation is removed, because it belongs to no organisation.
Served. No new field. The scheduler part of system/health/components reads the rows and returns a verdict, which is critical with job_stopped or job_hung, unknown with job_not_recorded, or a verdict as before. The job names and the times are not returned.
Logged. A write that fails is logged with the job's name and the database's error, which names the statement that failed and no value. The pass then runs.
Not recorded. What a pass did, what it found, and any error it raised. Those are recorded where they always were.
Asserted by tests/job_heartbeats.sh: the loops write their own rows with no test writing them; a timestamp authority that is unreachable still leaves a finished pass; a finished pass is stopped only after three intervals; a started pass is hung only after an hour. The verdict for each case is also a unit test in domain/components.rs.
87. LDAP console sign-in against Active Directory (DLP-2160, ADR-0073)
A person can sign in to the console with their directory password, which this server checks by binding to the organisation's Active Directory over LDAPS.
Sent. The password, once per sign-in or step-up, in a simple bind as the account's own DN over certificate-verified LDAPS, to the directory the Owner configured. The identifier the person typed, escaped, in a search made with the organisation's service account. Nothing goes anywhere else.
Stored. Nothing of the password. On the account: the directory's objectGUID as ldap_subject, the mail address (or user principal name) and display name the directory returned, and the role the groups map to. The group SIDs are used for the mapping and not kept. For a directory account that has failed a password: its GUID, a count and a lock time in ldap_sign_in_failures, deleted at the next right password. For the provider: a reference to the service credential (the secret stays in the server's environment), the CA, the mappings, the category of its last failure and the time of its last success. Audit entries name the account by the address the directory returned: a wrong password with the attempt count, a refusal while locked, an account created or linked, a role the directory changed, a linked account refused for an unmapped group, and a refusal that would have demoted the last Owner.
Served. The settings answer says whether the credential resolves, never the reference or the secret, and gives the mappings and the last failure's category. The unauthenticated availability answer gives only whether LDAP sign-in is on and its display name.
Logged. A refusal for a configuration, TLS, directory or account reason, with the organisation's id and the failure category. A wrong password and a locked account are audited instead. No identifier, password, DN, filter or directory diagnostic is logged.
Not recorded. The password, in any form; the directory's own error text; the groups.
Asserted by tests/ldap_console_sign_in.sh against a real slapd: no password of the run and not the service credential appears in the server log, in a dump of the whole database or in the settings answer, with a control beside each search.