Appearance
API
Versioned under /api/v1. Two audiences with different authentication:
| Prefix | Audience | Authentication |
|---|---|---|
/api/v1/agent/* | Endpoint agents | Device-key signed request (Ed25519 or ECDSA P-256, pinned at enrolment); optional mTLS |
/api/v1/* | Dashboard, integrations | Session cookie or scoped API token |
/healthz, /readyz | Operations | None; no tenant data |
Every response is JSON. Errors share one shape and never carry sensitive content, SQL or stack traces:
json
{ "code": "policy.signature_invalid",
"message": "Human-readable and safe to log.",
"details": [{ "field": "name", "message": "must not be empty" }] }404 is deliberately ambiguous. Requesting another tenant's resource returns exactly the same 404 as a resource that does not exist, so the API cannot be used to enumerate what other tenants hold.
Agent API
POST /api/v1/agent/enroll
Unauthenticated; the one-time token is the credential.
jsonc
// request
{ "schema_version": {"major":1,"minor":0},
"enrollment_token": "mydlp_enroll_…",
"hardware_id": "<sha256, computed locally>",
"hostname": "WKS-4102", "os": "windows", "os_version": "11 23H2",
"agent_version": "0.1.0",
"device_public_key_b64": "…", // private key never leaves the endpoint
"device_signature_algorithm": "ecdsa_p256_sha256",
"device_key_protection": "tpm_backed",
"csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\n…",
"capabilities": { /* per-channel matrix */ } }
// response
{ "device_id": "…", "tenant_id": "…",
"certificate_pem": "…", "ca_chain_pem": "…",
"certificate_expires_at": "2026-12-14T…",
"policy_signing_keys": [{ "key_id": "…", "public_key_b64": "…",
"algorithm": "ed25519", "not_after": "…" }],
"checkin_interval_secs": 60, "agent_group": "default" }The token is consumed by an atomic UPDATE … WHERE uses_remaining > 0 … RETURNING, so two agents racing on one token cannot both enrol. Expired, revoked, exhausted and unknown tokens all return the same 403.
Signed requests
Every other agent endpoint requires four headers:
| Header | Value |
|---|---|
x-dlp-device-id | The device id from enrollment |
x-dlp-timestamp | RFC 3339, within ±300 s of server time |
x-dlp-nonce | 128-bit random, never reused |
x-dlp-signature | Signature over the canonical string, base64. The server uses the algorithm pinned on the device row; the request cannot choose it |
text
canonical = METHOD ␟ PATH ␟ hex(sha256(body)) ␟ device_id ␟ timestamp ␟ noncePATH is the full request path (/api/v1/agent/checkin). This matters: axum::nest rewrites Request::uri to the path inside the nested router, so the server reads OriginalUri. Getting this wrong makes every signature fail.
PATH carries no query string, and a signed request may not have one (DLP-496). The server verifies OriginalUri::path(), which stops at the ?; the agent signs the whole string it is handed. A signed request with a query therefore signs something the server never verifies, the two disagree, and it comes back 401.
That refusal is correct - fail closed on identity - but a bare 401 explains none of it, and the shortest change that makes such a request pass is to strip the query on the agent side, which leaves the parameter unsigned in a protocol where every other byte is covered. A replay floor an attacker can rewrite from since=7 to since=0 is the floor not existing.
So the agent refuses first: transport::signable_path rejects ? and # before anything is signed, at all three signing entry points, with a message saying to use a signed POST body and not to strip it. Parameters go in the body. A fragment is refused for a worse reason - it never leaves the client, so the parameter would reach nobody at all.
Replay needs two checks and has both: the timestamp bounds how long a nonce cache must live, and the nonce's primary key rejects a second use inside it.
An unknown device and a bad signature are one refusal to the caller (DLP-560): unknown or revoked device, or a signature that does not verify. They used to be two sentences, and the second is reachable only for an enrolled id, so anybody who sent any signature learned whether a device id existed. The server's log keeps the distinction - warn for an enrolled device whose key did not sign the request, info for an id nothing is enrolled under - because an agent that lost its key and one that was never enrolled send an operator to different places. The other refusals (a stale timestamp, a malformed id, a spent nonce) still name themselves: the first two are reachable without an enrolled id, and the last only after the signature has verified. Ed25519 public keys are 32 raw bytes and signatures are 64 bytes. TPM-backed Windows identities use a 65-byte uncompressed SEC1 P-256 point and a fixed-width 64-byte r || s ECDSA-SHA256 signature. Unknown algorithms, malformed key shapes and a protection claim inconsistent with the algorithm are rejected before the one-time enrollment token is consumed.
POST /api/v1/agent/probe
Signed like every agent route; body {device_id, client_time}, answer {server_time, clock_drift_ms}. Proves the device can reach its server and is still accepted there, and changes nothing: no queued command is collected, no health or capabilities are recorded, last_seen_at does not move. Beyond the replay nonce every signed request spends, it writes nothing (DLP-1311).
It exists for dlp-agent doctor, which used a full check-in from its own process. The check-in hands over every queued command and marks it collected in the same statement, so a command queued for the endpoint was taken and run by whoever ran doctor, and never reached the service. A route of its own rather than a flag on check-in, because an older server ignores an unknown field and would run the full check-in; an unknown route answers 404, and doctor reports that the server was reached but the device's identity was not checked, without falling back to a check-in.
POST /api/v1/agent/checkin
Reports health, capabilities and the bundle in force. Returns clock drift, whether a newer bundle exists, whether the device has been revoked, whether the certificate should rotate, and any queued commands.
The endpoint's operating system is what its capabilities say at each check-in, not what it said at enrolment (DLP-1545): a feature update shows on the endpoint record at the next check-in, and the change is audited once as agent.os_changed with from and to. An empty, over-long (more than 200 characters) or control-character value is not recorded, and the last good one stays.
The request also carries policy_trust_version and trusted_policy_key_ids, plus edm_key_ids: the EDM secrets successfully persisted in the endpoint keystore. The latter is the acknowledgement used by ADR-0071's rotation gate; a bundle version does not prove that its separately delivered EDM key arrived. When the server has a newer policy signing-key set, the response includes policy_trust_update: a complete replacement set whose exact payload bytes are Ed25519-signed by a key present in both the endpoint's current set and the replacement set. The agent verifies and persists this update before fetching policy. Trust-set versions only increase; an equal or older version, an empty set, another tenant's set, an unsupported algorithm, or a signer outside either side of the transition is refused.
Rotation is deliberately two check-in-visible transitions:
- the old key signs
{old,new}; - after fleet convergence, the new key signs
{new}.
A direct {old} to {new} replacement has no mutually trusted signer and is refused rather than turning a key rotation into a fleet re-enrolment.
POST /api/v1/system/signing-keys/{key_id}/retire requires tenant:manage and retires only a rotating policy key. It returns 409 while any active endpoint reports a trust-set version below the overlap revision; the operator must let that endpoint check in or revoke the endpoint before removing the old root. A successful retirement is audited as signing_key.retired. A suspected or confirmed compromise needs the separate signing-key incident procedure: normal overlap rotation is not emergency revocation. An existing tenant whose policy-bundle key has expired receives a conflict instead of an automatically generated, untrusted replacement; see the pre-expiry rotation procedure.
GET /api/v1/agent/policy
Returns the latest signed bundle, or {"bundle_version": 0, "bundle": null} with a reason when none is published - a normal state for a new tenant, not an error. A bundle that carries an exclusion is withheld from an endpoint whose last reported capabilities lack scope_exclusions (DLP-1484): 200 with {"bundle_version": N, "bundle": null, "refused": {"code": "upgrade_required", "missing": [...]}, "reason": ...}. The requirement is read from that exact bundle, and nothing about whom it excludes is said. Check-in's policy_update_available is false in the same case.
POST /api/v1/agent/events
Accepts a batch. Each event is acknowledged individually, because the agent's queue drains on those acks:
| Ack | Agent behaviour |
|---|---|
Accepted | Drop it |
Duplicate | Drop it. The normal outcome when the network failed between our commit and the agent seeing the response. Treating it as failure would make every post-outage reconnect loop forever |
Rejected { reason } | Drop it; a poison record is kept server-side so nothing disappears silently |
Retry { reason } | Keep it and back off |
A whole batch never fails because of one bad event. Retrying a batch reuses its id, and the server answers from an idempotency record rather than reprocessing.
An event dated more than a day ahead of the server is kept, not rejected (DLP-1612). It is placed at the time it arrived, and the endpoint's own time is kept in occurred_at_reported, which the incident's events list returns. Within a day ahead, the endpoint's time stands. Before, such an event was rejected, the agent dropped it, and a finding recorded while a clock was wrong (a VM restored from a snapshot, a dead CMOS battery) was lost. An event older than 90 days is still rejected (occurred_at_older_than_retention).
A file event may carry file.mark_of_the_web: the zone (zone_id, 0 this computer to 4 restricted sites) and the hosts (host, referrer_host) from the file's Zone.Identifier stream, never the rest of either address (DLP-1684). The server stores them masked like a file name, and the incident's events list returns them as file_mark_zone, file_mark_host and file_mark_referrer_host. Any program can write the stream, so it is what the mark says, not provenance, and no policy condition reads it.
POST /api/v1/agent/evidence-files/{event_id}
A copy of the file an event described (DLP-1188, ADR-0038). The body is the file's bytes, signed like every agent call, up to 50 MiB; the tenant's cap applies within that (413 above it). The copy is sealed with AES-256-GCM under the tenant's key and written to its bucket. It is kept only when all of these hold:
| Refused with | When |
|---|---|
409 | the organisation has no evidence storage configured, or its Owner has it switched off (DLP-1189) |
404 | the event does not exist, or is not this device's |
409 | the policy version that decided the event did not ask for the file |
400 | the bytes' content hash (BLAKE3) is not the one the event reported |
502 | the store did not accept the write; no copy is recorded, and a retry may succeed |
A refused upload writes nothing. A retry for an event already kept answers the first copy with "new": false. A kept copy answers its id, size_bytes and expires_at, and is audited as evidence.file_stored. The agent sends its copies after the events they name, a few per delivery cycle (DLP-1190).
POST /api/v1/agent/evidence-files/{event_id}/withheld
Why the copy a policy asked for is missing, as {"reason": "…"} (DLP-1190). The reason is one of over_cap, changed, unreadable, spool_full, spool_failed, given_up and refused; any other is 422. The event must be the reporting device's (404 otherwise). It is recorded once per event and reason, and saying it again is not an error. The incident's list of copies shows it as state: "withheld" with the reason.
POST /api/v1/agent/certificate
Issues a fresh device certificate, authenticated by the device key rather than by the certificate being replaced. An endpoint that was switched off long enough for its certificate to expire can therefore still renew it instead of needing to re-enrol.
The agent calls this itself, at the end of a check-in, once the certificate it holds has less than certificate_renew_before_days (default 30, against the server's 90-day DLP_DEVICE_CERT_DAYS) left. A renewal that fails is not fatal: the endpoint keeps the certificate it has and tries again at the next check-in.
Rate limiting
Every signed agent endpoint is charged to a per-device token bucket, keyed on the device identity proven by the signature - not on the source address. An address-based limit is not usable here: a NAT-ed office shares one address, so it would either throttle a hundred innocent machines along with the noisy one or be set so high that one machine could still flood ingestion. nginx's per-address limit stays in place as the outer defence.
| Setting | Default | Meaning |
|---|---|---|
DLP_AGENT_RATE_PER_MINUTE | 60 | Sustained requests per device per minute |
DLP_AGENT_RATE_BURST | 120 | Bucket size - the largest burst after idling |
DLP_AGENT_RATE_ALERT_COOLDOWN_MINUTES | 15 | Minimum gap between throttle alerts for one device |
Neither rate may be set below 1; the API refuses to start otherwise, because 0 reads like "no limit" and behaves like "no endpoint may ever connect".
Backpressure comes in two forms:
| When | How it is carried | |
|---|---|---|
| Soft | The bucket has dropped below a quarter of the burst | throttle_ms on the successful POST /events response |
| Hard | The bucket is empty | 429 with Retry-After (whole seconds, rounded up, never 0) |
The soft hint is what keeps a busy-but-honest endpoint out of the 429 path: it drains its backlog a little slower instead of being refused. The agent honours both, and honours them without discarding anything - deliver_once releases its event leases on any failed upload, so a throttled batch is retried, not lost. A throttled agent also stays online: the server answered, so reporting it as unreachable would send an operator to look at the network.
Exceeding the limit is throttling, not revocation. The device recovers on its own as tokens refill - no operator action, no re-enrolment, no restart.
Ingestion quotas
The rate limit bounds how often a device may call. It does not bound how much it sends: a device well inside its rate can still push five hundred events per request, forever. POST /events is therefore charged against three further per-device quotas, all using the same token-bucket arithmetic with a cost weighted by what the call actually carries.
| Quota | Setting | Default | Refused with |
|---|---|---|---|
| Events per hour | DLP_QUOTA_EVENTS_PER_HOUR / DLP_QUOTA_EVENT_BURST | 20 000 / 40 000 | 429, reason event_volume |
| Bytes per hour, measured on the wire | DLP_QUOTA_BYTES_PER_HOUR / DLP_QUOTA_BYTE_BURST | 256 MiB / 512 MiB | 429, reason byte_volume |
| Events stored at once | DLP_QUOTA_STORED_EVENTS | 2 000 000 | 429, reason storage_footprint |
| Events per batch | DLP_MAX_BATCH_EVENTS | 500 | 400, not 429 |
The batch limit is a 400 on purpose: an oversized batch is a client defect that no amount of waiting fixes, so telling the agent to retry later would be a lie. It is also checked before the quotas are charged, so a buggy agent cannot spend a healthy device's event budget on batches that were never going to be stored.
The event and byte buckets are charged only when both have room. Charging one while refusing on the other would drain the event bucket on every refused attempt, and a device over its byte quota would end up locked out of both for far longer than either limit describes.
The storage footprint is not a rate, and its Retry-After is deliberately long: waiting does not clear a footprint, only retention does. The counter is incremented as events are stored and reconciled against the events table by every retention pass, scheduled or manual. Without that reconciliation the counter only ever grows, and a well-behaved endpoint would eventually be refused for a footprint it no longer has.
All of these are per device, never a tenant-wide pool. A tenant-wide cap would reintroduce exactly the collateral damage this control exists to prevent: one compromised endpoint would eat the shared allowance and throttle its innocent colleagues. A tenant's ceiling is its device count times the per-device quota, and enrollment is what bounds the device count.
Crossing a quota writes an agent.quota_exceeded audit entry, once per episode per device, and increments a per-reason counter visible on /api/v1/system/rate-limits.
Console API
Request ids (DLP-2131)
A response carries x-request-id, minted by the server for that request. A client's own x-request-id is ignored. An audit entry written by a request carries the same value as metadata.request_id. The request span names the id in the debug log (request{... request_id=...}), so an entry, a response and a log line can be joined on one id. A write made outside a request, such as a background sweep, has no request_id in its metadata.
Read-only AD/Entra sources and identity lookup (DLP-1435, DLP-1436, DLP-1438)
Method/path under /api/v1 | Permission | Request/result |
|---|---|---|
POST /directory-sources | tenant:write (Owner) | {name, credential_ref, reason, source_kind?:"active_directory"|"entra_id"} → {id, name, enabled:false, verification:"unknown"}; omitted kind retains AD |
GET /directory-sources?after=<source UUID> | policies:write and directory:read | Tenant-only metadata, max 16 entries, next continuation; no credentials/bind DN |
POST /directory-sources/{id}/enabled | tenant:write (Owner) | {enabled, reason}; changing the switch clears verification and invalidates cursors |
POST /directory-sources/{id}/lookup | policies:write and directory:read | {kind:"user"|"security_group", query, limit:25, cursor?} or {kind, object_sid} (AD) / {kind, object_id:<UUID>} (Entra) |
GET /agents/unmanaged?limit=50&offset=0 | agents:read; refused for a role scoped to endpoint groups | Enabled AD computers and enabled Entra ID devices with no active agent (DLP-1502, DLP-1503, ADR-0045): {total: number|null, items:[{name, dns_host_name, operating_system, source_id}], sources:[{id, name, kind, state:"counted"|"failed", failure, message, computers, stale, truncated}], stale, stale_after_days:90, incomplete, observed_at}; total is null when no source was counted; read live, cached five minutes, never stored |
The console adds sources and switches them under Settings > Single sign-on > Directory sources (DLP-1895), for tenant:write only. It composes credential_ref from this tenant's prefix and the operator's variable name, and it never reads the secret. Nothing verifies a source on request: last_verified_at is set when a lookup, the unmanaged-computer count or the department resolver uses it.
An incident's department from the directory (DLP-1772, ADR-0048). With an enabled source, a background resolver asks the directory for the department of each person with an open, unattributed incident:
- AD: an exact
sAMAccountNameoruserPrincipalNamematch, requesting onlyobjectClass,sAMAccountName,userPrincipalName,department,userAccountControlandisDeleted; - Entra:
GET /users/{upn}?$select=id,userPrincipalName,department,accountEnabled. It fills that person's open incidents. Every incident row carriesdepartment_source:endpointordirectory, or null when there is no department. A closed incident is never changed. Correlation reads only the resolver's cache, so a new incident for a person already looked up carries the department at creation. The answer per person is cached for 24 hours, a failure for 15 minutes. A source whose lookup failed rests for 15 minutes, records the failure inlast_failure, and is audited asdirectory.source_health_changedby the system. Two enabled accounts that answer to one name, or a disabled account, give no department.
Searching is POST so personal search terms do not enter URL/access logs. Exactly one literal query (2–128 characters) or stable SID is required. Pages contain at most 100 minimal source-qualified SID/GUID objects, enabled state, observation time and explicit selectability. AD users are not yet enabled for typed targeting: the local SID reader still requires native interactive acceptance and compatible delivery proof (DLP-1437); this route does not pretend metadata lookup implements user targeting. Distribution groups and computer objects are not offered as security groups or users. No member, memberOf or tokenGroups is requested or stored. A renamed object can be looked up by its SID; a deleted object is absent, never replaced by its name.
Only enabled, tenant-owned configured sources can be queried. Foreign and missing source IDs both return 404. Incident-group-scoped roles cannot query tenant-wide directory identities. Owner/Admin hold directory:read by default; policy authoring alone, Auditor and Viewer do not. API tokens need both explicit scopes. Configuration and health-state transitions are audited atomically without queries, returned identities or secret references/values.
Verified LDAPS, a pinned AD domain SID, no referral chasing, a ten-second end-to-end deadline, eight live searches per API process and one attempt per source per second bound calls. Failures return 502 with a sanitized reason, not a verified empty page. The source exposes last attempt/verification, unknown/stale status and a fixed failure code. Continuations are encrypted, actor/tenant/source/configuration/query/kind/limit-bound and expire in five minutes; API restart or routing to another instance asks for a fresh search. Multi-instance cursor affinity/shared cursor keys are not implemented here.
apps/dashboard/src/lib/directory-api.ts is the typed client contract for the existing routes, not a second search endpoint. It fetches one page only, accepts an abort signal for an obsolete lookup, and binds response source, object kind and exact ID to the requested context. Strict schemas refuse extra membership/credential/identity fields, overfull/duplicate pages and unsupported objects marked selectable, rather than stripping a privacy regression silently. A 403, 429, 502 or schema mismatch remains a failure, never a verified empty page. Picker interaction is separate (DLP-1439). The recording Graph integration fixture represents 100,000 users and 10,000 groups lazily and asserts that only four explicitly requested 100-object pages are fetched. This proves bounded API pagination, not customer-directory performance, consent, endpoint membership or cross-page snapshot consistency.
The policy version editor's DirectoryIdentityPicker (DLP-1439) consumes this contract. A 350 ms debounced lookup is cancelled when its source, kind or query changes; continuations require an explicit action. Only fresh metadata (under five minutes) marked selectable by the API can add a target. AD user, Entra/dynamic and department enforcement remain unavailable. Results show object type, source, masked stable identifiers and an explicit reason for unavailable choices; keyboard users reach native selection buttons with Tab or arrow keys. Included and excluded targets preserve the exact canonical coordinates in the ordinary audited policy-version save, not labels alone. Stored targets can be verified by exact ID to show rename/deletion state; failed or empty lookup never removes them or silently broadens the scope. Existing legacy group strings are retained with a removal action, not offered as a free-form way to add new groups. Material selection/removal changes use the existing editor's unsaved-work guard. Native Windows capability acceptance is still a separate gate (DLP-1437); browser fixtures do not replace it. Source and lookup metadata are private to the mounted picker, not shared query-cache data. An actor, tenant or permission change remounts only the picker, cancelling obsolete lookups and clearing its metadata without discarding the editor's unsaved draft. API authorization remains authoritative.
Entra metadata is not enforceable targeting. An operator assigns a tenant-qualified env:DLP_DIRECTORY_<MyDLP tenant UUID without hyphens>_<NAME> secret through the same key resolver as AD. Its base64-encoded JSON contains tenant_id (Microsoft tenant UUID), client_id (app UUID), client_secret. Base64 is encoding, not encryption; provision it in the protected server environment, not through a console response. Registration pins the Microsoft tenant in the source row; changing the secret to another tenant is refused. An existing SharePoint grant is not consent to directory lookup.
The customer explicitly consents to application User.Read.All for user state and GroupMember.Read.All for basic security-group metadata. The app can hold one without the other; each query checks its own grant. No write or Directory.Read.All grant is requested. GroupMember.Read.All also grants membership access in Graph, but this adapter never calls membership endpoints. Use a dedicated app when reusing discovery's app would widen its purpose. Counting devices without an agent (GET /agents/unmanaged, DLP-1503) needs the application permission Device.Read.All as well; without it that source is reported consent_required and contributes no number, while lookups keep working on their own grants. The token must state the pinned tid and the required read-only role; Graph authenticates it. Opaque/non-inspectable tokens are refused, not guessed.
Search uses bounded display-name prefix filtering and explicit minimal $select; it is not full-directory sync or arbitrary OData. User results have enabled:true|false|null (null means unknown); security groups also expose dynamic. All Entra results have selectable:false with the current endpoint contract's reason. No group membership, UPN, email, phone, membership rule or file content is requested. An exact object lookup survives rename; a 404 is not_found, never substitution by display name. Graph is eventually consistent: observed_at means response time, not last directory change or proof of current token membership.
Only the operator can override Graph/login base URLs through the existing DLP_M365_GRAPH_URL / DLP_M365_LOGIN_URL national-cloud settings. Redirects are never followed. Each response has a 256 KiB byte limit; nextLink must keep the exact Graph origin, collection and selected fields/page size, is encrypted by the existing actor/source/query-bound cursor, and is never returned raw. Ten seconds bounds token plus lookup together; cancellation drops the request. Expired credentials, wrong tenant, consent loss, throttling, missing objects, partial/oversized pages and invalid objects produce sanitized 502 failures and unknown health. No automatic retry hides consent loss or throttling. This API and tests/entra_directory.sh verify the adapter contract, not customer consent or Entra endpoint enforcement.
Microsoft reference: users, groups, permissions, pagination.
Authentication
POST /api/v1/auth/login - {tenant, email, password}. Sets an HttpOnly, Secure, SameSite=Strict cookie and also returns the token for non-browser clients. Every failure mode returns the same 401.
POST /api/v1/auth/logout · GET /api/v1/auth/me
GET /api/v1/auth/me carries the workspace display timezone, date_format and time_format. If no display zone has been saved, timezone falls back to the business-hours zone or UTC. The display preference does not change the business-hours clock used to count response targets (DLP-1807). A sign-in's body leaves these out; they are read with the tenant's settings, which the sign-in response does not load.
Idle timeout (DLP-997). A console session ends once nobody has used it for the tenant's idle time: settings.session.idle_minutes, 15 to 480, 60 by default. The session is revoked and the request refused with 401 auth.session_idle, whose message names the setting, and the sign-in page shows it. Use is a request that changes something (any method but GET, HEAD or OPTIONS), or POST /api/v1/auth/activity. The console sends that heartbeat while somebody types, clicks, scrolls or moves the pointer, at most once a minute, and it answers 204. A read does not count, because the console reads on its own (the session re-check, refetches) and an unattended open tab would otherwise never go idle. last_activity_at is written at most once a minute per session. API tokens have no idle timeout.
POST /api/v1/settings/session - {idle_minutes, reason}, tenant:write, audited as settings.session_idle_changed with the old and new values. It applies to every session at its next request, and signs nobody out by itself.
POST /api/v1/settings/incident-response - {critical_minutes, high_minutes, medium_minutes, low_minutes, reason}, tenant:write, audited as settings.incident_response_changed with both values and the reason (DLP-1518). How soon an incident of each severity must be triaged: the clock starts at created_at, when the incident was raised, not at first_seen, the endpoint's event time (DLP-2002), and stops when the incident leaves new. Each target is 5 minutes to 30 days, and a more severe incident may not be given longer than a less severe one (422 naming the field). info has none. The defaults are 1 h, 4 h, 24 h and 72 h, and GET /settings returns the current ones as incident_response.
It also takes resolution_critical_minutes, resolution_high_minutes, resolution_medium_minutes and resolution_low_minutes (DLP-2002): how long an incident of each severity may stay open, from created_at, with the time spent in awaiting_user left out. Each is 1 hour to 90 days, ordered by severity, and none may be shorter than the same severity's response target. The defaults are 1, 3, 7 and 14 days. One left out of a request keeps its stored value, so a client that knows only the response targets does not reset them.
Whether each target was met is recorded on the incident when it is decided: response_met at the first move out of new, resolution_met at a close from an open status (cleared by a reopen, and decided again at the next close). Changing a target changes what is due from then on, never what was met. Tests, info, and incidents decided before this was recorded have neither. Not backfilled: what the targets were for an older incident is not known.
POST /api/v1/auth/accept-invitation - {invitation, password}, unauthenticated. Redeems a console invitation and signs in, answering exactly as login does. Single use: the invitation is cleared in the same statement that writes the password. A malformed invitation, an unknown one, an expired one and one already redeemed all return the same 401.
The console calls it from /accept-invitation (DLP-684). Users & roles shows a new invitation as https://<console>/accept-invitation#<invitation> (DLP-685). The code travels in the fragment, which browsers send to no server and put in no referrer, and the page removes it from the address once it has read it.
POST /api/v1/auth/reset-password - {reset, password}, unauthenticated (DLP-795). Redeems a password-reset link an administrator issued, sets the password, and revokes every session the account holds, in one transaction with the user.password_reset_redeemed audit entry (which records how many sessions ended). Answers 204 and opens no session: the person signs in with the new password. Refused alike, with 401, for a malformed, unknown, expired, used or disabled account's link. The console redeems it at /reset-password#<link>.
The password both routes accept (DLP-991). At least twelve characters, and refused with a 422 in the password field, with a sentence saying which rule, when it is a keyboard or alphabet run, a short pattern repeated or padded with a run or a year, on SecLists' 10k-most-common list of 10,001 passwords (read after removing padding digits and symbols, undoing letter substitutions and folding a word repeated to reach the length), when it contains the whole e-mail name, or when it is little more than a piece of that name, the organisation's slug or name, or the product's name. There are no composition rules and no expiry (NIST SP 800-63B §3.1.1.2). The refusal never repeats the password. It is applied only after the invitation or link is proved, so a forged one is still a 401 whatever the password, and a refused choice spends neither. --bootstrap applies the same rule to DLP_BOOTSTRAP_PASSWORD, against the owner's address and the tenant slug, whether it creates the tenant or resets an existing owner. A refusal exits non-zero with the sentence and changes nothing (DLP-1103).
Only single sign-on, with break-glass Owners (DLP-994)
GET /api/v1/settings/sign-in-mode system:read. {sso_only, break_glass[{user_id, email, usable}]}
POST /api/v1/settings/sign-in-mode sso:write. {sso_only, break_glass[user ids], reason}
step-up sign_in_settingsWith sso_only on, a session proven by a password is refused (403) for everybody but a named break-glass account that is still an active local Owner. The refusal sentence names single sign-on. It applies to a sign-in, its second step and an accepted invitation. A session the identity provider proved is never refused. The refusal comes only after the password is right, so a wrong password answers 401 as always, and nobody learns which accounts are named. A break-glass sign-in is audited as auth.break_glass_sign_in, beside the ordinary sign-in entry. A password reset link is not issued (409) for an account that could not use it.
Turning it on needs an enabled provider (409 otherwise) and at least one named account, each an active Owner with a password here (422). While it is on, the provider cannot be disabled or deleted (409): turn this off first. Changing it is audited as settings.sign_in_mode_changed, with the setting before and after and the reason. Sessions opened before it was switched on are not ended by it, and they expire as usual.
A second factor for local accounts (DLP-986–DLP-988, ADR-0036)
GET /api/v1/auth/second-factor {applies, enrolled, confirmed_at, recovery_codes_left}
POST /api/v1/auth/second-factor/enrol -> {secret, otpauth_uri} step-up second_factor
POST /api/v1/auth/second-factor/confirm {code} -> {recovery_codes[10]}
POST /api/v1/auth/login -> a session, or {next, challenge, expires_at}
POST /api/v1/auth/login/second-factor {challenge, code | recovery_code} -> a session
POST /api/v1/auth/login/enrol {challenge} -> {secret, otpauth_uri}
POST /api/v1/auth/login/enrol/confirm {challenge, code} -> a session with recovery_codes
GET /api/v1/settings/second-factor system:read. {required, since, grace_days, due_by}
POST /api/v1/settings/second-factor tenant:write. {required, grace_days 0-90, reason}
step-up sign_in_settings
DELETE /api/v1/users/{id}/second-factor users:write. step-up role_changeTOTP, RFC 6238: HMAC-SHA1, six digits, thirty-second steps, one step of drift either way. Enrolment is two calls. enrol answers the secret once, and confirm takes a code from the app and answers ten recovery codes once. Nothing is asked at sign-in until confirmation. Only a person on a session with a local account enrols: a token is 403, and a federated account 409.
Sign-in. A right password for an enrolled account makes no session and sets no cookie. It answers {"next": "second_factor", "challenge", ...}. The challenge lives five minutes and takes five attempts. A code, or a recovery code, with the challenge makes the session. A code is accepted once: the factor keeps the last step it accepted, and a code for that step or an earlier one is 401. A recovery code is spent on use. A wrong code counts towards the account's lockout as a wrong password does, and is audited as auth.second_factor_failed. A wrong password answers exactly as before.
The requirement. With required on, an unenrolled local account signs in until since + grace_days, and its sign-in answer carries second_factor_due_by. After that date the right password answers {"next": "enrol", ...}. That challenge can only start and confirm an enrolment, and the confirmation completes the sign-in, with the recovery codes in the answer once. Federated accounts are not affected. Changing it is audited as settings.second_factor_changed, with a reason.
At rest. The secret is sealed with AES-256-GCM under DLP_FACTOR_KEY (32 bytes, base64) or, when that is unset, a key the server generates once and keeps in server_secrets. That second case protects a secret copied out of the table and not a whole database dump (ADR-0036 §3). Recovery codes are kept as SHA-256 digests. No secret, code or digest is written to a log, an audit entry or a later response. user.second_factor_enrolled and user.second_factor_reset record counts only.
Reset. DELETE /users/{id}/second-factor removes the factor and its codes for somebody who has lost both. It is refused against your own account (409) and, for a non-Owner, against an Owner (403), as an unlock is.
Users and roles (DLP-523)
Permission::UserManage, which is granted to Owner and Admin. Every act is audited: user.invited, user.role_changed, user.disabled, user.enabled, user.invitation_reissued, user.password_reset_issued, user.unlocked, user.session_revoked, user.profile_changed, each naming the subject, the actor and - for a role change - both roles.
GET /api/v1/users the tenant's directory; also audit:read, projected
POST /api/v1/users {email, role, display_name?, department?, outside_domain_confirmed?}
→ {user_id, invitation, expires_at}
PATCH /api/v1/users/{id} {display_name?, department?} → the row
POST /api/v1/users/{id}/role {role}
POST /api/v1/users/{id}/disable {reason} → the row plus incidents_still_assigned
POST /api/v1/users/{id}/enable {reason}
POST /api/v1/users/{id}/reinvite → {user, link, expires_at} step-up role_change
POST /api/v1/users/{id}/password-reset
→ {user, link, expires_at} step-up role_change
POST /api/v1/users/{id}/unlock → the row step-up role_change
GET /api/v1/users/{id}/sessions → [{public_id, issued_at, expires_at, source_ip, user_agent}]
GET /api/v1/users/sessions?limit=25&offset=0 → {items, total, limit, offset}; users:write, tenant-wide live human browser sessions only
POST /api/v1/users/{id}/sessions/{public_id}/revoke → {revoked: true}
GET /api/v1/users/roles the role/permission matrix, as enforcedThe organisation's domains (DLP-1822). An invitation is judged against the tenant's domains: the allowed_domains of its enabled identity providers, or, when none lists any, the domains of its active Owners. Inside them it is sent as before. Outside a configured allowlist it is refused 422 on email, because the identity provider would refuse the account it makes. Outside the Owners' domains only an Owner may invite, and only with outside_domain_confirmed: true; without it the answer is 422 on outside_domain_confirmed, which the console turns into a checkbox. The user.invited audit entry then carries outside_tenant_domains, the domains it was outside of. A tenant with no domains at all decides nothing.
Locks and sessions (DLP-982). DLP_MAX_FAILED_LOGINS failed sign-ins in a row (8 by default) lock an account for DLP_LOCKOUT_MINUTES (15). The lock's end, locked_until, is now on the directory row while the lock holds. Before this, nobody could see a lock or lift it before it passed. unlock clears the lock and the failure count together, so the next wrong password counts as the first. It is refused with 409 when the account is not locked, and it asks for a recent sign-in: it gives access back, as enable does, and a lock is what stands between a password guesser and the account.
sessions lists the console sessions that could still be used: start, expiry, address and browser. It never returns the token or its hash, only the session's public id. revoke ends one session, and that session's next request is refused with 401. It is containment, like disabling, so it does not wait for a confirmation. The list includes Owners' sessions for any users:write holder. Acting on them is Owner-only.
GET /users/sessions is the paged tenant-wide inventory for the Settings Active Sessions view. It requires users:write without an endpoint-scoped role, accepts limit 1–100 and offset 0–10000, and includes only active users' unrevoked, unexpired sessions that have not exceeded the tenant's current idle timeout. It returns the user's display name, email, role and account identity source (not the authentication method of this particular session), last activity, source address, browser and a current_session flag; it never returns session tokens or hashes. API tokens are not browser sessions. Revocation still uses the per-user route and its Owner/self guards.
The directory for an auditor (DLP-803). GET /users also answers audit:read. A user-access review asks who holds which role, whether the account is live, how it signs in and when it was last used, and an Auditor could read what each role grants but not who held it. The auditor's answer carries id, email, display_name, role, custom_role_id, custom_role_name, status, department, federated, last_login_at, created_at. It leaves out invited, invitation, has_password and locked_until, because only the people who administer accounts act on those. Without either permission the refusal names users:write.
Name and department (DLP-983). PATCH /users/{id} changes either after the invitation, which could not be done before. An empty department clears it. It is audited as user.profile_changed, with each changed field's old and new value. An edit that changes nothing returns the row and records nothing. Editing your own profile is allowed, and an Owner's is edited only by an Owner. A federated account's display name is refused with 409, because single sign-on writes it from the provider's claims at every sign-in and would undo the edit. Its department, which sign-in does not write, can be edited.
Neither unlock nor revoke may be aimed at your own account (409): locked out, you cannot call it, and your own sessions end by signing out. An Owner's account is unlocked or its sessions ended only by an Owner, as with its other credentials.
The invitation is returned once and only its SHA-256 is stored; it is valid for seven days. An administrator never chooses somebody else's password.
Each directory row carries invitation - pending, expired or null once accepted - and has_password (DLP-794). invited is kept and means pending.
reinvite replaces an outstanding invitation, pending or expired, with a new seven-day one; the old link stops working in the same statement. An account that has already set a password is refused with 409 and pointed at a reset. password-reset issues a one-time link valid for 24 hours to an account that has a password (DLP-795); the password keeps working until the link is redeemed. Neither link is stored except as a hash, and neither appears in an audit entry or the server log. Both follow the Owner rule - only an Owner may act on an Owner - and both refuse a disabled account and a federated one, whose credentials are its identity provider's, with a sentence saying so.
Four refusals, and each names itself rather than returning a bare 403: only an Owner may create or promote an Owner; only an Owner may change or disable an Owner; the last active Owner may not be demoted or disabled; and no administrator may aim either change at their own account. Custom roles add two more (see Custom roles): you cannot give what you do not hold, and you cannot change somebody who holds more than you.
Disabling answers with the count of incidents still assigned to that person (DLP-525). Not a warning and not a block - disabling is not the moment to make somebody decide who inherits a queue - but the number belongs in the answer, because the alternative is that it surfaces weeks later as a queue nobody is working. GET /api/v1/incidents?assignee=departed is the screen that answers it.
An account is disabled, never deleted - an incident names the person who triaged it, and that has to stay answerable after they leave. Disabling revokes their sessions and clears any outstanding invitation; access ends on the next request, not at the session's expiry.
GET /api/v1/users/roles is readable with AuditRead as well, because checking which role can do what is an auditor's job and requiring the permission to grant roles in order to look at them would hide the matrix from the person whose job is to check it.
Custom roles (DLP-601)
A tenant can define roles of its own beside the five built-in ones. A custom role is a name, a description and a set of permission identifiers: the same strings API-token scopes use (incidents:read, policies:approve), which are stable across builds.
GET /api/v1/roles {built_in[], custom[]}: each with its permissions and member count
GET /api/v1/roles/permissions the catalogue: id, description, domain, sensitivity,
delegable, grantable_by_you
POST /api/v1/roles {name, description?, permissions[], incident_scope?} -> 201
PUT /api/v1/roles/{id} {name, description?, permissions[], incident_scope?, revision}
POST /api/v1/roles/{id}/retire {reason}Reading needs AuditRead or UserManage, as /users/roles does. Defining, editing and retiring need UserManage and then a recent confirmation (role_change, see Confirming it is you).
What a definition may contain. It is checked before anything is stored. Each refusal is 422 and names the identifier:
- only identifiers this build knows. An unknown one, or
*, is refused rather than stored; - never
tenant:write. Ownership is granted by making somebody an Owner, and the last-owner rule protects Owners; a custom role holding it would be a second kind of owner that rule cannot see; - only what the person defining it holds. On an edit, keeping a permission the role already has is allowed. Adding one or removing one the editor does not hold is refused. So an administrator can neither write a role stronger than themselves nor weaken one that is stronger than them;
- not a built-in role's name, in any spelling;
- a live name unique in the tenant. A retired role's name is free again.
The permissions are stored sorted and without duplicates. So two revisions of one role differ in the audit log only where they really differ.
Edits name the revision they read. One made against a revision somebody has since changed is 409, and nothing is changed.
Retiring, not deleting. A role somebody holds is not retired. The refusal counts its members, disabled accounts included, because a re-enabled account gets its role back. A retired role stays listed and cannot be edited: it is part of the record of who could do what. The database enforces the same thing: a member's row refers to its role, and the role cannot be deleted from under it.
Built-in roles are not editable. They are not rows at all: they are auth::role_has, in code, and GET /roles marks each protected. tests/custom_roles.sh compares them on every run with the recorded baseline in tests/fixtures/built_in_roles.json. Granting a built-in role a permission is therefore a change to two files, both reviewed.
Incident scope (DLP-1001, ADR-0035). incident_scope is a list of endpoint group ids. A holder of the role reads and acts on only the incidents raised on devices currently in those groups. Every other incident answers as though it did not exist: 404 on every route that names one, left out of the queue, its count, the export, search and related incidents, unavailable in a bulk change, and not counted in any figure on GET /overview or GET /policies or in GET /detectors/quality/observed. A device outside the scope is served with its incident count and last user withheld. API tokens and scheduled reports act within their creator's scope.
The list may also hold 00000000-0000-0000-0000-000000000c1d, which stands for cloud storage (DLP-1988, ADR-0035 addendum): SharePoint and OneDrive incidents, which have no device, are inside a scope that names it and inside no other.
It is checked like the permissions, and each refusal is 422:
- an empty list;
- a group that is not the tenant's;
- any of the permissions that are tenant-wide by nature:
directory:read,audit:read,audit:export,legal_hold:write,subject_access:export,identity:reveal,integrations:write,tenant:write,behaviour:read,users:write,sso:write,health:read,health:manage. The refusal names the permission and why.
Absent on an edit keeps the scope the role has. null makes the role tenant-wide. A client that does not know about scopes cannot widen a role by leaving the field out. GET /roles returns each custom role's incident_scope, which is null for a tenant-wide role. Retiring an endpoint group that a live role's scope names is 409, naming the role.
Audit. role.created (with every permission and the incident scope), role.updated (before, after, added, removed, the incident scope before and after, the revision, and the member count at the time) and role.retired (with the reason).
Assigning a custom role (DLP-602) uses the same routes as a built-in one, with the role named by id:
POST /api/v1/users {email, role: "custom", custom_role_id, ...}
POST /api/v1/users/{id}/role {role: "custom", custom_role_id}custom_role_id is required with custom and refused with any built-in role. A role from another tenant, or a retired one, is 422 "no live custom role with that id in this tenant": the answer does not confirm the id exists anywhere.
What a holder can do is the role's current definition, resolved on every request. The session, an API token acting as the holder, and a scheduled report running as them all resolve it the same way: one join, auth::CUSTOM_ROLE_JOIN. Nothing caches it between requests, so a change to the role reaches every holder on their next request, without signing in again. A token is narrowed by both its scopes and the role. The scopes a holder may put on a token are the role's (GET /api-tokens/scopes).
It fails closed. A holder whose role is retired, or whose row names a missing role, resolves to no permissions at all. So does an identifier in a stored row that this build does not know, and so does tenant:write in one, even if the row was edited by hand.
Delegation is bounded both ways. Two refusals are added to the four above, checked after them:
- "that role carries permissions you do not hold": you can give somebody only what you could do yourself. This applies to built-in roles too: a custom user administrator who lacks a Viewer's permissions cannot make somebody a Viewer.
- "this account holds permissions you do not": changing, disabling or re-enabling somebody who holds more than you is refused.
For the built-in roles neither ever fires where it did not before: an Admin holds everything a custom role can hold.
Separation of duties is per action, not per role. The four-eyes rule on policy release (DLP-536) is decided by who wrote or submitted a version. It therefore holds for custom roles, and across a role change: somebody who submitted a version and is then given policies:approve still cannot approve it.
Single sign-on maps to built-in roles only. The provider sets a federated account's role at every sign-in (POST /settings/oidc group mappings), so a role given to such an account here, custom or built-in, would be silently put back at the next sign-in. POST /users/{id}/role on a federated account is therefore refused with 409 (DLP-791). The refusal names the two controls that hold: the person's groups in the identity provider, and disabling the account. A mapping or default naming custom is refused at configuration. Mapping groups to custom roles is a later slice. When a sign-in changes an account's role, it writes user.role_synced, with the old role, the new role and the provider. A sign-in that leaves the role as it was writes nothing. Before this, the provider's changes left no entry, and an auditor reading role history saw promotions and demotions nobody had made.
GET /auth/me and the sign-in answers carry custom_role: {id, name} beside role: "custom". Their permissions list is what the request resolved, not what the role's name suggests. incident_scoped (DLP-1501) is true when that role reads incidents for some endpoint groups only: a flag, not the groups, which is all the console needs to withhold the fleet filters such a reader is refused.
Migration and rollback. Migration 0062 is additive. It creates the new table and adds a nullable users.custom_role_id, and changes no existing row. Every account keeps its built-in role. tests/custom_roles.sh, given OLD_API_BIN, migrates a database with the previous build, seeds an account in every role, migrates forward, and checks each account's id, email, role and status are as they were.
A server binary older than v0.327.0 does not start against a database migrated past it. sqlx refused by default: "migration 62 was previously applied but is missing in the resolved migrations". This was checked against the v0.314.0 binary. So rolling back past 0062 to a build before v0.327.0 means restoring the database, not reinstalling an older binary. An earlier version of this paragraph said the older binary would ignore the new table. It does not, and the comment inside 0062 says the same wrong thing. That comment cannot be corrected, because an applied migration file is checksummed and editing it would stop the server starting. From v0.327.0 a build starts against a newer schema (DLP-628). See docs/DEPLOYMENT.md, Rolling the server back.
Within builds that have 0062, the data rolls back by being reassigned. Give each custom-role holder a built-in role (SELECT email FROM users WHERE role = 'custom'), then retire the roles.
Global search (DLP-521)
GET /api/v1/search?q=<text> → {query, hits[], not_searched[]}One query string against the kinds an administrator arrives holding a reference for. The first five were: incidents (by reference, hostname or username, and since DLP-1862 by the six-character reference the endpoint showed the person on its notice or prompt, matched exactly and case-insensitively), endpoints (by hostname), policies, classifications and detectors (by id or name). Five rows of each kind, grouped by kind. Not ranked by a relevance score, because a reference and a hostname are exact strings somebody is holding, and a score would rank a fuzzy policy-name match above the endpoint they typed in full. Within a kind, the operational ones (incidents, endpoints, policies, exception requests, users, scans) are newest first. The classification objects (classifications, detectors, datasets, fingerprint sets, labels and user tags) put an exact identifier or name first, then those that begin with the query, then the rest, each by identifier ascending (DLP-1081). Somebody typing AUDIT-TEST-01 wants that one and then its neighbours in order; recency put the last one edited first and cut its neighbours off at five. The console offers "See all" from the classifications and detectors groups, into their list filtered by the query.
A hit is {kind, id, label, detail, at} and nothing else. No hit carries a matched value, a file name, or an incident's title - the title interpolates the file name, so a row carrying one would put a file name in front of anybody who typed three characters of it. The title is not matched on either.
A kind the caller holds no permission for is never queried, and its name is returned in not_searched. Filtering after the query would mean the database had already counted rows the caller may not see, and a count is a disclosure. Naming the unsearched kinds discloses nothing that /auth/me does not already say, and without it an empty answer cannot be told apart from a kind that was skipped.
q is a literal: % and _ are escaped before they reach ILIKE, so a search for % finds rows containing a percent sign. Fewer than two characters is refused with 422 rather than answered with most of a tenant.
Five more kinds since DLP-610. Each is behind the permission its own list route requires, so search never shows a row that the page it links to would refuse:
| Kind | Permission | Matched on | Never returned or matched |
|---|---|---|---|
exception_request | exceptions:read | reference, policy name | the file name shown to the person, the destination |
user | users:write | email, display name | - |
edm_dataset | edm:read (DLP-883; edm:write until then, from before reading had its own permission) | dataset id, name; deleted ones excluded | fields, record counts |
fingerprint_corpus | policies:read | corpus id, name; active ones only | any document in it |
discovery_scan | discovery:read | name | the target path or bucket |
label (DLP-1081) | detectors:read | label id, name | - |
user_tag (DLP-1081) | detectors:read | code, label; retired ones excluded | - |
What search records about itself (DLP-612). Per kind: how many queries ran, how many were skipped for want of permission, and how many rows came back. Also the number of searches, their total time, and the slowest. The series are mydlp_search_kind_queries_total, mydlp_search_kind_not_searched_total, mydlp_search_kind_hits_total, mydlp_search_requests_total, mydlp_search_duration_seconds_sum and mydlp_search_duration_seconds_max. Never the query: not in a metric, and not in the log at any level. The request span records the path and not the query string, for every route, because an incident filter can name a person as surely as a search can. tests/search.sh §7 runs the server at debug, then searches for a marker, and asserts that the log shows that a search happened and does not show the marker. The same counters show that a refused kind ran no query: its queries counter does not move.
Federated sign-in (DLP-28)
Authorization Code with PKCE. The implicit and hybrid flows are not offered, not configurable and not implemented: both return a token in a browser redirect, and a browser redirect is something an operator's proxy logs.
GET /api/v1/auth/oidc/available?tenant=… unauthenticated. {enabled, display_name?}
POST /api/v1/auth/oidc/start unauthenticated. {tenant, return_to?}
→ {authorization_url, expires_at}
GET /api/v1/auth/oidc/callback where the provider sends the browser.
Always a 302 back to the console
POST /api/v1/auth/oidc/exchange unauthenticated. {handoff} → a session,
same body and cookie as /auth/loginThe flow is split in two on purpose. The callback is a browser navigation, so a session token returned there would be in the address bar, in history, in a Referer header and in every proxy log on the way. It returns a handoff instead - single-use, sixty seconds - and the console exchanges that for a session over an ordinary POST.
available answers identically for a tenant that does not exist and one with no provider, because it is unauthenticated and a different answer would enumerate the tenants on the server.
Two things Entra ID does that a mock does not (DLP-42)
Group claims are object ids, not display names. A role mapping must name the group's object id - 8f4e2a1b-… - and a mapping written against "Security Analysts" matches nothing. Pinned by a test rather than only stated here.
A user in more than about two hundred groups gets no groups claim at all. Entra sends _claim_names and _claim_sources pointing at Microsoft Graph instead, per OpenID Connect Core §5.6.2. The server detects that and says so:
this user is in too many groups for the provider to list them in the token, so it sent a reference to … instead. The group membership is not the problem.
Without the detection the same user is told "no role: the token's groups claim carried 0 value(s) and none is mapped", which is the opposite of what happened and sends an operator to check a group membership that will look perfectly correct. The fix is at the provider - restrict which groups it emits, or map from an app role claim - so the message says that.
The referenced endpoint is not followed. Doing so would mean this server holding a Graph credential and making a call per sign-in, which is a much larger change than telling an operator what happened.
The callback never renders a page. Everything it could render would be an HTML document on the API's own origin built partly from provider-supplied text, which is a scripting surface on the origin that holds the session cookie. It redirects to {console}/sign-in?sso_handoff=… on success and ?sso_error=provider_refused|no_access|account_disabled|incomplete_callback|sign_in_failed otherwise.
A disabled account stays disabled (DLP-777). Disabling an account in MyDLP is authoritative over federation: a sign-in the provider vouches for is refused with account_disabled when the MyDLP account is disabled, both for an account matched by its sub and for one being adopted by address. The refusal is audited as auth.oidc_refused_disabled, naming the account. Only POST /users/{id}/enable brings the account back. Through v0.349.3 the sub branch set every returning account active on sign-in, so a disablement lasted only until that person's next SSO login.
What is checked before a session exists, and each of them refuses rather than warns: the signature, against the key the provider publishes for the kid in the token; the algorithm, against a closed list of asymmetric ones - every HS* is refused by name because a symmetric algorithm keyed on the provider's public key is the JWT algorithm-confusion attack, and so is none; iss byte for byte; aud, and azp when the token names several audiences; exp, nbf and iat with a minute of leeway; sub, non-empty; the typ header, so an access token signed by the same key cannot stand in for an ID token; and the nonce, against the one this server generated for this sign-in. The state and the handoff are each consumed in the statement that reads them, so two callbacks racing on one sign-in cannot both proceed.
GET /api/v1/settings/oidc sso:write. The secret is never returned
POST /api/v1/settings/oidc sso:write. Secret and CA are write-only;
omitting either keeps what is stored
DELETE /api/v1/settings/oidc sso:write
POST /api/v1/settings/oidc/test sso:write. Fetches the discovery document
and the key set and reports what came backConfiguring a provider is sso:write (DLP-767, ADR-0032). It was tenant:write, which cannot be delegated, so whoever ran the identity provider had to be made an Owner. sso:write can be put in a custom role; Owners hold it, tenant:write implies it (so a token minted with tenant:write keeps working), and Admins do not, because they did not configure sign-on before the split.
Whoever writes that row decides who may sign in to the tenant and as what, so one rule stays with Owners: a provider that signs anybody in as Owner, by a group mapping or as the default role, is saved or removed only by an Owner. That covers the stored provider as well as the new one, because changing the issuer or the groups claim of a provider that already makes Owners decides who becomes one. A non-Owner is refused with 403 and "You do not have permission to change single sign-on that signs people in as owner; only an owner can." Saving and removing also need the sign_in_settings step-up. A request that would itself grant Owner is refused before the step-up is asked for. The stored provider is checked after it, under a row lock, so an Owner adding a mapping cannot race a delegated save past the rule. Enabling a provider with no group mapped and no default role is refused, because it would put a button on the sign-in page that refuses everybody.
LDAP sign-in against Active Directory (DLP-2160, ADR-0073)
A separate provider from Directory Services lookups: its own LDAPS connection, its own tenant-bound secret reference (env:DLP_LDAP_AUTH_<tenant>_<NAME>, base64 of the connection's JSON) and its own group mappings.
GET /api/v1/auth/ldap/available?tenant=… unauthenticated. {enabled, display_name?}
POST /api/v1/auth/ldap/login unauthenticated. {tenant, email, password}
→ a session or {next, challenge, expires_at},
as /auth/login
GET /api/v1/settings/ldap-auth sso:write. The provider, or null; the
reference itself is never returned
POST /api/v1/settings/ldap-auth sso:write, fresh session. {display_name,
credential_ref?, ca_certificate?,
clear_ca_certificate?, role_mappings,
enabled, create_users} → 204
DELETE /api/v1/settings/ldap-auth sso:write, fresh session. Disables every
LDAP account and ends its sessions
POST /api/v1/settings/ldap-auth/test sso:write. {verified, detail}email may be a mail address, a user principal name or a sAMAccountName, matched exactly. Every refusal is the same 401: a wrong or empty password, an unknown, disabled, deleted, foreign-domain or ambiguous account, a group that maps to no role, a certificate that does not verify and a directory that is down. The provider keeps only a category of its last failure, which the settings answer shows. After DLP_MAX_FAILED_LOGINS wrong passwords an account is locked for DLP_LOCKOUT_MINUTES, counted by its objectGUID, and while it is locked the password is not sent to the directory. A changed group takes effect at the next sign-in, which ends the sessions from before; it does not reach a live session sooner. A mapping that grants Owner can be added, changed or removed only by an Owner.
Provisioning over SCIM (DLP-1187, ADR-0041)
GET /api/v1/settings/scim-tokens sso:write · {items, base_path, provisioned_accounts}
POST /api/v1/settings/scim-tokens sso:write · {name, expires_in_days?} · step-up sign_in_settings · audited
POST /api/v1/settings/scim-tokens/{public_id}/revoke sso:write · {reason} · audited
/api/v1/scim/v2/ServiceProviderConfig GET
/api/v1/scim/v2/ResourceTypes GET
/api/v1/scim/v2/Users GET ?filter&startIndex&count · POST
/api/v1/scim/v2/Users/{id} GET · PUT · PATCH · DELETE
/api/v1/scim/v2/Groups GET ?filter&startIndex&count&excludedAttributes=members · POST
/api/v1/scim/v2/Groups/{id} GET · PUT · PATCH · DELETEThe identity provider is pointed at /api/v1/scim/v2 with a SCIM token. Only a SCIM token (mydlp_scim_…) opens those routes; a session or an API token is refused with 401. A token needs single sign-on configured and enabled, is created by a person holding sso:write after the sign_in_settings step-up (never by another credential), lives 180 days unless asked (365 at most), and is shown once. Its uses are recorded (last_used_at), and every change it makes is audited with the actor scim:<public_id>.
The rules, each asserted by tests/scim.sh:
- Roles come from single sign-on's own mappings. They match a pushed group's display name or external id, then the default role. Entra sends a group's object id as
externalId, so an object-id mapping works for both sign-in and provisioning. - An account with no mapped role is kept disabled, and is opened when a group maps it. A provisioned account is created disabled and opened only then, so it never holds a role nobody decided.
- Provisioning never gives or changes Owner. An account whose groups map to Owner stays disabled until an Owner enables it. An existing owner account is not adopted (
409), and any write aimed at one is refused (403). - It reopens only what it closed.
active: false(Entra sends the string"False"; both forms are read) disables the account and revokes its sessions, andactive: truereopens it. An account an administrator disabled stays disabled whatever the provider says.GET /Users/{id}reports the provider'sactive, which is its intent. Whether the account can sign in is the console's status. - An existing account with the same address is adopted, not duplicated.
DELETEdeprovisions. The account is disabled, leaves its groups and is released from SCIM, so it is404there afterwards. It is not removed, since its audit history refers to it.- An address outside single sign-on's allowed domains is refused.
- Only
attribute eq "value"filters (userName, emails.value and externalId for users; displayName and externalId for groups), no bulk, no sort, no ETag (ServiceProviderConfigsays so). Attributes this product does not keep, such as a title or the enterprise extension, are accepted and not stored. Refusing them would fail every sync of a provider on its defaults.
Errors are RFC 7644 error bodies (scimType uniqueness, invalidValue, invalidFilter, invalidSyntax), and responses are application/scim+json.
Overview
GET /api/v1/overview - every figure computed from stored rows. An empty deployment returns zeros, not sample data.
The three overview routes need overview:read, which every role holds (DLP-542). The lists on them that can name somebody - top_users, top_users_by_volume, top_endpoints and destinations here, unclassified and non_business on /overview/destinations - are served only to a caller that also holds incidents:read. Anyone else gets them empty, with names_withheld: true or destinations_withheld: true beside them, so an empty ranking is never mistaken for a quiet tenant. Suppressing a person's name does not remove them from the figure's count. A token scoped to incidents:read alone, minted before overview:read existed, still reads these routes.
The two lists that rank people, top_users and top_users_by_volume, have the person-risk page's gates as well (DLP-1973): they are served only while the tenant has turned person analysis on (behaviour.per_user_enabled) and to a reader holding behaviour:read. Otherwise both are empty and people_withheld says why, per_user_off or no_behaviour_read; endpoints and destinations are unaffected. top_users ranks confirmed conduct only: incidents that are not false positives or tests and that the person-risk score counts as confirmed (a disposition of confirmed_violation, contained while open, or resolved before dispositions existed).
For a custom role with an incident scope, incident figures use the device's current group, event-derived prevention and blocked-trend figures use the event device's current group, and inspection gaps include only active, non-retired endpoints in the role's groups. Unscoped callers retain tenant-wide values. Fleet coverage.* counts (agents_total, agents_active, agents_healthy, agents_stale, agents_on_latest_policy, agents_outdated_version, agents_monitoring) count only the endpoints in the role's groups (DLP-1400): the fleet a scoped reader manages is the one they need, and a tenant-wide count beside their scoped incidents disclosed devices outside their groups. latest_bundle_version, latest_agent_version, policies_published and detectors_enabled describe the tenant's configuration, not its devices, and stay tenant-wide, as do pipeline.* (the ingestion pipeline's own health) and users_excluded_from_naming (the size of a privacy setting). tests/overview_scope.sh holds every other numeric field to the figure of a tenant containing only the reader's groups.
top_users_by_volume ranks people by how many data-related incidents they raised in the last 30 days, with bytes: the total size of the files those incidents concern. It is not a quantity of data that left, and nothing in this product measures that - an incident is raised whether the operation was blocked or merely recorded, and one incident folds many events. The console heads the column "files concerned" and repeats the sentence beneath the table, because a byte figure read quickly becomes a loss figure in somebody's report. top_users remains, ordered by worst single risk score over confirmed incidents: one answers "who is at the sharp end of something severe", the other "where is the volume".
Incidents
GET /api/v1/incidents ?status&severity&channel&username&policy_id
search matches its text literally (% and _ are themselves)
and is at most 500 characters (DLP-1927); it reads the
reference, title, file name, hostname, destination, policy
name, the reference a notice showed the person (any part
of it), and the username unless names are hidden (DLP-2008)
the page carries names_hidden: true while people are named
by pseudonym, so the console can say what is searched
&action&category&search&open_only&min_risk
&assignee&enforcement&created_since&created_until
&device&sla&file_state
&sort&origin&person&anomaly®ime&limit&offset
status, severity and channel are sets:
`severity=high,critical` matches either (DLP-1295);
a value that is not a status, a severity or a channel
is 422 naming the field and every value it takes (DLP-2017).
A request axum refuses before a handler runs (malformed
JSON, a wrong content type, an id that is not a UUID)
answers in the API's error shape, code request.malformed
`regime=pci_dss`: incidents whose stored compliance tags
name the regime, as tagged when raised (DLP-1777); a
code that is not lower-case letters, digits, dots and
underscores is 422
GET /api/v1/incident-assignment-rules incidents:read (DLP-1774)
POST /api/v1/incident-assignment-rules {name, policy_id?, channel?, min_severity?,
department?, assignee_email} · tenant:write
PATCH /api/v1/incident-assignment-rules/{id} {enabled?, position?} · tenant:write
DELETE /api/v1/incident-assignment-rules/{id} tenant:write · every change audited
the first enabled rule, by position, whose conditions all
hold assigns a new incident as correlation creates it
(min_severity: that and worse; a missing condition matches
anything); history names "assignment rule: <name>"; an
incident already assigned is never reassigned; the
assignee must hold incidents:write, as for assign;
at assignment time a rule whose person cannot sign in,
no longer holds incidents:write, or has a role scoped to
other endpoint groups is passed over, with a history
line saying why, and the next matching rule is tried
(DLP-1971)
GET /api/v1/saved-views?surface=incidents|activity_explorer
incidents:read (DLP-2023, DLP-2059, AE9): the view the
surface opens in first (opens_in), then the
organisation's, the caller's teams' and the
caller's own; each {id, name, state, visibility,
is_default, owned, team_id, team_name, opens_in}.
opens_in is the one team default the caller has,
else the organisation's (ADR-0063)
POST /api/v1/saved-views {surface, name (1-60), state (an object, at most
4096 bytes), visibility: me | team | organisation,
team_id (for team only)}; organisation is
tenant:write, team is for the team's members
(403 otherwise), both audited; a name taken
where it is seen is 409
DELETE /api/v1/saved-views/{id} one's own view, a view of a team one is in, or an
organisation view with tenant:write (the shared
two audited); a view one cannot see is 404
POST /api/v1/saved-views/{id}/default {default} · audited; an organisation view with
tenant:write, or a team view by a member; one
default for the organisation and one per team,
so making one the default clears the last of its kind
POST /api/v1/saved-views/{id}/used incidents:read · records use of a visible server view in the
tamper-evident audit log with its surface and visibility;
a view the caller cannot see is 404
GET /api/v1/teams incidents:read (DLP-2059): the teams the caller is
in, each {id, name, member_count, member,
members: null}; with users:write every team, and
members [{user_id, email}]
POST /api/v1/teams {name (1-60)} · users:write · audited; a name taken
(without case) is 409
DELETE /api/v1/teams/{id} users:write · audited; removes its memberships and
the views shared with it, answers views_removed
POST /api/v1/teams/{id}/members {user_id} · users:write · audited; a person of
another organisation is 422; already in is
{added: false} and not audited
DELETE /api/v1/teams/{id}/members/{user_id} users:write · audited; not in the team is 404
GET /api/v1/incident-playbooks incidents:read (DLP-1812)
POST /api/v1/incident-playbooks {name, policy_id?, steps[{title, link?}]} · tenant:write
PATCH /api/v1/incident-playbooks/{id} {enabled} · tenant:write
DELETE /api/v1/incident-playbooks/{id} tenant:write · every change audited
GET /api/v1/incidents/{id}/playbook incidents:read, scoped · the playbook the incident
shows, each step with done_by and done_at, or null
POST /api/v1/incidents/{id}/playbook/steps/{n} {playbook_id, done} · incidents:write, scoped
an incident shows the enabled playbook naming its policy,
else the enabled one naming no policy; one enabled
playbook per policy and one without (422 names the one in
the way); 1 to 12 steps, a title each, a link https only;
steps cannot be edited, since a tick is kept by step
number; a tick names the playbook it was read from and is
409 when the incident now shows another; a tick or untick
that changes something writes a history line (field
`playbook`) and an audit entry; steps are advisory and an
incident closes with any unticked
GET /api/v1/incidents/groups ?days (1-90, default 7)&open_only · incidents:read (DLP-1769)
one row per person and policy: incidents, open,
highest_severity, first_seen, last_seen; scoped like
the list, test incidents left out, the 100 largest;
nothing merged: username+policy_id lists each group;
a person excluded from naming (DLP-350) is withheld,
counted in groups_withheld and
people_excluded_from_naming (DLP-1903)
GET /api/v1/incidents/{id} the row's fields, and: justifications, detector_summaries
(no match previews), detector_feedback, risk_factors,
risk_model_version, confidence, file_path, file_size_bytes,
file_mime, file_owner_sid, file_owner_name, process_name,
policy_version, device_id (DLP-1834), department_reason
when department is empty (DLP-1838), resolution,
triaged_at, resolved_at, compliance_tags, the newest 200
history entries with history_total counting all of them,
allowed_transitions, external_tickets, cloud_sightings,
related, under_legal_hold, removable_devices, locations with
locations_total, enforcement, not_read and
matched_policies (DLP-1884)
POST /api/v1/incidents/{id}/closure/{approve|reject|withdraw} {note?} · incidents:write;
a close waiting for a second person (DLP-1996, ADR-0058): approve
and reject are refused (403) to the asker and to the subject,
withdraw is the asker's; an approval of a request the incident has
moved on from is 409
GET /api/v1/incidents/single-person-closures accepted risks and critical false-positive or
closed incidents no second person approved · audit:read
POST /api/v1/settings/incident-closure {second_person_accepted_risk, second_person_critical}
· tenant:manage · audited
POST /api/v1/incidents/{id}/status with status awaiting_user and {question} (at most 1,000 characters),
asks the person through the incident's endpoint (DLP-1997): an
AskPerson command is queued; refused 422 for an incident with no
endpoint or no person, for a question with any other status, or
one holding a detected value. The record carries pending_question.
POST /api/v1/agent/incident-answers {question_id, answer} · signed by the device the question went to;
the answer becomes a discussion comment (from_person: true) and the
incident moves from awaiting_user to investigating. 409 with
"this question has already been answered", "this question has
expired" or "this question was withdrawn by the analyst"; 404 from
another device. Unanswered after 3 days, a question lapses.
POST /api/v1/incidents/{id}/status with a second person required for this close, answers 202
{status, pending_close} and the incident does not move (DLP-1996);
an accepted risk takes accepted_until (90 days when absent, a year
at most) and reopens to investigating then;
enforces the transition table; resolution at most 500
characters and note 10,000 (DLP-1927); {status, resolution?, note?, expected_version?}:
with expected_version (the row_version the change was made
against) a stale one is 409, as bulk reports stale (DLP-1914);
a terminal status needs disposition (confirmed_violation,
business_need, coached_mistake, benign; false_positive is
detector_error, given when not sent, and detector_error goes
with no other status) and a resolution of at least 15
characters, each 422 naming its field; an open status takes
none; a reopen clears it with the resolution. Rows, the record,
the CSV and reports carry `disposition` (DLP-1989);
an incident about the actor's own account (its username,
by ADR-0033's matcher) is refused with 403, as are marking it
as a test and assigning it to oneself; bulk refuses the item;
each refusal is audited as incident.self_action_refused (DLP-1995)
POST /api/v1/incidents/{id}/detector-feedback {detector_id, detector_version, verdict, reason?, note?} · incidents:write (DLP-1678, DLP-1696)
POST /api/v1/incidents/{id}/assign {assignee_email | null, expected_version?} · incidents:write
POST /api/v1/incidents/{id}/synthetic {synthetic, reason, expected_version?} · incidents:write (DLP-1268); a stale expected_version is 409 (DLP-1999)
GET /api/v1/settings/test-folders incidents:read · [{path, marked_7d, open_unmarked}] (DLP-1815, DLP-1825);
both counts within the reader's incident scope (DLP-1916)
POST /api/v1/settings/test-folders {folders[], reason} · tenant:write · step-up enforcement_bypass
when a folder is added · audited
an incident raised from an event at rest (file_create,
file_modify, file_rename, file_delete, discovery,
archive_create) whose path is under a test folder is
created marked as a test: synthetic_by "test folder rule",
the reason naming the folder, an `origin` history line, and
no assignment rule run; a leaving channel is never marked;
a folder matches only at a separator, Windows paths without
case unless the folder is case-sensitive; at most 20, each
below a drive or share (a whole drive is refused); an
incident is marked when raised, or later when an event joins
it and its own file is under a folder (DLP-1825); a real
incident a test-folder event joins stays real
POST /api/v1/settings/test-folders/apply {reason} → {marked, folders} · tenant:write · step-up enforcement_bypass (DLP-1825)
marks the open incidents already under the folders, each
with the history line a folder writes at birth, in one audit
entry (incidents.marked_by_test_folders) with what each folder
marked; GET's open_unmarked is what it would mark; a rerun
marks nothing and writes nothing
GET /api/v1/incidents/test-folder-suggestion tenant:write · {folder, open, of_open} or null (DLP-1825)
the first folder below a drive, share or root holding at
least 50 open, unmarked incidents at rest and at least half
of them; a suggestion, never a mark; folders already set
are not suggested
GET /api/v1/incidents/bursts ?open_only=true&origin · incidents:read (DLP-1826)
→ {gap_minutes, window_days, truncated, items[]}: runs of at
least 3 incidents from one person, endpoint, policy and
channel, each within 10 minutes of the last, created in the
last 7 days, largest first, at most 20; each item names the
person, device, hostname, policy, channel, count, worst
severity and first/last created, which the queue's own
filters (device, policy_id, channel, username,
created_since, created_until; person=unattributed for a
burst naming nobody, DLP-1944) open exactly; a view, never a
merge; within the reader's incident scope
GET /api/v1/incidents/assignees who an incident can be given to · incidents:write (DLP-657); the id is what ?assignee= takes (DLP-2022);
?incident=<id> lists only people whose role can open it, and
assigning one who cannot is 422 on assignee_email, refused per
item in bulk (DLP-1915)
POST /api/v1/incidents/bulk {idempotency_key, action, items, …} · incidents:write
action: assign {assignee_email|null}, status {status, resolution?, note?, disposition?}
(the single route's closing rules, once for the batch; DLP-1989),
mark_test {reason: 5-500 chars} or relate {related: "INC-…", note?} (DLP-1828);
each item is decided on its own: updated, unchanged (already a test,
already related), stale (its row_version moved), unavailable, or
refused (a status it cannot take; relate's own target, "cannot be
related to itself"); mark_test writes one origin history line per
incident with the batch reason, relate both incidents' histories,
and the request one incident.bulk_triaged audit entry
POST /api/v1/incidents/{id}/links {related: "INC-…", note?} · incidents:write
GET /api/v1/incidents/{id}/related-suggestions incidents:read (DLP-1834)
→ {person_policy_days: 14, endpoint_minutes: 10, items[]}: at
most 10 incidents, each with every reason it is suggested:
same_content (an event in it holds a content hash one of
this incident's events holds, on any endpoint),
same_person_and_policy (first seen within 14 days of this
one's span), same_endpoint_at_the_time (overlapping this
one's span, widened by 10 minutes); most reasons first, then
newest; never this incident, one already related, one outside
the reader's scope, or a test beside a real incident or the
reverse; nothing is linked; another tenant's id is 404
DELETE /api/v1/incidents/{id}/links/{related_id} ?reason=… · incidents:write
GET /api/v1/incidents/{id}/comments · POST to add, with incidents:comment (implied by incidents:write; DLP-762)
GET /api/v1/incidents/{id}/events the event timeline; each row's user_reference is the reference the endpoint showed the person (DLP-1862)
print_job {printer, port, submitted_from, pages, copies, duplex} and
process_detail {executable_path, executable_hash, signed_publisher,
signature, integrity_level}, each null when the agent reported none
(DLP-1987, PRIVACY_MODEL section 71)
GET /api/v1/incidents/{id}/locations every place, ?limit=50&offset=0 (max 500) → {items, total, limit, offset} (DLP-1517)
GET /api/v1/incidents/{id}/history record history, newest first; pages like the timeline (DLP-825)
POST /api/v1/incidents/{id}/evidence {justification} audited, justification required (DLP-950)
GET /api/v1/incidents/export CSV, formula-injection safe · incidents:read and incidents:exportlocations on incident detail lists one row per endpoint path. Its state is current, moved or removed; a moved row carries moved_at and moved_to. A removed row carries departure (DLP-1539): deleted_permanently, recycled or moved_out where the endpoint's change journal said why the path is empty, and deleted where it could not, which means only that the path is empty. An event saying a path is gone joins the newest open incident on that endpoint with an event at that path, whatever channel opened it (DLP-1553), so a file found in a synced folder and deleted later is one incident whose place reads removed. A walk's sighting at a path an open incident already holds joins it too, including one opened as cloud_sync_folder (DLP-1595): the same file in the same place. The same content at another path keeps an incident of its own. Equal content hashes alone never make a move because two surviving equal files are copies. A move requires the source path to disappear and the same content to appear at another path on that endpoint within thirty seconds (DLP-1382). The detail holds the first 50 places, earliest first, and locations_total says how many there are; GET /incidents/{id}/locations pages through all of them in the same order (DLP-1517). An incident that is not there, or is outside the reader's scope, is 404.
not_read on incident detail says what of the file was not read although something in it was found (DLP-1525). It comes from the newest event that says so: cause (the not-inspected cause, such as encrypted or partially_read), reason (the agent's sentence, null where the tenant redacts file paths), file_name, and partial_events of events. It is null when every event read its file whole.
matched_policies on incident detail lists every policy the incident's events matched (DLP-1884), at most 20, as {policy_id, name, applied_events, superseded_events}: how many events each policy decided, and how many it also matched and lost to another policy in conflict resolution. It is read from each event's recorded decision, policy_id and superseded_policy_ids. A rule id is reduced to its policy, so a sibling rule of the winning policy is not counted as that policy losing. policy_id is null for an id that is not a policy of this server, and name is null for a losing policy that no longer exists. The policy that decided most comes first. The list is empty when no event names a policy.
sort (DLP-1266, DLP-1277) names a column:
- the columns are
priority,reference,title,severity,risk,status,assignee,user,endpoint,channel,matches,events,createdandlast_seen; withoutsortthe queue is newest first,created:desc(DLP-1621; it wasprioritybefore); - a bare column takes its natural direction: highest, worst or newest first for
priority,severity,risk,matches,events,createdandlast_seen, and A to Z for the rest; column:ascorcolumn:descnames the direction;statusfollows the workflow (new, triaged, …, closed), not the alphabet;- empty words, such as no person or nobody assigned, come last either way;
- the older names
newest,oldestandlast_activitystill meancreated:desc,created:ascandlast_seen:desc.
Anything else is refused with 422 rather than ignored. Every order breaks ties on the latest activity and then the incident's id, so rows keep one order from page to page. The CSV export uses the same order as the page it was taken from.
Export needs reading as well (DLP-656). GET /incidents/export asks for incidents:read and then incidents:export, so a custom role or a token scoped only to export is refused with a 403 naming incidents:read, and cannot take away a queue it cannot open. The saved-report exports already required both, because they read through the queue's own query.
An export carries at most 10,000 rows, and says when it was cut (DLP-813). The response carries x-mydlp-export-total (how many the filters match) and x-mydlp-export-truncated (true or false), and the incident.exported entry records rows, total and truncated. Until DLP-813 a larger set stopped at 10,000 with nothing in the response, the audit entry or the console to say so. The console's Export control reads "Export the first 10,000 of N" when the queue's total is larger.
Assignees (DLP-657). GET /incidents/assignees answers [{id, email, display_name}], ordered by e-mail, for the active users whose effective permissions (built-in role, or a live custom role) include incidents:write. The record page chooses from it; GET /users needs users:manage and answers far more than an assignment needs. Single and bulk assign apply the same rule (DLP-831): an active user without incidents:write is refused 422 on assignee_email, because an incident given to somebody who cannot change it reads as owned and is worked by nobody. An account that cannot sign in - an invitation never accepted, expired or not, with no identity-provider link - is neither listed nor accepted (DLP-1822), and the refusal says the invitation was not accepted.
Approving and removing a device (POST /approved-devices, DELETE /approved-devices/{id}, policies:write) answer with bundle_version: the bundle compiled because of the change, or null when no published policy has a removable-device condition with include_approved_inventory (DLP-903). A 409 refuses an approval beyond 5,000 devices; re-approving a serial already on the list relabels it and is never refused.
Five lists page on request (DLP-758, DLP-825): the incident timeline and record history above, GET /discovery-scans, GET /discovery-scans/{id}/findings and GET /approved-devices/seen. Each takes limit and offset. With paged=true it answers {items, total, limit, offset}, the shape the audit log and the incident queue use. Without it, it answers the bare array it always did, so an older reader keeps working. The cap each had as a fixed limit is its default page and its largest (timeline 500, record history 200, scans 200, findings 1,000, seen devices 200), and a larger limit is clamped to it. total counts what the same filters page through, and ties in the sort order are broken on a unique column, so a page boundary is stable.
One scan by id (DLP-899). GET /discovery-scans/{id} (discovery:read) answers the scan and previous_run_id: the completed run before it in its series, or null. The console opens a linked scan with it, because the list's newest 200 need not hold the scan, or the run before it. A scan of another tenant is 404. exposed_only, on the findings routes, filters on severity high and critical. By exposure, that is everything readable by everyone, everything whose permissions could not be read, and group-readable files with 100 matches or more. A group-readable file with fewer is medium and is left out.
Match previews are not in the detail (DLP-651). Every detector_summaries entry arrives with previews: [], previews_withheld: true, preview_count (how many there are) and previews_withheld_reason: open_through_evidence if the caller holds evidence:read, otherwise evidence_read_required. The previews themselves come from POST /incidents/{id}/evidence with {"justification": "…"}. That call needs evidence:read and a justification of at least five characters (400 if missing, 422 if shorter), and writes one evidence.accessed audit entry per successful call, with the justification. An incident in another organisation is 404 and writes nothing. Every call is counted on the operational port, without its justification, as mydlp_evidence_views_total{tenant, outcome="opened"|"refused_reason"|"refused_permission"|"not_found"|"file_downloaded"} (DLP-753; file_downloaded since DLP-1189).
A copy of the file itself (DLP-1189, ADR-0038) is a separate permission, evidence:file, which only the Owner holds among the built-in roles.
GET /incidents/{id}/evidence-fileslists the copies kept for an incident's events:id,event_id,size_bytes,state(stored,delete_failedordeleted),stored_at,expires_atanddeleted_at. A copy the policy asked for and did not get is listed aswithheld, with the endpoint'sreason(DLP-1190). It cannot be downloaded (404). It never gives the content or where the copy is stored.POST /incidents/{id}/evidence-files/{file_id}/downloadwith{"justification": "…"}needs the permission, a justification of five characters or more (422) and a recent sign-in (step-upevidence_file). It answers the file as anattachment, never for a browser to render, named for the file.- A deleted copy is 409, saying when. A copy the store will not return is 502.
- A download writes one
evidence_access_logrow (kindfull_file) and oneevidence.file_downloadedaudit entry with the copy's id, its size and the justification. A refusal writes neither and is counted by its kind. - An incident outside the caller's scope, or another organisation's, is 404.
Bulk triage (DLP-581). One status change or one assignment applied to up to 200 selected incidents:
json
{ "idempotency_key": "…16-128 chars, one per intended action…",
"action": "status", "status": "triaged", "resolution": null, "note": null,
"items": [ { "id": "…", "row_version": 7 } ] }action is status (with status, and resolution for a terminal one, as for a single change) or assign (with assignee_email, or null to unassign). Each item carries the row_version the caller was looking at; every incident in the list and the detail has one, and every write to an incident bumps it, whoever makes it.
Each item is locked, decided and written in its own transaction, so one problem item does not roll back the rest, and the answer has one result per item, in the order sent:
outcome | Meaning |
|---|---|
updated | Changed; carries the new row_version |
unchanged | Already what was asked. Nothing written; not a failure |
stale | Changed by something else since it was selected. Left as it is: the decision was about a version that no longer exists |
unavailable | Not an incident this caller can change. Identical for one in another tenant and one that never existed |
refused | A status it cannot take from where it is, with the reason |
counts totals them. A retry with the same idempotency_key gets the stored answer back with replayed: true and applies nothing; the same key with a different body is 409, and so is one whose first request is still being applied. Keys are kept for a day. Every change writes the usual history line and audit entry with the request's correlation_id, and the request is audited once as incident.bulk_triaged with the counts and the ids that were changed -- never an id the caller could not change.
Related incidents (DLP-543, ADR 0026). A relation between two incidents and nothing more: no status, no assignee, no second answer to "is this closed". It has no direction - made from either incident, it is listed on both as related, each with the other incident's own status - and it is stored once, with the pair in a fixed order, so the primary key makes a second link of the same two a no-op (created: false) rather than a duplicate. The other incident is named by reference, in this tenant only: another tenant's reference is refused in the same words as one that does not exist. Making or removing one writes a related line to the history of both incidents and an incident.linked / incident.unlinked audit entry; removing one needs a reason, because it undoes somebody's judgement.
Who holds it (DLP-525). ?assignee= takes four forms and nothing else:
me the signed-in analyst, resolved from the session
none nobody holds it
departed held by an account that no longer has access
<user id> a named analystme is resolved server-side and never from the query string, or the parameter becomes a way to read another analyst's queue. Anything else - an e-mail address, a username, an empty value - is refused with 422, not ignored: a filter the server cannot honour would answer 200 with the whole tenant under a heading naming one person.
departed is not a synonym for none. An account is disabled rather than deleted (DLP-523), so somebody who has left keeps their assignments: those incidents are neither free nor being worked, and folding them into none would reassign them by making them look available. The filter applies to the list, its count and the CSV export alike.
category matches a data classification inside the incident's detector summaries - pii, financial - rather than a column, so an incident whose detectors found two classifications is returned by a query for either. The dashboard's what went where chart (DLP-338) links here with channel and category together, because a segment of one bar is one classification to one destination type and a link on the channel alone would open a list larger than the segment it came from.
Closing an incident without a resolution returns 422. An illegal status transition returns 409 with both states named.
External device channels (#474)
"External device" is not one thing. mobile_device, optical_disc and bluetooth are channels of their own beside removable_storage, because a phone, a burnt disc and a USB stick are three different things to write a policy about - reporting all three as removable_storage meant a rule could not say "not to a phone".
- A phone on USB is usually not a block device. It presents an MTP or PTP endpoint that the desktop surfaces as a FUSE mount, so every check that asks
/sys/blockabout it saw nothing. That was the largest real gap of the set: the most common way data walks out of a building was the one class the product could not name. It is now recognised by the FUSE filesystem name or by the protocol gvfs puts in the mount path. - A phone is never reported as identifiable. MTP carries the device name over the protocol, not through the mount table, so
is_distinguishable()is false and a policy that approves specific devices cannot be fooled into approving a phone the agent cannot name. - The device
class-usb_mass_storage,memory_card,optical_disc,mobile_device,unclassified- is finer than the channel. A memory card and a USB stick share a channel and differ in class, so a report can tell them apart even where a policy treats them alike.unclassifiedis reported rather than assumed to be a USB stick: an event that names the wrong device is worse than one that admits it does not know. - Reporting, not prevention. Blocking any of it needs the enforcer (#387, #390), and
docs/AGENT_CAPABILITY_MATRIX.mdsaysnot implementedper platform. Bluetooth is not detected at all; on Windows, neither are phones nor optical discs. A matrix that lists a channel because a policy can be written for it is how a customer finds the gap during an incident.
Deletions of files known to hold sensitive data (#364)
An event on the file_delete channel, raised only when the endpoint's scan cache remembers that the file had findings. Deleting files is what people do all day; an agent that reported every one would bury the deletions that matter in the ones that do not.
- The event's
detector_resultscarry the earlier detection's detector id, category and match count, and nothing else. No preview, and no confidence: a preview is redacted from content, and once the file is gone there is nothing left to redact from - a preview here would be the one copy of matched data that outlived the file it came from. A confidence borrowed from a scan of content nobody can re-read is a number nobody can check. - The cache entry is taken, not read: an entry left behind would be inherited by whatever is created at the same path next, and that file would arrive already accused of containing something it has never been scanned for.
- A policy scoped to
file_deleteevaluates it like any other event, so "deletion after a copy" can be a rule rather than something an analyst has to spot.
Dynamic policy actions (#472)
A policy may carry risk_thresholds and pick its action from the risk of the specific event. The table is explicit - below, then at_or_above/action steps - and is validated when the policy version is created, not when it reaches an endpoint: no steps, a threshold above 100, two steps at the same score, or a higher score producing a gentler action are all refused with the reason.
Where it sits in the order, and why, is docs/POLICY_MODEL.md § Risk thresholds. In short: an applied exception skips the table entirely, and a dynamic action never changes which policy wins - conflict resolution runs afterwards, unchanged.
The decision's explanation carries the score, the band, the model version, which step fired, and the three factors that actually moved the score. That is a requirement rather than a nicety: a control that varies by person will be argued about in an HR meeting rather than a security one, and "the model decided" is not an answer anybody can check. POST /api/v1/policies/simulate returns the same explanation, so a rule can be argued with before it ships.
When nothing scored the event the policy falls back to its standing action and says so in the explanation, rather than behaving statically in silence.
User justifications (#471)
GET /api/v1/incidents/{id} carries justifications: what the person typed when a policy asked them why, grouped by answer with the number of operations each one covered.
- The answer is checked for being one, not for being long enough.
asdfclears any length rule. The agent requires three distinct words, rejects a single repeated character or word, and rejects the category's own label typed back - it needs to cost more than pressing a key, not to be unbeatable. - A category travels with the text. Free text alone is unsearchable and in practice empty; "how often is 'sending to the client' the reason?" is a question an operator can act on.
- One answer covers a burst. Keyed on policy and destination for five minutes, so a folder copy raises one prompt rather than fifty - and every operation it covered records that it was covered and by which answer, so the trail shows one decision and fifty acts.
- It gates nothing. There is no enforcer, so the operation proceeds whatever is typed, and the prompt says so. The incident panel repeats it. See
docs/KNOWN_LIMITATIONS.md§1. - Where there is nobody to ask - a service with no session, which is most deployments today - the event records
[not_asked]with the reason rather than inventing consent. "They had a reason" and "nobody was there" are different facts.
Insight priority (#478)
Every incident row carries priority_score (0-100) and priority_factors, and GET /api/v1/incidents?sort=priority orders by that score descending, then by last_seen. The queue's default order is newest first (created:desc, DLP-1621); priority was the default before. risk_score stays exactly as it was: it says how bad this would be if it were real. Priority says how likely it is to be real and to matter, which is the question a queue has to answer.
A listing is time-bounded (DLP-2037). The queue, its count, a saved report's run and /incidents/export run under a 30-second statement timeout (DLP_INCIDENT_QUERY_TIMEOUT_MS). One that overruns answers 503 with code server.query_timeout and a sentence, rather than holding a pooled connection while it runs. The person's and the rule's counts behind each score are computed once per request: at 200,000 incidents under one rule, a priority sort took over 150 s before and 0.8 to 1.5 s after, on a debug build (tests/incident_queue_scale.sh).
What it is built from, and why each one:
| Signal | Weight | Why it predicts an analyst caring |
|---|---|---|
risk_score | 40 | Already a considered number; priority leans on it rather than replacing it |
| Detector confidence | 15 | |
| A structural validator passed | 15 | The strongest single signal that a match is a real value and not something shaped like one |
| Destination not trusted | 10 | Unknown counts as untrusted: the burden is on a destination to be known-good |
| Prior confirmed incidents for this person | 12 | Confirmed, never merely alerted - see below |
| Events folded into the group | 8 | Fifty events is still one thing, but a bigger thing |
| The rule's false-positive record | −35 | A rule wrong nine times in ten should lose to one that is usually right |
- Prior alerts are deliberately not an input. Ranking on them is circular: a noisy rule makes a person look risky, which promotes that rule's alerts about them, which makes them look riskier.
- A rule's record counts only once ten of its insights are closed. Below that, one analyst's click would read as "wrong 100% of the time" and bury a brand-new rule on its first firing.
priority_factorsalways contains every factor, including those contributing zero. "No structural validator passed" is the most useful thing to know about an insight that is about to turn out to be a product code; an absent factor says nothing.- Priority orders the queue when asked to, and never filters it. There is no
min_priorityparameter and there will not be one. The same rows come back in a different order; the CSV export takes the samesort, so a file and the screen it came from agree. GET /api/v1/policiesgains arecord-closed,false_positives,false_positive_rate(null until judgeable) andneeds_review- so the person who can fix a misfiring rule is told, which is the half of prioritisation that actually improves anything.
Where a delivery may go (DLP-540)
An integration url, a discovery scan target and a tracker base url are the three places a tenant administrator can name an address this server will then request with its own network identity. All three refuse the same two things and allow the same one.
Refused: the link-local metadata address in both its IPv4 and IPv6 forms, and a URL carrying credentials in its userinfo - https://collector.example@evil.example/, whose host is not the one the operator approving it reads.
Allowed: private and loopback addresses. A SIEM collector or an S3-compatible store inside the customer's own network is the ordinary deployment, and refusing 10.0.0.0/8 would refuse the feature for exactly the customers most likely to use it.
Every client that fetches one of these sets redirect::Policy::none(). Without it the https:// requirement constrains the URL an operator types and not the request the server makes: a correctly-https collector answering 302 Location: http://169.254.169.254/ would reach the metadata endpoint with every other check satisfied. A collector does not redirect, and following one means the configured destination is not the destination.
The address is checked when it is saved and when it is used. The second is the one that holds - a row can be written by a migration or by hand, and a name that resolves publicly today can resolve elsewhere tomorrow; the first exists so the operator is told on the screen where they can fix it.
Coverage gaps (DLP-1835)
GET /api/v1/coverage-gaps - incident:read, within the caller's incident scope. The incidents with an event in the last seven days whose action was reduced (events.reduced, decided when the event was stored, DLP-1784), grouped by the incident's policy, channel and endpoint. The requested and applied actions are those of the incident's latest reduced event; events counts the reduced events:
json
{ "window_days": 7,
"gaps": [{ "policy_id": "...", "policy_name": "Cards", "channel": "discovery",
"device_id": "...", "hostname": "FIN-07",
"requested_action": "block", "applied_action": "audit",
"incidents": 3, "events": 5, "last_seen": "...",
"reason": "This endpoint reports it can monitor on discovery (filesystem walk) but cannot block there, so it applied audit." }] }incidents is the number /incidents?policy_id=&channel=&device=&enforcement=unsupported&open_only=false returns for that cell; tests/coverage_gaps.sh asserts the two agree. The reason is built from the capability matrix the endpoint last reported, and says so when it has reported none.
Activity Explorer
Activity Explorer uses the shared server saved-view endpoints with surface activity_explorer. Personal views follow the account across browsers; a team member may share with their team and tenant:write may publish to the organisation. Applying a server view is audit-logged. The console migrates its old browser-only {name, query} entries once and removes transient page/event selection from the stored view.
GET /api/v1/activity-events tenant- and incident-scope activity; filters include time, search,
channel, outcome, applied action (action), category, trust, user, endpoint,
policy, exact policy_id, and related_file (an event UUID whose content
hash or endpoint+path is matched without returning either locator), and
incident=matched_no_incident. enforcement=reduced_by_capability uses the
canonical event reduction flag shared with Policies and Overview. Every row
exposes requested_action, applied_action, enforcement_result and reduced so
the requested policy and effective endpoint behaviour cannot be conflated.
event_kind=user_activity excludes discovery scan events from the ordinary
analyst stream; omitting it preserves the complete API view.
The page also returns the contract channel
vocabulary and every data category, marking whether an enabled tenant
detector currently emits each category. `timeline` is a continuous set of
hourly (up to 24h), six-hourly (up to 7d), or daily buckets with total,
by_channel and by_outcome counts over exactly the same scoped filters;
each bucket carries exact since/until bounds for drill-down.
GET /api/v1/activity-events/discovery-runs
groups recorded discovery file events per endpoint, starting a new inferred
run after a gap over 15 minutes. Returns start/end, recorded event count,
matched files, matches and incidents. It explicitly does not call the event
count "files scanned": clean files with no policy or label action are not
sent by the endpoint.
POST /api/v1/activity-events/export the same filtered view as a bounded CSV; incidents:export. It participates
in the shared export rate/volume budget and tenant approval threshold,
carries export_watermark on every row, and returns
x-mydlp-export-reference and x-mydlp-export-sha256. An approved request id
is posted as approval in the same body.
POST /api/v1/activity-events/export-requests
{query: <the export filter body>, reason}; asks through the same
second-person, one-use approval workflow listed and decided under
/reports/export-requests. Paging and an earlier approval token are not
part of the stored approved view.Reports (DLP-527)
A report is a named set of incident filters. It stores no results and no authority: every run is evaluated for whoever is running it, through the same function GET /incidents uses.
GET /api/v1/reports the library
POST /api/v1/reports {name, description?, subject?, kind?, filters, template_id?}; template_id names a built-in template and needs its filters unchanged (DLP-2084)
POST /api/v1/reports/{id} the same shape; renames and re-filters; template_id is recorded at creation and not changed here
kind: list (default), triage_sla (DLP-2108), policy_effectiveness (DLP-2109) or
executive_summary (DLP-2107), ADR-0062. A report of figures takes exactly
{"window":{"last_days":1..90}}. executive_summary runs to {kind, days, window_since,
previous_since, generated_at, this_period, previous_period, trend}: each period is
{incidents, blocked, critical, high, medium, low, info}, the sums of GET
/overview's daily trend for those days and the runner's scope; its CSV adds a
signed `change` per figure.
triage_sla runs to {kind, days, window_since, generated_at, sla, detection,
open_by_age, open_by_assignee}, the same figures as GET /overview/security for that
window and the runner's scope. policy_effectiveness runs to {kind, days,
window_since, generated_at, policies[{policy_name, incidents, false_positives,
blocked}], never_fired[]}: GET /overview's policy_effectiveness over the same
rolling window and scope without its limit of ten, then the published policies with
no incident in it. Either exports as CSV section,item,value (or print), marked and
digested as any export. A view or an approval request on one is refused.
DELETE /api/v1/reports/{id} {reason}
GET /api/v1/reports/{id}/run the rows, for the caller: {items, total, limit, offset, window, truncated, cap,
summary}. summary is {rows, complete, by_severity, by_status} over the rows the
run kept, the whole view before its page; complete is false when the cap cut
them (DLP-2085, ADR-0062). The printed export states the same counts.
A run on screen keeps no history (DLP-2095): no last-run time, no record of who
ran which report, and no rows. What it showed then cannot be shown later, and a
report's list does not claim it can. An export is the record of what left, in the
audit log. A scheduled run keeps its own history. A run is counted toward the
person's rate limit for 25 hours, as who and when, without the report (DLP-2088).
GET /api/v1/reports/{id}/export the same rows as CSV, with x-mydlp-report-rows, -total, -truncated, -window headers
GET /api/v1/reports/{id}/export?format=print the same rows as a document to save as PDF (DLP-1777)
run and export take a view (DLP-2094): sort=<reference|severity|risk_score|
priority_score|first_seen|last_seen|status|channel>, dir=<asc|desc>,
filter.<status|severity|channel|policy|assignee|data_category|hostname>=<value>
(exact, any case); run alone takes page_size=<25|50|100|200> and offset. A view
narrows the definition's rows and never widens them; any other parameter is
refused 422 by name, and an export's audit entry records the view it took.
Both are limited per user (DLP-2088): 60 runs a minute and 30 exports an hour
by default, refused 429 request.rate_limited with Retry-After. A run of a
report that does not exist spends nothing. See DEPLOYMENT.md for the settings.
Every export carries a reference (DLP-2087, ADR-0060): header
x-mydlp-export-reference, a last CSV column export_watermark =
<tenant slug>/<reference>, a mark on every printed page, and the
incident.exported audit entry. x-mydlp-export-sha256 and the entry's sha256 are
of the bytes sent, the CSV or the print document, never of a PDF the browser
makes (DLP-2086, ADR-0061). Above the tenant's
report_exports.approval_above_rows an export is refused 403
report.export_approval_required (details rows, threshold) unless it carries
approval=<request id>: approved, the caller's, unused, under a day old, and for
this report, view and format (409 naming which otherwise).
POST /api/v1/reports/{id}/export-requests ask for an export above the threshold: the export's view and
format parameters, body {reason}; 201 {id, rows, threshold}, 200 with the pending
one when asked again, 409 when no approval is needed (DLP-2087)
GET /api/v1/reports/export-requests the organisation's requests, newest first, incidents:export
POST /api/v1/reports/export-requests/{id}/approve {note}; incidents:export, a person, not the asker; 204
POST /api/v1/reports/export-requests/{id}/reject {note}; the same; 204
POST /api/v1/reports/export-requests/{id}/withdraw the asker's own pending request; 204
POST /api/v1/settings/report-exports {approval_above_rows: 1..9999 | null, reason}; tenant:manage, audited
GET /api/v1/report-templates compliance report templates, incidents:read (DLP-1777)What a report keeps. A definition's filters are the queue's: status, severity, channel, action, category, username, policy_id, search, open_only, min_risk, enforcement, assignee, origin, window, regime, and since DLP-1906 sla, file_state, device, person and anomaly. Each means the same for every runner; limit and offset are refused, and so is anything else, by name.
Printing is exporting (DLP-1777). ?format=print returns an HTML document for the browser to print or save as PDF:
- the report's name, description and filters;
- the period, the total, and whether the rows were cut at the cap;
- who generated it and when, and that it shows what that account may see;
- the rows;
- a footer saying it lists what MyDLP recorded and is not an assessment of compliance or legal advice. It needs
incidents:exportlike the CSV and is audited the same way (incident.exported, withformat: "print"). Every value is escaped, and it is served with a Content Security Policy that allows no script and no fetch. There is no PDF library: the browser makes the file. Any otherformatis422.
Report templates (DLP-1777) are saved reports already written, one per regime: PCI DSS, GDPR, Azerbaijani personal data and KVKK. KVKK joined the regime vocabulary in this release, offered and disabled like the others. Each template's filters are its regime (?regime=), real incidents only, and the 30 days before each run. The console saves one with POST /reports as an ordinary definition.
The listing also says whether the regime is enabled and how many classifications carry it. When either is zero, the console says the report will be empty, and why. A template lists what the organisation itself tagged. None calls itself a compliance report or claims that anybody is compliant.
A report is the whole answer, up to 10,000 rows (DLP-923). Run, export and scheduled delivery all return up to that cap. Until DLP-923 every one of them stopped at the incident queue's 200-row page while the total above them said more. When more match than the cap, truncated is true and total says how many exist. The export says the same in its headers rather than inside the CSV, where a line that is not a row breaks every tool that opens it. The incident queue still pages at 200.
A report can name a period (DLP-926): "window": {"last_days": N} (1-366) is the N days before each run, and "window": {"from": …, "to": …} (RFC 3339) is a fixed period. It filters on when an incident was created. A definition with no window covers all time, as before. A scheduled run resolves a relative window at its occurrence, so every retry of one occurrence covers the same period and sends the same bytes.
Reading and running a definition need incidents:read, which a Viewer does not hold (DLP-542). Creating, changing and removing one need reports:write (Owner, Admin, Security analyst), or reports:schedule, since a schedule needs a report to run (DLP-691). Other people's schedules run these definitions, so a read-only role that could rewrite or delete them could quietly change what a schedule delivers. Exporting needs incidents:export, which an Auditor does not hold: they may read a report on screen and may not take it away. The API refuses the call; the console hides the control afterwards, in that order, because a hidden button has never been what stands between somebody and a disclosure.
Stored filters are an allow-list - status, severity, channel, action, category, username, policy_id, search, open_only, min_risk, enforcement, assignee, window - because a definition is run by people who did not write it, and a parameter nobody has considered fails that question by default. limit and offset are refused as pages rather than as unknown filters: a report is the whole answer to its filters, and one that named a page would return different rows as incidents arrived with nothing saying so.
assignee: me is storable precisely because it is resolved against the runner. Storing me stores a question; storing the author's id would store an identity, and hand out a way to read somebody else's queue under a friendly name.
An export writes incident.exported with the report, the filters, the row count, the total, the window and whether it was cut. A scheduled run records total_count, window_from and window_to on its run row, and report_schedule.run_succeeded carries total, truncated and window. The audit log is append-only, so removing a definition leaves the record of what it returned.
Recurring execution (DLP-584)
GET /api/v1/report-schedules all schedules in this tenant
GET /api/v1/reports/{id}/schedules schedules for one saved report
POST /api/v1/reports/{id}/schedules create under that report
GET /api/v1/report-schedules/{id} detail
PATCH /api/v1/report-schedules/{id} replace cadence and enabled state
DELETE /api/v1/report-schedules/{id} {reason}
GET /api/v1/report-schedules/{id}/runs latest 100 occurrence states
GET /api/v1/report-delivery-destinations approved HTTPS receivers
POST /api/v1/report-delivery-destinations approve a receiver
PATCH /api/v1/report-delivery-destinations/{id} change/disable a receiver
POST /api/v1/report-delivery-destinations/{id}/test signed test, no report data
GET /api/v1/report-deliveries latest 100 immutable attempt records;
?destination_id= for one receiverEvery route here requires both reports:schedule and incidents:read. Approving, changing and testing a destination also needs integrations:write, because a destination is where incident-derived data goes. Approving or changing one asks for the incident_delivery step-up, as incident delivery does (DLP-694); a test changes nothing and does not ask.
A destination's signing secret and CA certificate are write-only. The list says signed and has_ca_certificate, never either value. A PATCH that omits secret or ca_certificate keeps what is stored, so a caller that only renames or disables a destination cannot clear its CA by not knowing it (DLP-695). A stored CA is replaced by sending another. Removing one without replacing it is not supported; re-create the destination instead.
The test (DLP-695) posts a signed JSON payload with schema mydlp.report_delivery_test.v1 and no report data. It goes through the same transport as an export. It answers {ok, detail, schema}, with the receiver's reply in detail when it fails. It marks the destination healthy, or degraded with last_error_code: test_failed (never failed, which is kept for exhausted deliveries). It is audited as report_destination.tested with the outcome. reports:schedule belongs to Owner and Admin; it is deliberately not implied by incident triage, report reading or export. API tokens need both explicit scopes. A schedule stores the user or token that created it, but never stores that principal's permissions. The worker resolves the current user role or token scopes again for every occurrence; a disabled user, revoked/expired token, removed report or lost permission fails closed and pauses the schedule. Once a definition has a schedule, changing or deleting that definition also requires reports:schedule; otherwise a caller allowed to save report filters could change or remove background work through the less-privileged report route.
timezone is an IANA name and local_minute is minutes after local midnight. Daily, weekly (Monday=0) and monthly (1–31) cadence are supported. A monthly 31st skips a month without that day. During a DST gap, a nonexistent time is moved to the first real minute on the same date; during a repeated hour, the first occurrence wins and the second cannot produce a duplicate run.
Workers claim due rows with FOR UPDATE SKIP LOCKED. The unique (schedule_id, occurrence_at) occurrence row is the idempotency boundary, so concurrent workers and retries cannot create a second logical execution. Transient errors retry at 30 and 120 seconds, then stop after the third attempt; permanent authorization and object errors are never retried. Runs retain status, occurrence, principal attribution, bounded error code and row count only-never filters, report rows, evidence or generated files. Deleting a report atomically deletes its executable schedules while occurrence history and audit records remain non-executable evidence.
The console exposes this contract contextually from Reports → Schedules. Only accounts holding reports:schedule see or call the scheduling surface. The form previews the authored cadence in its IANA time zone, while the saved row renders the server-computed next_run_at back in that same zone. Disabling and deleting require explicit confirmation; deletion also records a reason. Per-schedule history presents bounded status/error codes, retry state and row counts, never report content. A URL with report and schedule query keys is a stable deep link, and server authorization remains authoritative for every linked object.
Governed delivery (DLP-585)
A schedule may name an approved report destination and delivery_format: csv. Destinations are separate from incident integrations: approving a report receiver does not subscribe it to the incident feed. Destination creation and changes additionally require integrations:write; list and history require reports:schedule, so a direct API call cannot enumerate receivers through a less-privileged account. URLs are HTTPS-only, checked against the outbound address policy when saved and again when used, and a private CA may be pinned. The HMAC secret is write-only.
At execution the worker resolves the current principal and requires reports:schedule, incidents:read and incidents:export. It uses the interactive export's query and CSV renderer, so scheduled delivery cannot bypass export control or gain a second field list. The CSV exists in memory for the request and is not stored. Its SHA-256 is retained against the run: if the report changes before a retry, the retry fails closed rather than sending different bytes under one idempotency key. Every request carries Idempotency-Key: report-run-<run id> and the existing HMAC signature over the exact body.
The run becomes succeeded only after the receiver answers 2xx. A disabled or missing destination, lost permission, render failure or changed retry sends nothing and records a bounded reason. Transport failures retry within the schedule's three-attempt bound. Attempt history is append-only and contains only ids, object names, status/error class, format, row/byte counts and duration-never the report, filters, URL, secret, evidence or response body. Schedules without a destination retain DLP-584's execution-only behaviour for backward compatibility.
Exception requests (DLP-461, DLP-815)
GET /api/v1/exception-requests exceptions:read · ?state=pending|approved|...|all
&subject&policy&limit&offset → {requests, total, limit, offset, lapsed, sla_hours}
GET /api/v1/exception-requests/{id} exceptions:read
POST /api/v1/exception-requests/{id}/decision exceptions:decide · {approve, note?, minutes?}
POST /api/v1/exception-requests/{id}/withdraw exceptions:decide · {reason}
POST /api/v1/agent/exception-decisions agent-signed · {since} → the device's signed decisions above its floorThe queue is oldest first and pages with offset (DLP-816), so past the first page the newest requests are reachable. It filters by subject (without case) and by policy name. GET /{id} opens one request that a page does not hold, for a link that names it.
A decision is made once, clamped to eight hours, signed, and audited in its transaction. Nobody decides a request raised from their own account (DLP-819, ADR-0033). The request's subject is compared with the approver's address as a UPN and by local part. A match is refused with 403 and audited as exception.self_decision_refused. A request with no subject is decided, and its audit entry records identity_unmapped: true.
A live approval can be withdrawn with a reason (DLP-820). The withdrawal is a second signed decision at a new serial from the same sequence, so the endpoint's replay floor accepts it and refuses a replayed copy of the approval it ends. The endpoint stops honouring the approval when it next collects its decisions. It is audited as exception.withdrawn with the reason. Only an approval that has not run out can be withdrawn (409 otherwise). An agent built before withdrawals existed cannot read one: it logs the decision as refused and keeps the approval until its window ends, at most eight hours.
An endpoint collects decisions every 30 seconds while one of its requests is pending or an approval is in force, doubling to at most five minutes while the server does not answer, and every 15 minutes otherwise (DLP-1876). When one arrives for the person signed in, the endpoint tells them with a notice in their session.
Every transition is audited (DLP-1874). A request raised by an agent (POST /agent/exception-requests) writes exception.requested in the same transaction, by the agent, carrying the reference, the policy name, the category and whether a person was named. It never carries the reason, the file name, the destination or the account. A request nobody answered inside the 48-hour service level is expired by the server itself: a pass 30 seconds after start and every five minutes (DLP_EXCEPTION_LAPSE_TICK_SECS in tests) expires it with a signed decision the endpoint collects. Each expiry writes exception.expired by system:exception_lapse. Listing the queue still lapses what is due, as before, so the two never disagree; the pass is what makes it happen when nobody looks.
Who decides is named to the person who asked (DLP-1875). POST /api/v1/settings/approver-label {label, reason} (tenant:write, audited as settings.approver_label_changed) sets one line of at most 60 characters. Blank restores the product's wording, "Your security team". POST /agent/exception-requests returns it as approver_label beside {id, state}, and POST /agent/exception-decisions returns it beside decisions. It is display only and outside every signed decision. No individual approver's name is ever sent unless the tenant writes one here. The agent keeps it on the request's row and shows it beside a pending request on its About page and request status card. GET /settings carries exception_approver_label when one is set.
The person who asked withdraws their own pending request (DLP-1873):
POST /api/v1/agent/exception-requests/{reference}/withdraw agent-signed · {}Every pending request the calling device raised with that reference is set to withdrawn with withdrawn_by_requester, and each writes exception.request_withdrawn (actor agent:<device>, by: requester). The answer is 200 {reference, state: "withdrawn", withdrawn_at, withdrawn: n}. It is 404 when this device raised no request with that reference, including another device's, and 409 with the state when none is pending. An approval is not the requester's to take back: an approver withdraws it, signed (DLP-820). Nothing is signed for a requester's withdrawal and the decision poll delivers nothing for it; the endpoint applies the answer to its own ledger. The approver queue and the request's page say "Withdrawn by the person who asked". On the endpoint, the person runs dlp-agent requests --withdraw <reference>. The agent service checks the account and queues it, and the next delivery cycle sends it. A request that has not reached the server yet is dropped locally and nothing is sent.
GET /overview carries exceptions_waiting {count, oldest_lapses_at} for a reader holding exceptions:read, and null for one without (DLP-823).
Policies
GET /api/v1/policies · POST to create
each policy carries number, its readable per-tenant id shown as POL-n; never reused (DLP-1326); GET /policies/{id}/lifecycle carries it too
and group (email, web, removable_media, cloud, print, clipboard, network_share, discovery, other, or null) with effective_group, the chosen group or the one its channels imply; POST to create takes an optional group (DLP-1327)
GET /api/v1/policies/inventory policies:read · search, filters and bounded pages over the same rows (DLP-1447); see below
GET /api/v1/policies/{id}/versions/{version}/package policies:read · one exact version as a portable package · audited policy.exported (DLP-1452); see below
POST /api/v1/policy-packages/dry-run policies:write · {package, mappings?, name?} · what an import would do; writes nothing (DLP-1453)
POST /api/v1/policy-packages/import policies:write · the same body · a new draft only, never published · audited policy.created with imported_from (DLP-1453)
POST /api/v1/policies/{id}/group {group | null} policies:write · audited policy.group_changed · makes no version and no bundle (DLP-1327)
GET /api/v1/prompt-messages policies:read · the prompt library: {id, name, action (warn|justify|block), texts {en, az, ru, tr}, builtin, version, retired_at} (DLP-1330)
POST /api/v1/prompt-messages policies:write · {name, action, texts} · en required; a text the endpoint would not show (empty, over 512 characters, naming a detector or pattern) refused by texts.<language> · audited
POST /api/v1/prompt-messages/{id} policies:write · a new version of a message still offered · audited
POST /api/v1/prompt-messages/{id}/retire policies:write · no longer offered, still listed · audited
a policy chooses one with spec.notifications.prompt_message_id (must be this tenant's and written for the policy action); the bundle compiler writes its text into notifications.user_message in the tenant's notice_language, English where the message has none, so any agent shows it (DLP-1331)
spec.notifications.justification {reasons[], free_text, min_chars} (DLP-1865): the reasons offered by the agent's category names (empty is all), whether a sentence is required (default true) and its least length (12 to 500, default 12); left out of the bundle when it is the default, so older agents read the same bytes
the signed bundle carries the tenant's help_url (DLP-1880), re-checked as http or https when it is compiled, for the agent's Help entry
GET /api/v1/policies/{id}
GET /api/v1/policies/{id}/versions · POST to create a version
a credential-shaped value in spec.description, spec.notifications.user_message or a user_messages text is refused, naming the field and never the value, at create, at a new version, at submit, publish and rollback, in simulation, on a template and on a package import (DLP-2334); nothing is stored, so the value is in no version, diff or audit entry. The bundle compiler does not re-check it, so a revision stored before the rule still compiles, but it cannot be submitted, published or rolled back until its text is changed; scrubbing such revisions awaits an owner decision
GET /api/v1/policies/{id}/lifecycle policies:read
every newly created policy/version requires change_reason whose reason text is 10-500
characters; an optional ticket/reference uses `[REFERENCE] reason` (reference at most 80
characters). The reference does not count towards the 10-character reason minimum. The
complete value is returned by both version and lifecycle history. Existing published
versions are not retroactively refused during bundle compilation.
A change_reason holding a credential-shaped value is refused the same way, naming
change_reason and never the value, on a new policy, a new version and a restore-draft; so
is a credential in a new policy's top-level description, at create, on a template and on a
package import (DLP-2369). Both are checked only where they are written, not at submit or
publish, so a revision stored before the rule still compiles.
POST /api/v1/policies/scope-preview policies:read and agents:read · {scope}
which active endpoints a scope selects, before it is saved or published (DLP-1442):
eligible_endpoints of active_endpoints, ineligible counted by the first reason that left
each out (platform_not_in_scope, endpoint_not_targeted, endpoint_group_not_targeted,
excluded), whole_fleet when no endpoint dimension includes anything, user_membership
"unknown" when a directory group or person is named (membership is read on the endpoint,
so never a number), and unresolved typed targets by their label and reason (at most 50).
directories: for each AD domain a target names, how many targets and whether a
directory source vouches for them: verified (an enabled source verified within five
minutes), stale, unknown (never verified, or the last attempt failed) or no_source,
with last_verified_at. Read from the stored source rows, never a live directory call;
enforcement does not depend on it.
Counts only, never names of people; a role limited to some endpoint groups counts only
theirs. Eligible is not delivered: the lifecycle's deployment bands say that.
Every publication, approval and direct rollback records the same counts for the whole
tenant as `resolved_scope` on its `policy.published` audit entry (unresolved
targets as a number there, not by label)
POST /api/v1/policies/{id}/versions/{version}/publish policies:publish
POST /api/v1/policies/{id}/versions/{version}/approve policies:approve
POST /api/v1/policies/{id}/versions/{version}/reject policies:approve · reason
POST /api/v1/policies/{id}/rollback policies:publish · to_version, reason
low-risk direct rollback is all or nothing: 422 if the
target no longer validates; a governed rollback creates
a pending version without changing the live bundle
POST /api/v1/policies/reorder policies:publish · {order, reason} · step-up
POST /api/v1/policies/simulate policies:read · {text, channel, …, policy_id?, version?, user_groups?, endpoint_id?}
a named draft's typed targets are lowered as the bundle compiler lowers them, with the tenant's current endpoint-group names, in memory only, so a draft and the same policy published give the same answer (DLP-1480)
endpoint_id (also needs agents:read) imagines the operation on that managed endpoint: the server reads its id and current group into the evaluation, and returns {endpoint: {id, hostname, os, group, last_seen_at, online}}; another tenant's, a missing or an out-of-scope endpoint is 404, a revoked or uninstalled one 422 by endpoint_id; without one, a scope naming endpoints or endpoint groups matches nothing (DLP-1481)
GET /api/v1/policy-bundles · POST /compileThe bundle list keeps its original array response and 50-row limit for callers that do not request paging. GET /api/v1/policy-bundles?paged=true&limit=20&offset=0 returns {items, total, limit, offset}; limit is bounded to 1–50 and rows are ordered newest first. Each row includes reason (or Reason not recorded for a legacy issue without an audit cause).
GET /api/v1/policy-bundles/diff?from=7&to=8 (policies:read) compares any two ascending bundle versions in the caller's tenant. It verifies both stored signatures and returns added/removed/changed policy, detector and classification IDs, field names for changed IDs, names of other changed sections, and content_unchanged. It never returns signed payloads, policy condition values, EDM tags or document fingerprint hashes. A renewal whose only changes are bundle version and issue/ expiry times has content_unchanged: true; missing versions return 404. The Deployments UI defaults to adjacent versions and permits any earlier from.
Publish, approve and direct rollback answer alike (DLP-842): {policy_id, version, status: "published", bundle_version, coverage_warnings, overlap_warnings, policy_disabled}. Each one that carries warnings writes a policy.published_with_warnings entry naming which act it was (via: publish, approve or rollback). Until DLP-842 only publish did this, and with the tenant's release rule on every release is an approval, so the warnings reached neither the approver nor the record.
Potential policy overlaps. Each lifecycle version returns overlap_warnings[] for currently published policies that share a positive, direct detector reference and at least one channel. An empty channel scope means all channels. A negated detector does not trigger a warning. Each entry names the peer policy, shared detectors/channels, and which policy has the lower (higher-priority) number. This is a conservative pre-release prompt, not a proof that full conditions or user/device scopes overlap, and not a claim about the event-time winner: the selected conflict strategy and actual matches still decide that. Classification/category-only conditions are not yet expanded for this check. The console reads this preview before a list Publish/Submit/Approve and refuses to send the release when it cannot read it. The release response repeats the current warnings; the audit entry stores peer IDs, not policy content. A peer changed between preview and release may change the warning set, so clients should also inspect the response.
Exception coverage (DLP-1444). Coverage warnings may carry exception_id. exception_scope_unevaluable says no active endpoint on the policy/exception platforms reports the required department or directory-group attribute; exception_scope_partial describes partial capability coverage, not actual group membership. These warnings describe an ineffective exemption, not an ineffective policy. Every saved lifecycle version carries its own coverage_warnings, including drafts and pending approvals, computed against the current fleet before release. Warning audit metadata records exception_ids alongside the kinds, never the exception reason or membership.
The inventory (DLP-1447). GET /policies/inventory answers "which rules are these" without sending the catalogue. Every parameter is optional:
q: a policy number (POL-12,pol12,12) finds that policy exactly; anything else is a case-insensitive part of the name or description. At most 200 characters.family(data,applications,websites,devices,auditing): the Policies tabs.group: the effective group.status: the policy row's status.action(audit,warn,require_justification,block,quarantine): the online or the offline response.channel: anyChannel; a rule scoped to no channel covers every channel and answers every channel filter.classification: a name the row lists, without case.scope(whole_company,targeted;DLP-1474): a rule with no published summary is neither. Whole company means no positive person or endpoint target, including typedinclude_targets; exclusions do not turn a broad release into a targeted one.owner: exact, without case.deployment: see below.attention(cannot_fire,needs_review).updated_since: a date or an RFC 3339 time.sort(priority,name,updated,severity,number; defaultpriority) anddir(asc,desc). Every sort ends on priority and then the policy number, so pages over unchanged data neither repeat nor skip.limit1 to 100 (default 50) andoffset0 to 1,000,000. Out of range is refused, not clamped, because a clamped page breaks the caller's next offset.
A value outside its set is 422 with details[].field naming each field. The answer is {items, total, limit, offset, unfiltered_total, complete_order, owners, attention: {cannot_fire, needs_review}, counts: {active, drafts, awaiting_approval, changes_unpublished}}. counts (DLP-1474) is over the whole tenant by deployment state: active is live plus changes_unpublished, drafts is never_published. Each row's enforcement_7d also carries incidents, the incidents it raised in the last seven days. items are GET /policies rows. total counts the filtered set across pages, and unfiltered_total counts the tenant. attention is counted over the whole tenant, so the notes under the list survive a filter. complete_order is true only when nothing narrows the set, it is sorted by priority ascending from offset 0, and it fits on the page. That is the only case in which the items are the whole priority order, and POST /policies/reorder accepts nothing less.
Each row of both routes carries deployment. It is one of live, changes_unpublished (a newer version exists and is not released), awaiting_approval, switched_off, never_published or retired, read from the policy's own lifecycle columns, never from a queued bundle. When more than one applies, a version waiting for a second person comes first.
The search term is not logged. Request tracing records the path only.
Policy packages (DLP-1452, DLP-1453, ADR-0044). The export is {format: "mydlp.policy-package", format_version: 1, product_version, exported_at, source: {tenant_id, policy_id, policy_number, version, version_status}, policy: {name, description, group, spec}, dependencies, digest}. The spec has id, tenant_id and owner cleared, and the compiler's exclude_* fields and compiled prompt texts removed. Each dependency is {key, kind, value, version?, revision?, target?, name?, origin?, paths}. kind is one of detector, classification, prompt_message, scope_user, scope_group, scope_department, scope_device, scope_endpoint_group or scope_target. digest is sha256: over the canonical JSON of the package with digest empty. It is integrity only, and grants nothing.
A dry run and an import take {package, mappings: {<key>: {to, version?, revision?, domain_sid?, remove?}}, name?}, at most 256 KiB. They answer {source, product_version, digest, same_tenant, name, name_conflict?, previously_imported, dependencies, issues, blocking}. Each dependency there adds a state:
exact: the same tenant, a built-in detector, or a built-in classification at the same version and revision;requires_mapping;missing;incompatible: a built-in at another revision;mapped;removed: a prompt message only.
Each dependency also carries resolved_to, message and removable. Nothing from another tenant resolves by itself, and nothing resolves by name. An import that blocking would stop is 422, with a field per reason (mappings.<key>, name, package, package.policy.spec.<field>), and it writes nothing. Otherwise it is {id, version: 1, report}: a first draft, written as any new policy is, with policies.imported_from recording the digest and the source. A fifth concurrent dry run or import is 429.
How often each rule was reduced (DLP-1298). Each row of GET /policies carries enforcement_7d: {events, reduced}: the rule's events in the last seven days, and how many of them the endpoint could not carry out as asked. It uses the predicate prevention.unsupported_7d and ?enforcement=unsupported use, and monitor mode is left out, as there. The console links the figure to /incidents?policy_id=<id>&enforcement=unsupported, which lists the incidents those events are in. A count per rule, naming nobody.
Whether an event was reduced is decided once, when it is stored (DLP-1784): a database trigger sets events.reduced from the event's enforcement_result and decision, for every insert and for any later change to either, and all three figures read that column. Before, each request walked every event's decision JSON for the week. On 300,000 seeded events across 40 rules, that per-rule count fell from 1.8-2.0 s to 79-91 ms, and the two definitions agreed on every row.
A block with no channel scope is still a claim to block (DLP-1288). A policy scoped to no channel applies to every channel, and it gets no channel_unobservable warning for channels like print: a broad rule is not a claim that print is covered. But a block or quarantine policy with no scope gets one cannot_block warning, with channel: null, when no active endpoint can block on any channel it observes. An endpoint that can block on some channel is partial coverage, which is not warned about. Every cannot_block message names what the action is reduced to, as the endpoint's clamp does it: warn where the endpoint can show a warning, otherwise audit.
Releasing a version never enables a disabled policy (DLP-842). The version becomes current, policy_disabled is true in the answer and in the policy.published entry, and the policy is left out of every bundle until POST /policies/{id}/enabled switches it on. Until DLP-842 a publish or a rollback set it back to published, putting a rule somebody had switched off, with a reason on the record, back in force as a side effect of editing it. The enable/disable call returns 409 if the status or published version changes after the caller's check but before its update; it leaves the newer state and bundle alone, and the caller should refresh before trying again.
Switching off a Block, Quarantine or ApprovalRequired policy, a policy with whole-company scope, or any policy while the tenant-wide second-person rule is on is itself governed. POST /policies/{id}/enabled with {"enabled":false,"reason":"…"} answers 202 with {"status":"pending_approval","request_id":"…","bundle_version":null}. The live policy and signed bundle do not change, and the list row carries pending_disable: {id, version, requested_by_id, requested_by, requested_at, reason}. A different user with policies:approve and a fresh policy_release confirmation applies the exact reviewed request with POST /policies/{id}/disable/{request_id}/approve. Reusing the requester's identity is 403; a changed live version or a superseded request is 409. The request may instead be rejected with a reason at POST /policies/{id}/disable/{request_id}/reject, or withdrawn with a reason by its requester at POST /policies/{id}/disable/{request_id}/withdraw. Rejecting and withdrawing leave the policy and bundle active. Targeted low-risk policies still switch off directly when the tenant-wide rule is off.
The list's order is the order endpoints use (DLP-833). A policy's place in the list, its priority column, is authoritative. Every compiled bundle stamps it onto each shipped spec's priority, which is what the engine ranks conflicts by (the lowest number wins under highest_priority). Until DLP-833 a reorder renumbered only the column: endpoints kept the old winner while the audit log said policy.reordered, and the next publish copied each spec's number back over the column, undoing the reorder on screen too. Publishing no longer writes the column, and a new policy is placed last. On a tenant whose columns and specs had drifted apart, the first compile after this adopts the column order, which is what the list already showed.
A reorder must list every policy once and needs a reason (422 without one). It asks for the policy_release step-up, compiles a new bundle, and is audited as policy.reordered with the order before and after and the reason. The policy_bundle.compiled entry for that bundle names it as the cause.
lifecycle (DLP-606) is one policy's history and reach, for the detail screen. Each version carries its author, submitter, approver and publisher (the last from the audit log, which is the only record of it) and the lifecycle actions that apply to it, each allowed for this caller or refused with a reason: the server decides what the console may offer, so the console never offers a button the server refuses. A version that was live is offered as rollback or request_rollback, never as publish. The rule text is not included; the versions endpoint has it.
deployment names the first signed bundle that carried the live version - read from the bundle payloads, not inferred from timestamps, because a bundle compiled concurrently can be issued either side of a publication - and puts every active, unretired endpoint in one band: current and behind (heard from within the organisation's listing cutoff, fifteen minutes by default), offline (silent past that cutoff, with offline_current of those current when last heard from), unknown for an endpoint that has never reported a bundle, which is neither behind nor zero, and upgrade_required (DLP-1485): when the live version excludes someone, the endpoints that do not report scope_exclusions and so are withheld its bundle (DLP-1484). That band takes precedence, so such an endpoint is never counted current or behind. unsupported counts endpoints that report capabilities and can observe none of the policy's channels, and is null for a policy naming no channel; capabilities_unknown counts those that have reported none. A policy in another tenant is 404, the answer for one that does not exist.
publish answers one of two ways (DLP-536). An approver publishing a targeted low-risk draft with no known coverage or overlap warning while the tenant-wide rule is off gets {"status":"published","bundle_version":N,…}. A caller who cannot approve, or whose draft contains Block, Quarantine or ApprovalRequired in its mode, response, offline response, risk thresholds or associated-rule override, or whose draft has a current coverage or overlap warning, or whose draft or current live version has whole-company scope or a high-risk action, gets {"status":"pending_approval"} and no bundle: the version waits for a second person. The list row carries pending - author, submitter and when - while it waits.
A tenant can require a second person for every release (DLP-672):
jsonc
POST /api/v1/settings/policy-release tenant:write · step-up policy_release · audited
{ "require_second_person": true, "reason": "SOX control 4.2" }With it on, an approver's own draft is submitted like anybody's and answers pending_approval. The lifecycle read offers them submit, not publish, and says second_person_required: true. Its author and submitter are then refused approve as above, so somebody else releases it. The rule binds the Owner as well. It is off by default for other actions; the high-risk and known-warning and whole-company floors cannot be disabled by this setting. A change needs a reason and a recent sign-in, is audited as settings.policy_release_rule_changed with both values, and sending the current value records nothing. A rollback touching Block, Quarantine or ApprovalRequired, restoring a version with current coverage or overlap warnings, or made while the tenant-wide rule is on, is submitted as a new version copied from the requested historical version. The response is pending_approval with version (the new version) and restores_version (the historical version), and has no bundle version. Its requester cannot approve it; the current bundle stays unchanged until another person approves. A rollback touching a whole-company version also needs independent approval. A targeted low-risk rollback with the tenant-wide rule off still releases immediately. Enabling a policy and directly disabling a targeted low-risk policy release no version. Governed disables use the independent request flow described above.
approve publishes a pending_approval version and records approved_by; it is 403 for the version's author or submitter, with a sentence saying which, and 409 for a version that is not waiting. reject needs a non-empty reason (422 otherwise) and returns the version to draft. The four routes enforce the rule the lifecycle read shows (DLP-608): publish, approve, reject and rollback (including request_rollback) each ask the same function that computes a version's actions, so a control the console offers is one the server accepts and one it disables is one the server refuses, with the same sentence. A refusal about the caller - a missing permission, or four eyes - is 403; one about the version's state is 409. publish applies only to the latest draft: a waiting version is approved or rejected, and a version that was live is returned to with rollback or request_rollback, which records a reason - before this an administrator could re-publish an archived version through publish with neither. rollback is 409 for a version that has never been published or is live now. Every refusal is audited as policy.lifecycle_refused with the action, the version and the sentence given. One version of a policy waits at a time, and it is the latest: creating a version while one waits is 409, and so is submitting a version that is not the latest. An approval reads the version under its row lock, so one that meets a reject or a classification withdrawal committed under it is 409 rather than publishing what was just withdrawn. Audit actions: policy.submitted_for_approval, policy.approved (beside policy.published, which every publication still writes), policy.approval_rejected, policy.rolled_back, policy.lifecycle_refused.
simulate runs the same detection engine and policy evaluator the endpoint runs - the crates compile into both binaries - and returns matched policies, per-policy explanations, the applied exception, conflict resolution, the final action, the risk score with its factors, and timings.
It evaluates the latest signed bundle unless the request names a draft (DLP-838). policy_id and version together substitute that stored version into the bundle in memory: in place of the policy's published version, or beside the others if it was never published. The response's simulated_draft {policy_id, version, status, replaced_published} says which case it was. The draft is validated as a publish validates it, so a version the server would refuse to release is refused here, with the same 422. One of the pair without the other is 422, and a version that does not exist is 404. Simulating a draft does not sign, publish or change it.
user_groups carries the directory groups of the signed-in user, as SIDs or DOMAIN\Name, which a Windows endpoint reads from the logon token. Without them, a group-scoped policy cannot match in simulation. department is still accepted, but no endpoint reports a department yet (DLP-666). An outcome for a department-scoped policy therefore carries not_on_endpoints, a sentence saying that the match would not happen on an endpoint. It is absent on every other outcome.
Creating a policy or a version returns 422 with a schedule field when the schedule could not be honoured as written:
| Condition | Why it is refused |
|---|---|
timezone is not an IANA zone name | It would be applied as "always active" on every endpoint, which is the opposite of what was written. An offset such as GMT+4 is refused for the same reason: it cannot follow daylight saving, so the policy would be an hour wrong for part of the year |
start_minute or end_minute is 1440 or more | The field is minutes from midnight, 0 to 1439 |
start_minute equals end_minute | A zero-length window never opens, which is indistinguishable from a policy that simply never fires |
| a day is greater than 6 | 0 is Monday and 6 is Sunday |
Schedules are evaluated in the policy's own zone, with daylight saving followed and a midnight-crossing window belonging to the day it starts on. See Policy model.
The rule library (DLP-1333)
GET /api/v1/policy-rules[?retired=true] policies:read
GET /api/v1/policy-rules/{id} policies:read -> the rule and its versions, newest first
POST /api/v1/policy-rules policies:write {name, description?, tags?, scope, reason?} -> {id, number, version: 1}
PUT /api/v1/policy-rules/{id} policies:write same body -> {id, number, version, new_version}
POST /api/v1/policy-rules/{id}/retire policies:writeA rule is the route data takes, reusable across policies: scope is {channels, destinations?, applications?, operating_systems?} (dlp_contracts::policy::RuleScope), with the meanings a policy's scope has: a destination also matches its subdomains, an application is a process name in any case, and no platforms means every platform. At least one channel is required, because a rule with none would put no channel limit on a policy.
Each rule has a per-tenant number, RULE-n, never reused, and a version. Editing the name, description or tags changes them in place; a different scope adds a version and keeps the old one (the same scope in another order is not a change). A retired rule leaves the library, keeps its number, and cannot be edited; its name is free again. A new tenant starts with six seeded rules: copy and paste into email clients, email attachments, file upload to webmail, copy to removable storage, files in cloud sync folders, and printing. Every write is audited as policy_rule.created, .updated or .retired, with the rule's number, version and scope.
On a policy (DLP-1334). A policy version's spec carries rules: [{rule_id, number, version, action_override?, condition_override?}], omitted when empty. Each entry pins the rule version it was associated at, so a later edit in the library changes nothing the version enforces; the console offers "Use vN" when the library has moved on. On every create, submit, publish and bundle compile, each entry must name a rule and a version that exist in the tenant, under its own number, and no rule twice. A retired rule may stay on a policy that already had it; adding one to a new version is refused with "RULE-n is retired". A condition override is validated, and its detectors shipped, like the policy's own data asset.
In the bundle (DLP-1335). A published policy with rules ships as one ordinary policy per rule, with id {policy}/rule-{n} and a name that adds "(RULE-n rule name)". Each copy keeps the policy's who, takes the rule's route, uses the rule's action and data-asset overrides, and uses the platforms both allow. A copy whose platforms and the policy's share none is left out. An event under a copy's id belongs to its policy.
POST /api/v1/policy-rule-issues policies:read {spec} -> [{rule_id, number, issues: [{kind, message}]}]What will not work (DLP-1336). For a draft spec, each associated rule expanded as the compiler would, put to the same coverage check a policy gets, restricted to the platforms the compiled rule can reach: a block no endpoint on those platforms can perform on that rule's channel, or a channel none of those endpoints reports. A rule left out for sharing no platform says so (rule_no_common_platform), and one not in the library says that (rule_not_found). Nothing is stored; the editor's rules table shows it in an Issues column.
Policy templates (DLP-591, DLP-592)
GET /api/v1/policy-templates policies:read
GET /api/v1/policy-templates/{id}/versions/{version} policies:read
POST /api/v1/policy-templates/{id}/versions/{version}/instantiate policies:write
{name?, parameters?: {mode, severity, min_matches}} -> 201 {policy_id, status: "draft", template_*}A curated place to start a common control. Each template names its detectors, channels, a jurisdiction note, what it is known to miss, and the parameters it leaves open. Every template's mode defaults to audit: a new control is watched before it is enforced. There are seven in this release: payment cards to removable media, credentials leaving, Azerbaijani personal identifiers, Azerbaijani IBANs, Turkish personal identifiers, IBANs in bulk, and documents marked confidential.
The credentials template at 1.2.0 includes explicit password assignments as well as provider-specific keys and tokens. Its generic detector requires an assignment to a literal of at least eight characters; variables, placeholders and prose remain outside that template's claim.
Part of the product, not the tenant. Templates are defined in the build, reviewed as code, and recorded in policy_templates once per version with the SHA-256 of their content. A database trigger refuses to rewrite a recorded version. If a build carries different content under a recorded number, the server logs it, reports that version with eligible: false ("recorded with different content"), and refuses to instantiate it. It never overwrites the record to match. Tenants cannot author templates in this slice.
Instantiating makes an ordinary draft, and nothing else. It is validated exactly as a hand-written policy is (every detector enabled in this tenant, name unique), written by the same function, and audited as policy.created with the template's id, version and hash. The policy row keeps the same three fields for good. It is unpublished: no bundle is compiled, and it reaches no endpoint until somebody submits it and a second person approves it, as with any draft. A later template version changes nothing in a draft already made.
eligible / why_not say whether this tenant can instantiate a version and, if not, what to do: which detectors to enable, that the version is retired, or that it was tampered with. A parameter outside the template's own list, or a value outside its range, is 422 naming it, never clamped.
Advice, not law. A template's jurisdiction note says what it was written with in mind. None claims that using it makes anybody compliant.
Compliance packs (DLP-1779)
GET /api/v1/policy-packs policies:read
POST /api/v1/policy-packs/{id}/apply policies:write and detectors:write
{reason, citation} -> {regime_enabled_now, tagged_now, created, existing, status: "draft"}A pack puts together three things that already existed: a compliance regime (DLP-426), the templates that address it, and the classifications it governs. This release has three packs: payment card data (PCI DSS), Azerbaijani personal identifiers, and Turkish personal identifiers (KVKK). GDPR has none because this product does not have a sufficiently broad template for it.
GET shows, per pack:
- the regime and whether it is enabled;
- each template, with the policy already made from it or why it cannot be made here;
- each classification it would tag;
preview: the events in the last 30 days in which one of its detectors matched on one of its templates' channels, with distinct files and a count per channel;applicable, false once there is nothing left to do.
apply checks every template first, and changes nothing (409, naming the template and why) if one cannot be made. It then enables the regime with reason and tags the classifications with citation, both in one transaction and audited as on Classifications, records policy_pack.applied, and makes each missing template an ordinary draft in audit mode.
Applying again changes nothing:
- a template that already has a policy is reported in
existing, not remade; - a tag already there keeps the citation somebody first wrote.
A pack tags only the detectors its templates watch. The Azerbaijani and KVKK packs watch VÖEN/VKN and country-specific IBANs but do not automatically tag them as personal data, because they can also identify organisations. Like a template, a pack never claims that applying it makes anybody compliant, and the regime is the organisation's own determination, in its own words.
Agent authentication
Every /api/v1/agent/* endpoint except enroll is signed with the device's Ed25519 key. A request with a missing or unreadable signature header answers 401, not 400: a missing credential is not a malformed request.
That distinction is operational rather than pedantic. Runbooks, SIEM rules and WAF policies count 401s to spot credential stuffing and enumeration; while these endpoints answered 400, an attacker probing the agent API produced responses indistinguishable from a client sending bad JSON, and authentication-failure alerting missed the agent plane entirely (#442).
The response does not say which header was missing - that would tell a caller which of four to try next. The name is logged at debug level, so an agent developer can still find out from their own server.
400 is kept for what it is for: a body that cannot be parsed, or a device id that is not a UUID.
Metrics
GET /metrics on the operational listener, not the public one. Prometheus text exposition, version=0.0.4.
DLP_METRICS_BIND where it binds. Default 127.0.0.1:9090. Empty disables it.
DLP_METRICS_TOKEN optional bearer token. Empty means none.Why a separate port. Metrics carry tenant names and volumes - how many incidents a customer raised, how many endpoints they run, whether their retention is wedged. That is commercially sensitive about the customer and operationally useful to an attacker. Three options were weighed:
| Unauthenticated on the main listener | Refused. The main listener is public behind nginx; anything on it is one location block from the internet |
| On the main listener behind the session guard | Workable - Prometheus supports bearer_token_file - but it puts a credential with SystemRead in every scraper, and that credential reads the console API too. A monitoring outage should not be able to become a data breach |
| A separate bind | Chosen. Not proxied by nginx, so not public by construction rather than by a rule someone has to remember |
The default is loopback, so a bare install exposes nothing. Set DLP_METRICS_TOKEN whenever the bind is not loopback: the port being unroutable is a network control, and network controls are the ones somebody else changes.
Contractual metrics. These names and labels will not change without a major version. Alerts may be written against them.
| Metric | Type | Labels |
|---|---|---|
mydlp_build_info | gauge | version, commit |
mydlp_tenants | gauge | - |
mydlp_agents | gauge | tenant, status |
mydlp_agents_silent | gauge | tenant (active endpoints with no recorded stop reason, silent past the tenant's alert cutoff, DLP-2177) |
mydlp_incidents | gauge | tenant, status |
mydlp_events_received_1h | gauge | tenant |
mydlp_events_rejected | gauge | tenant |
mydlp_sequence_gaps_open | gauge | tenant |
mydlp_sequence_events_missing | gauge | tenant |
mydlp_retention_last_success_age_seconds | gauge | tenant |
mydlp_retention_failures_24h | gauge | tenant |
mydlp_policy_bundle_version | gauge | tenant |
mydlp_agents_by_bundle | gauge | tenant, state (current, behind, unknown) |
mydlp_incident_bulk_requests_24h | gauge | tenant, action (status, assign, mark_test, relate) |
mydlp_incident_bulk_items_24h | gauge | tenant, action, outcome (updated, unchanged, stale, unavailable, refused) |
mydlp_incident_bulk_duration_seconds_max_24h | gauge | tenant, action |
mydlp_api_token_refusals_total | counter | reason (unknown, revoked, expired, wrong_secret, owner_disabled, source: used from an address the token is not allowed, DLP-1236) |
mydlp_custom_roles | gauge | tenant |
mydlp_custom_role_permissions_max | gauge | tenant |
mydlp_step_up_total | counter | action (the action keys under Confirming it is you), outcome (fresh, grant_used, required, token_refused, granted, failed) |
mydlp_step_up_grants_24h | gauge | tenant, action, state (used, expired_unused) |
mydlp_step_up_grant_to_use_seconds_max_24h | gauge | tenant, action |
Anything not in that table may change or disappear.
Bulk triage (DLP-583) is read from the idempotency records bulk requests keep for a day, which hold the action and the counts and nothing about which incidents or whom, so no label can carry an identifier. A replay is not counted twice. outcome="stale" is the one to alert on: a tenant where a large share of every bulk change comes back stale is two analysts working the same rows.
Two deliberate shapes. Retention staleness is exposed as an age in seconds, not a boolean - a gauge lets whoever writes the alert pick the threshold, where a boolean bakes ours into their alerting and cannot be tightened without a release. And a tenant that has never had a retention pass emits no sample rather than a sentinel: a new tenant legitimately has no age, and -1 or 0 would chart as "very fresh", the opposite of true.
Cardinality. Per-tenant labels are the point - "is one tenant's retention wedged" cannot be answered without them, and a deployment has tens of tenants. There are no per-device labels: a fleet is tens of thousands of endpoints, and a device label on even one metric would multiply every series by that, which is how a monitoring system gets taken down by the thing it monitors. Device-level detail stays in the API, paginated and asked for on purpose.
Overview aggregates
GET /api/v1/overview returns ten aggregates computed from stored rows, plus five added for the rebuilt dashboard (#488).
The window (DLP-1426). ?days= 1 to 90, clamped as /overview/security clamps its own (a value that is not a number is 400), and 30 when absent; the response states it as window_days, the name /overview/security uses. It applies to the rankings - categories, channels, destinations, destination_classification, departments, top_users, top_users_by_volume, top_endpoints, policy_effectiveness - and, when the request names a window, to the trend, which is then days points long. Without days the trend keeps its fourteen days. The current figures do not move with it: open incidents and their severities, coverage, new_24h, new_7d, resolved_7d, the seven-day prevention counts, and the thirty-day false-positive rate and triage and resolve times.
| Field | Window | Notes |
|---|---|---|
incidents.open_by_severity | open now | All five severities, always present. "Severe" means critical and high; critical_open alone understated it |
trend[].by_severity | 14 days | The bands sum to that point's incidents, so a stacked chart cannot lose one. Each day is a day on the tenant's business-hours clock (DLP-1807) |
coverage.agents_outdated_version | now | Active agents on a build older than this release. Different from agents_on_latest_policy: an agent can hold the newest bundle and still be an old binary that cannot enforce half of it |
coverage.latest_agent_version | now | What an up-to-date agent should run: the newest published agent package, which is older than the server whenever a release changed no agent code (DLP-632); the server's own version only when no package says what it is |
destinations[] | 30 days | Top ten destination_targets. Distinct from channels, which groups by destination type |
destination_classification[] | 30 days | Destination type crossed with data category |
policy_effectiveness[] | selected Overview window | Real incidents plus their false-positive and actually-prevented counts; events is the endpoint-event sample and reduced is the subset whose requested action was weakened. Test-marked incidents and their linked events are excluded. At 7 days these definitions match the Policies page's 7-day enforcement figures |
incidents.median_time_to_triage_minutes, median_time_to_resolve_minutes | 30 days and every open incident | Median minutes to a first look (the earlier of triaged_at and resolved_at) and to resolution, where an incident still waiting counts with its wait so far, so a backlog moves them instead of vanishing from them (DLP-1809). mean_time_to_triage_minutes and mean_time_to_resolve_minutes remain, and average only the incidents that reached the state: on production they read resolve 7.8 min beside triage 1,529 min, with thousands waiting in neither. Null for a tenant with no incidents |
incidents.untriaged_open, oldest_untriaged_minutes | open now | Incidents at status new, of any age, and how long the oldest has waited (null when none). The set /incidents?status=new lists, which is where the console's posture row links once the oldest has waited a day |
incidents.open_tests | open now | Open incidents marked as tests (DLP-1824). Every other incidents.* figure here, the SLA filters and the response timers leave them out; this says how many. The Prometheus gauge mydlp_incidents carries an origin label, real or test |
triggers[] | open now | What the open incidents were triggered by: the action a policy asked for crossed with the channel, top ten. categories answers what kind of data; this answers what kind of event, which is what a queue is triaged by. Each carries its action and channel so the console can open /incidents filtered to exactly the rows it counted; action is null where the incident recorded none, and that group says so rather than being folded into audit - the mildest of the five (DLP-336) |
Every count is present and zero for an empty tenant, never absent and never null: a chart that has to guess whether a missing key means zero or "not reported" will guess wrong on one of them.
pipeline (DLP-809) carries the facts System health shows, from the same functions: correlation_failed (events that will never become incidents), correlation_stalled_minutes (set when the oldest pending event has waited more than fifteen minutes), retention_stale, retention_failed (the latest pass failed and none has succeeded since), sequence_gaps and events_missing. They are counts and flags, readable with overview:read. They are not windowed, because System health's are not.
?enforcement=unsupported on GET /incidents (DLP-807) selects incidents holding an event, in the last seven days, whose enforcement could not be carried out. It uses the predicate prevention.unsupported_7d counts events with, so the dashboard's link opens the incidents those events belong to. Several events can share an incident, and an event that never became one is counted and cannot be listed. An event recorded in monitor mode is not in either (DLP-1231): nothing failed, a group was told not to enforce. prevention.unsupported_previous_7d applies the same event and reader-scope predicate to the immediately preceding seven days. The Security Overview uses that value for its coverage-gap trend; it never infers a direction from the current count alone.
?enforcement=would_have_blocked (DLP-1231) selects incidents holding an event that monitor mode turned from a block or a quarantine into an audit record: what enforcing would have stopped, which is the figure a rollout's next step is decided on. It has no seven-day window, because a group can stay in monitor mode for longer than a week. A monitor-mode warning or justification prompt is not in it.
?file_state=no_longer_matches (DLP-1284) selects incidents whose file an endpoint has read again and found clean since that file's last detection. An agent sends that as an ordinary file event with no_longer_matches: true, no findings and no decision, once, when a rescan of a file it had found something in comes back clean and fully read. A file it could not inspect is not called clean. Each incident row and detail carries it as file_clean_reported: {at, file_name}. It is derived when the incident is read, not stamped: the newest such report from the same device for one of the incident's file paths. It stands only while nothing has matched that file on that device since, in this incident or a later one. Nothing is closed by it, because the data may have left before the clean-up. Any other value is refused with 422.
?enforcement=warning_not_shown (DLP-1276) selects incidents holding an event, in the last seven days, recorded not_delivered: a warning or justification prompt the policy asked for that the endpoint showed nobody. prevention.warnings_not_shown_7d counts those events with the same predicate. It is apart from unsupported because nothing was meant to be stopped; what failed is that the person was never told, and they are the one person who cannot report it. Any other value is refused with 422. prevention.warnings_not_shown_previous_7d is its preceding seven-day comparison.
The Security Overview renders observation and enforcement failures in a separate full-width Coverage gaps block, ordered by security impact and with an explicit severity, impact sentence and drill-down. The two event metrics above show their real prior-period comparison. Agent health ledgers, open sequence gaps and unpoliced-detector ledgers currently retain no matching prior-period series, so those cards say that comparison is unavailable rather than fabricating a trend.
?device=<agent id> (DLP-1506) selects one endpoint's incidents, for its page in the console. The reader's endpoint-group scope still applies on top, so a scoped reader asking for a device outside it gets an empty list, not that device's incidents. The export (/incidents/export) takes it too and records it among the filters the file was made with.
Each queue row also carries data_category (DLP-1508), the first contributing detector's category, null for a rule that matched an activity rather than content; and destination_trusted, false when any event behind the incident went to a destination the tenant does not trust and null when no event said - the same value the priority reads. The console's Data column and its "Unapproved" mark read them.
?sla=at_risk (DLP-1518) selects the untriaged incidents with a quarter or less of their response target left, overdue ones included; ?sla=breached only the overdue ones. Both are measured against the tenant's targets above. A triaged incident, an informational one and one marked as a test (DLP-1824) are in neither, and any other value is refused with 422. Each row carries response_due_at while it is new, is not a test, and its severity has a target, from the same targets, so a row the filter calls overdue says so too.
?sla=at_risk_resolution and ?sla=breached_resolution (DLP-2002) say the same of the resolution target, for open incidents that are not tests and are not awaiting the user, whose clock is stopped. Each open row carries resolution_due_at, null while it awaits the user (with awaiting_since saying since when), and every row carries created_at, response_met and resolution_met. GET /overview/security returns sla: response_met of response_decided, and resolution_met of resolution_decided, over the responses and closes decided in its window.
?sla=response_met, response_missed, resolution_met and resolution_missed (DLP-2006) select the real incidents whose target was met or missed as recorded when decided, and ?decided_since= (an instant) keeps only those decided at or after it, by triaged_at for a response and resolved_at for a resolution. With the overview's window_since they open exactly the set its sla figures count.
GET /overview/security also returns three measures (DLP-2006), each equal to the queue it opens:
detection:incidentsraised in the window (real ones), and themean_minutesandmedian_minutesfrom an incident's first event (first_seen) to when it was raised. A negative gap, from an endpoint clock ahead of the server's, counts as none. Opens?created_since=the window's start.open_by_age: the open real incidents in four buckets (under_1d,1d_3d,3d_7d,over_7d), each with thesinceanduntilits queue takes ascreated_sinceandcreated_until.open_by_assignee: open real incidents per assignee (assignee_id,assignee_email) and unassigned (assignee_idnull,?assignee=none), at most 25 rows, most first.nullwithoutincidents:read, since it names people.
inspection_gaps (DLP-1279) counts, over active and non-retired endpoints (within the caller's endpoint-group scope when one applies), the files they could not inspect in the last seven UTC days:
files_7d: the total;endpoints_7d: how many endpoints they are on.GET /agents?status=active&health=not_inspectedlists exactly those, by the same predicate;by_cause:{cause, files, endpoints}, largest first.
A cause is one of size_limit, encrypted, timed_out, image_not_read, alternate_streams, no_text, partially_read, extraction_failed, unsupported_format, link_not_followed, in_use, vanished, access_denied, blocked_by_antivirus (DLP-1596) or other. A cause a newer agent names that this server does not know is counted as other. The figure names no file and no endpoint.
AI exposure (DLP-340)
GET /api/v1/overview/ai-exposure?days=30 overview:read · names nobodyWhere company data is going that nobody approved. A file pasted into an assistant is an upload to a third party that keeps it, and it carries whatever classification the file had.
- The grouping is the tenant's own. It counts operations to destinations whose categorisation rule carries the label
AI tools- rules that ship as defaults and are edited or deleted like any other.rules_configuredsays how many carry it, and zero is a different answer from zero operations: one means nothing to match against, the other means nothing happened. - Built from events, not incidents. An upload to an assistant is exposure whether or not a policy objected; an aggregate over incidents would report only the part somebody had already written a rule about.
- Under the reader's scope. A reader whose role is limited to some endpoint groups counts only the events of endpoints in those groups, as every other Overview figure does (
DLP-2110). Until then this panel and/overview/destinationscounted the whole tenant for everybody. - Three trust bands, always present and summing to the total:
trusted,untrusted,unassigned. The third is the one that matters - nobody has decided is work outstanding, not a decision made - and a destination recorded both ways is counted with the unapproved, because the cautious half is the one an operator needs. usersis a count and never a list. "Is this one person or the whole company?" is this endpoint's question; naming them is/behaviour/people, behind a tenant switch and a recorded justification.- No durations.
unitisoperations, as everywhere else. The reference console's equivalent table reports time spent in each tool; this product measures no time and is not going to (docs/PRIVACY_MODEL.md§9). - A zero is qualified by live visibility.
visibilitycontainstarget_routes,current_routesandobserved_routesfor browser upload, using the same recent-check-in andcan_monitorrules as policy coverage. A route is one relevant channel on one target endpoint or connector. Zero events with zero observed routes means not monitored, not no exposure; fewer observed than targeted routes means visibility is partial.
destination_target is returned verbatim. Until DLP-542 that was justified by the permission - the incident list returned it to the same caller. A Viewer now reads this panel without the list, and what is accepted instead is the filter: a destination is listed only if it contains a pattern from a rule the tenant labelled AI tools. That is a service's address, sometimes with a path after it; it is a mailbox only if a tenant wrote an AI rule whose pattern matches one, which is the residual this panel carries and the other two destination lists do not. Labels are truncated to 120 characters for display - a destination can be a URL with a query string, and a 2 kB label breaks an axis rather than informing anyone.
Destination trust and the unclassified (DLP-337)
GET /api/v1/overview/destinations?days=30 overview:read · names nobodyFile operations split by whether the destination was approved, and - the half worth having - which destinations nobody has ever classified. Every unclassified destination is a hole in every policy written against destinations, and finding one used to mean reading the events.
- The same three bands as above, from the same
trust_ofrule, over the same per-destination aggregate, under the same reader's scope (DLP-2110). The two panels share one query so they cannot disagree about what a destination is. trust_recordedsays whose gap it is. False means no approval decision was recorded in the window, so everything isunassignedbecause of our telemetry rather than because the tenant has decided nothing.destination_trustedis set in two places. The agent sets it for a sync folder it can answer for: trusted for a root an administrator declared, not trusted for a personal account (cloud_sync::SyncRoot::trusted). Ingest sets it for a removable device whose serial was on the tenant's approved list before the event occurred (DLP-1210), and never over the endpoint's own answer. Anything else stays unset. A business sync tenant is among the unset, until a sanctioned-tenant list exists (DLP-1210).unclassifiedlists hosts only, andpath_operationscounts the rest. A destination rule matches a host, so no rule can ever classify/media/usb0or\\fileserver\finance. Putting a path on a list headed classify these would send an operator to do something the product cannot do; those operations are counted, named as paths, and left to the channel split.unclassified_destinationsis the count before the cap, and the list is the busiest five. A list of five with no count reads as "five destinations need classifying", which is the wrong size of problem.rules_configuredcounts destination rules only. An application rule classifies a process, so counting it here would let a tenant read "19 rules" beside a list of things no rule can reach.non_businessis a fleet fact (DLP-339): the busiest destinations this tenant's own rules call non-business, with the grouping that matched and a count of distinct people, never who.suspiciousdestinations are deliberately absent - that is the one disposition with a human consequence, nothing ships carrying it, and a dashboard is where such a list gets read by somebody who never went looking for it.visibilityqualifies empty results with the same three route counts as AI exposure, across only channels whose events can carry a destination: removable and mobile devices, optical disc, Bluetooth, print, browser and email upload, network share, sync folder, cloud storage and connection filters. Generic local file activity is not counted: observing a create does not reveal where data went. The counts are capability-driven, so the panel changes from not monitored to limited visibility or monitored, zero events as current agents and connectors report those channels.
Version comparison is semantic, not lexical. A string comparison puts 0.9.0 above 0.10.0, which would report a healthy fleet as outdated for every release between x.9 and x.10. A version that cannot be parsed counts as outdated: an agent we cannot vouch for belongs in the row that says "these need looking at".
The Security Overview (DLP-1429)
GET /api/v1/overview/security?days=7 overview:readThe figures the Security Overview adds to GET /overview, over the window the page shows (days, default 7, clamped to 1-90 like the other overview windows). Every figure is computed over the caller's incident scope, including those built from devices and events: a role limited to some endpoint groups counts only their incidents, their endpoints and their file operations (ADR-0035). GET /overview keeps the same rule for its event and fleet figures since DLP-1398, and tests/overview_scope.sh holds both routes to it field by field.
open,open_by_severity,critical_open,unassigned_critical_open: incidents not resolved, accepted, false positive or closed; "unassigned" is no assignee, including one whose assignee was deleted.new_today: raised since midnight on the tenant's clock (its business-hours zone; UTC where none is set,DLP-1807).timezone: the zonenew_todayand the trend's days are counted in, so a reader is told which midnight rather than assuming one.trend[]: one row per day of the window on the same clock, days with nothing included as zeros.incidents,dataandusersare counts by severity of incidents raised, file operations in which a detector matched, and distinct people with an incident raised.devices_reportingis the endpoints that sent an event that day (there is no check-in history), anddevices_on_latest_policythe ones among them whose events were evaluated under the newest published bundle.channels[]: every channel incidents were raised on in the window, most first.endpoints: enrolled (not retired) is the denominator, since there is no separate managed-device count;onlineis a check-in within the tenant's listing cutoff (fifteen minutes by default,DLP-2177);protected/degraded/unprotected/monitoringareprotection_of's states, the same asGET /agents/protection;outdatedis active agents older than the published release;on_latest_policyofactive.effective_protection: published content-policy coverage computed by the same current/observe/enforce calculation used by policy preview and deployment.enforceable_policiescounts a policy only when every compiled route has a current target able to apply its exact action on every route channel. Channel figures count policy-channel routes rather than flattening a channel across different target scopes. Connection policies are excluded; their WFP deployment lifecycle is measured separately.delivery_failing: integrations with incident deliveries that gave up; present only withsystem:read.high_risk[]: the five most severe incidents (critical, high, medium) active in the window, with the data category of the first detector summary, never a matched value; present only withincidents:read.
Grouping the fleet (DLP-363)
GET /api/v1/agents/grouped?by=protection|client_version|os|health|group
&<the same filters as /agents>Counts per value over everything the filters match, each one usable as a filter, so the rows a group opens are the rows it counted. by=protection folds rather than groups - it is not a column - and reuses the same function the widget counts with.
The column name never comes from the request. by selects from a closed set written out in the handler; interpolating a column name that arrived in a query string is an injection waiting for somebody to be clever.
The status chips are deliberately not groupable. They are multi-value, and the reference's own documentation has to explain what grouping does to them: a device that is Deactivated, Does not communicate, Outdated lands in a group for that exact combination and in none of the three single-value groups, so "show me everything outdated" answers wrongly unless the reader already knew to filter instead. Rather than reproduce a rule that needs an apology, the console offers the filters and says so under the grouping control.
Narrowing the fleet (DLP-367)
GET /api/v1/agents ?status&search&health&group&os&silent&protection
&client_version&client_band&seen&last_user&bundle_band
&bundle_version&risk&limit&offsetbundle_band(DLP-607) iscurrent,behind,offlineorunknown, andbundle_versionthe bundlecurrentandbehindare measured against - the four bands of a policy'slifecycledeployment, with the same predicates, so a band's number and the list it links to are the same devices. An unknown band, orcurrent/behind/olderwithout abundle_version, is 422 naming the field rather than an empty list.older(DLP-730) is any device on a bundle beforebundle_version, silent or not: the set the Overview counts as outdated, whichbehindmisses for every silent device.upgrade_required(DLP-1488) is the devices that do not reportscope_exclusions, by the lifecycle band's own predicate, and needs nobundle_version. The lifecycle counts it only for a policy whose live version excludes someone, and while that band is non-zero itscurrentandbehindleave those devices out where this list does not.healthis a state, orunhealthyfor anything buthealthy: the set the Overview counts as reporting issues (DLP-730).client_band=older(DLP-1416) is the active devices whose agent is older than the release this server publishes: the set the Overview counts ascoverage.agents_outdated_version, by the same semantic comparison (0.9.0is behind0.10.0) against the samelatest_agent_version, so the figure and the list it opens are the same devices. With no package published, that release is the server's own version, as it is for the figure./agents/protectionand/agents/groupedhonour it too. Any other value is 422 naming the field.seenis a band, not a duration:online(within 10 minutes),recent(to 3 hours),away(beyond),never. The same three boundaries the console's presence dot uses, so a filter and the dot beside it on the same row cannot disagree.client_versionandlast_userare exact, not substrings. For the version because "0.9" would quietly include 0.9.x and 0.90.0; for the username because a partial match is a way to browse people, and this is a fleet screen - §6 governs naming.last_useris refused (403) to a caller withoutincidents:read(DLP-542), here and on/agents/protectionand/agents/grouped: ignored, it would answer which machine did this person use with the whole fleet. It is refused (403) to a reader scoped to some groups too (DLP-1501), with "it needs: view incidents in every group": rows outside the scope withhold their user, and whether such a device is in the answer would say this person used it.risk(DLP-1493) iscritical,high,medium,low,infoornone: the highest severity among the device's open incidents, the same value asopen_max_severitybelow, withnonefor a device that has no open incident. Anything else is 422 naming the field. Refused (403) withoutincidents:read, and to a group-scoped reader, likelast_user, since it is read off incidents;/agents/protectionand/agents/groupedhonour it too.Every filter narrows on the server, over the whole tenant. One applied to the rows already fetched would describe a page and call it a fleet, and the pager underneath it would then be wrong too.
Filters survive a change between the Active, Revoked & uninstalled and Retired lists, because they describe devices rather than a list.
Not yet filterable: installation and troubleshooting status. Neither has any states to filter by until the actions that produce them exist (DLP-369…DLP-372); the columns are in place and empty, and adding the filters before the states would be adding controls that can only ever return everything.
What a device row carries (DLP-366)
Three fields are derived rather than stored, from one lateral over the events already recorded for that device:
records_sent_at- when its most recent record reached the server. Deliberately notlast_seen_at: a device can be checking in every minute and sending nothing, and those are two faults with two fixes.last_username- the user named on that record. Nothing new is disclosed to a caller who can read incidents, since the same username is on every incident the device raised, and it is withheld from anyone else (people_withheldbelow). The console shows it by default sinceDLP-1493, with the domain masked.open_max_severity(DLP-1493) - the highest severity among the device's open incidents (any status butresolved,accepted_risk,false_positiveandclosed), or null when it has none. The console's Risk column. Withheld withincident_count.files_not_inspected_7d,not_inspected_causes_7d: files this device could not inspect in the last seven UTC days, and the count per cause (DLP-1279). They come from the per-day counts the agent keeps across restarts, so unlikefiles_not_inspected_totalan update does not reset them. The week is counted by the server's date, so a silent device's week ages out. Each file is counted once in the week, on the first day it was met, so the sum of the days is a count of files (DLP-1715). From an agent before that, a file met on several days is counted on each. An agent older thanDLP-1279reads as0andnull.?health=not_inspectedlists the devices with at least one.warnings_not_shown_7d- events this device recorded asnot_deliveredin the last seven days: warnings nobody saw (DLP-1276). On a machine people use, the agent failed to reach them. Withheld withincident_count.people_withheld-last_username,incident_count,open_max_severityandwarnings_not_shown_7dare null because the caller lacksincidents:read(DLP-542). A flag rather than bare nulls, because a null username already means the device has sent nothing yet.queue_depth- records the agent is holding, from its own health report. Null where it has never sent one, and the console renders that as a dash: a device we have no number for must not read as a device with nothing waiting.health_stale-health_stateandqueue_depthare the device's last report, as oflast_seen_at. This is true when that report is older than the listing cutoff thesilentfilter uses, the same predicate (DLP-1262,DLP-2177). A device silent for two hours last saidhealthy, and the console must say so, not that it is healthy.refused_count,last_refused_at,last_refused_status- requests this device made after the server stopped accepting it: an uninstalled, revoked or retired machine still running and asking (DLP-1262).- Counted at most once a minute, and only for a request signed by the device's own enrolled key. A request under another key, or for an id nobody issued, is refused and recorded nowhere, so nobody can mark a device refused.
- The caller's answer is unchanged in every case.
- The fix is an enrolment token given to the installer, which re-enrols the machine (
DLP-1259).
settings_updated_at is not returned, and the console's Settings column says which bundle a device holds rather than when it took it. Nothing records that moment, and inventing it from the check-in would produce a timestamp that looks like an answer.
Deciding which devices an update reaches (DLP-369, SPIKE-009)
POST /api/v1/agents/update endpoints:manage · device_ids + reasonSPIKE-009 designed the update mechanism and its seven follow-up tickets were never filed, because the tracker was unreachable when it was written. This lands the half that is verifiable without a Windows installer: the manifest contract, the rollout resolver, and the action that says who is being updated and who is not, with the reason.
- Queued, never pushed.
ADR-0019records why the server has no inbound path to an endpoint, and an update is the last thing that should be the exception. - No manifest means no update, said plainly: the action refuses rather than queueing commands nothing can carry out. A button that queues work no endpoint will ever do is a button that lies, which is what
DLP-430was filed about. - A group with no rollout rule is not on the default version. No rule means no update - the difference between a rollout and a stampede. A pinned group is
paused, which is a different fact from unlisted and is reported as one. minimum_upgradable_fromis the agent floor. An endpoint below it is reinstalled rather than upgraded: a chain of in-place upgrades across a state-format break is how an update breaks a fleet, and "reinstall" is a worse morning than a bricked estate.- Versions compare numerically.
0.9.0is below0.10.0, which a string comparison gets backwards - and backwards here means refusing to upgrade a fleet or upgrading one that should have been reinstalled. - Every skip carries a sentence an operator can act on, not a code.
- The audit entry carries the manifest version and the counts, never a list of five thousand hostnames: the audit log is long-lived and that is a copy of the fleet inventory nobody asked for.
Following the latest release (DLP-1254)
POST /api/v1/agent-groups/{id}/follow-latest agents:write · {enabled, reason} · step-up agent_update to switch on · audited
POST /api/v1/settings/agent-updates agents:write · {ungrouped_follow_latest, reason} · step-up agent_update to switch on · audited
GET /api/v1/settings/health-silence system:read · returns {checkin_interval_seconds, list_after_seconds, alert_after_seconds}
PATCH /api/v1/settings/health-silence tenant:write · {list_after_seconds, alert_after_seconds} · listing 15 min to 7 days, alerting 30 min to 7 days and never before listing · audited when changed (`settings.health_silence_changed`) · DLP-2177
GET /api/v1/settings/health-ingest system:read · returns {warning_drop_percent, minimum_expected_daily}
PATCH /api/v1/settings/health-ingest tenant:write · same fields; drop 1–99%, minimum 1–1,000,000 events/day · audited when changed (`settings.health_ingest_changed`)
GET /api/v1/settings/health-backup system:read · returns {warning_after_hours, critical_after_hours}; defaults 24/48
PATCH /api/v1/settings/health-backup tenant:write · same fields; whole hours 1–720, critical greater than warning · audited when changed (`settings.health_backup_changed`)A group that follows the latest release is moved to every new agent build by the server itself. Devices in no group move when the tenant's ungrouped_follow_latest is on. They resolve under the rule name default, as the update resolver has always named them, so a group literally called default shares that rule. Every group row carries follows_latest, and GET /settings carries agent_updates.
At start and every ten minutes (DLP_AUTO_UPDATE_TICK_SECS), for each tenant with anything following:
- It publishes. It signs and stores a manifest when the newest published build differs from what the last manifest gives the following groups: a new version, or a rebuilt file at the same version with a new digest. It also re-signs one within 7 days of expiry. Every rule for a group that does not follow is kept as it was, with its release, so a group a person pinned or held back stays where they put it.
published_byis empty and the audit entry isagent_update.auto_published, by the system, naming the groups that caused it. - It queues. Each active device in a following group that the manifest moves, and that can apply the release's kind, is sent
UpdateTo, withissued_byempty. This happens once per device per release: a device whose update failed is not sent the same release again, and the next release is a new attempt. It is audited asagent.update.auto_issued, with counts.
A group that stops following keeps its last rule in later manifests, and the server no longer moves its devices. A device that joins it afterwards is not moved either.
Update rings: a group that follows another group (DLP-1780)
POST /api/v1/agent-groups/{id}/follow-group agents:write · {leader_id | null, delay_hours (1-168, default 24), reason}
· step-up agent_update to start · audited
GET /api/v1/agent-groups agents:read · follows_group_id, follow_delay_hours, ring per groupA pilot group follows each release, and the rest of the fleet follows the pilot some hours behind, so a build that breaks something reaches the pilot first and the fleet restarts at most once per build the pilot has kept.
Only the current build can be given to anybody. An agent package is stored once per platform and kind, and a new build replaces the old one's bytes (ADR-0049). So a delayed group is given the current build once its leader's rule has named that build, without a break, for delay_hours. A newer build before then starts the wait again. Where builds arrive faster than the delay, the group does not move, and its row says so.
The listing gives each delayed group a ring:
{"state": "waiting", "version", "until"}: the leader has the current build, and the group takes it atuntilunless a newer build replaces it first.{"state": "due", "version"}: the group has been given the current build.{"state": "leader_behind", "version"}: the leader has not been given the current build, so nothing is soaking.{"state": "no_build"}: no agent build is published.
What is refused, with a sentence on leader_id or delay_hours:
- a reason under 5 characters or over 500;
- a delay outside 1 to 168 hours, because a longer one means a fix for a broken agent never reaches the group;
- the group itself;
- a leader updated by hand, which never runs a new build, so the group would never move;
- a leader that follows this group, directly or through others, because each would wait for the other.
Starting asks for a fresh sign-in (agent_update), as following each release does. Stopping (leader_id: null) is not asked. Following a group switches off following each release, and switching following each release on clears the group, so a group has one rule. Each change is audited as agent_group.follow_group.changed, with the leader's name, the delay, the reason and the endpoint count. Devices are moved by the same pass as following each release, once per device per release, and the command's reason says its group's update rule moved it. Devices in no group can follow each release but cannot be put in a ring.
Publishing the update manifest (DLP-920)
GET /api/v1/agents/update-manifest agents:read · the newest manifest, or null
POST /api/v1/agents/update-manifest agents:write · {platforms[], rollout[{agent_group, version?}],
minimum_upgradable_from, expires_in_days (1-90, default 30),
reason} · step-up agent_updateUntil this route existed nothing inserted a manifest, so the route above always refused. A manifest is composed from the packages this server publishes, never from a version typed by hand:
each release is a file its platform updates from, with its version, digest, size, the package signature
dlp-agent verify-packagechecks, and itskind(DLP-1253,DLP-1254). For Windows there are two, each published when its file is on the server:msi:dlp-agent-windows-x86_64.msi, at/agent/packages/windows-x86_64/{sha256}, for an install Windows Installer made. It is applied withmsiexec /i, which cannot open the bare.exe(DLP-1166).executable:dlp-agent-windows-x86_64.exe, at/agent/packages/windows-x86_64-executable/{sha256}, for an install the setup program made. It is applied by the new binary's own in-placeinstall, because an MSI over such an install fights the service it already runs.
A device is sent only a kind listed in its
capabilities.update_kinds, and one that lists none is sent only an MSI. With neither file on the server, a Windows release is refused, and the sentence names the file;the setup program is not an update;
a platform with no published package, a ring naming a group that does not exist or naming one twice, a version no release carries, and a manifest with no ring are each refused (422), with the reason.
A ring given no version pins its group. The manifest's version is the next number, and it is its own floor (min_acceptable_version), so an endpoint that has seen it refuses an older one replayed at it.
Each release carries a kind (DLP-1253): msi (the default, and omitted from the JSON, so every manifest signed before the field re-serialises to the bytes that were signed) or executable, the agent binary itself, which an endpoint installed by the setup program applies with its own in-place install. An endpoint reports the kinds it applies in its check-in as capabilities.update_kinds: ["executable"] for a setup-program install, ["msi"] for an MSI install, and nothing when it could not tell or is older than the field, which means msi. It refuses any other kind. Which kind this server publishes for Windows, and to whom, is DLP-1254.
It is signed with the tenant's policy-bundle key, over its canonical JSON: its fields in declaration order. The packages are signed with the same key. It is the key every endpoint is enrolled with, and the only one an endpoint verifies an update against. Until DLP-1104 the packages were signed with an update_manifest key no endpoint was ever given. The copy stored as JSONB re-serialises to the bytes that were signed, and GET serves it that way. tests/agent_update.sh verifies the signature over the manifest GET returns, checks that one tampered ring breaks it, and has a real agent, enrolled against the same server, verify the package against the keys enrolment gave it. No release key enters the repository or CI. The server holds this key as it always has: in the database, the environment or a hardware token.
Publishing needs agents:write, which POST /agents/update already asks for and a security analyst holds, plus a reason and a recent sign-in (agent_update). It is audited as agent_update.published, with the releases, the rings and the reason. Handing the manifest to endpoints at check-in, and applying it on Windows, is DLP-447.
Reading what endpoints said about an update (DLP-921)
GET /api/v1/agents/update-results?target_version=&group=&outcome=&limit=&offset=
agents:read · {by_outcome[], by_group[], endpoints[], total, limit, offset}Agents have reported update results since DLP-369, and until this route nothing read them. Each endpoint counts once, by its newest report, for the named target_version if there is one: an endpoint that failed and then applied is an applied endpoint. by_outcome and by_group follow the group filter and not the outcome filter, so choosing failed still says how many failed out of how many. An endpoint row carries its hostname, group, target and running versions, outcome, and the agent's one-sentence detail. That sentence is never an installer log, which would name every file on the machine. outcome must be one of the six the schema allows, and is refused otherwise. This is the read half of DLP-919's rollout; publishing a manifest is DLP-920. The console reads this route on the Endpoints page and shows a rollout panel only once an endpoint has reported an applied update (DLP-922); before that the panel renders nothing.
Which endpoints Windows' own removable write control reaches (DLP-1149)
GET /api/v1/agents/removable-write-control?state=&group=&limit=&offset=
agents:read · {by_state[], endpoints[], total, limit, offset}MyDLP records a write to a removable drive and does not stop it (ADR-0004). Windows can, through Group Policy that the agent reads at check-in and never sets (SPIKE-014). This lists the active Windows endpoints by the strongest control their agent reported:
all_access_denied(Deny_All);write_denied(Deny_Writeon removable disks);unencrypted_write_denied(BitLocker'sRDVDenyWriteAccess);none, when nothing is configured;unknown, when the policy could not be read;not_reported, for an agent that does not send the field, including one that has never checked in.unknownandnot_reportedare neither protected nor unprotected.
Each endpoint row carries the policy values as sources. by_state follows the group filter and not the state filter, so choosing none still says how many out of how many. A state that is not one of the six is refused. What is reported is the machine's configured policy: a per-user policy is not visible to the service, and a drive mounted before the policy arrived keeps its access until it is remounted.
The command carries the whole release - artefact, size, digest, detached signature and the key that made it - not a version number. From 2026-09-21 until DLP-1109 it did not: the route queued the version alone, which the agent could not parse, so every update command was collected and ignored. tests/agent_update.sh now proves the command with a real agent. Two reasons, and the second decided it: the endpoint never has to ask the server a second question, which is what makes an air-gapped update expressible at all; and a version number alone would make the server the authority on which bytes get installed, which is exactly what the endpoint's signature check exists to stop being true.
The platform is composed by platform_id(os, architecture), shared with the agent. It reads the architecture the endpoint reported rather than assuming x86_64 - right for most of an estate and silently wrong for every ARM64 Windows machine, which CI already builds a separate MSI for.
Applying it, on the endpoint
GET /api/v1/agent/packages/{platform}/{sha256} signed by the device · the update artefact a manifest declares (DLP-2227)
GET /api/v1/agent/packages/{platform} signed by the device · the file on disk, for manifests issued before DLP-2227
POST /api/v1/agent/update-result signed by the deviceA release's artefact is the device route above (DLP-1110). It serves the updatable platforms only, never the setup program, and answers 401 to a request no enrolled device signed. The console's /agent-packages/{platform}/download wants a signed-in person, so a manifest that named it could be received and never downloaded. The bytes are not what the endpoint trusts; step 3 below is.
The order on the endpoint is the substance, and each step is there because the one before it is not enough:
- Decide before downloading. Platform and newer-than-installed are answerable from the command alone. A downgrade is refused here, with the reason, rather than handed to an installer that refuses it with a number: the MSI's
AllowDowngradesis deliberately unset, and the only way round it would destroy the endpoint's identity. - Download bounded, to a
.partialname, from the device route. The transfer stops at the declared size, so a wrong or hostile URL cannot fill the disk of an estate. - Verify, then rename. Size, then digest, then signature - against the keys the endpoint was enrolled with, never the key served beside the artefact. A server that has been taken over serves a forged artefact and a matching hash without difficulty; it cannot produce that signature. The rename is what makes the file reachable by the applier, so it happens last, and a download interrupted by a flat battery leaves something inert.
msiexec /i … /qn /norestart. Never/forcerestart: an update that rebooted the machine it was installed on would interrupt whoever is using it with no notice at all. When the installer reports3010(ERROR_SUCCESS_REBOOT_REQUIRED) the endpoint writes theDLP-370restart marker, and the reboot goes through that action - which warns the person and gives them five minutes.- Report whatever happened, including a refusal. An endpoint that silently declined an update is indistinguishable from one that never got it, and the console would show a fleet mid-rollout for ever.
1618(another installer holds the lock) is reported asbusy_retry_laterrather than a failure, because it resolves itself and an operator chasing it is an operator chasing nothing.
The six outcomes are a closed set in the contract and a CHECK constraint on agent_update_results, proved by apps/management-api/tests/sql/update_results.sql - a free-text column is how a typo becomes a seventh category nobody notices until a chart is wrong.
What is not here. The applier. DLP-447 added the endpoint's decisions - the rollback watchdog (updater::watchdog), the offline plan (updater::plan_offline) and the staging sequence (updater::stage_from_directory) - and the dlp-agent update --from <directory> command that stages and verifies an artefact from a memory stick. What none of that does is run msiexec: the command stages, verifies and says so, and installing needs a real Windows host, an MSI and a code-signing certificate to prove rather than to claim (DLP-448, DLP-449).
So there is still deliberately no console button. The API decides, the endpoint verifies and stages, and a screen that offered "Update" to a fleet while nothing on the endpoint installs one would be the lie DLP-430 was filed about. The watchdog exists in code and has not yet watched a real machine come back from a reboot.
The rule library, and the sentence it is not allowed to write (DLP-388)
GET /api/v1/classification-rules detectors:read · q, country, domain, regulationClassificationTemplate distributes a whole classification. This distributes one rule, so an author building their own classification can take the procurement-reference rule without taking the classification it came in.
Derived from the shipped templates, not written twice. A second hand-maintained list would drift on the first edit and the drift would be silent: the library would offer a rule that no longer matches what the template installs. No console screen reads this route (DLP-846); the console adopts a whole template through the catalogue in Classification (DLP-845). A rule id is <template id>:<rule id>, so two templates carrying a rule called identifier stay distinguishable and a fork records which one it came from. A rule taken out of its template keeps that template's limitations: dropping them would make a library item look better than the thing it came from, which is the same information with the caveats removed.
The claims guard
A library of rules named after regulations is the single easiest place in this product to imply something legally false. CLAUDE.md §7 forbids claiming complete coverage; "GDPR compliant" claims exactly that, to a reader deciding whether they still need a lawyer.
So the wording is constrained by a function rather than by a review. claims_compliance refuses compliant, compliance with, guarantees, fully covers, certified, meets the requirements, zero false positives and four more - and returns the phrase, so the refusal quotes it back. An author told "that is not allowed" rewrites the sentence at random; an author told which words rewrites the right part.
What is allowed is the "helps identify" construction: a rule may say it helps find data a regulation is about. That is a statement about the rule. "Ensures GDPR compliance" is a statement about the reader's legal position, and this product is not in a position to make one.
It is enforced where a template enters the product, not only on ours: a signature proves who wrote a template, not what it says, and a publisher signing their own is exactly who might over-claim. The filter is short and specific on purpose - a broad one would refuse the honest limitation sentences this product most wants people to write, and a filter that refuses honest text is one somebody switches off.
Filtering
AND across axes, OR within one: "Azerbaijani and procurement", but "either GDPR or the Azerbaijani personal data law". That is what somebody means when they tick two boxes in one list and one in another, and the opposite reading produces an empty picker that looks broken. An empty filter shows the whole library, so the picker opens with something in it.
The available axes are computed from what the library actually carries rather than from a fixed list - a filter offering a country no rule covers returns nothing and looks broken. Results are ordered by id, deterministically, because two operators filtering the same library must be able to describe a row to each other.
Whether a rule is deciding anything at all (DLP-484)
GET /api/v1/classification-health gains a reach array beside coverage.
coverage says where evaluation did not happen. reach says what that adds up to, which is the question somebody writing a policy actually has and the one no other row of that panel can answer: a rule that never matched because nothing triggered it and a rule that never matched because it could never decide look identical everywhere else.
ConditionOutcome::NotEvaluated resolves to no match, which is the right default - asserting a match nobody established would be an invention - and it means every indeterminate evaluation is a policy quietly not firing. There are now eleven reason codes that reach it.
| verdict | meaning |
|---|---|
deciding | matched at least once. Outranks everything: a rule that fires is doing its job even if some inputs defeat it |
nothing_triggered_it | decided every time, matched none. May be correct - no file on this fleet looked like this |
often_undecidable | a fifth or more could not be decided and nothing matched. The endpoints are behaving correctly; the control is decorative on this fleet |
not_enough_data | under twenty evaluations |
Both thresholds are decisions rather than round numbers. Twenty evaluations, because one indeterminate result out of one is not a pattern and a panel that cries wolf on the first day of a rollout is a panel people learn to close. A fifth, because every estate has encrypted archives, oversized files and containers, and a few per cent of indeterminate results is the price of being honest about them - a fifth is a rule that systematically cannot see what it was written about.
The reason codes come back with the count, commonest first: a number nobody can act on is a number nobody looks at twice.
Console panel, no alert. The ticket asked which; an alert on a statistical property of a fleet fires at three in the morning for something nobody can act on before Monday. It is shown where the rule is read, and it says nothing at all about a rule that is matching files - a panel that speaks on every row is one people stop reading.
Tags a person applies, from a catalogue an administrator bounds (DLP-387)
GET /api/v1/user-tags detectors:read
POST /api/v1/user-tags labels:write · create only; 409 on an existing code (DLP-851)
PUT /api/v1/user-tags/{code} labels:write · edit
GET /api/v1/user-tags/{code}/impact detectors:read
DELETE /api/v1/user-tags/{code} labels:write · reasonThree things this is not, because the nearest neighbours in this schema are each one of them. It is not a compliance regime (DLP-426) - that is an administrator recording which instrument governs a classification, and this is a person at a keyboard saying something about a file. It is not a third-party label: ADR-0020 fixes the boundary and a user tag is MyDLP-owned metadata in MyDLP's own stream. And it is not a sensitivity level - a tag influences policy, it does not rank.
The catalogue is bounded on purpose. A free-text tag is a field every user spells differently, which makes a rule about it a rule about a spelling rather than about a meaning. Every tag an endpoint can offer is carried in the signed bundle, and an endpoint offers nothing else.
Transitions, and what absence means
may_become is a table, not a free-for-all: "anybody may change anything to anything" is not a policy an organisation can state, and the interesting cases are the ones it forbids - a document marked approved should not become draft because somebody clicked the wrong item.
Absence means forbidden, which is the safe default for a catalogue somebody is still designing. A tag with no outgoing transitions can be applied to an unmarked file and never changed - and not removed either, because otherwise "restricted sticks" is a rule anybody can undo by removing the tag and not re-applying it, which is the same thing as it not sticking.
Applying is additive by default. Any live tag may go on an unmarked file: a catalogue that had to enumerate every permitted first tag is one nobody finishes, and the question worth asking is what a tag may become.
Offline
resolve_offline answers what happens when a disconnected laptop reconnects. The person's act wins, and the server's catalogue decides whether it still stands. Somebody did something deliberate and attributable; silently discarding it would leave a tag they can see on their own screen that no rule honours. A change the catalogue now forbids is refused, because the endpoint's copy was stale when it decided otherwise. And a tag retired while the laptop was away is accepted and reported - the file carries a mark the catalogue no longer offers, and somebody has to know.
Withdrawal
Refused while a classification binds it - the ticket's "no dangling classification" - with /impact listing what depends on it, the same shape as /classifications/{id}/impact so an operator does not learn two screens.
Retirement keeps the row and the code stays taken: a tag is named in an incident from March and "what did restricted mean" has to stay answerable after somebody tidies the catalogue. Files already marked keep their tag, which is the difference between this and disabling - disabling stops it being offered, retirement stops it existing as a choice at all.
The dependency read is kind->'codes' ? code, a JSON key test rather than a substring search, and apps/management-api/tests/sql/user_tag_retirement.sql proves the difference: draft is a prefix of draft_v2 and a substring match would refuse to retire one because the other is in use.
What is not here. The Windows shell and Office surfaces that let somebody actually apply a tag are DLP-446, and until they exist the endpoint reports user_tags: null - not read - so a rule about what a person marked is indeterminate rather than a confident "they did not mark it".
What touched the file, by category (DLP-384)
packages/contracts/src/applications.rs maps executable names to eight groups - browsers, office suites, mail, messaging, cloud sync, archivers, remote access, developer tools - with the same versioned-catalogue treatment the file types get, and the same drift test between the console's copy and the contract's.
A rule about "a browser" written as a list is a list somebody has to maintain, and the day msedgewebview2.exe appears on an estate it silently stops covering the thing users paste into. That one executable is the argument for categories.
Matching is on the executable's own name: C:\Program Files\Google\Chrome\Application\chrome.exe is chrome.exe, because a rule about Chrome must not be satisfied or defeated by where it was installed.
Membership is by name, not by signature, and that is stated rather than glossed. A renamed binary is not caught by a category - authenticode is where that question belongs. A category is a convenience for authoring, not a security boundary, and an operator reading "Browsers" should not believe it is one.
Everything inside one element is OR, and the console says so where the element is authored: a file matches if any named application or any chosen category touched it. AND lives one level up, between the elements of a rule. An author who reads a list of three and expects all three has written a rule that never fires.
A path rule that cannot be stepped around by spelling (DLP-384)
A Path element matched */? patterns against the raw source path the endpoint reported. Windows accepts many spellings of the same path and a string comparison accepts none of them as equal, so the element was steppable around without touching a file:
| written | reaches | a rule on c:\finance\* |
|---|---|---|
C:\public\..\finance\pay.xlsx | C:\finance\pay.xlsx | missed it |
C:/finance/pay.xlsx | the same file | missed it |
C:\finance\\pay.xlsx | the same file | missed it |
C:\finance.\pay.xlsx | the same file - Windows drops the trailing dot | missed it |
\\?\C:\finance\pay.xlsx | the same file | missed it |
None of those requires anybody to be clever; a shell, a script and an application each produce some of them on their own. packages/contracts/src/paths.rs now reduces both the subject and the pattern to one spelling before they meet - canonicalising only the subject would mean a rule written with forward slashes silently never fired.
It is lexical only. Nothing touches a filesystem, because this evaluation runs on a server compiling a bundle as well as on the endpoint, and a resolution that depended on the disk would give two answers for one event.
Two things it deliberately does not flatten:
- A UNC path keeps its two leading separators.
\\fs01\financeand\fs01\financeare different places. - A rooted path stays rooted.
\financeis on the current drive;financeis wherever the process happens to be.
And one it cannot decide: an 8.3 short name. C:\PROGRA~1 may be C:\Program Files, and expanding it needs the volume. A rule that would otherwise answer "no" returns path_not_canonical instead - indeterminate, because answering "no" would assert something nothing here knows. A short name that does match its pattern is simply a match; the uncertainty only matters when the answer would be negative.
A third-party classification as a rule element (DLP-386)
ExistingClassification matches a label another product wrote - Microsoft Purview, Netwrix, Boldon James, Tukan, or a custom document property - by provider and identifier together. That is what separates it from Label, which compares flat strings and cannot say whose label it matched: a Boldon James marking spelled like a Purview GUID is not a Purview label.
Read-only, and the shape enforces it. ADR-0020 fixes the boundary: this product compares a customer-owned label and never writes, downgrades or removes one. There is a test over the element's serialised field list, so a field added later that could express a write shows up before it ships.
| Field | |
|---|---|
provider | a named vocabulary - a typo in free text would simply never match |
identifier | the provider's own id. Never empty: an empty identifier matching everything from a provider by accident is a rule that looks like it is working |
tag_identifier | optional. Absent means the rule does not care about the tag, not that the tag must be absent |
match_mode | exact (default) or glob |
Comparison is case-insensitive in both modes, because a Purview label GUID is written in whatever case the tool that last touched the document used, and a rule that matched one spelling would work only on its author's machine. "Every label from this provider" is glob with * - which has to be typed. There is no regex, for the reason DLP-385 records: dlp-contracts is the wire crate that the agent, the server and the console all compile.
Three states, not two
This is the fourth acceptance criterion and the difference between "check this" and "this is fine":
observed_classifications: null- not read. The element is indeterminate (label_metadata_unavailable).observed_classifications: []- read, and the file carries none. A clean no-match.metadata_formats_unsupported: true- there is no reader for this format, OpenDocument today. Its own code,metadata_format_unsupported, because "there is no reader" and "the read failed" send somebody to two different places: one is a gap in this product, the other is a fact about the file.
Where the observations come from. LabelRead::observed_classifications() maps what the endpoint read: this product's own stream becomes mydlp, and an enabled MSIP_Label_<guid>_* group becomes microsoft_purview. Two things are withheld on purpose. A disabled property is a label Purview removed, and reporting it would let a rule match a classification somebody took off the file. An id that appears both enabled and disabled is the conflict interpret_microsoft refuses to resolve, so this does not resolve it either - taking the enabled one would be taking a side that function deliberately does not take.
Only what can be attributed. A Microsoft property namespace names its provider and our own stream names ours. A value found with neither is left out rather than guessed at: a wrong provider is worse than a missing one, because it makes a rule match something it was not about.
read() reports a MetadataReadState, which is what makes the three states above reachable - it used to be infallible and return an empty result both when a file carried no classification and when nothing had looked. The Linux agent is frozen and reads no document metadata, so it returns NoReaderOnThisPlatform and every file on it is unknown rather than unclassified.
FormatUnsupported is not returned by the Windows reader yet, and the reason is worth knowing: read_ooxml opens a zip and looks for docProps/custom.xml, and an OpenDocument file is also a zip with no such part - indistinguishable from an OOXML file that simply has no custom properties without reading META-INF/manifest.xml. Claiming "no reader" for every zip that lacks the part would make half an estate indeterminate. DLP-446 closes it with the OpenDocument reader; the state exists now so that nothing has to change shape when it does.
What kind of file this is, defined once (DLP-385)
packages/contracts/src/file_types.rs is the catalogue: thirteen curated categories, each with its extensions and its media types, plus a CATALOGUE_VERSION a classification revision records so that "what did document mean when this fired" stays answerable after a category grows.
It lives in the crate the console, the server and the endpoint already share, because three copies of "is .xlsm a spreadsheet" is three chances to disagree - and the disagreement is silent: a rule an operator believes covers spreadsheets quietly stops covering one of them.
The media types are listed separately from the extensions rather than derived from them. A sniffer reports application/zip for an .xlsx, so a category that inferred its media types would put every modern Office document in Archives.
Which side of the file to believe
FileProperty.type_match is the decision, and its default is detected. Trusting the suffix is a one-rename bypass: call the executable invoice.docx and a rule about executables stops seeing it. With the sniffed type deciding, the rename changes nothing - which is why it is what an author gets without choosing anything.
| Mode | What it matches | For |
|---|---|---|
detected (default) | what the bytes are | the ordinary case, and the one a rename cannot defeat |
claimed | what the name says | a business convention, where the convention is the point |
either | either reading | a net rather than a test |
require_agreement | both, and they must agree | "only genuine spreadsheets" |
A type that could not be established is NotEvaluated, never a clean negative. An encrypted archive is not "not a document" - it is a file nobody read, and an AND rule has to tell those apart or a container becomes a way to be absent from every rule at once. The same holds for require_agreement over a type the catalogue cannot place: an unrecognised extension on a recognised file is exactly where a bypass would hide, so it is reported as unread rather than answered "no".
The name
ClassificationFileContext::new derives claimed_extension from the file name, in one place, so every channel answers the same way - three call sites deriving it three ways is how a rule about .XLSX stops matching on one channel and keeps matching on another. A dotfile claims nothing: reading gitignore out of .gitignore would put every one of them in whatever category somebody later gave that name.
file_name_patterns are anchored, full-name globs over the basename, and a pattern containing a path separator is refused at authoring time. It would otherwise compile, save, and match nothing for ever; an author who wrote docs/*.pdf has a reasonable model of what they asked for and deserves to be told it is wrong rather than to find out from an incident that never happened. There is deliberately no regex here: dlp-contracts is the wire-contract crate that the agent, the server and the console all compile, and a backtracking engine is not something to put at the bottom of that graph for a file-name match an anchored glob already expresses in linear time.
Removing a classification, which retires it (DLP-380)
GET /api/v1/classifications/{id}/impact detectors:read
DELETE /api/v1/classifications/{id} detectors:manage · expected_revision + reason
· optional replacement_classification_idDELETE does not delete. classification_revisions is ON DELETE CASCADE, so removing the row takes every revision with it - and an incident raised three months ago names an exact revision. "What did this classification say when it fired" has to stay answerable after somebody tidies the catalogue, which is precisely when nobody is thinking about an investigation that has not happened yet. The SQL proof demonstrates the cascade rather than asserting it: with the UPDATE swapped for a real DELETE, the history check fails with zero rows.
So the row is marked retired_at, and three things follow, each deliberate:
- the classification leaves the catalogue and the compiled bundle immediately - a
CHECKmakesretired_at IS NOT NULLandenabled = TRUEan impossible state, so it cannot sit in a bundle while being absent from the list; - the id stays taken. A new classification reusing it would inherit the old one's lineage, and an incident would point at a rule that never produced it;
- the act is reversible in the data, and no endpoint offers the reversal. An "undelete" button teaches people to treat removal as casual. Nothing has been lost if somebody asks for one.
Refused while anything depends on it, in the order the operator can act on:
- built-in and template content - disable it instead, which is reversible;
- a label mapping, named first among the dependencies because an operator told about three policies will unbind three policies and then be refused again for a reason nobody mentioned;
- published policies, by name - those are enforcing on endpoints now;
- draft policies, by name, pointing at
/impact.
retirement_refusal is a pure function over those four facts, so the decision and its order are tested without a database.
GET /impact answers "what does this break" exactly, with published and draft policies reported separately - enforcement running now and somebody's unfinished work are different facts. It replaces a client-side computation in Classifications.tsx that could only see the page it had already fetched. A policy whose spec will not parse counts as a dependency: "we could not read it" is not "it does not depend on this".
The atomic replacement (DLP-481)
DELETE takes an optional replacement_classification_id. Absent, everything above holds unchanged. Present, draft policy bindings on the retired classification are repointed at the replacement and the retirement happens in the same transaction, so the whole call is one act or none - repointing three drafts and then failing to retire is worse than a refusal, because the operator believes the tidy-up happened.
A published policy's binding is never rewritten, and naming a replacement does not change that: the refusal fires first, names the policies, and nothing is retired. Those are enforcing on endpoints now, and moving them would change what the estate blocks as a side effect of somebody tidying the catalogue. The operator republishes them deliberately, against the new classification.
The binding carries the replacement's own version and revision, not the ones it replaced. A binding is pinned on purpose (DLP-396); the old revision against the new id is a coordinate that has never existed, and an endpoint refuses the whole bundle rather than guess.
PolicySpec::rebind_classification is the traversal, pure and in packages/contracts. Its match over ConditionNode is exhaustive, with no _ arm - unlike the read-only walkers beside it - because a condition variant added later with children inside it would otherwise leave a binding pointing at a classification that is gone, silently. A new variant is a compile error instead.
Each rewritten draft records why in its own change_reason, not only in the audit log: somebody opening the draft and wondering why it points elsewhere reads the policy, not the audit trail of a classification that is no longer in the catalogue. The retirement's audit entry names both ids and every policy moved.
apps/management-api/tests/sql/classification_replacement.sql proves the parts Rust cannot reach: that the rewrite carries its own status <> 'published' filter, that the pin is the replacement's, and that a rollback takes the rewrite and the retirement together.
Restarting a device that is already waiting for one (DLP-370)
POST /api/v1/agents/restart endpoints:manage · device_ids + reasonThe restriction is the feature. A console that can restart any machine is a console that can interrupt anybody's work. Restricted to machines whose own install is unfinished, it finishes a job they are stuck on anyway - so this endpoint does not take "restart these devices". It takes a selection and restarts the subset that is waiting, naming the rest.
- Only waiting, and only for us. The agent reports
restart_requiredin its health with a reason.agent_replacedanddriver_changedare ours;elsewhereis a pending reboot Windows Update or some other installer asked for, and finishing somebody else's install is not this console's to do. It is shown in the Installation column and refused by the action. - A reason this console does not recognise counts as not ours. A newer agent reporting an unfamiliar reason is not a licence to reboot the machine it runs on.
- The endpoint decides again. The console chooses from health reported at the last check-in; by the time the command is collected the person at the machine may have restarted it themselves. The agent re-reads its own state and refuses rather than rebooting a working machine.
- Twelve hours is how old a reported state may be and still be acted on (
RESTART_STATE_MAX_AGE_HOURS). The state does not change by itself - a machine waiting for a restart stays waiting - so the bound is about the device still being there. A laptop shut in a bag overnight is the case it must not refuse. - Five minutes of notice, not a parameter.
RESTART_GRACE_SECONDSis a constant because an operator clearing a batch of unfinished installs is exactly who would set it to zero, and the person whose machine it is has no say in that dialogue. The notice names MyDLP, so a restart countdown from software somebody cannot identify is not reported to a helpdesk as malware. - Not available as a generic command.
command_is_issuablerefusesRestartthe way it refusesUninstall: a generic endpoint that accepted it would be a way to reboot any machine in the fleet with a free-text reason.
Where the state comes from: the installer writes the marker at the one moment anything knows it - when it replaced an agent image that was still loaded, so the old agent is the one running until the machine restarts. The marker carries the boot it was written during, and a later boot retires it; nothing runs at the moment of the reboot to clean it up, and a marker cleared when the command was sent would leave a machine that failed to restart looking finished.
Removing an agent (DLP-374)
POST /api/v1/agents/{id}/uninstall agents:decommission (DLP-763) · reason + confirm_hostnamecommand_is_issuable has refused a generic Uninstall since the command queue existed, on the grounds that it needs an authorisation rather than a free-text reason. This is that authorisation, and it remains the only path - the generic endpoint still refuses.
- The hostname is typed, not a box ticked. This destroys the device key, and typing the name is the difference between deciding and clicking. Trailing whitespace and a different case still confirm; nothing typed never does.
- The authorisation is a row, created before the command and quoted back by the endpoint when it reports. Without one there is nothing to attribute the removal to, and recording it as authorised would be a lie in the audit log.
- It stands for seven days. A laptop in a drawer for a month should not wipe itself on the strength of a decision somebody made in March.
- One live authorisation per device, or "which decision removed this agent" is unanswerable - the only question anybody asks afterwards.
- The endpoint delivers before it wipes (
DLP-275), reports before it destroys the key - the notice is signed with it - and wipes regardless: if the report fails, the removal still happens, because an administrator asked for this machine to stop being protected and leaving it enrolled because a request failed would be the product deciding it knows better. - The device moves to Revoked & uninstalled, not away. Its status becomes
uninstalledwithstop_reason = 'authorised_uninstall', so the silence is explained rather than alerted on, and its incidents stay queryable: removing an agent must never remove the record of what it observed. - What the binary on disk does afterwards is the installer's business - the agent stops protecting and is no longer enrolled. Re-enrolment is the only way back, which is what the confirmation says.
apps/management-api/tests/sql/uninstall_authorisation.sql is the proof.
A bypass code for an offline endpoint (DLP-1154)
POST /api/v1/agents/{id}/bypass-codes exceptions:decide · step-up enforcement_bypass · audited
{scope: "warn_instead_of_block", hours 1-72, reason 5-500}
→ {code_id, code, scope, expires_at}
GET /api/v1/agents/{id}/bypass-codes agents:read · what was issued, newest first, never the codeFor a person blocked while the endpoint cannot reach the server. The code is a grant signed by the tenant's policy-bundle key, the key every endpoint is enrolled with. The endpoint checks it offline, with dlp-agent bypass <code>: the signature against its enrolled keys, this tenant, this device, and the expiry against its own clock. It refuses a code for another endpoint, an expired one, and one altered after signing, each by its reason. The accepted code is kept in the agent's state directory as it arrived, and is verified again at every check-in, so a copy edited on disk is no code at all.
While it runs, a decision to block, quarantine or ask for a justification becomes a warning on the file and browser-upload paths. Every such decision is still an event, reported when the endpoint is back, with a line naming the code and what the policy asked for. A code never turns reporting off.
Issuing one needs whatever approving an exception needs (exceptions:decide), because a code is an exception for a whole device for a while. It also needs a recent sign-in, because a stolen session that could mint one would switch blocking off on the machine it chose. The code is returned once. The record keeps who issued it, for which device, the scope, the expiry and the reason, and agent.bypass_code.issued audits the same. tests/bypass_codes.sh drives it end to end with a real enrolled agent.
A person without administrator rights runs the same dlp-agent bypass <code> (DLP-1156). On Windows only an administrator can read the identity or write the state directory, so for everybody else the command hands the code to the running agent service over the session channel. The service checks it with the same verifier, keeps it, and answers once. That connection is not a session and takes nobody's place as one. Before sending, the command checks who owns the pipe, so a pipe somebody else created under the agent's name never receives a code. The service logs the account that entered it and the code id, never the code, and applies it from its next check-in (every checkin_interval_secs, whether or not the server answers). A code the verifier refuses is refused the same way from either path.
Every attempt is reported (DLP-1562). Each code entered through the service, accepted or refused, is kept in bypass-attempts.json in the state directory (at most 50, the rest counted) and sent after the next check-in:
POST /api/v1/agent/bypass-attempts signed by the device
{attempts: [{id, at, accepted, reason?, code_id?, entered_by}], dropped}Each is audited once by its id as agent.bypass_attempted against agent:{id}, with who entered it, the refusal sentence or the code id, and never the code. A count past the 50 is audited as agent.bypass_attempts_dropped. The endpoint's page lists them under its activity. A report over 100 attempts, or with a field over its length, is refused with 400. tests/agent_bypass_attempts.sh drives it with a real agent.
Not built: an "enter a code" control in the session agent's window. The command is the way in. The Windows server path has run under Wine, not on a Windows host.
API tokens: policy, sources and usage (DLP-1236)
GET /api/v1/api-token-policy tokens:write · the tenant's own limits
PUT /api/v1/api-token-policy tenant:write · step-up api_token · reason · audited
PUT /api/v1/api-tokens/{id}/sources tokens:write · step-up api_token · reason · audited
GET /api/v1/api-tokens/{id}/usage?days= tokens:write · requests per day, 30 by default, 90 at mostPolicy. tokens_enabled, max_days (1 to 365, empty for the product's year) and forbidden_scopes. It is enforced when a token is created or rotated, and each refusal is answered by the rule it breaks and audited as api_token.refused with a policy_* reason. The scopes route stops offering a forbidden scope. A token made before the policy is marked out_of_policy with the rule, not cut off. Rotating it keeps its lifetime but not a scope the policy now forbids.
Sources. allowed_sources on create, or through this route: addresses or CIDRs, stored as the network they describe, at most 32. Empty means anywhere. A request from elsewhere is refused with 403, counted as source, and audited as api_token.refused_source with the address. A restricted token with no address the API can establish is refused.
Usage. last_used_ip on the token, and requests per day, kept 90 days (PRIVACY_MODEL §46). Counts only.
A group in monitor mode (DLP-1230)
POST /api/v1/agent-groups/{id}/monitor-mode agents:manage · reason · audited
POST /api/v1/agent-groups/{id}/follow-latest agents:write · {enabled, reason} · step-up agent_update to switch on · audited (DLP-1254)
step-up enforcement_bypass when switching it on
GET /api/v1/agent-groups agents:read · monitor_only, monitor_since per groupHow prevention is rolled out. A group runs in monitor mode, its false positives are measured without anybody being stopped, and then it is switched to enforce, group by group, with no policy edited.
Every endpoint in a monitor-mode group records what each policy asks for and enforces none of it. Block, quarantine, a justification prompt and a warning all become an audit record, on every channel including a denied file open. A warning is included because monitor mode means nothing a person can see. The event keeps the policy's own ask (requested_action) and says monitor mode made the difference, so an analyst can count what enforcing would have done.
The body is {"monitor_only": true|false, "reason": "..."}. A reason is required both ways. Switching it on also needs a fresh sign-in (enforcement_bypass), because it turns enforcement off for a whole group and a stolen session must not be able to silence a department. Switching it off only restores enforcement and is not asked. Each change is audited as agent_group.monitor_mode.changed, with the group, the new state, the reason and how many endpoints it reached; a request that changes nothing is neither audited nor re-signed.
The change reaches endpoints in the signed policy bundle (monitor_only_groups), compiled on the spot. So it cannot be changed on an endpoint, and it holds offline and across a restart. Renaming a monitor-mode group signs a new bundle with the new name. An endpoint takes the change at its next bundle fetch, and a group move takes effect at its next check-in.
Where it is read (DLP-1231): an endpoint that honours monitor mode is monitoring in the protection figures, not protected, and a device row carries monitor_mode - monitoring, or not_honoured for an agent older than 0.421.0, which ignores the field and keeps enforcing and is counted by what it does. coverage.agents_monitoring on the overview is the active endpoints that honour it, and ?enforcement=would_have_blocked on GET /incidents lists what they would have stopped. tests/monitor_mode_reading.sh asserts each against one fixture.
Not covered: an agent built before DLP-1230 does not know the field, ignores it and keeps enforcing. tests/group_monitor_mode.sh drives the server half; the agent half is unit-tested (monitor_mode, policy_runtime, agent/core/tests/open_enforcement.rs).
Switching protection off (DLP-373)
POST /api/v1/agents/{id}/deactivate endpoints:manage · reason required
POST /api/v1/agents/{id}/activate endpoints:manage · reason required
GET /api/v1/agents/{id}/protection-gaps endpoints:readThis is the console's ability to switch a security control off. It is legitimate - an administrator debugging a conflict needs it - and it is also the single most useful action to somebody who has reached the console.
- A reason is required at both ends, and both are audited. A control switched off with no stated reason is one nobody can answer for.
- Deactivated is its own status, not revoked. Revocation destroys the device key and the endpoint must enrol again; a deactivated agent keeps its identity and can be switched back on.
- The device counts as unprotected immediately -
protection_ofdecides on status alone, so it leavesagents_activeand enters every unprotected figure the moment it is deactivated. A deactivated agent that still counted as protected is the lie the whole devices screen exists to prevent. - It stops enforcing, not reporting. The endpoint keeps checking in and its health says
Impairedwith the reason. An agent that went quiet would be indistinguishable from one somebody killed, and that silence is what an attacker wants (CLAUDE.md§7). - It is not covert. The state is on disk, in health, and in what the endpoint says about itself locally.
- It survives a restart, and an unreadable state file stays deactivated - the one place in the agent where a corrupt file fails safe rather than available, because re-enforcing would undo an administrator's decision silently.
- The period is a row, kept after reactivation: "was this device protected when that happened" is asked months later. One open period per device, enforced by a partial unique index, or the period would be unanswerable.
apps/management-api/tests/sql/device_deactivation.sql is the proof, and CI runs it.
Commands a device collects (DLP-369, ADR-0019)
POST /api/v1/agents/{id}/commands endpoints:manage · reason requiredThe server never reaches into an endpoint. A command is a row that waits and is handed over at the device's next check-in, in the commands field the check-in response already carries.
- A reason is required and is stored on the row and in the audit entry. A command nobody can explain is one nobody can answer for.
- Payloads are validated against the contract's own enum, not a list kept beside the endpoint. A payload the agent cannot deserialise would be queued, collected, and do nothing, while the console reported it delivered.
Uninstallis refused here. It destroys the device key and needs the authorisation flowDLP-374will build - not a generic command with a free-text reason. The two look identical from a console, which is exactly why the API distinguishes them.- Collection is stamped in the statement that reads it, under
FOR UPDATE SKIP LOCKED. A read followed by a separate update has a window in which a retried check-in collects the same command twice. - Expiry is not deletion. After seven days a command stops being collectable and the row stays, because issued and never collected is a different fact from collected and failed, and an operator asking "did that laptop get it" needs to tell them apart.
- A payload this build cannot read is skipped rather than failing the check-in: an agent that cannot fetch its policy because one command row is malformed is an agent taken off the air by a typo.
What an agent does with each one. FlushQueue drains the local queue, ReportHealth is satisfied by the check-in that carried it and says so in the log, CollectDiagnostics builds and uploads a bundle (below), SyncPolicy {request_id} fetches the current bundle and re-verifies and rewrites it even when the agent already holds that version (DLP-1507; a newer bundle is taken at every check-in anyway, so this is for a cached bundle in doubt, not a way to be faster), and RunDiscoveryScan is named as unimplemented in the agent's log rather than silently dropped - an operator waiting for a scan that was never going to run should be able to find out why. Uninstall is refused at the agent as well as at the API; one arriving here means something issued it around this endpoint.
POST /api/v1/agents/{id}/commands/{command_id}/cancel endpoints:manage- A command the device already collected cannot be recalled. The server does not reach into an endpoint, so a cancel that reported success after collection would be a button that changes nothing on the device. It is refused, in those words.
- Cancelling is not deleting. The row keeps
cancelled_atandcancelled_by, because issued and called off is a different fact from never issued, and the check-in stops collecting it. - Cancelling twice is the same outcome, not a conflict. Two operators reacting to the same resolved problem is the ordinary case.
- The collection statement's predicate is asserted against a real database by
apps/management-api/tests/sql/command_cancellation.sql, which CI runs. AWHEREclause cannot be unit-tested in Rust, and it is the whole of the rule.
Diagnostics collected from a device (DLP-371)
POST /api/v1/agent/diagnostics signed · from the device
POST /api/v1/agent/problem-reports signed · from the device (DLP-1879)
GET /api/v1/agents/{id}/diagnostics endpoints:read
GET /api/v1/agents/{id}/diagnostics/{bundle_id} endpoints:manage · auditedIssue {"command": "collect_diagnostics", "request_id": "<id>"} and the endpoint builds its support bundle at the next check-in and uploads it. The bundle travels as opaque JSON: a server older than the agent stores a bundle it does not fully understand rather than refusing one it cannot parse, and a version mismatch is a common reason to be asking for diagnostics in the first place.
POST /agent/problem-reports takes the same upload when the person at the machine reports a problem (DLP-1879). request_id is the reference they were shown: 4 to 12 characters of the agent's alphabet (A-Z without I and O, 2-9). The answer is 200 {id, reference, status: "stored"}. The same reference again from the same device is 200 with status: "already_stored" and the first id, so a delivery can retry. Each device may send three a day, and a fourth is 429 with retry_after_ms. A bundle without its omissions is 400, as below. The row carries reported_by_person: true, the list on GET /agents/{id}/diagnostics says so, and each report writes agent.problem_reported with the reference. On the endpoint, the tray card's "Report a problem" row asks the agent service over the session channel. The service makes the reference and answers at once with it, and the card shows it. The next delivery cycle sends the ordinary bundle, without log contents. A 429, 400 or 413 is not retried; anything else is.
- A bundle that does not declare its omissions is refused. The agent states what it withheld - no secrets, no log file contents, no local history, no matched values, configuration values hashed - and the server stores that declaration beside the body and rejects a bundle without one.
- A bundle nobody asked for is refused. The upload must name the
request_idof a command actually issued to that device. - Listing is
endpoints:read; opening one isendpoints:manage, and every download is audited. A bundle is the most detailed view this product offers of one person's machine. - Kept for thirty days, then deleted by the retention pass on the row's own
expires_atrather than on a tenant window. The audit entry outlives the bundle and carries the omissions with it. - Over 1 MiB is refused before it is parsed. A real bundle is tens of kilobytes; a large one is an agent attaching something it should not.
GET /api/v1/agents/{id}/diagnostics also returns pending: the requests this device has not answered yet, with the reason each was issued with, so an operator who asked and sees no bundle is told the device has not checked in rather than left to conclude the feature is broken.
PRIVACY_MODEL.md §15 is the register row.
One classification semantic, and honest boundaries (DLP-416)
GET /api/v1/classifications/{id}/capabilities detectors:readA classification loses its meaning the moment the same definition decides differently on two paths. The strongest available answer is that there is one implementation: ClassificationDefinition::evaluate_with_context in the contracts crate. The Windows agent calls it, the server's discovery scan calls it, and anything added later calls it. The OR/AND semantics, the typed elements and the not-evaluated aggregation have one home.
What genuinely differs between channels is the facts available. A clipboard paste has no file on disk; a discovery scan has no process that acted; a print job has no destination domain. That difference is published rather than described:
support_on(channel)per element kind -supported,conditionalorunsupported- andcapability_matrixover a whole definition, ordered so two runs and two diffs agree.- An element some channels cannot decide is a limitation, not a fault. A classification about a destination domain is a good classification; it simply cannot be decided when somebody prints. At runtime that element is
NotEvaluated, never a silent "no" - which is the property that keeps one definition meaning one thing. - An element no supported channel can decide is refused at authoring. The rule containing it can never match, and a rule that can never match reads as coverage. Authoring is the only moment anybody is looking.
EVALUATED_CHANNELSlists what this build actually evaluates. A channel is added when its conformance vectors pass, not when it is implemented.
Conformance vectors live in packages/contracts/conformance/classification_vectors.json and are executed twice: by packages/contracts/tests/classification_conformance.rs and by agent/core/tests/classification_conformance.rs, the second of which is also wired to run natively on Windows in CI, a job that has not yet passed on windows-latest (Windows CI status). Breaking rule evaluation makes both fail, which was checked on Linux. The corpus carries coordinates, thresholds and outcomes and is asserted to carry no content, sample or identifier - a conformance file is one that gets pasted into bug reports.
Retention proposals (DLP-415)
GET /api/v1/retention-rules discovery:read
POST /api/v1/retention-rules discovery:write · reason
POST /api/v1/retention-proposals discovery:write (preview)
GET /api/v1/retention-proposals discovery:read · newest 100, with who made and approved each (DLP-895)
GET /api/v1/retention-proposals/{id} discovery:read
POST /api/v1/retention-proposals/{id}/approve discovery:write · reason · a second person
POST /api/v1/retention-proposals/{id}/cancel discovery:write · reason
POST /api/v1/retention-proposals/{id}/export discovery:writeClassification knows what a document is; a schedule knows how long that kind of thing is kept. Joining them is where a classification service quietly becomes a deletion engine, so this one proposes and never disposes. There is no code path here that deletes, moves or changes access to anything, and no column that could record that it had: disposition is proposed or ineligible, and the database refuses a third value.
- The preview is deterministic.
judge()takesnowas a parameter, so the same inputs produce the same proposal - which is what makes approving one mean anything. - Legal hold always wins, tested first and unconditionally.
- A classification that was not evaluated is not a basis for disposal. Treating "we could not tell" as "not the protected class" is how a coverage gap becomes a deletion.
- A repository's clock is untrusted until the tenant says otherwise. File created and modified times are only as good as whatever wrote them; the product's own first-observation is trusted because we wrote it. The choice is stored per rule, defaulting to untrusted.
- A stale classification revision invalidates a proposal, for the same reason
DLP-432refuses to evaluate under a moved primitive. - Ineligible material is listed with its reason, never filtered out: material absent from a proposal reads as material that is not due.
- Nothing is handed over unapproved, and an exported proposal is not exported twice by a retry. The export is a document for whatever already owns disposition; every row carries the classification revision and the timestamp basis that produced it, so the system that acts can answer for it.
- Audit entries carry counts and reasons - never a path, a timestamp or a label value.
apps/management-api/tests/sql/retention_proposals.sql is the proof: an item cannot be recorded as disposed of, a proposal cannot reach a disposal state, and a file cannot be proposed twice.
Correcting labels (DLP-412)
POST /api/v1/label-remediation discovery:write · reason + idempotency key
GET /api/v1/label-remediation discovery:read · newest 100, with who made and approved each (DLP-895)
GET /api/v1/label-remediation/{id} discovery:read
POST /api/v1/label-remediation/{id}/approve discovery:write · reason · a second person
POST /api/v1/label-remediation/{id}/run discovery:write · {"rollback": bool}
POST /api/v1/agent/label-remediation signed · from the deviceThe dangerous version of this feature writes to a customer's file server. This one queues a command to an endpoint agent, which writes the one NTFS alternate stream MyDLP owns. Nothing here moves, deletes or re-permissions anything, and no code path could: the connectors that reach shares and buckets are read-only by construction (s3.rs says why), so their material appears in a plan as unsupported, with the reason, rather than being left out. An operator who sees nothing for a share would conclude the material there is correctly labelled.
- A plan writes nothing. It is built from events the endpoints already sent - what the governance recommended for each file and what the agent did about it - so planning needs no new access and re-reads no files.
- Approving is a separate act, by a second person, with its own reason, and running is a third. The person who made a plan is refused approving it (
403: "approve something you made yourself; a second person approves it"), audited asremediation.self_approval_refused. The database refuses a row whoseapproved_byis itscreated_by, whatever writes it (DLP-894). A retention proposal is held to the same rule. Before DLP-894 the page said a second person approved and the server checked only the status. A draft cannot be run. - A retry cannot duplicate. The idempotency key is unique per tenant in the schema; a repeat returns the plan that already exists. It is unique per tenant rather than globally, or one customer could block another's retries.
- A partial failure is resumable. Every item carries its own result, so a second run acts on what is still pending.
- The ceiling is re-checked at run time against what the plan holds now: a scan between approval and execution is exactly when a plan grows.
- The endpoint decides on what it sees. The agent's own precedence rule applies; a label it declines to replace comes back as
skipped, and the server records that rather than overriding it. - A rollback restores what the agent found, which it reported when it applied - not what the plan predicted. By the time a laptop checks in, the file may have moved on.
- Items are
missing,stale,incorrect,conflictingorunsupported. Only the first three are acted on.
apps/management-api/tests/sql/label_remediation.sql is the proof, and CI runs it.
Classification overview (DLP-1564)
GET /api/v1/classification-overview?days=30 detectors:read and agents:read · days 1 to 90, else 422The Data Classification overview's figures in one read:
protectedcounts policy-acted file-detector matches in the selected window, once per device and path.unprotectedis the retained endpoint-ledger count for detectors last matched in that window; it is not itself windowed.unprotected_windowis the exact selected-window count as of each endpoint's check-in, ornullif any contributing endpoint lacks trustworthy match times.unreadcovers only the last 7 days (size, encryption, images, partial reads, time, access, alternate streams); unsupported formats and links are not counted. These measures must not be combined into a coverage percentage.endpoints:active,on_latest_bundle,latest_bundleandagents_outdated(older than the published agent release).cloud: active endpoints that watch their cloud sync folders, of the active ones.shares: network-share discovery targets with a completed scan in the window, of those configured.classifications: per classification, its detectors'protected,unprotected, and nullableunprotected_window, the most severe detector's defaultseverity, andendpoints_current, the active endpoints on a bundle issued since the classification last changed.severityis not a sensitivity label: classifications have no independent Restricted/Confidential/Internal field, and the console must not manufacture one from this value.
tests/classification_overview.sh seeds every source and asserts each figure.
Classification health (DLP-413)
GET /api/v1/classification-health?days=30 detectors:read
POST /api/v1/classification-feedback incidents:writeA feedback request must name a classification this organisation has, and an incident of its own when it names one. Otherwise it is 422 on classification_id or incident_id (DLP-861). It used to take any coordinate, so a verdict could be filed where no Health panel would show it. tests/classification_feedback.sh asserts the write, the refusals, the incidents:write requirement and the count in classification-health.
Detector feedback
POST /api/v1/incidents/{id}/detector-feedback requires incidents:write. Feedback is separate from incident resolution. The detector ID and version must occur in that incident's stored summary; out-of-scope or unknown incidents answer 404. verdict is correct, false_positive or unknown. Every change is retained and audited; the latest verdict for each incident, detector and version is shown in incident detail.
A false_positive verdict needs a reason (DLP-1696):
pattern_or_validator,context,extraction,scope,duplicate,otherornot_yet_known;- 422 on
reasonwithout one, and on a reason given withcorrectorunknown; - a
note, at most 200 characters, only withother.
A note is refused, 422 on note and nothing stored, when it holds four or more digits in a row or anything a shipped detector reports (a card, an IBAN, a credential). Matched text is never stored. A note that passes is kept in the verdict and shown in incident detail. The audit entry carries the reason code and has_note, never the note. The quality report's false_positive_reasons counts each detector version's false positives by reason, with verdicts from before reasons existed under unrecorded. Each row also carries slices (DLP-1697): the same counts and precision per channel and file_type. The file type is a fixed group from the incident's MIME type (pdf, office, email, text, image, archive, other), and unknown when there is none. A slice below the sample floor has no precision; the console says "insufficient sample". Slices are never combined across versions, and carry no file name. Observed detector precision counts only explicit correct and false_positive verdicts; unknown, unreviewed contributions and the incident's own false-positive flag do not enter its denominator. Figures remain separate for each detector version so a newer release is not blended with older results.
A count of matches cannot be operated on. What an administrator needs is which definition decided a result, where evaluation did not happen, and whether quality is moving.
- Lineage is stored, not re-derived. The agent has always sent the classification coordinate, the rules that matched, the elements that contributed and the reasons anything was indeterminate; the server now keeps it on the event and snapshots it onto the incident. The server's own opinion about what should have matched is not evidence.
- Everything reconciles with something signed. Rollout is counted from the
policy_bundle_versionand revision each endpoint reported, so the figures agree with the fleet rather than with our intentions. The bundle window (oldest … newest) is shown rather than one number: a single figure reports a rollout as complete while a third of the machines are still behind. - A rollback stays separately attributable. Every row is keyed by version and revision, so republishing an older definition as a new revision (
DLP-410) produces its own row instead of merging into the numbers of the revision it restored. - A coverage gap cannot render as zero findings.
not_evaluatedis its own count andcoveragebreaks it down by channel and reason code. An empty report says nothing has been measured rather than showing no matches. - Latency and error rate come from the endpoint's own durations, per detector version, with the detector's locale tag where it has one. A timing measured on the server would be a measurement of the server.
- Feedback is about the rule.
false_positive,false_negativeorcorrect- the last so a report is not built only from complaints - with the reviewer's words about the classification. Nothing quotes a document: a feedback form that collects samples is an evidence store in other clothes. Each verdict is audited by coordinate, and the note stays on the row rather than entering the long-lived audit log.
apps/management-api/tests/sql/classification_health.sql is the proof: events reconcile, a rolled-back revision keeps its own row, and the gap names itself.
The classification inventory (DLP-411)
GET /api/v1/classification-inventory?classification_id=&target_kind= discovery:readDiscovery already walks endpoints, shares and buckets and already runs the detector engine. This reads the same runs a second way, aggregating by classification coordinate rather than by detector category.
- No second scanner. Classifications are evaluated from the detector results a run already produced, against the tenant's enabled definitions, and stored on the finding as coordinates and outcomes - no matched value, no preview, no document content. The shape is decided by one function whose test asserts what is absent from it.
- The inventory is a query, not a counter, so "counts reconcile to source findings" is true by construction rather than by a reconciliation job.
not_evaluatedis its own column. A repository that could not be read is not a clean repository, and a coverage gap rendered as zero findings is the most dangerous number this product could print.coverage_gapsbreaks it down by reason code, because "OCR was unavailable" and "this file type is unsupported" call for different work.- A revision change marks the rows stale until a rescan. They are evidence about a definition that no longer exists; counting them under the new meaning would be inventing a measurement nobody took. The same rule
DLP-432applies to evaluation. - Capability differences are shown, not guessed.
target_kindstays in the grouping, so a share and a bucket are never averaged into one figure. - Tenant isolation is structural. The tenant is a bind parameter on every branch of the query; no argument widens it.
apps/management-api/tests/sql/classification_inventory.sqlseeds two tenants and asserts the second one's findings never reach the first's totals - removing the filter makes it fail. - Rows that matched nothing are not recorded: the inventory is about where material is, and a row per classification per file would be one per classification per file on every share in the estate.
Classification templates (DLP-410)
GET /api/v1/classification-templates detectors:read
POST /api/v1/classification-templates detectors:write · signed
POST /api/v1/classification-templates/{id}/instantiate detectors:write
GET /api/v1/classifications/{id}/template-update detectors:read
POST /api/v1/classifications/{id}/template-update detectors:write · reason · expected_revision
POST /api/v1/classifications/{id}/rollback classifications:rollback · reason · expected_revision
GET /api/v1/classifications/{id}/revisions detectors:read · newest first (DLP-856)
GET /api/v1/classifications/{id}/bundle-state detectors:read · what the signed bundle carries (DLP-1016)Revisions (DLP-856). revisions lists every stored revision, newest first, each with created_by (the publishing account's address, or null), created_at, source_version and the whole definition as it was published. A rollback re-signs the bundle for the whole fleet, and the dialog used to offer revision numbers with nothing about any of them. The rollback line above said detectors:write; the route has always required classifications:rollback.
Stewardship and review (DLP-1083).
PUT /api/v1/classifications/{id}/stewardship detectors:write · {owner, review_by} · audited
POST /api/v1/classifications/{id}/review detectors:write · {note, next_review_by?} · auditedA classification names its owner, an active user of the tenant, and its review_by date. The resource returns them with reviewed_at, reviewed_by and review_overdue, which the database computes against today in UTC so every reader agrees on the day. Recording a review needs a note of 1 to 2000 characters. The note is kept in the classification.reviewed audit entry, and the review sets the next date or clears it. Built-in classifications are refused with 409: the product owns them, and a release is their review. Neither route changes the revision or compiles, because nothing a bundle carries changes.
Bundle state is read from the bundle (DLP-1016). bundle-state returns {bundle_version, carried_revision, current_revision} from the newest unexpired signed payload. carried_revision is null when that bundle carries none. The detail used to say "Included" whenever the classification was enabled, which was wrong for any change not yet compiled.
A compatibility classification says whether it follows its detector (DLP-858). Every detector has a classification with its own id (DLP-379). The resource's detector_managed is true while that classification follows the detector's switch. Switching the classification itself clears it, and from then on the detector's switch no longer moves the classification. Authored classifications are always false. compatibility says whether a classification is a detector's own at all (its id is a detector's), so the console can tell a decoupled one from an authored one; both have detector_managed: false.
A template is a reusable classification structure with a regulatory or business purpose. Shipping one is a claim about somebody else's compliance, so:
- Provenance travels with it. Locale, what it applies to, the corpus version it was measured against, what it is known to miss and what it is known to over-match. A template without those would be claiming complete coverage by omission, which
CLAUDE.md§7 forbids. The console shows them on the page rather than behind a link. - Published templates are signed, and verified on every read - not trusted because they are in our own database. A row somebody edited in the database never passed a signature check. The publisher key is configuration (
DLP_TEMPLATE_PUBLISHER_KEY); absent, every published template is refused rather than accepted unverified. Built-in templates are trusted because the binary is. - Templates install; they never instantiate themselves. A release that started classifying a tenant's documents because it shipped a new template would be reading a regulation on their behalf. An instantiated copy starts disabled, like a fork.
- The built-in catalogue includes real composites. Rules are OR and the elements inside one rule are AND. Besides the Azerbaijani personal-data template, this build offers identity-plus-contact records, ten-distinct-value bulk identifiers, and confidentiality-marked financial data. Each starts disabled and carries its thresholds and limitations into the tenant copy. There is no person-name detector and no separate expiry/CVV detector in this build, so the catalogue does not pretend that capitalised words are names or expose card context terms as independently measured elements.
- The copy keeps its own stable id for ever.
source_idandsource_versionrecord where it came from, which is what makes an update a comparison rather than a guess. - Only a copy has a template update (
DLP-623).GETandPOST template-updateanswer a classification whoseoriginistemplate. A built-in is 409 - it is updated with the product; itssource_idnames the detector pack it ships in, not a template - and so is a custom classification, and a copy whose template is no longer installed. 404 means only that there is no such classification. - An update is planned before it is applied. The plan is three-way - the template at the version this copy came from, the version being offered, and what the tenant holds - because two of those cannot tell a publisher's change from a tenant's. Paths both sides touched are reported as
conflicts, never resolved: there is no correct automatic answer and the product does not invent one.strategyistake_upstreamorkeep_mine, andkeep_minerecords the new version without changing the classification, so a decision does not have to be made again every week. - The diff is deterministic, so the plan an operator approves is the plan that runs. Re-pinning to a newer catalogue (
DLP-432) is excluded from it: otherwise every catalogue release would look like the tenant had rewritten their classifications. - Rollback goes forward. The old definition is published as a new revision rather than the counter being rewound - a revision number is a coordinate policies bind. Asserted by
apps/management-api/tests/sql/classification_rollback.sql, which CI runs. - Rollback and
take_upstreampass the same gate as an edit (DLP-1015). Both take the policy-graph lock and requireexpected_revision, answering 409 when the classification has moved on. A classification a published policy is bound to, or a label maps, is refused with 409 naming them. The operator takes the policy out of enforcement first, as for an edit (tests/predefined_rule_activation.shwalks it through). Both re-sign the bundle when the classification is enabled and returnbundle_version.keep_minechanges nothing a bundle carries and compiles nothing. Before this, both moved the revision under a published binding without compiling, and the next compile in the tenant failed as stale (tests/classification_template_update.sh§3b–3c). - Every revision is kept in full in
classification_revisions. The audit log records structure only, deliberately, which makes it the wrong place to restore from. - An offline agent keeps its last valid bundle. Template changes reach endpoints the way every other catalogue change does - through a bundle signed when the change is applied - and an endpoint that cannot fetch one keeps what it verified last.
A classification revision pins what it was authored against (DLP-432)
A classification names a detector by id. A builtin catalogue reconciliation can replace that detector's definition without the classification changing at all, so the same signed coordinate - classification_id / version / revision - could acquire different matching semantics between two bundles. Policy bindings pin the coordinate; nothing pinned what the coordinate meant.
Every detector-bearing element now carries a pin: the detector version it was authored against, and a digest of that definition's semantic fields.
- The digest, not just the version. A pack that edits a pattern without advancing its version would pass a version check and change what every classification referencing it means.
- Name, description, tags, severity and category are excluded from the digest. None of them changes a match, and a review nobody can act on is a review everybody learns to click through.
- A moved primitive makes the element indeterminate, not negative. It may well match under the new semantics; answering "no" would invent as much as answering "yes". The reason code is
primitive_changed, and it travels innot_evaluated_reasonslike every other indeterminate predicate. - Elements authored before pinning existed carry no pin and evaluate exactly as they always did, so old bundles stay readable and rollback works.
GET /api/v1/classificationsreturnsbinding_drift: the elements whose pin no longer matches the catalogue, computed on read rather than stored - a stored flag would be a second copy of a fact the catalogue already holds.- Re-pinning is an edit, which advances the revision, which is a coordinate a policy has to bind on purpose. That is the deliberate act the ticket asks for.
- The pin carries a version and a digest and no configuration, which is what keeps raw detector configuration out of audit and event evidence.
agent/core/tests/classification_primitive_pinning.rs is the release-upgrade test: the same coordinate, byte-identical across a catalogue that moved, refusing to assert a match until a new revision is published.
Compliance tags (DLP-426)
GET /api/v1/classifications/compliance-regimes detectors:read
POST /api/v1/classifications/compliance-regimes/{code}/enabled detectors:write · reason
POST /api/v1/classifications/{id}/compliance-tags detectors:write · reason
POST /api/v1/classifications/{id}/compliance-tags/{code}/remove detectors:write · reasonWhich instrument governs which material is the tenant's legal determination. The API records it and never makes it:
- Every regime arrives disabled, seeded per tenant by a trigger so a tenant created by any path gets the same vocabulary and none of it applied.
- A regime that is not enabled cannot be used as a tag, and is not returned as one to offer.
- A regime still recorded on a classification cannot be disabled. Remove the determinations first, each with a reason.
- Enabling, disabling, tagging and untagging commit with their audit entry (
compliance.regime.enabled,compliance.regime.disabled,classification.compliance.tagged,classification.compliance.untagged), in one transaction (DLP-864). Disabling locks the regime row before the tags are counted, and tagging reads itFOR SHARE, so a tag cannot land between the count and the switch-off. The console's Compliance tags panel switches a regime off with a reason (DLP-863). - Each transition is audited with who, when, the citation and the reason.
- A classification carries
compliance_tags; an incident carries the tags as they stood when each of its events joined it, snapshotted rather than joined at read time. An event's tags are those of its detectors and of every classification its policy decision says matched, and every joining event's are added to the incident's as a set, with its classification lineage (DLP-1984). A tag lookup that fails holds the event for a retry rather than raising the incident with no tags.
PRIVACY_MODEL.md §17 is the register row, and apps/management-api/tests/sql/compliance_tags.sql is the proof that both tags resolve from a classification to an incident.
A troubleshooting session (DLP-372)
POST /api/v1/agents/{id}/troubleshooting endpoints:manage · reason + hours
POST /api/v1/agents/{id}/troubleshooting/{session}/stop endpoints:manage · {"collect": bool}
GET /api/v1/agents/{id}/troubleshooting endpoints:readStarting queues a start_troubleshooting command carrying the deadline; stopping queues stop_troubleshooting with collect. Both are written in the same transaction as the session row - a session recorded without a command would show an operator detailed logging that never started, and a command without a session would start it with nothing saying who asked or when it ends.
- Hours, bounded at both ends. Under an hour cannot reproduce anything; over a day is logging nobody renews and so nobody remembers. The agent clamps the deadline again on arrival: a deadline is an instruction from a system that could be wrong.
- One live session per device, enforced by a partial unique index and reported as a refusal rather than silently replacing the other operator's session.
collect: falseis Cancel. Nothing is gathered, which is better than gathering and deleting.- A session ends whether or not anybody ends it. The endpoint stops on its own clock - across a restart, and without the server - and the housekeeping pass marks the row
expiredso the console does not show it running for ever. GET /api/v1/agentscarriestroubleshooting_untilfor a live session, read from the session table rather than from the agent's health: a device that went quiet after somebody turned detailed logging on is the state an operator most needs to see.
The bundle a collected session produces is the DLP-371 bundle with the log files in it, and its omissions list says so. PRIVACY_MODEL.md §16 is the register row. It is stored under the session's id. Until DLP-1165 it never was: the server accepted a bundle only in answer to collect_diagnostics, so every collecting stop was refused as unrequested while its row said collected. tests/agent_commands.sh now drives a session to a real agent and back. A cancelled session asked for nothing, and an upload for it is still refused.
How much of the fleet is protecting anybody (DLP-362)
GET /api/v1/agents/protection?<the same filters as /agents> endpoints:read
GET /api/v1/agents?protection=protected|degraded|unprotected|monitoringFour counts - protected, degraded, unprotected, monitoring - plus their total, over every device the filters match, not over a page. A widget built from a page describes fifty devices and prints the number over two hundred.
degradedmeans still protecting. An agent a version behind is enforcing the policy; a deactivated one is not. Which way that is folded decides whether a fleet looks better or worse than it is.- A device silent past the listing cutoff counts as unprotected (
DLP-2177): fifteen minutes by default, or the organisation's own cutoff. It is the same line thesilentfilter draws, so the protection counts and that list agree. - A revoked or uninstalled agent counts as unprotected. It is nobody's fault, and the console does not colour it as one, but it is not protecting that machine and a coverage figure that said otherwise would be counting paperwork.
- The fold is in Rust, not SQL, because it compares versions semantically: a string comparison puts
0.9.0above0.10.0and would mis-file a fleet for every release between x.9 and x.10.?protection=therefore resolves ids in one pass and pages over them - two queries, one definition. - Files not inspected and warnings not shown are
degraded(DLP-1505): a gap in the last seven days (or a latest uninspected file under seven days old, from an agent without the week's count), or a warning recorded asnot_delivered. The agent is working and not doing all of it. A silent or impaired device with a gap is still unprotected, and monitor mode replaces it as it replaces every degraded state. SinceDLP-1766a gap made only of limits of the product (vanished,no_text,link_not_followed,image_not_read,unsupported_format) is not a gap here: every Windows PC has them, and counting them made no endpoint ever protected. They are still counted and shown (the console puts that chip in the troubleshooting lane). Encrypted and over-the-size- limit files still degrade, and so does a cause this build does not know. The list isNOT_A_FAULTinroutes/endpoints.rsand inlib/not-inspected.ts, and a console test holds the two equal. - The same rule is implemented in
apps/dashboard/src/lib/device-status.tsfor the per-row column. Each side asserts its own copy of one table of cases, which holds the cases both know and catches nothing a change adds to one side only:DLP-1270,DLP-1276andDLP-1279added the two states above to the console alone, and the headline and the rows disagreed untilDLP-1505. Two implementations of one rule is a cost paid deliberately; the alternative is a widget that counts a page. monitoringis reporting and deliberately not enforcing (DLP-1231), because the device's group is in monitor mode and its agent honours it. It is decided after every fault: a silent device in a monitor-mode group is unprotected, because monitor mode excuses nothing. Counting it as protected would credit a group with prevention it was told not to do.- An unrecognised
protectionvalue narrows nothing rather than returning an empty list: a filter that silently answers "you have no devices" is one somebody acts on.
Endpoint reporting and tamper visibility
GET /api/v1/system/sequence-gaps events that never arrived, worst first
GET /api/v1/system/health/gaps endpoints with gaps, one row each
GET /api/v1/system/health/endpoints/{device_id}/gaps one endpoint's gaps
POST /api/v1/system/health/gaps/{id}/accept health:manage · accept a gap's loss, with a reason
GET /api/v1/system/silent-endpoints who stopped reporting, and for how long
POST /api/v1/agent/uninstall signed; an agent reporting its own removalsystem/health carries a silence summary: reporting, unexplained, explained, never_reported, interruptions_24h (kills, crashes and power losses), stops_24h (stops through the service manager, DLP-1613), of those stops_unexplained_24h (no update and no shutdown behind them, as the agent judged at its next start) and stops_update_24h (DLP-1770; a stop from an agent older than that judgement is in neither), and the threshold_seconds actually applied.
An endpoint counts as silent after fifteen minutes by default, and is worth interrupting someone after thirty minutes. Both are tenant settings (GET and PATCH /api/v1/settings/health-silence, DLP-2177). The listing cutoff may not go below fifteen minutes, the alerting cutoff may not go below thirty, alerting may not start before listing, and neither may exceed seven days. The server's check-in interval is returned beside them and is not applied to them: a deployment whose agents check in less often than every few minutes must raise the listing cutoff itself.
Silence is classified by what is known, not guessed:
| State | Meaning |
|---|---|
explained | A stop reason is recorded: an authorised uninstall, a revocation, a decommission |
unexplained | Active, no stop reason, past the threshold. This is the alert |
never_reported | Enrolled and never checked in - a failed install, which needs a different response |
POST /api/v1/agent/uninstall is signed like every other agent call, and must be: an unauthenticated version would let anyone mark any device as cleanly uninstalled, which is exactly the cover someone stopping an agent would want. It requires a non-empty authorization_id - without one there is nothing to attribute the removal to, and recording it as authorised would be a lie in the audit log. It is accepted from an already-uninstalled device so a retry after a network failure is not refused.
Event continuity (#351). Every agent numbers its events monotonically. A hole in that numbering means events were produced and never delivered - file operations that happened and were not recorded - and the server had no way to notice: a device whose sequence jumped from 100 to 105 looked exactly like one that sent five fewer events.
system/health carries a sequence_gaps summary (open_gaps, events_missing, devices_affected, and apart from those the accepted loss, accepted_gaps and accepted_events_missing); open_gaps and events_missing count only gaps nobody has accepted. system/sequence-gaps names the endpoints and the ranges, and each gap carries its id and, once accepted, accepted_at, accepted_by and accepted_reason.
Accepting a gap says its events will not arrive. POST /api/v1/system/health/gaps/{id}/accept takes {"reason"}, trimmed and required, at most 500 characters; a blank reason is refused with 400, a read-only role with 403, and a gap of another organisation with 404. It is refused with 409 once the gap is filled, since there is no loss left to accept, and again while it stands accepted, so each decision is one audit entry (sequence_gap.accepted). Acceptance never changes events_missing, and a late batch that fills the gap later resolves it while keeping the decision.
Gaps are also read by endpoint (DLP-2195). GET /api/v1/system/health/gaps returns one row per endpoint that still has an unfilled gap: open_gaps and events_missing count the gaps nobody has accepted, accepted_gaps and accepted_events_missing the ones somebody has, and last_observed_at is the latest time one of that endpoint's gaps was observed (not its last check-in). Rows are ordered by events_missing, most first, then device_id, so endpoints still owed events come before endpoints whose loss is all accepted. limit defaults to 50 and is clamped to 1 to 100. q is a case-insensitive part of the hostname, at most 100 characters; a longer value is refused with 400. next_cursor is <events_missing>:<device_id> of the last row, and a malformed cursor is refused with 400. The count is the sort key, so an endpoint whose count changes between two requests can move across a page boundary; a client that sees the counts change should start again without a cursor.
GET /api/v1/system/health/endpoints/{device_id}/gaps returns that endpoint's unfilled gaps, oldest sequence first, each with the fields the accept route returns. limit defaults to 100 and is clamped to 1 to 200, and next_cursor is <first_missing>:<id>. Accepted gaps stay in the list with their reason, so the decision can be read where the loss is. Both routes need the system read permission. A device of another organisation, or an unknown id, returns an empty list rather than a 404.
A gap closes itself when the missing events later arrive, because a gap can be transient - the agent retries and a batch lands out of order. A product that only ever opened gaps would accumulate a list of solved problems and teach its operators to ignore the list.
A device's first batch is never a gap, however high it starts. An agent re-enrolling while still holding its own buffer legitimately resumes at 500, and reporting a 499-event loss would raise an alarm on every re-enrolment.
Two silence thresholds, not one. silence.threshold_seconds is when an endpoint is listed; silence.alert_threshold_seconds is when it is worth interrupting someone. They answer different questions: a long lunch is listed and is not an alert, while silence past thirty minutes is not explicable by a commute or a hotel network. Both are the tenant's values, and the classifier, system/health, the silent-endpoints list and the Administration section all read those same values (DLP-2177).
A device row carries unclean_exits_1h, the times its agent stopped without its shutdown path in the last hour (a run ended while it was stopping, as an installer that stops waiting ends it, is reported as a stop and not counted here: DLP-1672), and latest_unclean_exit, what the latest report said (DLP-1619). From three, a device that reports itself healthy is counted as needing attention (degraded) everywhere the protection fold is read: between exits a crash-looping agent checks in and says it is healthy.
Audit actions emitted: agent.uninstalled, agent.interrupted, agent.stopped (a clean stop, reported at the next start with gap_seconds) and agent.sequence_gap.
What "alert" means here. The condition is detected, audited, counted and surfaced. It is not delivered anywhere: webhook and SIEM delivery are contract-only (#357, #358), so nothing pages anyone yet. Somebody still has to open the console. That is recorded in docs/KNOWN_LIMITATIONS.md rather than implied away.
Detectors
GET /api/v1/detectors
GET /api/v1/detectors/{detector_id} detectors:write or audit:read · the raw definition (DLP-1088)
POST /api/v1/detectors/{detector_id}/enabled enable directly; disable {enabled:false, reason} waits for approval
GET /api/v1/detectors/{detector_id}/impact detectors:read · what depends on it
GET /api/v1/detectors/{detector_id}/revisions detectors:read · every stored definition, newest first
POST /api/v1/detectors/{detector_id}/revisions/{n}/restore detectors:write · reason · an edit
POST /api/v1/detectors/test live test bench
GET /api/v1/detectors/unpoliced ?days=1..90 · detectors:read and agents:read (DLP-1272)
POST /api/v1/detectors/quality detectors:write · measure the tenant's detectors on the shipped corpus (DLP-434)
GET /api/v1/detectors/quality/observed detectors:read · precision from reviewers' verdicts (DLP-1678, DLP-1696)
GET /api/v1/detectors/{detector_id}/quality detectors:read · the latest run and its baseline (DLP-1694)
POST /api/v1/detector-packs import/activate one versioned country packA quality run names the exact corpus it measured (DLP-1694). A run stores corpus_digest, a SHA-256 of the corpus's parsed content, beside corpus_id. The first corpus was shipped-v1; the complete 46-detector corpus is shipped-v2. The digest, rather than the friendly name alone, prevents two runs on different test sets being compared as if only the detector had changed. GET /detectors/{id}/quality answers {current, previous, not_comparable}:
previousis the latest run of another revision with the same digest;- otherwise
not_comparablenames the latest other revision, its time, andwhy:different_corpus, orcorpus_unknownfor a run from before the digest was kept. No digest is invented for an old run; it is never anyone's baseline.
GET /detectors includes quality for the detector's current exact revision: tested, not_in_corpus, or not_run, the run time, confusion matrix, precision and recall. A result from an older revision is never carried forward. A quality run also names unmeasured_detectors, whose rows have no labelled positive case and therefore no recall figure. A Block, Quarantine or approval-required policy that depends on one of those revisions receives an untested_detector release warning and requires an independent approver; the warning remains on the published policy and in its lifecycle history.
Detectors that matched where no policy acts (DLP-1272). GET /detectors/unpoliced answers [{detector_id, name, data_category, files, window_files, last_at, endpoints, classifications}], with the most retained files first. The rows are added up from active endpoints' health reports. files is the retained per-detector ledger count, not a count in days. window_files is the exact count in days as of each endpoint's check-in, or null if any included endpoint has unknown time data.
endpointsis[{id, hostname, files, window_files, window_as_of, last_at}], most recent match first. A missing/unknown window count isnull, not zero.classificationsis[{id, classification_id, name, enabled}], the classifications that use the detector; a new policy usesclassification_id.daysis 1 to 90, 7 by default; anything else is refused with 422. It filters detectors by latest match and selects the same-length endpoint window count when available. Endpoint check-in times need not be identical.nameisnullfor a detector the catalogue no longer has.
No file name and no value is sent or answered (PRIVACY_MODEL §48).
Authoring (DLP-107).
POST /api/v1/detectors detectors:write · create only; 409 on an existing id · audited
PUT /api/v1/detectors/{id} detectors:write · edit {definition, enabled?} · audited
DELETE /api/v1/detectors/{id} detectors:write · auditedCreate and edit are separate (DLP-851). POST was an upsert, so posting an existing id replaced that detector. Because the console always saves enabled: false, it also switched the detector off, past the guard that refuses to disable one an enabled classification requires. POST now answers 409 for an existing id ("already exists") and changes nothing. PUT /detectors/{id} edits a tenant's own detector:
- the definition's id must be the path's, or it is 422 on
definition.id; - an unknown id is 404, not a create;
- a builtin is 409;
enabledis optional and absent leaves it as it was;falseon a running detector goes through the/enabledguard, then waits for a second approver with a meaningful reason; it does not change the live detector or bundle while waiting. The request is audited asdetector.change_proposed; approval recordsdetector.disabledwithenabled: {before, after}anddetector.change_approved. User tags follow the same split:POST /user-tagscreates (409 for a code that exists), andPUT /user-tags/{code}edits (404 for an unknown code, 409 for a retired one).user_tag.savedrecordschange: created | updated.
What the switch-off guard refuses (DLP-1027, DLP-1028). Switching a detector off, through /enabled or PUT, answers 409 and changes nothing when either of these holds:
- an enabled classification uses it, through an element of either spelling (
content, which the console writes, or the olderdetector). The message is "required by enabled classification(s)" and names them. - a published policy names it directly in a condition. The message is "is named by published policy" (or "policies") and names them. Allowing it would land the change and then fail every later bundle compile in the tenant, because a published policy whose detector is off does not validate.
A published policy that cannot be read refuses the change with 409, as DELETE does (DLP-625). DELETE is stricter on classifications: it refuses while any classification references the detector, enabled or not, in either spelling.
A change to what a detector matches moves its version while anything depends on it (DLP-1019). PUT /detectors/{id} answers 409 ("changes what detector X matches but keeps version V") when the semantic digest changes, the version does not, and an enabled classification uses the detector or a published policy names it. The message names them. A change that leaves matching alone, such as a name or description, is not refused, and neither is one that advances the version. A classification pinned to the old version then reads as drifted, and endpoints treat it as not evaluated until a new revision is published (DLP-1018). A published policy that names the detector directly has no pin, so for it the version bump is an acknowledgement, not an approval. Approval is DLP-1023.
Who reads a definition (DLP-1088). A detector's raw definition, its patterns, terms and allowlist, is returned only to detectors:write (to edit it) or audit:read (to review what it looks for). A pattern can encode an internal project name. GET /detectors/{id} answers 403 to anyone else, and the revision history returns definition: null to them while keeping each digest, so they still see whether matching changed. The detector list and its technique summary stay on detectors:read. The signed bundle still carries every definition to endpoints, so this narrows who reads it in the console. It does not make a definition secret. Every successful raw-definition read appends detector.definition_viewed to the audit chain with the actor and detector ID. The definition itself is deliberately excluded from audit metadata, so the audit trail does not become a second long-lived copy of the sensitive matching logic.
A change that waits for a second person (DLP-1025, ADR-0034).
GET /api/v1/detectors/{detector_id}/proposals detectors:read · newest first; definition as for revisions
POST /api/v1/detector-proposals/{id}/approve policies:approve · not the author · recent sign-in (policy_release)
POST /api/v1/detector-proposals/{id}/reject policies:approve, or the author withdrawing · {reason}Every security-sensitive edit or restore is proposed instead of applied: matching semantics, lower severity, changed data category, an allowlist change, or disabling a detector. This is mandatory like a high-risk policy release, independently of the tenant's low-risk release setting. It answers 202 with {detector_id, proposal, state: "waiting", published_policies, risk_reasons}, and nothing it contains reaches a bundle. Only one change waits per detector, and a second is 409. Approving applies it through the ordinary edit path: the same guards, a detector revision carrying the author's reason, and a signed bundle. Its author cannot approve it (403). Adding or removing an allowlist value is the Owner's alone (DLP-2324, the tenant:write permission): an Admin is refused 403 on the edit and on the approval, whatever else it holds. An Owner's allowlist change is proposed like any other, and a second Owner applies it. A rejection needs a reason and changes nothing. A rename, a description, or another change that cannot weaken detection is applied at once. Tenant allowlist values also need an expiry within 90 days; expiry automatically removes the exception, audits it and re-signs the bundle. The detector list says change_waiting so an approver can find one. Each step is audited as detector.change_proposed, detector.change_approved or detector.change_rejected.
Revisions (DLP-1021). Every create, edit, pack import and restore of a tenant's own detector stores the whole definition in detector_revisions, numbered per detector, and a stored revision cannot be changed (a trigger refuses the UPDATE). revisions lists them newest first, each with version, digest (so a rename reads as unchanged matching), source, reason, created_by and definition. restore stores an earlier definition again as a new revision. It is an edit, so it passes every edit guard, including the same-version rule, and re-signs the bundle. Builtin detectors are not recorded, because their history is the release. A detector that existed before the table did starts at revision 1 with source: "existing", which is its definition as it stood then, not its first.
Test cases (DLP-1052, register entry docs/PRIVACY_MODEL.md §34).
GET /api/v1/test-cases?kind=&id= detectors:read · the cases of one classification or detector
POST /api/v1/test-cases detectors:write · {kind, id, name, sample, expected} · audited
DELETE /api/v1/test-cases/{id} detectors:write · audited
POST /api/v1/test-cases/run detectors:read · {kind, id, detector? | rules?} · outcomes onlykind is classification or detector, and expected is match, no_match or not_evaluated. A sample is at most 4 KiB, and an object keeps at most 50. A sample that any of the tenant's EDM or fingerprint detectors recognises, enabled or not, is refused with 422 on sample, naming the detector: it holds a real record or a protected passage. sample is returned only to detectors:write or audit:read, and is null for other readers. run evaluates the stored definition, or an unsaved detector or classification rules, and returns each case's expected, actual and passed, never a sample or a matched value. A classification is run through the tenant's enabled detectors, as the fleet would run it. Deleting a detector deletes its cases. The audit entry names a case and its expectation, never its sample.
Impact (DLP-1029). GET /detectors/{id}/impact returns {detector_id, classifications: [{id, name, enabled}], published_policies: [{id, name}], blocks_disable, blocks_delete}. blocks_disable is the switch-off guard's rule: an enabled classification uses the detector, or a published policy names it. blocks_delete is true when any classification uses it or a published policy names it. DELETE also refuses when a draft's current version names the detector, and that refusal names the policy. The console reads this before it asks to disable or delete. When something blocks the action, it names the dependents with links instead of offering the button.
The test bench runs what it is given (DLP-852). POST /detectors/test {text, detector_ids?, draft?}:
detector_idsruns those detectors, enabled or not. A new detector is saved disabled so that it is tested here first; the bench ran enabled detectors only, so the only way to see what one matched was to enable it for the fleet.draftis an unsaved definition, validated as a save would be (a refusal namesdraft.…), run in memory and never stored. It replaces a stored detector of the same id, and with no ids beside it runs alone.- With neither, every enabled detector runs, as before.
- EDM and fingerprint detectors are evaluated (
DLP-1048) against the newest active dataset or ready set their definition names, published or not, with the same sets and keys an endpoint uses. Before this, they were always undernot_evaluated. A match names the detector, never the record.POST /policies/simulatedoes the same from the signed bundle's ownedm_setsandfingerprint_corpora, so it evaluates exactly what an endpoint holding that bundle would.
Signed policy bundles also carry classifications: stable definitions above the detector catalogue. Rules in one classification are OR; elements in one rule are AND. Existing tenants are migrated losslessly as one classification, one rule and one detector-reference element with the same id, so the detector API and already-published policies keep their behavior while first-class classification authoring is introduced. An enabled classification with a missing rule or element makes bundle compilation fail; the server never signs a plausible partial tree.
Classification evaluation is tri-state: matched, not_matched, or not_evaluated. The caller supplies detector results, the scan report's not-evaluated detector ids and canonical endpoint context. With complete coverage, a detector omitted from both result lists ran clean and found nothing; encrypted or unsupported input, an error, timeout, truncated input, capped result, partial extraction or unavailable context stays indeterminate unless positive evidence already proves the rule. AND rules can be disproved by one complete negative and any complete matching OR rule proves the classification. Evidence carries stable rule/detector ids and bounded reason codes only, never matched values, paths or vendor error text.
Policy bindings (DLP-404). A policy condition may bind one exact classification coordinate:
json
{"op":"classification","classification_id":"acme.customer","classification_version":"2.0.0","revision":7}Draft save and publish reject missing, disabled, cross-tenant and stale coordinates. Publish and classification/detector mutations share one tenant graph lock, and bundle compilation revalidates every published policy before signing; a partially updated graph cannot escape as a plausible bundle. Published direct bindings prevent their definition from being edited or disabled. Existing detector predicates remain wire-compatible and retain their authored thresholds. New console authoring uses exact classification coordinates rather than rewriting legacy predicates through threshold-free compatibility definitions.
The endpoint evaluates definitions and policies from the same verified signed payload. Its PolicyDecision.classifications and simulator outcomes report the exact coordinate, evaluation status/outcome, matched rule ids, and contributing element/detector ids. definition_unavailable, revision_mismatch, and not_evaluated fail safe and remain visible to an analyst; no raw matched value or vendor extraction error is copied into this lineage.
Typed elements (DLP-403). type: content references a reusable detector and adds match-count, unique-count, confidence, validation and OCR-source thresholds. The legacy type: detector spelling remains readable with safe defaults. application, path, domain and channel consume facts supplied by the endpoint; channel values come from the wire enum, domains use exact or subdomain matching, and path patterns support bounded */? matching without running an authored regex. file_property uses MIME verified from bytes, filename patterns and inclusive size bounds. label reads existing MyDLP or third-party metadata without rewriting it. coverage can select complete, encrypted, unsupported, timed_out, partially_extracted or not_evaluated.
proximity_chars retains its wire name for compatibility, but its unit is now explicit: the difference between normalised UTF-8 byte match-start offsets in one extracted source, inclusive at the configured bound. Locations from two pages, sheets or other source references never satisfy one window. If a positive detector omitted its locations, the proximity predicate is not_evaluated, not false.
Classification authoring (DLP-401). The console API exposes the whole classification as one tenant-scoped aggregate:
GET /api/v1/classifications
GET /api/v1/classifications/{classification_id}
POST /api/v1/classifications
PUT /api/v1/classifications/{classification_id}
POST /api/v1/classifications/{classification_id}/enabled
POST /api/v1/classifications/{classification_id}/forkReads use detectors:read; mutations use detectors:write, preserving the existing catalogue RBAC boundary rather than creating an accidental second privilege path. Create and update submit the complete ordered rule/element tree. The server validates detector ownership and enablement plus bounded typed-element values, then replaces the tree and metadata in one database transaction. An enabled definition must have at least one non-empty rule; a disabled draft may be incomplete but never enters a signed bundle.
Every mutation after create carries expected_revision. A stale writer gets 409 and changes nothing; a successful mutation advances exactly one revision. Ids are stable, tenant-owned and deterministic on read. The core. namespace is reserved. Builtin and template definitions cannot be overwritten: fork creates a disabled custom revision-one copy with source_id provenance, which must then be deliberately edited and enabled.
Detector definitions remain reusable primitives. A detector referenced by an authored classification cannot be deleted, and one required by an enabled classification cannot be disabled. This guard applies across both APIs so a legacy detector operation cannot strand the new aggregate. Audit entries keep actor, action, stable ids, revision, enablement and rule/element ids; they do not copy names, descriptions, regexes, dictionaries, EDM values, samples or matched incident content into the longer-lived audit log.
The console route /classifications is the authoring surface for this aggregate (DLP-402). Its list/detail layout renders OR between rules and AND between elements explicitly, keeps the submitted draft in memory after a 409, and shows the expected revision and signed-bundle effect before a state change. Policy dependency names are fetched only for a role with policies:read; direct exact bindings are distinguished from legacy policies that share detector primitives. The Policies authoring form loads enabled classifications and writes their current id/version/revision tuple. /detectors remains a secondary Detector library and test bench. It never calls a detector a classification, and the classification response never includes a regex, dictionary, EDM source value or incident sample.
Version coordinates are intentionally explicit. version is the semantic definition version; revision (shown as content revision in the console) is the tenant-local immutable edit coordinate; bundle_version is the signed deployment artifact. GET /api/v1/detectors additionally returns content_revision, content_changed_at, content_changed_by and content_source for custom detector history. They are null for a built-in, whose semantic version belongs to the platform release. Quality and incident rows carry the exact detector version they measured or observed, so an older result is labelled as historical rather than presented as the current rule.
Label governance (DLP-405). Detection, label recommendation, metadata application and policy enforcement are separate states. The tenant taxonomy is one optimistic, revisioned aggregate:
GET /api/v1/label-governance
PUT /api/v1/label-governance
POST /api/v1/label-governance/transitionsReads use detectors:read. Changing the taxonomy, authorising a transition, promoting a rollout and rolling back use labels:write (DLP-765, ADR-0032), which also governs the user-tag catalogue: what the organisation calls its data is a different job from writing detectors. A role or token that held detectors:write before that release was granted labels:write once, at its first start, with an audit entry. A label has a stable id, display name, unique numeric priority, optional parent, exact channel scope, color and marking. The server rejects missing parents, hierarchy cycles, case-insensitive duplicate names, duplicate priorities and defaults outside a label's scope. Classification mappings carry the exact current classification_id, semantic version and revision. Editing or disabling a mapped classification is refused until the mapping is removed, so a reviewed recommendation never follows changed detection semantics silently.
The signed bundle carries the whole governance revision. The endpoint resolves all matched mappings from that same verified payload and selects the highest unique priority, then a channel-specific or tenant-wide default. Mandatory state and not_evaluated lineage remain explicit. PolicyDecision.label records detection, recommendation and application while enforcement_independent: true states that the policy engine chose the DLP response separately. This pure resolver is identical online and offline.
Only MyDLP-owned metadata can be changed. External/customer labels are fixed to preserve and never become a mutation target. When configured, removing or downgrading a MyDLP label requires one bounded reason code. Transition audit entries contain only stable from/to ids, transition kind and that enum; paths, filenames, excerpts and arbitrary reason text are not accepted. The /labels console authors taxonomy, hierarchy, exact mappings, scoped defaults, mandatory coverage and downgrade control. Recommended/automatic application and staged rollout remain DLP-406 rather than being hidden inside this model.
Two rules, both already in the schema before there was an endpoint:
- A builtin may be disabled, never edited or deleted. Whatever a pack ships has to stay what the pack ships, or an update silently reverts somebody's change and the catalogue stops meaning the same thing on two tenants. The refusal says to copy it under your own id.
- A pattern that will not compile is refused at authoring time, with the regex compiler's own words. Storing one would break the next bundle compile - for every policy, not just this detector, and for whoever publishes next rather than whoever wrote it.
An id beginning core. is refused: that prefix is the platform catalogue's, and a tenant rule taking one would collide on the next update in a way that presents as a detector quietly changing behaviour.
Deleting a detector a published policy names is refused, and the refusal lists the policies. A policy whose detector does not exist matches nothing and reports nothing, which is the failure this product exists to prevent.
Every change is audited and compiles a fresh bundle immediately, the same as enabling one. The audit entry records the name, category, severity and kind - not the pattern: a pattern encodes the thing it looks for, an account number format or an internal project name, and the audit log is read more widely than the catalogue.
Country packs (DLP-173). POST /api/v1/detector-packs takes {"pack": DetectorPack, "activate": boolean}. activate defaults to false; setting it true activates the whole submitted version, never a subset. Pack ids are lowercase wire names and form the detector namespace (de owns de.*). Every detector version must equal the pack's three-part semantic version, so the existing detector_version in every result identifies the pack revision.
A platform pack cannot be replaced, a pack cannot take over a standalone rule or another pack's id, and an existing pack cannot be downgraded or changed at the same version. Updates are additive: omission cannot silently remove a detector which a published policy may still name. The operation is audited without copying its patterns into the audit log and immediately compiles a new signed policy bundle. The submitted JSON is never an endpoint delivery format; endpoints see its definitions only after the normal signed-bundle verification and atomic activation path.
Exact Data Match (DLP-26)
POST /api/v1/edm/estimate edm:read; what a tag length would cost; reads and writes nothing
POST /api/v1/edm/projection edm:read
POST /api/v1/edm/rotate-key edm:write; {reason}; start an old/new-key overlap
GET /api/v1/edm/key-rotation edm:read; current overlap, dataset and endpoint progress
POST /api/v1/edm/key-rotation/complete edm:write; retire old data/key after convergence
GET /api/v1/edm-datasets edm:read (DLP-764); metadata only, never a value or a tag
POST /api/v1/edm-datasets edm:write; import; the file is fingerprinted and dropped
POST /api/v1/edm-datasets/import-file edm:write; the file itself as the body (text/csv); ?dataset_id&name&fields[&fingerprint_bytes]
GET /api/v1/edm-datasets/{id} edm:read
GET /api/v1/edm-datasets/{id}/delete-impact edm:read; what deleting this version changes (DLP-874)
GET /api/v1/edm-datasets/{id}/usage edm:read; the detectors, classifications and published policies that reach it (DLP-881)
DELETE /api/v1/edm-datasets/{id}?reason=... edm:write; removes the tags, keeps the recordDataset metadata includes low_entropy_fields. Import estimates the easiest normalised value in each selected column; below 48 bits the field is marked as offline-guessable because an enrolled endpoint necessarily holds both its tags and comparison key. An Exact Data Match detector cannot require such a field alone: at least one other distinct dataset field must co-occur. This is enforced by detector create, edit, restore, pack import and the test bench, not only by the console (ADR-0070).
Reading needs edm:read, and importing, deleting and rotating the key need edm:write, which implies it (DLP-764).
Import and delete reach endpoints at their next check-in (DLP-871). Both compile and sign a new policy bundle before answering, as key rotation and every fingerprint-set change already did, and return its bundle_version. Before this, the bundle carried EDM sets, but neither route recompiled it. A deleted dataset kept being matched, and a re-import did not replace the version endpoints held, until some unrelated publish happened to recompile. The policy_bundle.compiled audit entry names the cause (edm_dataset_imported or edm_dataset_deleted, with the reason).
A delete requires a reason (422 without one), recorded on edm.dataset.deleted. Endpoints are given the newest active version of a dataset, so deleting the newest version promotes the one before it. delete-impact says so before the delete is made, {in_use_version, becomes_active, detectors}, and the console's question states it.
What uses a dataset (DLP-881). usage answers {dataset_id, version, detectors, classifications, policies, reaches_endpoints}:
detectors: the EDM detectors naming the dataset, enabled or not. Each hasproblemset when it requires a column this version does not have. Detectors written beforeDLP-880were never checked, so they are flagged rather than refused.classifications: the classifications holding those detectors. The mirror classification every detector has is left out.policies: each published policy that reaches one of the detectors, and how (through:named,classificationorcategory), by the same routes the bundle ships the dataset by (DLP-969).reaches_endpoints: whether the next bundle carries this version.
A detector must name what exists (DLP-880). Creating or replacing an exact_data_match detector is 422 on definition.kind.dataset_id when the organisation has no active version of that dataset. It is also 422, on definition.kind.required_fields[i], when a required column is not one of the newest version's columns; the refusal lists the columns it does have. A document_fingerprint detector is 422 on definition.kind.corpus_id when no active corpus has that id. Pack imports are checked the same way, per detector. Before this, each was accepted, shipped, and matched nothing.
The import body is {dataset_id, name, fields[], fingerprint_bytes, csv}. The CSV is never stored. Rows are normalised, fingerprinted with the tenant's edm_hmac key and discarded; edm_fingerprints has no column that could hold a cell value, which is what makes storing one impossible rather than merely unintended.
The response reports what each named field actually contained - distinct_values against rows_with_value, and a verdict. That is the number worth reading. Fingerprint length is very nearly a red herring: at the enforced 8-byte floor the largest accepted dataset collides at 8.7e-7. A field with too few distinct values matches everything at any tag length, and that is the failure operators actually hit.
Re-importing the same dataset_id creates the next version rather than replacing the current one, so a wrong import does not destroy a right one. Import and deletion are both written to the audit chain inside the same transaction as the change, and the deletion records the reason.
A source file larger than a request (DLP-1040). The JSON import carries the CSV as a string inside the body, so the server's request limit (DLP_MAX_BODY_BYTES, 8 MiB) capped a dataset at a few hundred thousand records, below the documented million. import-file takes the file as the raw body, with the dataset id, name and comma-separated fields in the query. It has a limit of its own, DLP_EDM_MAX_UPLOAD_BYTES (128 MiB by default), and it is the only route with a larger limit. It goes through the same import: the rows are fingerprinted with the tenant's key and dropped, and the endpoint budget is checked before anything is stored. Memory is bounded by that limit. A reverse proxy in front of the API has its own limit, and it applies first: this route needs one at least as large as DLP_EDM_MAX_UPLOAD_BYTES, on this path only. The hosted deployment's nginx keeps 16 MiB site-wide and allows 128 MiB on this exact path, with request buffering off (DLP-1097). Starting rotation requires a reason of 5 to 500 characters (DLP-1041), which the edm.key.rotation_started audit entry carries. It makes the current key rotating, creates the replacement and snapshots active logical dataset IDs without deleting any tag. Because the source CSV is discarded at import, the server cannot re-fingerprint in place: the operator imports each source again with the same dataset ID, producing a new-key version.
GET /api/v1/edm/key-rotation reports pending dataset IDs and separately counts endpoints behind the bundle, missing the replacement key, or still holding an old key. POST /api/v1/edm/key-rotation/complete refuses while a dataset lacks a replacement-key version or an active endpoint has not reported all applicable convergence facts. A replacement-key acknowledgement is not required when the current bundle names no EDM set; there is no key that such an endpoint should receive. Once both data and fleet converge it retires old-key versions, tags and keys in one audited transaction. The agent prunes an old key when its accepted bundle no longer names it and reports its held key IDs at the next check-in. The 30-day target produces an overdue warning, never an automatic protection gap (ADR-0071).
Incident changes as they happen (DLP-2039)
GET /api/v1/incidents/changes incidents:read · a server-sent event streamEach committed write to an incident, by any route, the correlator, the reopen pass or retention, is announced to the console sessions of its tenant that may read it. A scoped role hears only of incidents in its groups. event: incident carries {"id", "row_version"}; a row_version of 0 means the incident was deleted. event: resync means notices may have been missed (the server's listener reconnected, or a burst outran it), and the reader should re-read what it shows. Nothing else is in the stream: no title, person, status or severity. The console re-reads through the ordinary routes.
The stream ends after fifteen minutes. The browser reconnects, which checks the session again, so a revoked session does not go on listening. A comment every 25 seconds keeps a quiet stream inside a proxy's read timeout, and the response carries X-Accel-Buffering: no so nginx does not hold events back. The console marks what changed and moves nothing until the reader refreshes.
Incident notices (DLP-2001)
GET /api/v1/notices/preferences the caller's own {mute_routine}
POST /api/v1/notices/preferences {mute_routine} · the caller's own, not auditedA person is told when an incident is assigned to them by somebody else, or by an assignment rule, and when an incident misses its response or resolution target (DLP-2002). A breach goes to the assignee, or to every active Owner and Admin while nobody holds it, and only while it is under a day old. Notices wait and go as one digest per person, at most every fifteen minutes (DLP_NOTICE_DIGEST_SECS), by mail when DLP_SMTP_URL is set and to chat destinations that asked. A notice names the reference, severity and what happened, never the title or the person it is about. mute_routine stops assignment notices; a missed target cannot be muted. Notices nobody can carry (no relay or address, no chat destination) are set aside with the reason rather than kept waiting. Each digest is audited as notification.sent or notification.failed.
Integrations (INT-001)
GET /api/v1/integrations integrations:write (every route here; DLP-537)
POST /api/v1/integrations configure, by name · audited
DELETE /api/v1/integrations/{id} · audited
POST /api/v1/integrations/{id}/test test the destination · audited
POST /api/v1/integrations/{id}/enabled {enabled} · switch on or off, nothing else · audited · step-up to switch off
POST /api/v1/integrations/{id}/payload-version {version: v1|v2} · webhook and Splunk HEC only · audited · step-up (DLP-2004)
POST /api/v1/integrations/{id}/events {events: lifecycle|created} · webhook and Splunk HEC only · audited · step-up (DLP-2003)
POST /api/v1/integrations/{id}/people-notices {enabled} · Slack and Teams only · audited · step-up (DLP-2001)
POST /api/v1/integrations/flush run a delivery pass now, over this tenant only (DLP-649) · audited
GET /api/v1/integrations/{id}/deliveries/failed what gave up: {items, total}, newest 200 (DLP-932)
POST /api/v1/integrations/{id}/replay {delivery_ids?} · failed ones back to pending · audited · step-upThe incident payload has two versions (DLP-2004). mydlp.incident.v1 carries the reference, title, status, severity, risk, channel, person, endpoint, policy, times, counts, detectors and the console link. mydlp.incident.v2 adds what a SOC triages on: action (what the endpoint did), requested_action and enforcement_result (what the policy asked, and whether it was carried out, from the newest event), destination, destinations (every place its events went, in the order seen, at most 20, DLP-1986), file_name, department, assignee, data_category, and the queue's priority_score with its tier as priority (P1 from 70, P2 from 40). Names are pseudonymised in both as they always were. In v2 a qualified name in the destination, each of the destinations or the file name is rewritten as the title's is, and a bare word is not. A webhook or Splunk HEC destination configured before v2 existed has no version and keeps receiving v1. A new one gets v2 unless it asks for v1 (payload_version on POST /integrations), and payload-version moves either way, with the step-up, because v2 sends more of the incident out. Trackers, syslog, Slack and Teams build their own messages and are not versioned.
A receiver can follow an incident, not only hear it was raised (DLP-2003). Every payload says which it is in event: created, updated (a status change, a severity raised by a joining event, a mark as a test or its removal, in the console or the bulk bar) or closed (a closing status). It also carries synthetic, true for an incident marked as a test. A webhook or Splunk HEC destination configured before this existed hears of creation alone, as its receiver was built for. A new one follows the lifecycle unless it asks for created (events on POST /integrations), the list says which in events, and events moves either way, with the step-up, because following sends more of each incident out, more often. Slack, Teams and syslog stay on creation alone: their message does not say what changed, so an update would read as a second incident. There is one delivery per destination and incident, carrying its latest change: a change while one is waiting replaces it, and the receiver is sent the incident as it is now, once. A change made while a send is in flight is sent after it rather than recorded as delivered with it. A receiver should therefore upsert on reference, not de-duplicate on it.
People's notices (DLP-2001). A Slack or Teams destination can also carry the digests that tell analysts of incidents assigned to them and of missed targets (people_notices on POST /integrations, or the switch above; the list says which). Off unless asked for, because a channel set up for incident alerts was not set up to say who was given what.
A delivery that gave up can be replayed (DLP-932). The failed list names each incident's reference, its attempts and what the receiver last said. A replay returns the named failed deliveries, or all of this destination's, to pending with their attempts reset, and the worker sends them at its usual pace. Only failed rows are touched, so a delivery that already arrived is never sent again by a replay. It asks for the incident_delivery step-up, because it sends incident data out again, and is audited as integration.deliveries_replayed with the count.
No two passes send the same delivery (DLP-761). The background worker, a manual flush, and the worker in every API replica can all run at once. Each claims a due delivery, leasing it for its request timeout plus twenty seconds before the call, and another pass skips a leased row. Delivery is still at least once, not exactly once. A pass that dies after the receiver accepted a delivery but before recording it leaves the row to be retried when the lease runs out, so a receiver that must not show an incident twice should keep one record per reference and replace it with each delivery, which is also what lifecycle events need.
One receiver cannot delay another (DLP-2005). A pass runs up to eight deliveries at once, and a destination never has more than one in flight, so a destination that accepts a connection and never answers holds only its own slot for the ten-second timeout while the others deliver. When a delivery fails, the destination's other waiting deliveries are held back with it for the same backoff, so a destination that is down is asked once per backoff, not once per alert. One in flight per destination is enforced within each API process; two replicas claiming at the same instant can each send one.
The connection test sends a test, not an incident. Its payload carries "schema": "mydlp.connection_test.v1" and no incident data. A test that looked like an incident would appear in somebody's SIEM as a fabricated one. It goes through the same code as a real delivery - same certificate handling, same signature, same ten-second timeout - because a test on a different path reports success on a configuration that cannot deliver. A failed test marks the integration degraded rather than failed: nothing has been lost yet.
Syslog (INT-002). kind: "syslog" delivers RFC 5424 over TLS instead of an HTTPS POST, because most enterprise security teams work in a SIEM and a finding that does not reach it is one nobody correlates with anything else. Configuration is host, port (6514 by default, RFC 5425), tls, facility (13, log audit), sd_id and app_name.
Framing is octet-counting, not newline-delimited: newline framing works until a message contains a newline, and then the collector reads one alert as two malformed ones. Header fields are reduced to PRINTUSASCII and length-limited, and structured-data values escape ", \ and ] - an unescaped ] in an incident title ends the element early, so every parameter after it disappears into the message, silently and only for the incidents whose text happens to contain one.
Severity maps critical→crit, high→err, medium→warning, low→notice, everything else→info. Nothing maps to alert or emerg: an organisation that pages on those would be paged by every critical incident. An unrecognised severity is informational rather than critical, because inventing urgency from a value we do not understand is how an alert queue stops being read.
Plaintext syslog is accepted only for a collector on this host, and the host is parsed as an address rather than prefix-matched - 127.evil.example is a name somebody can register and point anywhere. The connection test sends a real syslog message with MSGID dlp-test, not a bare TCP connect: a collector that accepts a connection and rejects the framing would otherwise pass the test and drop every incident.
Slack (DLP-1179). kind: "slack" posts one message per incident to a Slack incoming webhook: {"text": …} naming the reference, title and severity, the channel, policy, endpoint, user, risk and detector ids, and a link back when console_url is set. It carries no preview and no matched value, as no delivery does. Configuration is url (https), with optional console_url and ca_certificate.
The URL is the credential: anybody holding it can post into the channel. So it is stored as a secret reference, and the listing returns url: null and signed: false. The audit entry records only the host, and a delivery error names only the host, because an error becomes the destination's last_error. A secret is refused rather than ignored: Slack verifies no signature, and accepting one would imply a protection that is not there. The connection test posts a message Slack accepts, saying it is a test and carrying no incident data.
Microsoft Teams (DLP-1773). kind: "teams" posts one Adaptive Card per incident to a Teams incoming webhook. The card's heading is the severity, reference and title; a fact set names the channel, risk, policy, endpoint, user and detector ids; with console_url set, an "Open in MyDLP" button links back. Everything said of Slack above holds for Teams: the URL is the credential and is stored as a secret, the listing returns url: null and signed: false, only the host is audited or named in an error, and a secret is refused.
Splunk HTTP Event Collector (DLP-1773). kind: "splunk_hec" posts each incident to a HEC endpoint (url, usually ending /services/collector/event) as one event: {"time", "source": "mydlp", "sourcetype": "mydlp:incident", "event": <the webhook payload>}, with Authorization: Splunk <token>. The token is secret, required, and stored as a secret reference; the listing shows the URL and signed: false, because Splunk checks the token rather than a body signature. The connection test sends one event with sourcetype mydlp:connection_test and no incident data, so it can be filtered out of a search.
Audit events over syslog (DLP-2205). A syslog integration may take categories, a list of user_activity, policy_changes, system_events and audit_events. Each selected category sends the audit rows in it as RFC 5424 messages with MSGID dlp-event (or as CEF records, for a CEF destination), separately from incidents. Categories are assigned by action name: user., role., step_up., service_account. and service_credential. are user activity; policy. and policy_ are policy changes; settings., tenant., signing_key., directory. and scim. are system events; everything else is an audit event. An unknown name is refused with 422 on categories, a repeated name is kept once, and an omitted field selects none, so the destination carries incidents only. The list response gives the supported names as event_categories, and each syslog destination reports its selection as syslog.categories. A CEF destination takes the same categories. Its record carries act (the action), cs1 (category), cs2 (target), cs3 (actor type), cs4 (tenant), cn1 (sequence) and suser (the actor, omitted when there is none). The RFC 5424 structured data holds tenant, category, sequence, action, actor (- when there is none), actor_type and target; the message text is the action and target. Rows are sent in sequence order, once each, and a failed delivery is retried with backoff (10, 20, 60, 180 and then 600 seconds) and never dropped. Switching a category off stops it, and switching it back on does not replay the rows written while it was off. Saving a destination starts it at the audit position of that moment, so earlier rows are not sent. Pausing a destination holds its rows and sends them on resume.
CEF over syslog (DLP-1773). A syslog integration takes format: "rfc5424" (the default) or format: "cef"; anything else is refused. In CEF the RFC 5424 frame is kept, with MSGID dlp-incident and no structured data, and the message is CEF:0|MyDLP|MyDLP|<version>|dlp-incident|<title>|<severity>|<extension>. Severity is 10, 8, 5, 3 or 1 for critical, high, medium, low and anything else. The extension carries rt (last seen, epoch milliseconds), externalId (the reference), cs1 policy, cs2 channel, cs3 detector ids, cs4 status (each with its csNLabel), cn1 risk score, cnt event count, and, where known, suser, shost and request (the console link). Header fields escape \ and |; extension values escape \, = and line breaks, so a title or user name cannot forge a field. As in every format, no preview or matched value is carried.
Trackers (INT-003). kind: "jira" and kind: "servicenow" are different from the others: nothing is sent automatically. A SIEM is where things are correlated; a tracker is where work is assigned to a person, and a product that files a ticket for every detection produces a queue nobody reads and a team that turns the integration off.
GET /api/v1/incidents/escalation-trackers incidents:write · id, name, kind of enabled Jira/ServiceNow
POST /api/v1/incidents/{id}/escalate {integration_id} · incidents:write · auditedThe trackers to escalate to are read from GET /incidents/escalation-trackers (DLP-641), which needs what escalating needs, incidents:write, and returns each enabled Jira or ServiceNow integration's id, name and kind only. The incident page used to read GET /integrations, which needs integrations:write, so the Security Analyst who triages incidents never saw Escalate.
It answers {external_key, external_url, created}. created: false means the incident had already been escalated to that tracker and the existing key is returned - the row is unique per (incident, integration), because a duplicate in a service-desk queue is somebody's afternoon. GET /api/v1/incidents/{id} carries external_tickets so the link survives in both systems.
Escalating writes a history line naming the ticket (field tracker), and an incident still new, triaged, investigating or awaiting_user moves to escalated through the ordinary status change; a contained or closed one keeps its status (DLP-1998). It is refused, 403, to somebody the incident is about, as every other decision on it is (DLP-1995).
A background pass reads each open incident's tickets back, at most 25 a minute and each at most every 15 minutes: Jira's status and its category, ServiceNow's state label. Each external_tickets entry carries external_state, external_done, state_checked_at and state_error. A state change is a history line (field tracker_state, actor tracker). A ticket that finishes (Jira category done; ServiceNow Resolved, Closed or Canceled) says so in that line and on the record, and suggests closing the incident. It never closes it: the tracker knows its ticket is done, not that the data is safe.
Configuration is base_url (https only - the credential travels on every call), user (Jira: the e-mail the API token belongs to; ServiceNow: the integration user), project_key and issue_type for Jira, table for ServiceNow, and field_mapping.
field_mapping is an allowlist, checked when the integration is saved. A Jira project usually has more members than the security team and a ServiceNow incident is visible to a service desk, so only fields this product already redacts may be mapped: reference, title, severity, risk_score, risk_band, status, channel, username, hostname, policy_name, event_count, match_count, detectors, first_seen, last_seen, console_url. Anything else is refused at configuration time rather than filtered at send time - filtering at send time fails open the first time somebody adds a field to the payload, and by then tickets exist. The ticket body says in so many words that it contains no matched values, because the people reading it did not configure the integration and would otherwise read the absence as an oversight.
The connection test is a read (/rest/api/2/myself, or one row of the ServiceNow table), never a ticket: a test that files a ticket to prove it can file a ticket leaves one behind every time somebody presses the button. It tells 401, 403 and 404 apart, because a wrong token, a token without permission to create in that project, and a project that does not exist are three different fixes that every tracker reports as "the request failed".
Delivery is visible to monitoring, on the operational metrics port: mydlp_integration_deliveries_pending, ..._failed, mydlp_integration_delivery_attempts, mydlp_integration_last_success_age_seconds and the average and maximum successful delivery duration over the last hour, each labelled by tenant and integration. The age is absent rather than zero for an integration that has never delivered, for the same reason as retention: a zero charts as "just now", which is the opposite of true. The maximum duration is the series that approaches the timeout first.
An incident is queued for delivery when it is created - after the commit, never inside it, because a customer's SIEM being unreachable must not be able to roll back the incident it is meant to report. A worker posts it.
- https only, and not as a preference. A delivery carries who did what on which machine; in clear that is a summary of the organisation's incidents readable by anybody on the path. The refusal says that rather than just "invalid".
- Every webhook request is signed:
x-mydlp-signature: sha256=<hex>, HMAC-SHA256 over the exact body under the shared secret. (A Slack delivery is not: Slack checks no signature, and its URL is kept as the secret instead.) Without it, anybody who learns the URL can post invented incidents into a customer's SIEM - a more interesting attack than reading one. The secret is stored as a secret reference and never returned, not in the listing and not in the audit entry. - An internal CA is configured, not verification switched off.
ca_certificateadds a trusted root for a receiver whose certificate comes from the organisation's own authority. There is deliberately no option to skip verification: that would be set once by somebody in a hurry and leave every future delivery readable in the middle. - The payload is built field by field, never by serialising the incident row, so a column added later cannot start travelling to a third party because nobody thought about it. It carries detector ids and not the redacted previews: a preview is redacted for a console an analyst is already trusted with, and a third-party system is a different audience with a different retention policy.
- Retried, not dropped: six attempts over about fifteen minutes (waits of 10, 20, 60, 180 and 600 seconds), persisted as rows so a restart does not lose an alert nobody is waiting for. This said "about an hour" until DLP-932. A delivery that fails its last attempt is set aside as
failed. No pass retries it on its own, and it can be replayed (below). One delivery per incident per integration - two identical alerts in a SIEM are two incidents to whoever reads them. - Health is reported:
healthy,degradedorfailed, with the last error and the counts of pending and failed deliveries. The failed count is the one that means alerts are not arriving.
webhook, syslog (RFC 5424 or CEF), splunk_hec, slack and teams deliver every incident; jira and servicenow escalate on demand. Those seven are the only kinds (teams and splunk_hec since DLP-1773): siem, email and s3 were removed from the schema rather than built (ADR-0040), and configuring one is refused with a sentence saying where the need is met.
A justification travels in a body, never an address (DLP-950)
Every read that asks why takes the reason in a JSON body: evidence, the named-people behaviour views, a person's risk score and baseline, and the subject access export. Each is a POST because each writes an audit entry. The GET form of each path still exists, and only to refuse. It checks the read's permission first, so a caller without it gets 403 as before, and then answers 400 naming the POST form. It never reads or repeats the query. A query string is written whole into a reverse proxy's access log (nginx's default combined format records the request URI), and a justification is free text that can name a person or a matter.
Refusing the GET form does not stop a proxy logging a GET that an older client still sends: the refusal arrives after the proxy has written the line. The proxy's own log format is the other half, and it is an operator's change (DLP-952). The hosted deployment made it on 2026-09-27: dlp.erpona.app logs the path without its query string, and the Referer without its query string (DEPLOYMENT.md, nginx). Four earlier lines carrying a justification were scrubbed in the rotated logs.
Subject access (GDPR Article 15)
POST /api/v1/subject-access/{username} {justification} subject_access:export · audited (DLP-766, DLP-950)Everything the system holds that identifies one person, as one document: their events, their incidents, the devices their activity was recorded on, the justifications they typed, and a record that somebody looked at their data.
Gated exactly like a risk score - the same tenant consent switch, the same minimum justification, the same audit entry - because producing this is the most person-specific act the product performs. The entry names the subject, so an export appears in the next export.
Three things it is careful about:
- It does not disclose anybody else. Only rows about the subject. Who looked is recorded and deliberately not named: that person is another data subject, and naming them would answer one Article 15 request by breaching another. The controller can answer from the audit log.
- One overlap is unavoidable and is declared rather than hidden. The name of a machine the subject used is part of their record, and an organisation that names machines after people puts somebody else's name in that field when two people share a device. Stripping it would withhold the subject's own data to hide a naming convention.
- It says what it does not contain, in
not_included, because a subject cannot ask about what they do not know exists. No file contents - the system records that a file matched a rule, never what it said. No matched values; those are redacted on the endpoint where they are produced.
What this is not is a case management workflow. There is no request register, no clock against the one-month deadline, no verification that the requester is the subject, and no erasure or rectification. Those remain the controller's process; this removes the part that previously meant querying several tables by hand and hoping none had been forgotten.
Per-person baselines (#477)
POST /api/v1/user-risk/{username}/baseline {justification} behaviour:read · auditedThis is a rolling statistical baseline. Not a model, not AI, not learning in any sense the word is normally used for: a set of things a person has done in a rolling 30-day window, and a comparison against that set. method carries the string rolling_statistical_baseline in every response so no console, report or datasheet can describe it as something it is not.
- The cold start is the design. Below 7 active days and 20 events the baseline is not used at all: deviation contributes zero, and the response says there is no baseline rather than reporting a deviation of zero. Those are different statements and an analyst must be able to tell them apart - a new joiner reported as "deviation 0.0" reads as reassuringly normal, which is the opposite of the truth.
- Today is excluded. Otherwise the operation being judged is part of the normal it is judged against, which suppresses exactly the sustained activity the comparison exists to catch.
- Built from events, not incidents. An incident is something a policy objected to; a baseline has to describe ordinary work. Built from incidents it would say "normal for this person is what we already flagged".
- Seasonality: volume is compared against this person's busiest single day in the window, never their average, so an ordinary month-end is not a departure. Day-of-week and longer cycles are not modelled; see
docs/KNOWN_LIMITATIONS.md. - Deviation feeds the existing risk model as the
baseline_deviationfactor (weight 0.05,risk-1.2.0), and appears in the factor breakdown like every other. Five points moved there fromrecord_volume: "more than this person has ever moved in a day" is a better version of the same signal than "a lot in absolute terms".
Behavior Analytics overview (DLP-1565)
GET /api/v1/behaviour/overview?days=30 behaviour:read · days 7 to 90, else 422The overview's whole-company figures, naming nobody:
high_risk_usersandhigh_risk_users_week_ago: people the confirmed-incident score puts high or critical, now and as the same incidents stood seven days ago.anomaliesandanomalies_this_week: incidents attributed to people whose risk a departure from the person's own normal raised (abaseline_deviationfactor above zero).linked_incidentsandlinked_incidents_this_week: incidents attributed to a person, created in the window (DLP-1788).GET /api/v1/incidents?person=attributed&origin=real&created_since=...lists exactly these; the console's card is "Incidents attributed to people".baseline:establishedandlearning, by the baseline rule's own thresholds (7 active days and 20 operations in 30 days, today excluded). Discovery scans are not a person's operations and are not counted (DLP-1806).trend: a point per day on the tenant's business-hours clock (DLP-1807):observed(the mean risk of that day's incidents attributed to people, or null),baseline(the median of the fourteen days before it),baseline_points(how many of those fourteen were observed) andreporting_endpoints(endpoints that sent any event that day).baselineis null when fewer thanbaseline_min_days(7) of the fourteen were observed (DLP-1791), and on a day with no observation (DLP-1792). A nullobservedwithreporting_endpoints0 is a day nothing reported; with more than 0, a quiet day.drivers: the anomalies divided by why (DLP-1793), one per departure the comparison names: new destination, new application, new kind of data, unusual hour and unusual data volume. Each counts the anomalies (asanomaliesdefines them) whosebaseline_deviationsentence names it, so no driver exceedsanomaliesand every anomaly is under at least one. One naming two departures is under both.context: figures beside the drivers, not anomalies and each with itsunit: uploads to a personal cloud (incidents with an upload to an unapproved destination), controls a person went past (operations), and transfers outside working hours (operations with a match outside the tenant's working day).triage(DLP-1796): over the same incidents aslinked_incidents:open,closed_last_7_days,untriaged(open and never triaged),oldest_untriaged_seconds, andmedian_minutes_to_triage, the middle time to first triage or closing, with an incident still waiting counted by its wait so far.
Test incidents are left out. high_risk_users, high_risk_users_week_ago and baseline are null while naming individuals is switched off. tests/behaviour_overview.sh asserts each figure.
GET /api/v1/behaviour/anomalies?days=30 behaviour:read · days 7 to 90, else 422The incidents anomalies counts, newest first, at most 200 (truncated says if there were more) (DLP-1797): incident_id, reference, created_at, severity, status, department, the endpoint's group, the departures its comparison named (the drivers' keys), and person, which is null while naming individuals is switched off. GET /api/v1/incidents?anomaly=true narrows the incidents list by the same predicate; with person=attributed, origin=real and the window it lists the same set.
POST /api/v1/user-risk also returns context, keyed by each listed person: department, hostname and last_activity from their newest operation, and score_week_ago.
Per-person risk (#479)
POST /api/v1/user-risk {justification} behaviour:read · audited
POST /api/v1/user-risk/{username} {justification} behaviour:read · audited
POST /api/v1/settings/risk behaviour:manage · auditedBehind the same tenant switch as the behaviour table, and refused with 409 while it is off: scoring a named person is strictly more sensitive than listing their activity. Every call needs a justification of at least five characters and writes a user_risk.viewed audit entry against user_risk:<name> or user_risk:all.
- Only confirmed incidents count - one closed with the disposition
confirmed_violation(DLP-1989), orcontainedwhile it is open, and never one closed as a false positive. A closure recorded as a business need, a coached mistake or benign does not count. One resolved before dispositions existed has none and counts as resolved.escalatednever counts: escalating asks for a decision and is not one (DLP-2025). Nor does data found at rest, on thediscoveryorcloud_storagechannel: a scan names whoever was signed in, or a file's owner, and neither moved anything (DLP-2029). The same rule is what the prior-history risk factor, the queue's priority and the overview's highest-risk users count. An open incident is a judgement nobody has made yet, and counting one would let a noisy rule build somebody a record. - The score always travels with its
shape.deliberateis the share of the weight from operations where a control was shown and the person continued;repeatedis the decayed count of incidents;suddenis the last week against that person's own preceding eight, and isnullwhen there is too little history to say.dominantnames one component only when it clearly leads, andnull- the ordinary answer - means the number alone does not say why. The brief's malicious, accidental and compromised want different responses, and one number cannot distinguish them. - Decay is the tenant's decision.
user_risk_half_life_days, default 90, bounded 7-1461. Below a week the score is noise; above four years it is a permanent record by another name. Changing it needs a stated reason and is audited with the old and new values, because shortening it erases history that was counting against somebody and lengthening it makes an old mistake count again. - Right of reply.
/user-risk/{username}returnscontributions: every incident that counted, its age, what it was worth when new and what it is worth after decay. Assume it will be disclosed in an employment dispute, because it will be. - Against their department (
DLP-1781, ADR-0052)./user-risk/{username}also returnspeers: the department named by the person's newest event, the number of people whose newest event in the window named it, the group's median and upper-decile score, andscores_above, the share of the others scoring lower (0-1). It contains no colleague's name or score. With fewer than five people (MIN_PEERS), or no department, every figure isnullandwithheldsays why. People excluded from naming are not in any group. The comparison does not change the score. - A missing input is reported, not omitted. Behavioural deviation against a learned baseline (#477) does not exist in this build, and the factor says so with a contribution of zero. An input that is absent looks like an input that was zero, and that is how a score gets over-trusted.
- A person with no confirmed incidents is scored zero rather than 404ing: "no such user" and "nothing counts against them" are different answers.
Behaviour analysis (#475)
GET /api/v1/behaviour?days=30 behaviour:read · names nobody
GET /api/v1/behaviour/groups?days=30 behaviour:read · names nobody
&disposition=suspicious one band of the bar, opened
POST /api/v1/behaviour/people behaviour:read · audited, needs a justification
{"days": 30, "justification": "Investigating INC-1043",
"user": "alice"} user: optional drill-through
POST /api/v1/behaviour/person behaviour:read · audited, needs a justification
{"days": 30, "user": "alice", "justification": "…"} one person, opened
POST /api/v1/settings/behaviour behaviour:manage · audited
POST /api/v1/settings/behaviour/active-time tenant:write · {enabled, scope, idle_after_minutes?, reason} · step-up active_time to widen · audited
POST /api/v1/settings/privacy/exclusions behaviour:manage · audited, reason required
POST /api/v1/agent/activity-time signed by the device · hour totals (DLP-1243)Active time (DLP-1243, ADR-0043, PRIVACY_MODEL §47). Off until an Owner turns it on.
- The switch:
POST /settings/behaviour/active-timetakesscope(working_hours, the default, oralways),idle_after_minutes(1-30, absent keeps the current value, 5 by default) and a reason of 5 to 500 characters in both directions.- Turning it on, or widening it to
always, asks for fresh proof (active_time). - It is audited as
behaviour.active_time_enabled,…_disabledor…_changed, with both states. - The bundle is signed again. While on, it carries
active_time: {idle_after_minutes, working_hours_only}; while off, no such field, and an endpoint then does not sample.
- Turning it on, or widening it to
- What an endpoint sends:
POST /agent/activity-timetakesActivityTimeReport:activebuckets{hour_start, username, application, host?, active_seconds}andidlebuckets{hour_start, username, idle_seconds}. Each is the running total for a UTC hour. The server keeps the larger of what it holds and what it receives, so a restarted endpoint cannot lower an hour.- The answer is
{measuring, stored, dropped_out_of_hours, rejected}.measuring: falsemeans stop and drop. - A bucket is refused, and counted in
rejected, when it:- is not on the hour;
- holds more than 3600 seconds;
- is in the future or older than 35 days;
- has no user or application;
- has a host that is not a bare host (a scheme, port, path or query is refused, not trimmed).
- A report naming another device is
400. - With usernames not collected, the hour is stored under no name.
- The answer is
- What the Behaviour routes add, all in seconds (the operation figures are unchanged):
GET /behaviourgainsactive_time:enabled,unit: "seconds",scope,idle_after_minutes;total_active_seconds,total_idle_seconds;- the four bands in
by_disposition(seconds,share); labels(seconds).
GET /behaviour/groups?unit=secondsopens a time band into its application and host pairs, withactive_secondsandtotal_active_seconds.Each
/behaviour/peoplerow gains:active_secondsanddaily_active_seconds(over the days with any time);active_time_days;time_share(the four bands);label_seconds.
The response gains
active_time_enabled. A person with time and no operations has a row./behaviour/persongainsactive_seconds,time_by_dispositionandtime_groups(the largest 100, withtime_groups_not_listed).
group=NAMEon/behaviour,/behaviour/groupsand/behaviour/peoplenarrows every figure, operations and time, to one agent group. A name with no devices gives zeros.
What a person's row carries (DLP-348). Beyond the counts and the disposition split: department names their team only when the window holds exactly one - somebody who moved mid-window, or whose events carry a department from one source and not another, has several, and naming one of them would put a person in a team they are not in. departments is how many, so a consumer can say "2 teams" rather than rendering a blank that reads as missing data. devices counts the distinct machines their operations came from.
operations_outside_working_day is a count of operations, not a duration, and it is named that way deliberately: it was outside_working_hours until DLP-355, which invited exactly the reading the paragraph above forbids. It is counted against the UTC intervals BusinessHours generates for the window, so it cannot disagree with an incident's own out-of-hours verdict - one definition of the working day, asked two different ways.
Who is never named (DLP-350). POST /settings/privacy/exclusions takes the whole list rather than a delta - an add-one endpoint invites a client whose view has drifted to keep adding to a list it cannot see - and requires a reason in both directions, because adding somebody and removing them are both decisions about one named person that may have to be explained later. The names are recorded in the audit entry; the aggregates they affect never see them.
GET /settings returns the names in privacy.excluded_from_naming only to a holder of behaviour:manage, the permission that changes the list. Every other reader gets privacy.excluded_from_naming_count instead (DLP-769): the settings need only system:read, which a Viewer holds, and the list exists so that people such as works council members are never singled out.
Three payloads carry a count of who was suppressed, never a name: users_excluded_from_naming on /overview, and excluded_from_naming on /behaviour/people and /user-risk. A console that renders nothing when the field is absent is correct: absent means an older server that cannot answer, and "0 people are excluded" invented from silence is a false reassurance about a privacy control. /behaviour/person and /user-risk/{username} refuse outright for an excluded person and say why.
Nothing here reaches an incident. The username stays on it, because that is the record of one event rather than a ranking - see docs/PRIVACY_MODEL.md §11.
The unit is operations, and unit travels in every payload so a console cannot render these as a duration by accident. This product has no session or application-usage telemetry yet. Active time was decided on 2026-09-28 and is not built (ADR-0043, DLP-1241). Its one window-focus read happens at the instant a clipboard copy is reported, produces a single process, and accumulates nothing - see docs/PRIVACY_MODEL.md §3 and §9. A figure in hours would be one nothing measured.
A person opens into what they did (
DLP-331)./behaviour/personreturns one person's bands, their applications and destinations largest first, and the samegroups_not_listedcount. It carries every gate/behaviour/peoplecarries and for the same reason - the tenant's switch, a justification of at least five characters, and an audit entry written before the response is returned, underbehaviour.person.viewedand targetinguser:<name>. Looking at somebody and finding nothing is still looking at them, so the entry is written whether or not the window held anything.The username is matched case-insensitively and reported as stored: an audit entry and a heading that disagree about capitalisation are two records of one person. Events carrying no username are never folded in, because attributing an unattributed event to the person being opened is a guess about somebody by name.
A share opens into what it is made of (
DLP-323)./behaviour/groupsreturns the application-and-destination pairs behind one disposition, largest first, withtotal_operationscounting every group rather than only the hundred returned - so the figure still agrees with the tile that opened it, andgroups_not_listedsays how many are behind the ellipsis. It names nobody, for the same reason the summary does not: the question "what is our suspicious traffic?" should not require a justification, and the question "what has this person been doing?" is the table below, which does.The summary names nobody and works on every tenant. It returns all four dispositions, always, in a fixed order, summing to
total_operations; on an empty tenant everyshareis0.0rather than a NaN that reaches a console asnull.truncated: truemeans more distinct (user, application, destination) combinations than one pass folds, so the proportions are incomplete. A console must withhold them rather than annotate them./behaviour/peopleis refused with 409 until the tenant enables it, not 403: the caller has the permission, and this is the tenant's own switch. Enabling it throughPOST /settings/behaviourrequires a reason of at least five characters and is audited asbehaviour.per_user_enabled; disabling needs none.Every call to
/behaviour/peoplewrites abehaviour.viewedaudit entry carrying the justification verbatim, the window and how many people were listed - including a call that found nobody, because looking and finding nothing is still looking. The target isuser:<name>for a drill-through andusers:allotherwise.daysis 1-90. Events default to 90 days of retention, so a wider window would return less than it claimed.
Activity categorisation (#476)
GET /api/v1/activity-rules behaviour:read
GET /api/v1/activity-rules/preview behaviour:read
?application=chrome.exe&destination=https://example.com/x
POST /api/v1/activity-rules behaviour:manage · upsert on (kind, pattern)
DELETE /api/v1/activity-rules/{id} behaviour:manage
POST /api/v1/activity-rules/restore-defaults behaviour:manageA rule maps a pattern - a process file name, or a host - to one of business, non_business, suspicious, uncategorised, with an optional tenant-defined label grouping.
behaviour:readis held by Owner and Admin only, not by Security Analyst. Investigating an incident is asking what happened to some data; behaviour analysis is asking what a person did.suspiciousis refused without anote. It is the one category with a human consequence, and it may only come from a rule somebody wrote down and explained.- Matching: a host matches its own pattern or any parent domain (
mail.google.commatchesgoogle.com,notgoogle.comdoes not), longest pattern wins, and the destination decides over the application. An application is matched on its file name whatever path it arrived with. A path destination (/media/usb0/…) is not a host and matches no destination rule. previewruns the same categoriser the aggregation runs and returns the rule that decided, so a rule can be checked against a real value before an employee's row on a dashboard changes colour.- Upsert, not create-or-conflict: the natural gesture is "treat this domain as business", and the audit entry records what the rule said before as well as after.
restore-defaultsnever overwrites a tenant's own rule for a pattern the product also ships an opinion on.
Every write is audited: activity_rule.set, activity_rule.removed, activity_rule.defaults_restored. Deleting a shipped rule records a tombstone for its pattern (activity_rule.removed says "tombstoned": true), so a deleted shipped rule stays deleted across restarts and releases. The start-up pass does not put it back. restore-defaults is the way back: it clears the tombstones and its activity_rule.defaults_restored entry names them under cleared. The start-up pass writes when it corrects a shipped pattern nobody has edited: one activity_rule.defaults_reconciled entry, attributed to the system actor and naming what changed (DLP-2317). A start that changes nothing writes nothing. A deletion made before this build left no tombstone, so the first start after it puts that one shipped rule back once, and that entry names it. See docs/PRIVACY_MODEL.md §9 for what this is built on and, more importantly, what it is not.
Endpoints and enrollment
GET /api/v1/agents · GET /api/v1/agents/{id} · POST /api/v1/agents/{id}/revoke
GET /api/v1/agents/{id} also carries channel_last_event: {channel: latest occurred_at} for the events retention holds; a channel with none is absent (DLP-1312)
GET /api/v1/agent-groups agents:read · {id, name, description, agent_count}
POST /api/v1/agent-groups {name, description} agents:write · audited (DLP-911)
PATCH /api/v1/agent-groups/{id} {name, description} agents:write · rename · audited
DELETE /api/v1/agent-groups/{id}?reason=... agents:write · retire · audited
POST /api/v1/agents/group {device_ids, group_id|null} agents:write · move · audited
GET /api/v1/enrollment-tokens · POST to create {max_uses, ttl_minutes, agent_group}
POST /api/v1/enrollment-tokens/{public_id}/revoke {reason} agents:enroll · auditedEndpoint groups (DLP-911, DLP-912). A group is created, renamed, retired, and given endpoints through the API; there used to be only default, made at bootstrap.
- Names are 1 to 64 characters, and one that differs from another only by case is 409.
- A policy scope names a group by its name, and an endpoint matches on the name it holds. So a rename is 409 while a policy's latest version names the group, listing those policies.
- A retire needs a
reason. It is 409 while an endpoint is in the group, a live enrolment token would put endpoints in it, or a scope names it. The message lists each blocker. - Moving endpoints touches only this organisation's endpoints and only those whose group changes. It answers
{moved, unchanged, group}, and the singleagent.group_changedentry lists each endpoint with where it came from. - Each check-in now says which group the endpoint is in (
CheckInResponse.assigned_group). The agent adopts it and persists it before evaluating anything, so a move takes effect at the endpoint's next check-in. An agent used to hold the group it enrolled with for ever. - A scope naming a group that does not exist, or one with no active endpoint, is published with an
agent_group_missingoragent_group_emptycoverage warning, and the list carries it. Audit actions:agent_group.created,agent_group.renamed(from, to),agent_group.retired(name, reason) andagent.group_changed.
Enrolment tokens for a fleet, and revoking one (DLP-907). A token may enrol 1 to 1,000 machines and live from a minute to a week. Minting one for more than one machine asks for the enrollment_token step-up, because a fleet token held in a GPO share is readable by every authenticated user in the domain. agent_group must name an existing group: an unknown name is refused with 422 (it used to bind the token to no group, silently). Creation is audited as enrollment_token.created with the uses, lifetime and group. Revoking needs a reason of at least five characters and no recent sign-in, because it only stops a credential. It answers 409 for a token already revoked and 404 for one that does not exist, is audited as enrollment_token.revoked with the reason and the uses left, and the next enrolment with that token is refused like any unknown one. The list carries agent_group, revoked_by_email and revoke_reason.
Ending an endpoint's protection is its own permission (DLP-763, ADR-0032). revoke, retire, deactivate and uninstall need agents:decommission. agents:write keeps troubleshooting, diagnostics, restarts, updates and reactivation, so a helpdesk role can be given those without being able to remove protection. Security analyst, Admin and Owner hold both. At the first start of the release that introduced it, every stored custom role and every live API token holding agents:write was granted agents:decommission, once and audited (role.permission_split, api_token.permission_split), so nothing that could decommission before stopped being able to.
The token list never returns token values - only public ids. There is no path by which an issued token can be retrieved.
Agent packages
GET /api/v1/agent-packages
GET /api/v1/agent-packages/{platform}/download audited
GET /api/v1/agent-packages/{platform}/config generated agent.toml, no secretPlatform ids come from a fixed table, never from the request, so path traversal resolves to nothing.
Settings
GET /api/v1/settings
GET /api/v1/settings/display-preferences system:read · {timezone, date_format, time_format}
POST /api/v1/settings/display-preferences tenant:write · audited
GET /api/v1/settings/email-delivery system:read · SMTP status, never credentials
POST /api/v1/settings/email-delivery/test tenant:write · sends only to the signed-in user · audited · 1/minute
GET /api/v1/settings/notification-template system:read · tenant incident-digest template, no mail secrets
POST /api/v1/settings/notification-template tenant:write · validates allowlisted variables · audited
POST /api/v1/settings/monitored-paths recompiles and re-signs the bundle
POST /api/v1/settings/business-hours tenant:write · audited
POST /api/v1/settings/retention/preview tenant:write · what the next pass would remove
POST /api/v1/settings/retention tenant:write · step-up · audited
POST /api/v1/settings/retention/run tenant:write · run a pass now
GET /api/v1/settings/evidence-storage tenants:manage · where sealed copies go
POST /api/v1/settings/evidence-storage tenants:manage · step-up evidence_storage · auditeddate_format is dd_mmm_yyyy, yyyy_mm_dd or mm_dd_yyyy; time_format is 24h or 12h. timezone must be an IANA time-zone name. The console uses these preferences for its shared timestamp displays; they remain separate from business_hours.timezone, so changing display defaults does not move an SLA window.
The SMTP relay remains deployment-operator configuration (DLP_SMTP_URL, DLP_SMTP_PASSWORD_FILE, DLP_MAIL_FROM). The read endpoint returns only its non-secret status and connection metadata. A test email can be sent only by a dashboard session with tenant:write, only to that user's own account address, and at most once per minute per user. API tokens cannot send test email.
Where copies of matched files are kept (DLP-1188, ADR-0038)
jsonc
POST /api/v1/settings/evidence-storage
{ "target": "s3://evidence/keep?endpoint=https://s3.example.com®ion=eu-west-1",
"credential_ref": "env:DLP_EVIDENCE_ACME_S3", "max_file_bytes": 10485760,
"reason": "legal asked for copies under matter 1188" }- Owner only. It needs
tenants:manage, which no other built-in role holds, a recent sign-in (step-upevidence_storage), and a reason of five characters or more. It is audited assettings.evidence_storage_changedwith the old and new target, reference and cap. - The credential is a reference, never a key. It is
env:DLP_EVIDENCE_…. The variable holds base64 ofaccess_key_id:secret_access_key, and the operator assigns it to this organisation under"evidence"inDLP_TENANT_DISCOVERY_SCOPE. A discovery key, another organisation's variable, a variable that is not set, and aninline:key are each refused with 422. - The cap is 1 byte to 50 MiB, 10 MiB by default.
enabledis the Owner's switch (DLP-1189),falseunless sent. Configuring where copies go and deciding that copies are kept are two acts. While it is off, an upload is refused with 409 and nothing is written. Copies already kept stay until their retention runs out, and can still be downloaded.GETanswersconfigured, and when it is, thetarget, thecredential_ref(the reference, never the value),max_file_bytesandenabled. It also answersdelete_failures, the copies whose deletion the store has refused.
A policy may set evidence.full_file only while the switch is on: otherwise the policy API refuses it at create, submit and publish (DLP-1158, narrowed in DLP-1190). What a copy is and when it is deleted is in PRIVACY_MODEL §39.
/settings/retention/run also runs the evidence expiry pass for the tenant and answers its counts as evidence_files: {deleted, failed, held}. GET /system/health carries retention.evidence_file_delete_failures.
Retention windows (DLP-699)
jsonc
POST /api/v1/settings/retention
{ "events": 30, "incidents": 180, "audit": 365, "reason": "DPA review" }The windows are in days.
- Events and poison events follow
events. - Closed incidents and discovery findings follow
incidents. An open incident is never removed, whatever its age. - Audit entries past
auditare moved to the archive, not deleted (DLP-55). - Health history is not a window. A resolved fault is removed once it is 90 days past resolution and no alert notice still refers to it. A finished alert notice is removed 90 days after it was queued, except the newest notice of each rule (
DLP-2246). An open or acknowledged fault and a pending notice are never removed. The counts areremoved.health_events_deletedandremoved.health_deliveries_deleted. - Floors: events 7, incidents 30, audit 30. Ceiling: 3650 for each. A value outside them is refused with 422, naming the field and the bound. A window stored under its floor around this route - a direct write, an older build, an import - is kept at the floor, and
GET /api/v1/settingsand the activity explorer'sevent_retention_daysreport the window kept, not the stored number (DLP-2367).
/preview takes the same three windows and answers with what a pass would remove now, counted with the pass's own conditions: events, poison_events, discovery_findings, incidents, open_incidents_kept and audit_entries_to_archive. It is what the console shows before a change is confirmed.
A change needs a reason of five characters or more and a recent sign-in (step-up action data_retention), because a shorter window is a deletion that the next pass performs. Only retention_days is written, so a concurrent change to another setting is not undone. The change is audited as settings.retention_changed, with the old and new windows, the reason and what the next pass will remove. Sending the current windows again changes nothing and records nothing ("changed": false).
Paths must be absolute; a filesystem root is refused.
Each path carries enforce_opens (default false), which turns on open enforcement for that directory on Linux endpoints (#387). It is refused on a filesystem root and on system directories - /usr, /etc, /var and the rest - because enforcing there multiplies the cost of every program start on the endpoint, and the refusal says so rather than only saying no. Monitoring those paths is unaffected; only enforcement is refused.
Setting it nowhere is the remote kill switch: an agent stops enforcing on its next check-in, through the same code path that starts it. What an endpoint is actually doing is reported in its capability matrix at check-in, not inferred from this setting - an agent without CAP_SYS_ADMIN reports that it cannot enforce, and says that in those words.
Working hours (#583)
jsonc
POST /api/v1/settings/business-hours
{
"timezone": "Asia/Baku", // an IANA name; an offset is refused
"start_minute": 540, // minutes from local midnight, so 09:00
"end_minute": 1080, // 18:00; an end before the start is a night shift
"days_of_week": [0,1,2,3,4], // 0 = Monday; [] means every day
"windows": [ // optional (DLP-2069); when sent, the working day
{"day": 4, "start_minute": 540, "end_minute": 690},
{"day": 4, "start_minute": 840, "end_minute": 1080}
]
}windows sets several windows a day: a Friday with a prayer break, a short Thursday. When present they are the working day, a day with none is not worked, and the three fields before them are not read. Each window is a day 0-6 and two times under 1440, with a length; a window ending before it starts runs past midnight and belongs to the day it starts on. Windows that overlap, including one running past midnight into the next day's first and Sunday night into Monday, are refused with 422 naming both. The incident's off-hours sentence names every window ("Fri 08:00-11:30, 14:00-17:00"), and behaviour and active time count against the same windows.
What this decides is the off_hours risk factor and the clock a person's baseline hours are counted on. Before it existed both were UTC, which is correct for exactly one timezone: at UTC+4 an ordinary 09:00 start is 05:00 UTC, so every normal working morning scored as out of hours and said so in the incident, about a window nobody had configured.
Until a tenant saves its hours, the off_hours factor is not judged (DLP-2028): it adds nothing and its sentence says the hours were not set. The defaults the settings show (UTC, 08:00-19:00, Monday to Friday) are a placeholder, and GET /settings says so with business_hours_set: false. Saving through this route marks the hours as chosen, the exact defaults included. Hours stored before the marker existed count as chosen when they differ from the defaults. The other consumers of the hours (the day figures are bucketed on, active time, behaviour's out-of-hours counts) are unchanged, and still use the defaults until the tenant saves. One window per day; several are DLP-2069.
Its zone is also the day every per-day figure is bucketed on (DLP-1807): the trends of GET /overview, GET /overview/security and GET /behaviour/overview, "new today", the baseline coverage on GET /behaviour/overview, and a person's active days on Behaviour. A Baku tenant's day runs midnight to midnight in Baku; counted in UTC it ran 04:00 to 04:00, and an incident at 02:00 was drawn on the day before. Figures an endpoint reports by day (not_inspected_7d) keep the endpoint's UTC days, because the bundle does not carry the setting.
- All four fields are required, and no others are accepted. A body the server cannot read is refused with 422 rather than deserialised to the defaults - which would store a successful-looking change to the very setting being corrected.
- The zone must be an IANA name.
GMT+4is refused, because an offset cannot follow daylight saving and a window written against one is wrong for half the year wherever it is observed. - A window of zero length, a
start_minuteover 1439 or a day above 6 are refused, each with the reason. A setting that is accepted and then ignored is worse than one that cannot be set. - The default is UTC 08:00-19:00 Mon-Fri, and is stated rather than assumed - a tenant reading its settings can tell it has not chosen.
- Changes are audited as
settings.business_hours_changed, with the old and new windows in words, because an incident raised last month was scored against whatever was set then. - The verdict travels into the incident: the factor reads "Occurred at 03:00 +04, outside 09:00-18:00 Mon Tue Wed Thu Fri Asia/Baku." rather than asserting "outside business hours" and leaving the reader nothing to check it against.
- The endpoint does not judge this. The policy bundle does not carry the setting, so the agent leaves
off_hoursfalse and the server - which knows the answer - scores the factor once.GET /user-risk/{u}/baselinereports the zone itsactive_hoursare counted in for the same reason: a figure of "works 05:00-14:00" for somebody in Baku is what those hours look like counted in UTC, and an analyst could repeat it to their manager as a finding.
Audit and system
GET /api/v1/audit ?action&action_prefix&target&actor&reference&from&to&limit&offset
action_prefix: actions starting with it, e.g. policy. (DLP-1474)
reference: entries naming that incident reference, and the
retention.enforced pass that deleted it (DLP-1980)
GET /api/v1/audit/verify recompute and report the first break
GET /api/v1/audit/export audit:export · the filtered log with its chain fields (DLP-946)
POST /api/v1/audit/checkpoint take a signed checkpoint audit:read
GET /api/v1/audit/checkpoint the ones this server holds
POST /api/v1/audit/checkpoint/verify check one you kept
POST /api/v1/audit/anchor audit:read · stamp the head with the timestamp authority now (DLP-1185)
GET /api/v1/audit/anchors audit:read · every stamp, with its token
GET /api/v1/audit/anchors/report audit:read · every stamp checked again
GET /api/v1/system/health
GET /api/v1/system/health/components health:read · one verdict per part the platform runs on (DLP-2181); a request that cannot get a database connection within two seconds to read its session is refused with 503 `server.database_unavailable` and gets no verdict (DLP-2220)
GET /api/v1/system/health/classification health:read · the endpoints' classification queues: choices waiting and the age of the oldest, from what each endpoint last reported; `status` is `unconfigured` with no classifications, `unknown` when no endpoint has reported within the hour, `degraded` past 900 seconds and `critical` past 3600 (both configurable by `DLP_CLASSIFICATION_QUEUE_*`); `processing_p50_secs` and `processing_p95_secs` are the nearest-rank median and 95th percentile of how long deferred choices waited before they were written, pooled across the endpoints that reported in the hour, with `processing_observed_at`; they are null and `processing_measured` is false until one has been written (DLP-2200, DLP-2281)
GET /api/v1/system/health/integrations health:read · each external connection (directory, SMTP, SIEM and SSO) judged from what was recorded: Unconfigured when nothing is set up, Unknown when nothing has been recorded, Warning with a fixed code after a failure, Healthy after a recorded pass; the stored error text never leaves the server (DLP-2188)
GET /api/v1/system/health/metrics health:read · resource samples over 30m, 24h or 7d, at most 360 points a metric (DLP-2182)
GET /api/v1/system/health/agents health:read · every endpoint in one page list, with its silence, sequence gaps and reported load; `status=problem` for those needing attention (DLP-2184)
GET /api/v1/system/health/agents/{id}/interruptions health:read · one endpoint's reported interruptions, newest first (DLP-2185)
GET /api/v1/system/health/events health:read · health issues recorded from first reading to recovery, newest first; `state=open|acknowledged|resolved`, `cursor` (the id of the last event on the previous page), `limit` 1–200 (default 50); each event carries its code, severity, state, resource, owner and opened/resolved times (DLP-2190)
POST /api/v1/system/health/diagnostics health:manage · make a support bundle (DLP-2197): a zip of four files (manifest, component verdicts, resource samples over 30m, health-event codes), at most 2 MiB, built within ten seconds and held for an hour; 201 with `id`, `expires_at`, `bytes`, `sha256` and `download_url`
GET /api/v1/system/health/diagnostics/{id}/download health:manage · the bundle's zip, handed over once (`application/zip`, `no-store`); 409 when it was already downloaded or has expired, 404 for an unknown id or another organisation's
PATCH /api/v1/system/health/events/{id} health:manage · {owner?: user id or null, acknowledged?: bool}; a resolved event is refused with 409, an owner outside the organisation with 400, and each change is audited as `health_event.updated` (DLP-2190)
GET /api/v1/system/health/alert-rules health:manage · the organisation's health alert rules, each with its last notice's status, its last error and how many notices have failed (DLP-2192)
POST /api/v1/system/health/alert-rules health:manage · {condition_code?, min_severity: critical|warning, channel: email|webhook|syslog, email_to? (email only), integration_id? (webhook or syslog only), cooldown_minutes? 5-1440 (default 30), enabled?}; an unknown condition, a missing or wrong target, a disabled integration or one of another kind is refused with 400; audited as `health_alert_rule.created` (DLP-2192)
PATCH /api/v1/system/health/alert-rules/{id} health:manage · {condition_code? (null for any fault), min_severity?, cooldown_minutes?, enabled?}; the channel and its target are fixed at creation; audited as `health_alert_rule.updated` (DLP-2192)
GET /api/v1/system/health/alert-rules/options health:manage · the values a rule form may offer: condition codes with their labels, severities, channels, and the cooldown bounds and default (DLP-2193)
POST /api/v1/system/health/gaps/{id}/accept health:manage · {reason}: a non-blank reason of at most 500 characters, required; accepts the loss of an unfilled sequence gap, takes it out of the open count and the open-gap fault, and is audited as `sequence_gap.accepted` (DLP-2194)
POST /api/v1/system/health/alert-rules/{id}/test health:manage · sends one test notice through the rule's channel and returns {ok, detail}; `ok` is what the channel answered, not proof that anyone read it; writes no notice row and is audited as `health_alert_rule.tested` with ok and channel only (DLP-2193)
GET /api/v1/system/license system:read · verified signed product licence status and tenant usage
GET /api/v1/system/backup system:read · on-prem pgBackRest snapshot and manual-worker state; no restore operation
POST /api/v1/system/backup/run tenant:write + human step-up · queue one opt-in full/incr backup for the operator-owned worker
POST /api/v1/system/backup/restore-preflight tenant:write + human session · {label}: reports whether a restore of a listed backup would be allowed now and audits the attempt as `backup.restore_preflighted`; queues nothing and runs nothing
GET /api/v1/system/tls-certificate/pem system:read · download only the parsed public PEM chain from the operator-provided certificate file; no private key, live TLS verification or replacement
GET /api/v1/system/tls-certificate system:read · bounded public PEM inventory; no private key, live handshake or replacement
POST /api/v1/system/tls-certificate/preflight tenant:write + human session · {pem}: reports whether that chain could be put in place now and audits the attempt as `system.tls_certificate_preflighted` with the leaf's subject and SHA-256; never stores the PEM, writes a file or touches the terminator
GET /api/v1/system/rate-limits per-device throttle counters
GET /api/v1/system/open-sourceGET /api/v1/system/health also reports bundle_ttl_hours, the signed-policy validity configured on the server by DLP_BUNDLE_TTL_HOURS (default 168). When that window expires offline, an endpoint continues enforcing its last verified bundle and reports it as stale; the value is informational, not a tenant-editable cache control.
GET /api/v1/system/health includes backup from the same bounded, operator-managed pgBackRest status snapshot as GET /system/backup. The summary gives snapshot_status, condition, observed_at, the last completed backup's time/size/age and the tenant's warning/critical ages. A connected repository without any completed backup is critical, as are an invalid or unavailable snapshot and a repository error. A stale snapshot is at least warning; the last completed backup reaches warning at 24 hours and critical at 48 hours by default. not_configured is an advisory, not Healthy. The server-authored Health issue links to Backup & Restore; this is not evidence of a successful restore drill and exposes no repository key or path.
POST /api/v1/system/backup/run accepts { "kind": "full" | "incr" } and returns HTTP 202 with request_id and state: "queued". The host operator must configure the request/status paths and bound tenant, and the separate postgres worker must have checked in recently; otherwise the action is refused. The API writes a fixed-shape request, never runs pgBackRest, and never accepts a repository path, secret, command or restore action. A pending/running job or one whose result is less than an hour old is refused. The manual object returned by GET /system/backup reports queue_configured, pending, and the last sanitized worker outcome. queued and succeeded are distinct: the existing backup history is still verified independently through the pgBackRest status snapshot, and neither proves a restore drill.
POST /api/v1/system/backup/restore-preflight is a dry run for a restore. It refuses unless the pgBackRest repository is connected, the on-prem worker has checked in, no manual backup is queued or running, and the label is in the current listing. Scheduled backups are not visible to the API, so the in-progress rule covers manual backups only. The response is {allowed, refusals, request_id} with stable refusal codes. There is no restore route: the "Restore system" control stays disabled, and the restore rehearsal is performed by an operator on the host.
POST /api/v1/system/tls-certificate/preflight takes {pem} and refuses unless it is one to eight CERTIFICATE blocks with no key or other material, the first certificate is valid now and not within 30 days of expiry, it is not a CA, the host of DLP_PUBLIC_URL is configured and covered by its DNS names (a *. name covers one label), and each certificate is issued by the one after it. It does not verify the chain to a trusted root, match a private key, or observe what the terminator serves, so allowed: true is not a claim that the replacement will work.
high_availability is a bounded, read-only PostgreSQL observation: state is not_configured, streaming_observed, disconnected or unknown; the summary also includes expected, connected and streaming replica counts, physical slot count and nullable replay_lag_ms. The expected count comes from the host's DLP_HA_EXPECTED_REPLICAS (default 0). Only WAL senders attached to a physical slot or named by DLP_HA_REPLICA_APPLICATION_NAMES are counted; a logical replication sender is not automatically a standby. When neither an expected count nor a selected sender exists, Health says not_configured, not healthy; a physical slot alone is not proof that a standby was configured. A configured standby that is not streaming adds a shared warning issue. The PostgreSQL replay-lag field can be NULL on an idle caught-up standby or for a database role without statistics privileges; NULL is not interpreted as zero. This primary-side observation does not prove failover readiness.
ingest_baseline compares the rolling events_last_24h receipt count with up to seven completed calendar days in the tenant's display time zone (or its business-hours zone, then UTC). The tenant's creation day is excluded. It returns {previous_events, complete_days, average_per_day, change_percent, trend, timezone}; numeric averages/changes are null with fewer than three complete days or no prior events. trend is insufficient_history, no_baseline, normal, or low. A low trend adds one shared warning issue only when expected daily volume meets the tenant's minimum and the current count has dropped by at least its configured percentage. Defaults are 20 events/day and a 50% drop. Neither a young tenant nor one with zero historical events is treated as an ingest failure. The console accepts an absent field when talking to an older server during a rolling update.
GET /api/v1/system/health/components gives a verdict for each part, in this order: api, database, correlation_worker, queue, evidence_storage, scheduler, licence, certificates, tls_terminator and classification_queue. Each has status (healthy, degraded, critical, unknown or unconfigured), latency_ms (null for api, which is not measured, and for tls_terminator, which reads a file rather than the database), checked_at and last_error_code, which is null when the part is healthy. A database that does not answer within 2 seconds is critical, with reason timeout. Any other part whose reading fails is unknown, never healthy. evidence_storage, scheduler and licence are unconfigured until set up, not failed. The reads run concurrently, each bounded at 2 seconds, and the answer is cached for 10 seconds per organisation, so a repeat read inside that window returns the same checked_at. Operating-system metrics and the OCR queue are not included; GET /api/v1/system/health/metrics (below) reads their history.
scheduler judges the periodic jobs' own records of their passes, and then the report schedules. A job whose last finished pass is more than three of its intervals old is critical with job_stopped; a pass that started more than an hour ago and has not finished is critical with job_hung, which also catches a pass that is slow but still working; a job with no record at all is unknown with job_not_recorded. Otherwise an overdue report schedule is degraded with schedules_overdue, and with no schedules set up it is unconfigured. The jobs judged are housekeeping, and audit anchoring where a timestamp authority is configured; both run every five minutes.
licence judges the signed licence this organisation is bound to (DLP_LICENSE_FILE). It is healthy while the licence is in date and seats are under 80% of the limit; degraded inside 30, 14 or 7 days of expiry (expires_within_30_days and so on) or from 80% of a seat limit (seats_near_limit); critical once it has expired (expired), at 100% of a seat limit (seats_exhausted), or when the file does not verify for this organisation, including when it has been altered (invalid). A file signed for another organisation is invalid here. A file that cannot be read is unknown with reason unreadable. certificates is the earliest expiry of the device CA, the active signing key and the agent certificates still in use: degraded inside the same windows, with reasons such as device_ca_expires_within_30_days, and critical once one has expired (signing_key_expired). classification_queue judges the endpoints' deferred classification choices (DLP-2200): the age of the oldest choice waiting on any endpoint that has checked in within the hour, as that endpoint last reported it, plus the time since. It is unconfigured with no classifications, unknown with no_endpoint_reporting when no endpoint has reported a queue in the hour, degraded as queue_lagging past 900 seconds and critical as queue_stalled past 3600. A deep queue whose oldest choice is recent is healthy, since it is draining. Endpoints built before this report carry no queue, and are not counted as empty. Its processing figures are the pooled waits of the deferred choices written; they do not change the verdict, which still rests on the oldest waiting choice alone. tls_terminator judges the public chain an operator copies to DLP_TLS_CERTIFICATE_FILE. It is never healthy, because a file cannot show what the terminator serves: it is unknown with not_verified_serving while the chain is in date and outside the warning windows, and unknown with external_inventory_not_configured when no file is set. Once the chain nears or passes its end it takes the same warning and expiry verdicts as the other parts.
GET /api/v1/system/health/metrics?window=30m|24h|7d (default 30m) returns the history a sampler wrote on each tick (a minute apart by default), for cpu_percent, memory_percent, disk_percent, database_bytes, queue_depth and queue_oldest_seconds, in that order. Any other window is a 400. bucket_seconds is the width of each point: 60 for 30 minutes, 300 for 24 hours and 1740 for seven days, so no metric carries more than 360 points. A point is the largest figure in its bucket; a bucket with no sample is absent, which is a gap and not a zero. Each metric has latest (value, sampled_at), status and reason. status is healthy, degraded, critical or unknown for the judged metrics, and null for the reported ones (CPU, database size, queue depth). A latest older than three minutes is unknown with reason no_recent_sample, whatever verdict its old figure would have earned. A judged figure is unknown with the reason the sampler gave, such as not_configured for the disk when DLP_DATA_VOLUME is unset. Memory is degraded from 85% and critical from 95%; the disk is degraded from 80% and critical from 90%; queue_oldest_seconds is degraded above 900. The queue figures are the caller's organisation's own; the host figures are the same for every organisation on the installation.
GET /api/v1/system/health/agents lists the tenant's endpoints by hostname, limit at a time (default 50, at most 200). status=all (the default) returns every endpoint; status=problem returns those that need attention: an open sequence gap, or, unless the endpoint was stopped on purpose or is revoked or uninstalled, a health state other than healthy or no current check-in. The filter is applied in the database, not to a page of results. Each item carries state (reporting, stopped_explained, stopped_unexplained or never_reported, judged with the tenant's silence thresholds), health_state, last_seen_at, agent_version, policy_version and current_policy_version, the load the agent reports, and the counts of open gaps and interruptions. A figure the agent did not send, or a policy version of 0 (never sent a bundle), is null, never zero. next_cursor is the id of the last endpoint on the page; pass it back as cursor for the next, and it is absent on the last page. total counts the endpoints matching the filter across all pages. A cursor that is not an endpoint in this organisation is a 400, as is an unknown status.
Every page also carries summary, which counts the active endpoints of the whole tenant whatever the filter or page: versions is each agent version with the number of endpoints running it, most common first, and drifted is how many of those endpoints have an applied policy bundle below the tenant's current one. An endpoint stopped on purpose, revoked or uninstalled is not counted.
GET /api/v1/system/health/agents/{id}/interruptions returns the interruptions one endpoint has reported, newest first, limit at a time (default 50, at most 200). Each has reported_at, kind, detail, last_alive_at, gap_seconds and cause, each null where the agent did not report it. total counts every interruption the endpoint has reported. An endpoint outside this organisation, or an unknown one, is a 404; a malformed id is a 400.
The response's overall_status, evaluated_at and issues are one tenant-scoped evaluation shared by the health page and navigation rail. Each issue has a stable code, kind (fault, advisory or unknown), severity, operator-facing title and impact, an optional console target, and an actionable flag. unknown means a required reading is unavailable or a configured archive/authority has not yet succeeded; unknown checks are never reported as healthy. Healthy means all evaluated checks are known and there are no current issues. Silence becomes critical only after the tenant's alert threshold; unexplained endpoints below that threshold are warning advisories. Open event-sequence gaps remain critical until someone accepts their loss (POST /api/v1/system/health/gaps/{id}/accept); an accepted gap is counted apart and no longer holds the fault open. Unconfigured delivery, archive and timestamp authority are warning advisories. The cumulative poison_events count and failed correlation history remain diagnostics, not current issues, so old rejected records do not keep the overall status degraded.
The response's endpoint_states is the canonical reporting count: total is the sum of reporting, stopped_unexplained, stopped_explained, and never_reported. These counts and the detailed silence panel come from the same classifier snapshot and use threshold_seconds. Explained stops never count as reporting. The legacy agents_total, agents_active, and agents_stale fields remain available with their prior inventory/status and meanings, except that agents_stale counts devices silent past the tenant's listing cutoff (DLP-2177); clients should use endpoint_states for reporting.
GET /audit returns {items, total, limit, offset}, newest first. limit is 1–500 (default 100). total counts exactly what the same filters page through (DLP-721). action and target match exactly. actor matches any part of the stored actor, an API token's current name, or its human owner's email, ignoring case; % and _ are literal characters, not wildcards. An entry whose actor is an API token also carries current inventory context as api_token: {id, name, owner, scopes, last_rotated_at}. The immutable actor: api_token:<id> remains unchanged and is what the hash chain covers; renaming, rotating or revoking a token therefore cannot rewrite history. If the inventory record is unavailable, clients fall back to that raw actor. from is inclusive and to exclusive (RFC 3339), so two windows that meet at an instant cover every entry once. The console pages 50 at a time and sends a picked end date as the start of the following day.
Exporting the log for review (DLP-946). GET /audit/export takes the same filters as GET /audit (action, target, actor, from, to) and after_sequence. It answers the matching entries oldest first, at most 10,000 (cap), with next_after_sequence set when more match. Each entry carries what its hash covers: sequence, prev_hash, entry_hash, metadata, and hashed_occurred_at, the timestamp exactly as it was hashed. It needs its own permission, audit:export, which Owner, Admin and Auditor hold and which can be delegated. It is separate from audit:read so that producing evidence can be taken from somebody who may still read the log on screen. Each export is audited as audit.exported, with its filters, count and sequence range.
include_metadata decides whether each entry's metadata is the full value its hash covers (DLP-2114). Without the parameter, the export carries what the caller may read. Somebody holding audit:sensitive (Owner and Admin by default) gets the full metadata and a file that proves the chain. Somebody without it, the built-in Auditor included, gets the log with sensitive configuration values withheld and marked sensitive_details_redacted. Its verification sentence says such a file cannot verify the chain on its own, because the withheld values are part of each hash. include_metadata=true without audit:sensitive is refused (403), and false asks for the redacted file whoever asks. tools/verify_audit_export.py export.json [--checkpoint checkpoint.json] verifies a file with the Python standard library alone. It recomputes every hash, checks each link between consecutive sequences (a filtered export has gaps, and reports them as gaps), and checks that a checkpoint's head_hash is the entry at its sequence. The checkpoint's signature is verified with POST /audit/checkpoint/verify. Archive segments stay server-side.
Anchoring with a timestamp authority (DLP-1185). With DLP_AUDIT_TSA_URL and DLP_AUDIT_TSA_CA_FILE set (DEPLOYMENT.md), each tenant's head is checkpointed and the checkpoint stamped by that RFC 3161 authority. This happens once per DLP_AUDIT_ANCHOR_INTERVAL_MINUTES (a day by default), and only when the chain has moved. What is sent is the SHA-256 of the checkpoint's signed statement and a random nonce, nothing else. The answer is kept only after this server has verified it:
- the authority's signature;
- the digest bound by the signed attributes;
- a chain to the pinned CA, valid at the stamped time;
- the timestamping key usage;
- the digest and nonce that were sent.
A token says the chain ended on that head no later than the stamped time. Nobody can obtain a token dated earlier than the moment they asked, so a tail rewritten later cannot carry the old stamps.
POST /audit/anchorstamps now. It answers{sequence, status: stamped | already_stamped, gen_time, tsa}. With nothing written since the last stamp it answers the stamp that exists and does not call the authority, so it cannot be used to make this server call out in a loop.400says whether anchoring is unconfigured, the authority could not be reached, or its token was refused and why. Like a checkpoint, it is not audited, because the entry would move the head just stamped.GET /audit/anchorslists each stamp withresponse_b64, theTimeStampRespexactly as received, anddigest. A copy verifies without this server:openssl ts -verify -digest <digest> -in <file> -CAfile <the authority's CA>.GET /audit/anchors/reportchecks the newest 500 stamps again, against the pinned CA and against the chain as it stands. It returns{configured, stamps, holding, broken[], latest_holding, widest_gap_hours, interval_minutes}. A broken stamp whosetoken_validis true and whosehead_matchesis false is a genuine stamp over a head the chain no longer ends on: the log was rewritten, or that entry was archived. Times are read from the tokens, not from the stored column.
What it cannot do: somebody with the database can delete stamps. Deleting one hides it without forging anything, and shows as a gap. The listed tokens are self-verifying, so copies kept anywhere close that. tests/audit_anchoring.sh covers the whole flow against openssl as the authority: a rewrite, a backdated column, an altered token, a stranger's authority and an authority that is down. System health reports anchoring beside the archive (outbound.audit_anchoring).
A change and its entry commit together (DLP-705). Every console handler that writes with SQL also writes its audit entry in the same transaction, so a failure between the two leaves neither. tools/audit_coverage.py checks this in CI. Since DLP-865 it also follows a handler that hands the pool to a domain function which writes, and judges that write as the handler's own. It follows one further domain call and no deeper. A domain function that writes its own entry, inside its own transaction, needs nothing from its caller. The check names five handlers that are exempt, each with its reason: two routes that endpoints write, the SSO test's last-error, bulk incident changes, and the manual retention run's record of itself. The last two are recorded rows whose changes are audited elsewhere, in their own transactions. It also keeps a baseline of known gaps that can only shrink. DLP-760 emptied it. Following domain writes found two more, the signing-key retire and adopt routes, and DLP-1003 moved their entries into the key change's transaction, emptying it again. Entries added by DLP-706, each carrying ids, counts and enums and never content:
| Action | Written when |
|---|---|
incident.comment_added | a comment is added; the comment's id and length, never its words |
label.recommendation.feedback | recommendation feedback is recorded |
label.rollout.simulated | the simulator records a shadow-rollout sample |
retention.proposal.previewed | a disposal proposal is previewed; its counts |
GET /incidents/export records every filter it applied (DLP-707). The free-text search is recorded as "search": "applied" and never as what was typed, since that can be the very value the product keeps out of logs. It also records total and truncated beside rows (DLP-813).
Hardware-backed signing keys (DLP-239)
GET /api/v1/system/signing-keys audit:read or system:read · every key, its custody and status (DLP-771)
POST /api/v1/system/signing-keys/hardware tenant:manage · audited
{purpose, reference} → {key_id, public_key_b64, purpose}
POST /api/v1/system/signing-keys/rotate-software tenant:manage + fresh signing-key step-up · audited
{reason: at least 10 characters} → {key_id, public_key_b64, previous_key_ids}GET /system/signing-keys lists each key with key_id, purpose, algorithm, custody, status, public_key_b64, not_before, not_after, created_at and can_rotate_software. custody is hardware for a pkcs11: key and software otherwise; the rotation flag reveals only whether the software-rotation route can handle that key. The private reference names a module, a token or an environment variable, so it is never returned. A key replaced by adoption is rotating; one retired is revoked, and when it was retired is in the audit log (signing_key.retired). can_rotate_software is true only for an active, unexpired, database-generated policy-bundle key. Software rotation refuses an HSM or externally managed key, an expired old root, or active endpoints that have never accepted a signed policy trust set. The old key becomes rotating; the new key is active, and the reason and old key ids are recorded as signing_key.rotated_software in the same transaction. Retirement still waits for fleet convergence.
reference is a pkcs11: URI naming the module, the token, the key and an env: reference for the PIN. A literal PIN is refused: the whole reason for a token is that the key cannot be taken from the server, and a PIN beside the reference hands most of that back.
purpose is policy_bundle or audit_checkpoint. The policy-bundle key also signs agent packages and update manifests (DLP-1104), so it is the one to adopt to keep agent updates in a token. update_manifest is refused with a sentence saying so: nothing signs with that key, and a token holding it would protect nothing (DLP-1105).
The public half is read from the token and returned, never taken from the request. The token is made to sign a probe before anything is stored, so a token that cannot sign is discovered at configuration time.
Four failures get four messages, because a token that is absent, one that has locked itself, a PIN that is wrong and a key label that does not exist are four different fixes that PKCS#11 distinguishes and most integrations flatten into "HSM error". Every one of them ends with "Nothing was signed."
Ed25519 only (ADR-0016); a token without CKM_EDDSA is told so by name.
Exact Data Match size budget (DLP-240)
POST /api/v1/edm/projection edm:read · {record_count, fields, fingerprint_bytes}SPIKE-005 measured what a dataset costs an endpoint: 250,000 records across three fields is 13.5 MiB of bundle and 34 MiB resident, which fits the agent's 80 MB idle budget; a million is 54 MiB and 135 MiB, which does not. The documented maximum was 250,000 and nothing enforced it - the import took up to five million.
An import that succeeds and then quietly breaks a fleet's memory budget is the worst available outcome: it works in the console, it works in a test tenant, and it is discovered when endpoints start being killed by the OOM killer, at which point nobody connects the two events.
An import over budget is now refused before the transaction begins, naming the projected bundle size, the projected resident size, the budget it was judged against, how many records would fit at that shape, and the override. Nothing is written, so there is nothing to roll back.
projection answers the same question from a row count, so the console can show the figure beside the field-quality table before the upload. A refusal that arrives after the wait is a worse version of the same answer.
The budget defaults to the documented maximum and is overridable per tenant in settings.edm_budget (bundle_mib, resident_mib): refusing outright would be wrong for a fleet of file servers, and an operator who knows their endpoints have memory to spare should not be told no by a number chosen for laptops.
Protected-document corpora (DLP-244)
GET /api/v1/fingerprint-corpora policies:read
GET /api/v1/fingerprint-corpora/{id} policies:read
POST /api/v1/fingerprint-corpora edm:write · build the next version · audited
POST /api/v1/fingerprint-corpora/probe policies:read · what would this text match?
GET /api/v1/fingerprint-corpora/limits policies:read · what one request may carry
POST /api/v1/fingerprint-corpora/staged edm:write · reserve a version built across requests · audited
POST /api/v1/fingerprint-corpora/staged/{id}/documents edm:write · add one request's documents · audited
POST /api/v1/fingerprint-corpora/staged/{id}/finish edm:write · make it ready once · audited
DELETE /api/v1/fingerprint-corpora/{id} edm:write · withdraw it · auditedList and detail expose one authoritative lifecycle value: building, ready, deployed, failed or withdrawn. failed carries a closed error code, never an extractor message, document name or source value. Detector and policy dependencies are tenant-scoped and returned beside the version. The deployment block names the bundle version and counts reporting endpoints on and behind it; a fleet with no reporting endpoint is pending, never deployed. The coordinate comes from the bundle which actually carried that corpus version, and endpoint counts come from their acknowledged policy_bundle_version in the last ten minutes. included_at is when the corpus entered that signed bundle; it is intentionally not called a deployment timestamp because compiling a bundle proves no endpoint received it.
A build reserves its version as building, then becomes ready only after fingerprints and the audit entry commit together. A request that produces no usable fingerprints remains visible as failed, without retaining source bytes. Reusing an id with a different name is 409 Conflict; uploading the same fingerprint set again is also 409 and leaves a failed attempt rather than silently activating a duplicate version.
A new version can keep the one before (DLP-1045). A version is exactly the documents of its request, and endpoints carry only the newest, so adding one document used to stop protecting every other. With "carry_forward": true the build starts from the newest active, ready version's shingles and adds the request's. The response says carried_from_version and documents_added, and document_count includes the carried documents. It is refused with 409 before anything is reserved when that version uses a different shingle size, because shingles of different sizes never match. It is off unless asked, so a client that has always sent the whole set keeps getting exactly that. The console asks, ticked by default, and says what each answer does.
What one build may carry (DLP-587). GET …/limits returns max_request_bytes, the server's configured body limit (DLP_MAX_BODY_BYTES, 8 MiB by default), and max_document_bytes, the limit for one decoded document (32 MiB). The request limit is the one that bites: documents travel as base64 inside JSON, so the console allows three quarters of it, less a 64 KiB margin, per request. A document larger than that is refused before anything is sent, with a sentence naming the limit, and not with the bare 413 the server would answer. A selection larger than one request is sent in parts to a staged build (DLP-1046, below). The console also caps a selection at twenty files. That is its own choice, not the server's: more than that is a folder, and folder crawling is out of scope.
Testing a set before a policy uses it (DLP-1043). probe takes an optional corpus_id. With it, the probe checks that set's newest ready version whether or not a published policy reaches it, and says so in chosen_in_bundle. A set with no ready version is 404, not an empty answer. Without it, the probe checks every set the bundle carries, as before. Nothing is stored either way.
A version built across requests (DLP-1046). staged reserves the next version as building, exactly as a one-request build does, carrying the previous version forward when carry_forward is set. Each documents request is fingerprinted when it arrives and its bytes dropped; only shingle hashes wait between requests, under the reserved version. finish runs the same duplicate check, audit entry and state change as a one-request build, and signs a bundle. A version that is still building is carried by no endpoint and evaluated by no bench. Finishing with no document added is 400, a finished or failed version takes no more documents (409), and a staged build idle for more than 24 hours is marked failed, abandoned, when it is next touched or another staged build starts. The console sends a selection larger than one request this way, in parts, and says so before it starts.
What builds record about themselves (DLP-587):
mydlp_corpus_builds_total{outcome="built"|"refused"};mydlp_corpus_build_documents_total{documents="1"|"2-5"|"6-20"|"21+"};mydlp_corpus_build_size_total{size="<1MiB"|"1-4MiB"|"4-16MiB"|"16MiB+"};mydlp_corpus_build_duration_seconds_sumand_max.
Recognition tests and withdrawals are counted beside them (DLP-589):
mydlp_corpus_probes_total{outcome="matched"|"no_match"|"nothing_checked"|"refused"}, withmydlp_corpus_probe_duration_seconds_sumand_max;mydlp_corpus_withdrawals_total{outcome="withdrawn"|"needs_confirmation"|"refused"}.needs_confirmationis the 409 dependency question, which the console answers with a dialog; it is counted apart so that a question is not read as a failure.
None of these series carries a corpus id, name, file name, hash, or word of a document or of a tested passage. The id is the tenant's own vocabulary for what it protects. tests/fingerprint_corpus.sh §10 searches the series for each. A request the body limit refuses never reaches the handler, so it is not counted here.
A withdrawal removes every version of the corpus unless ?scope=version says otherwise, and the default is that way round on purpose (DLP-268): a bundle carries the latest live version, so removing only the newest would silently promote the previous one back into every endpoint's bundle. For "this document must stop being referenced" - the question that arrives with a deadline attached - that is the opposite of what was asked for.
?reason= is required and is a query parameter rather than a body, as the EDM delete is: some proxies drop a DELETE body silently, which would lose the reason without losing the deletion. Deleting recompiles the bundle afterwards, for the same reason building does - a corpus only reaches an endpoint through one, so a withdrawal that stopped at the table would leave every endpoint matching the document. Repeating a delete is not an error; it reports that it removed nothing. When an enabled detector or published policy still references the corpus, the first request is 409 Conflict with dependency counts. Withdrawal proceeds only when the caller repeats it with confirm_dependencies=true; the acknowledgement and counts are included in the immutable audit event.
The source documents are not stored, and there is no column that could hold one. Text is extracted, normalised, shingled into eight-byte hashes and discarded inside one function - the same discipline Exact Data Match uses. A hash is not reversible content, but it is not nothing: somebody holding the corpus can test whether a paragraph they already have is in it. That is what the feature is for, and it is stated rather than glossed.
Re-uploading makes the next version rather than replacing the current one. An endpoint that is offline while a corpus changes keeps matching against the version its bundle carries, and an operator can see which version found a thing.
A bundle carries only the corpora a published policy references - and the reference is two hops, because a policy names a detector and a document_fingerprint detector names the corpus. A corpus is tens of thousands of hashes and an endpoint should carry what its policies need rather than everything the tenant has ever protected.
A policy reaches a detector in three ways (DLP-969): by naming it, through a classification condition whose enabled classification has it as an element, or through a category condition matching its data category. All three ship the corpus, and EDM datasets and their keys follow the same rule. Before DLP-969, only a detector id found in the policy's text counted. The other two routes shipped the detector without its set, so it could never match. Because that search matched on substrings, and _ is a wildcard to LIKE, it also shipped the set of a detector whose id merely resembled a named one. The corpus page's dependencies.policies uses the same three routes.
A document the extractor cannot read is named in the response with the reason, not silently dropped: an image is not an empty document, and folding the two together is how a corpus ends up smaller than somebody thinks it is. A build in which nothing could be fingerprinted is refused rather than stored, because a corpus that matches nothing would report a policy as working.
probe runs the same corpus-coverage calculation an agent runs, so an operator can see whether the corpus recognises the paragraph they are worried about before a policy depends on it. The threshold is the share of protected reference shingles found in the candidate, so an excerpt is not diluted by unrelated text around it. containment remains in the response as a diagnostic: the share of candidate shingles found in the corpus. Nothing is stored, and the answer says how many corpora were actually in the bundle - an empty result otherwise reads as "nothing matched" when it can mean "no policy references a corpus".
OCR languages (DLP-231)
What the corporate network looks like to an endpoint is a tenant setting too, carried the same way (DLP-1148):
json
POST /api/v1/settings/network-indicators tenant:manage · audited · recompiles the bundle
{ "corporate_dns_suffixes": ["corp.example.com"], "domain_authenticated_is_corporate": false,
"reason": "the two office domains" }Each suffix is cleaned to a DNS name (lower case, no dots at either end, once each), and one that is not a name of at least two labels is refused, not dropped. A single label such as lan is what every home router hands out, and it would make a café the corporate network. A reason of 5 to 500 characters is required and goes into settings.network_indicators_changed with the old and new values. The setting is a declared field of the tenant settings, because every settings route writes the whole object back, and a key one of them did not name would be dropped by the next. tests/network_location.sh changes the OCR languages after it and checks the indicators are still there. Until something is set, every endpoint reports unknown.
The language an endpoint falls back to for a warning is a tenant setting as well (DLP-1135). Since DLP-1897 no agent reads it: the owner decided the agent's UI is English, the console no longer offers the choice, and the route below still stores and audits a value for the day that is lifted:
json
POST /api/v1/settings/notice-language tenant:manage · audited · recompiles the bundle
{ "language": "az", "reason": "the fleet reads Azerbaijani" }It is one of en, az, tr and ru, the languages warnings are written in. Anything else is refused and the supported ones named. A reason is required and recorded in settings.notice_language_changed. It is only the fallback: the signed-in person's own language, which their session agent reports on the endpoint, comes first. A tenant that never chose one gets no field in its bundle, and English applies.
While the owner's decision DLP-1897 holds, neither applies: the endpoint shows English to everybody, and this setting is stored and compiled into the bundle for when it is lifted (DLP-2030).
The organisation's help page, which an endpoint's About and help entries open, is set the same way (DLP-1880):
json
POST /api/v1/settings/help-url tenant:manage · audited · recompiles the bundle
{ "url": "https://help.acme.az/data-protection", "reason": "the help desk page" }It accepts only an http or https address with a host, at most 500 characters, with no spaces and no user name or password. A javascript:, file: or custom-scheme link is refused, because the agent opens the address in the person's browser. A blank address removes it. Each change is recorded in settings.help_url_changed with both values and the reason, and GET /settings carries help_url. The signed bundle carries it as help_url (since 7846558e; asserted by tests/notice_language.sh). On the endpoint, the tray card's Help and Report a problem rows open it (since v1.74.0), and the About page links it under the protection state. Both re-check it as http or https and fall back to the About page, or to no link, when it is anything else (DLP-1880).
Whether endpoints show the cyberkap icon in the notification area is set the same way (DLP-1947):
json
POST /api/v1/settings/tray-icon tenant:manage · audited · recompiles the bundle
{ "mode": "off", "reason": "kiosk machines show nothing" }mode is on or off, anything else refused by field. On is the default (DLP-1878, SPIKE-018 as amended) and leaves the bundle without a field; off puts "tray_icon": "off" in it. Each change is recorded in settings.tray_icon_changed with from, to and the reason, and GET /settings carries tray_icon once one was chosen.
Which languages an endpoint loads for OCR is a tenant setting, carried to endpoints in the signed policy bundle:
json
POST /api/v1/settings/ocr tenant:manage · audited · recompiles the bundle
{ "languages": ["aze", "tur", "eng", "rus"] }A fact about the organisation rather than about the laptop - an endpoint configured by hand in Baku and one imaged in London would otherwise read different things out of the same scan.
An unsupported language is refused, not dropped. Silently ignoring deu would leave an administrator believing German documents are covered, which is the belief this product exists not to create. A language nobody has measured is also a confidence figure nobody can defend, which is why the list is four and not forty.
Changing it recompiles the bundle, for the reason building an EDM dataset does: an endpoint reads this from the signed bundle, so a setting that stopped at the table would take effect whenever something else happened to trigger a compile.
An empty list is not a choice. It means the tenant has not set this, and the endpoint keeps whatever it was configured with - so a fleet that already works does not stop when a bundle is published.
Data discovery overview (DLP-1573)
GET /api/v1/discovery/overview discovery:read
GET /api/v1/discovery/findings?search=&category=&location=&review=&limit=25&offset=0
discovery:read · limit 1 to 200
GET /api/v1/discovery/assignees discovery:write
POST /api/v1/discovery/findings/{id}/assign {assignee_email|null}
discovery:write · auditedA location is a reusable target, or, for a scan that names none, its kind and path. Its findings are those of its newest completed scan, so a file is counted once, as the share held it at that scan.
sensitive_files: findings in each location's newest completed scan.new_findings: of those, the ones whose path and content the location's previous completed scan did not hold. A missing hash on either side is never a match. This is the run view's rule, except that the overview groups by location where the run view groups by series (DLP-1576).active_scans: running and queued scans, at most six, withfiles_scanned. A scan records no total, so there is no percentage to give.targets_needing_attention: live targets that fail validation or whose latest scan failed;targets_unavailablecounts only the first.last_successful_scan_at, andlocationsandcategories, which are what the list can be narrowed to.
/discovery/findings lists those same rows, newest first, with total. review is read from what was recorded: remediated (remediated, quarantined or deleted), reviewed (acknowledged), assigned (an assignee on an untouched finding), else needs_review; another value is 422. Marking a finding reviewed is the existing per-scan remediation call with state: acknowledged.
A finding can be given only to an active person of the organisation who holds discovery:write, listed by /discovery/assignees; anybody else is 422, and another organisation's finding is 404. Each change writes a discovery.finding_assigned audit entry with from and to. tests/discovery_overview.sh asserts each figure, filter and refusal.
Data discovery: cancelling, stopping and restarts (DLP-884)
POST /api/v1/discovery-scans/{id}/cancel discovery:write {reason}
POST /api/v1/discovery-scans/{id}/stop-repeating discovery:write {reason}; id is any run of the seriesCancel ends a queued run at once (outcome: "cancelled"). A running one answers outcome: "cancelling": the worker checks between files, stops at the next one, keeps the findings it wrote, and marks the run cancelled, with error saying how many files it read and that the inventory is partial. A cancelled run queues nothing after it, and neither does a run that finished after a cancel was asked for. A finished run, or one already stopping, is 409. Audited as discovery.scan.cancelled with the reason.
Stop repeating cancels the series' queued run and records the reason in schedule_paused_reason on the run still going, if one is. That run finishes, and nothing is queued after it. A one-off, or a series with nothing queued or running, is 409. Audited as discovery.schedule.stopped. Once nothing is queued or running, the series' target can be retired.
A restart does not strand a scan. A running scan renews a lease (heartbeat_at) every two seconds. The worker, on every tick, marks a running scan whose lease has not been renewed for ten minutes interrupted (audited as discovery.scan.interrupted, actor system), keeps its findings, and queues its series' next run as it would after a completed one. It reclaims only a lapsed lease, never "running when this server started", so a scan another live instance holds is left alone. Before this, an interrupted scan stayed running for ever, ended its series, and blocked its target's retirement.
Scans list and read with cancel_requested_at, cancel_reason and cancelled_by. Status is one of pending, running, completed, failed, cancelled, interrupted.
Data discovery: what a scan could not evaluate (DLP-889)
A file is counted in files_scanned only once its content was read and checked. Before, the counter moved first, walk errors were dropped and oversized files were skipped uncounted, so a share the server could not read reported as a clean one. Every scan now carries, beside files_scanned:
| Field | A file (or entry) seen and not evaluated because |
|---|---|
files_unreadable | its bytes could not be read: permissions, gone, I/O |
files_too_large | it is over 64 MB |
files_unsupported | no extractor makes text of it: an image without OCR, an encrypted or unknown format |
walk_errors | the walk could not enter or stat it; a folder it may not list is files it never saw |
Each is null on a run from before this was recorded, which means not recorded, not zero. GET /classification-inventory adds files_not_evaluated: {unreadable, too_large, unsupported, walk_errors, runs_counted, runs_not_recorded}, taken from each series' latest finished run, so a weekly scan is counted once rather than once a week.
A failed scan is audited as discovery.scan.failed, and a schedule that stopped itself after repeated failures as discovery.schedule.paused. Both use actor system and an error_class of target_unreadable, bucket_unlistable, out_of_scope, credential, detector_pack or other, and each is written in the same transaction as the change. GET /system/health has a discovery section, {failed_7d, paused_7d, recent}, read from those entries, so a schedule a person stopped is not listed as though it had broken.
What leaves the platform (DLP-954). GET /system/health has an outbound section, read by anybody with system:read. Before it, the page every role reads answered "healthy" while alerts were not reaching the SIEM.
| Field | What it says |
|---|---|
incident_delivery | One entry per integration kind that is enabled or has a failure: {kind, failed, pending, failing_integrations}. failed counts deliveries that gave up and have not been replayed. It is not windowed, because a delivery row records when it was queued and not when it gave up, and one that gave up last week has still not arrived. failing_integrations names them only for a caller with integrations:write; everybody else gets null. |
report_runs_failed_7d, report_last_error_code | Scheduled report runs that failed for good, after their retries, in the last seven days, and the most recent one's code |
devices_refused_last_hour | Endpoints refused by the request rate or an ingestion quota in the last hour. GET /system/rate-limits has the per-device counters |
report_users_rate_limited_last_hour | People refused a report run or export by its rate limit in the last hour (DLP-2088). A count; who is in the report.rate_limited audit entries |
report_users_unusual_export_volume_24h | People whose report exports in the last 24 hours crossed DLP_REPORT_EXPORT_ALERT_ROWS (DLP-2088). Each crossing is one report.export_volume_unusual audit entry, with counts and no report |
audit_chain | {checked_at, intact, first_bad_sequence, entries_checked, verified_through, complete, last_full_sweep_at, detail, unrecorded, last_error, last_error_at, stale, interval_seconds} (DLP-2199, DLP-2280, DLP-2279). The scheduled verification of the tenant's hash chain. intact false names the first entry that does not verify in first_bad_sequence. entries_checked counts the entries the current sweep has read and verified_through is the last sequence it has verified; complete is true once that sweep has reached the end of the chain, and last_full_sweep_at is when a sweep last did. A check reads at most DLP_AUDIT_CHAIN_CHECK_LIMIT entries and continues from where it stopped, so complete is false while the chain is still being covered. An entry changed after an earlier check passed it is seen only by a sweep from the start. unrecorded is true when the result shown is held in this server's memory because the store refused its write, so it is not yet in the audit log; it is lost on restart. stale is true when the last completed check is more than two intervals old. Absent from an older server |
audit_archive | {configured, last_attempt_at, last_success_at, last_error, last_error_at}. configured is whether DLP_AUDIT_ARCHIVE_DIR is set. last_error is present while the most recent pass for this organisation failed, and the next successful pass clears it. Before migration 0080 a failing pass was only a log line |
Data discovery: object storage (DLP-233)
A discovery scan's target_kind has allowed s3 since the table was written and nothing could read one. It can now, and a bucket is worth reading: it is where exports land, and it is also where nobody looks, because it has no directory listing anybody browses casually.
json
POST /api/v1/discovery-scans discovery:write
{ "name": "Quarterly exports",
"target_kind": "s3",
"target": "s3://exports/q1?endpoint=https://s3.eu-west-1.amazonaws.com®ion=eu-west-1",
"credentials_ref": "env:DLP_DISCOVERY_EXPORTS" }The endpoint is part of the target rather than a server setting, because an S3-compatible store is the point: a tenant with MinIO in their own data centre and a tenant on AWS are the same feature.
credentials_ref names where the credential is - env:DLP_DISCOVERY_NAME, a server environment variable holding base64 of access_key_id:secret_access_key. No response carries it back and the audit entry holds neither. Anything else is 422 (DLP-634): inline:<base64>, because that form is the key itself stored in the scan row (DLP-630), and a variable not named for discovery, because any name let an administrator learn from the scan whether the server held it. A run that cannot resolve its credential fails with a fixed sentence - no value set, or not decodable - and never the resolver's own words, which named the variable. A scan row written before this with inline: no longer exists after the first start of a build with DLP-630. The server clears the stored credential, cancels a queued run of that scan, and pauses its series with a reason the operator sees ("reconnect it with an env: reference or a saved target"). Each removal is audited as discovery.credential_removed, naming the scan and never any part of the credential. This was decided on 2026-09-24: clear and pause, rather than warn. Backups taken before that start still hold the old value, so rotate any key that was stored this way.
A redirect from the store is not followed (DLP-633): the scan fails, saying so. The endpoint check judges the address that was typed, and a redirect goes somewhere it never saw - before this, a store answering 302 to the metadata address had its target's reply read as an empty bucket.
A SharePoint site (DLP-1193, DLP-1194, DLP-1195, ADR-0017)
A site is read through Microsoft Graph with an app registration the customer grants Sites.Selected for that site, in their own admin centre. No write scope is asked for, and nothing is written. The target names the customer's Microsoft tenant id (a GUID) and the site (a Graph site id, host,siteGuid,webGuid, or host:/sites/name). Its credential is an env:DLP_DISCOVERY_… variable holding base64 of client_id:client_secret, under the same rules as an S3 key. A scan's target string is m365://<tenant>/<site>.
Every token is checked against the tenant the target names. A token whose
tidis another tenant's is refused and never used. The connection test sayswrong_tenant, and a scan fails naming the tenant the token was for. ADR-0017 calls this the one failure the feature must never have: another organisation's files in this console.The whole library is enumerated with Graph
deltafrom the start, following every@odata.nextLinkthat stays on the configured Graph. A link to any other host fails the scan rather than being followed with the token. Folders and deleted items are not files. There is no incremental run yet: each scan reads everything, as a bucket scan does (DLP-1196carries the change-notification half).Content is fetched by Graph's redirect to the tenant's download host, and the Graph token is not sent there. Files are scanned by the same engine, under the same size and count caps, as a share or a bucket.
A finding's exposure is how the file is shared, read from the item's permissions only for files that matched:
everyonefor an anonymous link, or a grant to a guest from outside the organisation (Entra writes#ext#into a guest's login name);groupfor an organisation-wide link;ownerfor named people inside the organisation.
detector_summaries.sharingsays which, in words. Sharing that could not be read isunknown, which severity treats as high.A finding names the file by its web URL, where somebody can open it.
The connection test reads the site's drive and reports
ok,credentials_rejected,wrong_tenant,denied(not granted underSites.Selected, or no admin consent),not_foundorunreachable.Where Graph and the sign-in service are is the operator's (
DLP_M365_GRAPH_URL,DLP_M365_LOGIN_URL, DEPLOYMENT.md), for the national clouds. It is never the tenant's, because the client secret is sent there.
A finding becomes an incident only through policy (DLP-1198, ADR-0042). Each SharePoint or OneDrive finding is evaluated against the tenant's published bundle on the cloud_storage channel, with its detector results, its location as the destination and its content hash. A finding no policy selects stays a finding. The outcome is always audit, since nothing there can be blocked. One incident per file, not per route:
- the same content found again under the same policy joins the incident it raised (
joined_by: rescan); - otherwise an open endpoint incident whose events carry the same BLAKE3 content hash, last seen within the correlation window, is joined (
content_hash); the agent and the server hash with the same function; - otherwise a new incident is raised (
raised), with channelcloud_storage, actionaudit, the file's URL and no device.
There is no join by path. GET /incidents/{id} carries cloud_sightings: each finding on the incident, with its URL, how it joined and its sharing. Publishing a policy scoped to cloud_storage warns when no SharePoint or OneDrive target exists, and when it asks to block (it becomes an audit). It does not warn that no endpoint observes the channel, since none is meant to.
Change notifications (DLP-1196). With DLP_PUBLIC_URL set to an https address Microsoft can reach, a worker subscribes each live SharePoint site target to Graph change notifications on its document library, and renews each subscription three days before it lapses (Graph allows under 30 days). A notification is a wake-up, never the truth: it queues a scan of the site, and the scan's delta enumeration is what is believed. Scheduled scans run whether or not notifications arrive.
POST /api/v1/m365/notifications public · Graph's notifications and its validation handshake
POST /api/v1/discovery-targets/{id}/subscribe discovery:write · subscribe a SharePoint target now- Validation. Graph checks the URL by posting
?validationToken=…. The endpoint answers with the token astext/plain, withnosniff. - A notification is believed only with its subscription's secret. Each subscription is made with a random
clientState, and only its SHA-256 is kept. A post naming an unknown subscription, or the wrong secret, is answered202like any other and ignored, so it can neither learn which subscriptions exist nor make the server scan. - A burst is one scan. A notification queues a scan only when none is queued or running for that target. The scan is audited as
discovery.scan.queuedwithby: m365_change_notification. - Lapsing is said. A failed renewal is written on the subscription and retried every pass.
GET /system/healthcarriesdiscovery.notifications{configured, live, lapsed, failing, last_error}, and the console shows a lapsed or failing subscription. - Only SharePoint sites. Every person's OneDrive would be a subscription per person, so OneDrive relies on its schedule.
Incremental runs (DLP-1201). A scan made from a target keeps, per drive, the delta link Graph gave at the end of its enumeration. The next run asks for only what changed since. It rescans the changed files, takes removal only from the delta's deleted items, and carries forward every earlier finding the delta did not touch, matched by Graph item and drive. Each run is therefore still a whole inventory, and the comparison between runs still means what it says. discovery_scans records incremental and files_carried_forward, so a small files_scanned is not read as a small site. A drive is read whole again when Graph refuses the cursor (a 410 asking for a resync, or any refusal of the stored link), and in any case once DLP_M365_FULL_SCAN_EVERY_HOURS has passed (a week by default; 0 reads whole every time), which is the floor. A cancelled or capped run saves no cursor and carries nothing forward. A one-off scan typed without a target reads whole.
Every person's OneDrive (DLP-1197) is a target of its own kind, "kind": "onedrive", "config": {"tenant": "<Microsoft tenant id>"}, scanned as m365://<tenant>/users. It reads each person's drive in turn and skips a person who has none. There is no target for one person's drive: choosing a single employee's OneDrive to read is targeted monitoring, and ADR-0017 frames OneDrive as one switch. It needs Files.Read.All and User.Read.All, grants SharePoint coverage never asks for, so it is off until an Owner switches it on:
POST /api/v1/settings/onedrive-discovery tenant:write · {enabled, reason} · step-up personal_drives to switch on · audited
POST /api/v1/settings/privacy/file-paths tenant:write · {redact, reason} · step-up file_names to keep names · audited · GET /settings reports privacy.redact_file_paths as in force and privacy.redact_file_paths_source (tenant | server_default) (DLP-1302)
POST /api/v1/settings/privacy/pseudonyms tenant:write · {enabled, reason} · step-up people_names to name people again · audited · GET /settings reports privacy.pseudonymise_people (DLP-1786)
POST /api/v1/settings/privacy/report-delivery tenant:write · {mode: full|minimised, reason (5-500 chars, else 422 field "reason")} · step-up people_names to choose full · audited as settings.report_delivery_changed {from,to,reason} · answers {report_delivery} · GET /settings reports privacy.report_delivery (the choice, absent until made) and privacy.report_delivery_in_force (what deliveries send now) (DLP-2082)
POST /api/v1/people/reveal identity:reveal · {pseudonym, justification (5-500)} → {pseudonym, name, owner_sids (the file-owner SIDs recorded as theirs, at most 20, withheld everywhere else; DLP-1928)} · audited as person.revealed (pseudonym and reason, not the name) before the answer · 409 when people are not pseudonymised · 404 for a pseudonym that is nobody here (DLP-1786)
with people pseudonymised, a query parameter or path segment that is a person's whole real name is 422 on that field; the queue, its export and /search do not match free text against the person, and sort=user orders by a hash of the name (DLP-1901)While the switch is off, a OneDrive target or scan is refused (409), and a scan already queued fails at its start, saying so. GET /settings carries onedrive_discovery. The connection test lists the tenant's people; it does not open anybody's drive to prove it could.
Proven against a recorded Graph (tests/m365_discovery.sh), not a live tenant.
A PostgreSQL database (DLP-1778, ADR-0050) is a target of its own kind, "kind": "postgres", "config": {"schemas": ["public", "sales"]} (no schemas means public; pg_catalog, information_schema and pg_toast* are refused), scanned as postgres:schemas=public,sales. Its credentials_ref names a DLP_DISCOVERY_… variable holding base64 of the whole connection URL, postgres://user:password@host:port/database?sslmode=verify-full. The operator chooses the database, host included, and the target cannot name a host. A host other than this one is read only with sslmode=verify-full or verify-ca; anything weaker fails the scan before connecting. A refusal never repeats the URL.
The connection is read-only (default_transaction_read_only) with a 30-second statement timeout. Every text, varchar, char, json and jsonb column of every table in the schemas, listed from pg_catalog so a table the role may not read is counted rather than skipped silently, is sampled: at most 1,000 non-null values and 1 MB. A finding names the column, postgres://host:port/database/schema.table.column, with categories and counts. No row, value or preview is kept. A column the role may not read counts in files_unreadable, and files_scanned counts columns. Proven against a real local database and role (tests/postgres_discovery.sh), not a remote one over TLS.
Reusable discovery targets (DLP-594)
GET /api/v1/discovery-targets[?retired=true] discovery:read
POST /api/v1/discovery-targets discovery:write
GET /api/v1/discovery-targets/{id} discovery:read
PATCH /api/v1/discovery-targets/{id} discovery:write
DELETE /api/v1/discovery-targets/{id}?reason=… discovery:write · retires; reason required (DLP-680)
POST /api/v1/discovery-targets/{id}/validate discovery:write · DLP-595
GET /api/v1/discovery-credential-references discovery:write · DLP-595json
{ "name": "Finance archive", "description": "",
"kind": "s3",
"config": { "bucket": "finance-archive", "prefix": "2026",
"endpoint": "https://s3.example.com", "region": "eu-central-1" },
"credentials_ref": "env:DLP_DISCOVERY_FINANCE_ARCHIVE" }A mounted share ("kind": "network_share", "config": {"path": "/mnt/finance"}), an S3 location, or a SharePoint site ("kind": "sharepoint", "config": {"tenant": "<Microsoft tenant id>", "site": "contoso.sharepoint.com:/sites/finance"}, DLP-1193), named once and chosen by every scan that reads it: POST /discovery-scans takes target_id instead of target, target_kind and credentials_ref (422 if given both).
env:DLP_DISCOVERY_…only.inline:is 422 with the reason, and so is a variable not named for discovery; the table's CHECK refuses both. An operator offers a variable to discovery by naming it so, and a target cannot be pointed at anything else the server holds - any name let an administrator ask whether the server had some variable, and send the first half of one that decoded asid:secretto an endpoint they chose (DLP-595). An S3 target needs a reference; a share takes none.GET /discovery-credential-referenceslists theDLP_DISCOVERY_…variables the server was started with - names and whether each has a value, never a value - and is what the console's selector offers.- What is returned is the location, the reference's name and whether the server can see that variable (
credential.available), the owner, the last validation, and how many scans are active and in total - never a value. The name is given only withdiscovery:write, the permissionGET /discovery-credential-referencesdemands, because a name says where a secret lives (DLP-900). A reader, including a token scoped todiscovery:read, getscredential.reference: nullandcredential.configured: truewhen the target uses a stored credential. The audit entry records where it points (for S3, the endpoint's host only) and whether it has a credential, not which variable. - A change follows through. Moving a target updates its pending scans, scheduled runs included, and voids its last validation; the next scheduled run reads the target afresh.
- Retired, not deleted - 409 while any scan is pending, running or scheduled against it, naming how many. A retired target keeps its scans' link, is not listed unless asked for, and cannot be scanned or edited (409). Retiring needs
?reason=(422 without one, or with more than 500 characters; checked before the scan count). The reason is written to thediscovery.target.retiredaudit entry (DLP-680). - Another organisation's target is 404, like one that does not exist.
- Only the organisation's own storage (
DLP-675). A share path must lie under a root, and anenv:credential must carry a prefix, that the operator assigned to this organisation inDLP_TENANT_DISCOVERY_SCOPE(docs/DEPLOYMENT.md). Anything else is 422 with one sentence that reads the same whether or not the path or variable exists, and the credential selector lists only the organisation's own variables. The rule is checked when a target or scan is created or changed, before a connection test, and again when a scan runs.
Testing a target's connection (DLP-595)
POST /discovery-targets/{id}/validate runs read-only checks and records the answer on the target (validation_state, validated_at, validation_error, validation_outcome):
json
{ "outcome": "denied", "ok": false, "duration_ms": 212,
"validated_at": "2026-09-24T10:00:00Z",
"message": "The key was accepted but may not read this location. Grant it read and list access - read only; nothing more is needed." }- What it does. For a bucket, one signed
ListObjectsV2withmax-keys=1- a GET - whose listing is discarded unread. For a share, astatand an open of the top directory; no entry is read. Nothing is written, and no object or file name reaches the answer, the target or a log. - Ten a minute, per organisation (
DLP-637,DLP_DISCOVERY_TESTS_PER_MINUTE). Over the budget is 429request.rate_limitedwithRetry-After, and an audit entrydiscovery.target.validation_refused; nothing is connected to and nothing on the target changes. Concurrent requests cannot overspend it. A test reports refused, dropped or answered for an address the administrator chose, private ones included - without a budget, the server would sweep its own network on request. - Bounded at 15 seconds, network included. Cancelling in the console only stops the browser waiting: a test already under way on the server may still finish and record its answer.
outcomeis one ofok,timeout,unreachable,untrusted(a certificate this server does not trust),redirected,address_refused,credential_missing,credential_unavailable(the variable has no value on the server),credential_malformed,credentials_rejected(unknown key or wrong secret),region_mismatch,denied(key accepted, no read access),not_found(no bucket, or the share is not mounted),not_a_directoryandunexpected(with the HTTP status in the message). Each is a different person's fix, which is why they are not one "failed".- The message is this server's sentence, never the store's own text, which a store can fill with anything, key ids included; the endpoint is not repeated either.
- A target changed or retired while its test ran is 409, and the answer is not recorded: it is about where the target used to point.
- The audit entry
discovery.target.validatedcarries the kind, the outcome and the duration, and nothing else.
Read-only, by construction rather than by permission. There is no code in this build that writes to a bucket, deletes from one, or changes a lifecycle rule. A discovery feature that asked a customer for write access to their object storage would be asking for more than it needs, and the credential supplied should not have it.
The endpoint is validated, because a scan target is one of the few places a tenant administrator names a URL this server will fetch with its own network identity. Private addresses are allowed - a store inside the customer's network is the ordinary deployment - and the cloud metadata address is refused, since the one address whose purpose is handing out credentials to whoever asks from inside is the obvious thing to point a scan at.
A bucket that cannot be listed fails the scan with that reason. It is not reported as an empty inventory, for the reason DLP-232 fixed for a mounted share: a target that is not there looking clean every week is the worst shape available.
Proved against a real MinIO in tests/s3_discovery.sh, including that nothing in the bucket is written or modified by a scan.
Legal holds (DLP-703, ADR-0031)
GET /api/v1/legal-holds[?active=true][&incident_id=] legal_hold:write or audit:read
POST /api/v1/legal-holds legal_hold:write · audited
POST /api/v1/legal-holds/{id}/release legal_hold:write · step-up · audited
POST /api/v1/legal-holds/findings legal_hold:write · auditedA hold keeps material past its retention window for one matter:
jsonc
POST /api/v1/legal-holds
{ "scope": "incident", "incident_id": "…", "matter": "Case 2026/114", "reason": "counsel's preservation notice" }
{ "scope": "person", "person": "a.mammadova", … } // their incidents and events, from now on too; not findings
{ "scope": "discovery_finding", "finding_id": "…", … }
POST /api/v1/legal-holds/findings
{ "finding_ids": ["…", "…"], "matter": "Case 2026/114", "reason": "the files named in the claim" }
→ 201 { "placed": [hold, …], "already_held": ["…"] }A person hold does not keep discovery findings (DLP-940, ADR-0031 as corrected): a finding's owner is uid:N or absent, never a name. Findings are held with discovery_finding holds. /legal-holds/findings places one on each finding in a selection, in one transaction, with one hold.placed entry per hold. A finding already held is named in already_held and left as it is. If any id is not this tenant's, the answer is 404 and nothing is held. At most 1,000 findings go in one request. GET /discovery-scans/{id}/findings carries held on each finding. Claiming a held finding remediated is 409, naming the file and the matter, and changes nothing in the request. It is audited as discovery.finding.remediation_refused. Acknowledging a held finding is allowed.
- A scope names its own target and nothing else, and a person is stored trimmed and in lower case.
matteris 1 to 120 characters, andreasonis at least 10. - Another tenant's incident or finding is
404. A second active hold on the same target is409: the first already keeps it. - Placing needs no recent sign-in, because it can only stop a deletion. Releasing needs a reason of at least 10 characters and a recent sign-in (
data_retention), and a hold already released is409. Neither deletes anything. A released hold's material goes at the next retention pass. A hold never expires and is never deleted. - Audited as
hold.placed(scope, target, matter, reason) andhold.released(scope, matter, reason), in the same transaction as the write. Both carryhold_id. A hold on an incident is audited againstincident:{id}, so it appears on that incident's trail (GET /audit?target=incident:{id}); a hold on a person or a finding is audited againstlegal_hold:{id}(DLP-826). - What it does: retention skips held events, closed incidents, evidence previews and discovery findings, and each pass reports
events_held,incidents_held,evidence_heldanddiscovery_findings_heldbeside what it removed./settings/retention/previewcounts held rows apart from the ones it would remove. Retention proposals mark held findings ineligible ("under legal hold, which always wins"), and label remediation records held files asskippedwith the hold as the reason. A database trigger refuses to delete held incidents, events and findings, so a tenant with an active hold cannot be deleted. GET /incidents/{id}carriesunder_legal_hold, and never the matter or the reason.
Audit archival (DLP-55)
GET /api/v1/audit/archive audit:read · where the older entries went
GET /api/v1/audit/archive/status audit:read · how the last pass went (DLP-968)
POST /api/v1/audit/archive tenant:manage · run a pass now, over this tenant only (DLP-770) · audited/audit/archive/status is {last_attempt_at, last_success_at, last_error, last_error_at}, or null before any pass has reached this organisation. It is the same record System health reports as audit_archive, served here with the audit log's own permission, because an Auditor reading the archive need not hold system:read. The Audit log's archive panel shows a failed pass beside its Run button. A segment row has no last_error (DLP-968): the column exists and was never written, since a failing pass usually stops before any segment exists, so the field was always null.
Retention has always skipped audit_log, because the append-only trigger refuses a delete and would abort the pass - so retention_days.audit was stored and had no effect at all. Deleting is not the answer; the append-only guarantee is the whole value of the log. Archival is.
The order is fixed and every step can fail safely: export the aged range, fsync it, read the bytes back off the storage, recompute the chain from them, sign a manifest, record the segment as verified, and only then prune. Anything failing leaves every row exactly where it was - including the case where DLP_AUDIT_ARCHIVE_DIR is unset, where the archiver refuses to run at all rather than choosing a directory nobody configured and nobody backs up.
The chain still verifies afterwards. The first retained entry's prev_hash points at a row that is no longer in the table, which read from the database alone is indistinguishable from somebody having deleted it. The segment records the entry_hash it closed on and GET /api/v1/audit/verify continues from there instead of from genesis.
prune_archived_audit() is the only path that may remove an audit entry, and it is narrower than the trigger is broad: it refuses a segment that has not been verified, and refuses one whose range no longer holds the number of rows it claims. The blanket trigger is unchanged and still refuses every ordinary delete. The property enforced is the one that matters - no audit entry is removed without a verified copy existing elsewhere - rather than "no entry is ever removed", which was not operable and was therefore not being honoured.
The manifest beside each segment carries the range, the opening and closing hashes, the content SHA-256, the signature, the public key and the signed statement verbatim, so an archive directory found years later can be checked without this database.
Checkpoints (DLP-SEC-018)
/audit/verify recomputes the chain and reports the first break. It catches a removed, reordered or edited entry - and not somebody with write access who recomputes the whole tail, because afterwards every hash agrees with every other and there is nothing left to compare against. tests/audit_checkpoints.sh does exactly that, with the append-only trigger switched off the way its owner would, and /audit/verify reports the log intact.
A checkpoint is a signed statement about the chain's head at a moment:
jsonc
{
"sequence": 5, // the last entry it covers
"head_hash": "…", // that entry's hash
"entry_count": 5,
"covers_from": 1,
"created_at": "…",
"key_id": "audit_checkpoint-…",
"signature_b64": "…",
"public_key_b64": "…" // carried with it, so it survives a key rotation
}- Take it out. A checkpoint stored only in the database it describes is one the same person can rewrite.
GET /audit/checkpointreturns the server's copies with a note saying so. It becomes evidence when it is in an archive, a ticket, or an email to whoever asked for it. POST /audit/checkpoint/verifytakes the checkpoint in the request, not a reference to one. That is the point: verifying from this database proves nothing. Hand it the copy you kept.- The answer separates three things, because they fail for different reasons:
signature_validfalse means the checkpoint was altered or did not come from this server;entry_presentfalse means the log lost the entry;head_matchesfalse means the log was rewritten.holdsis all three. audit:readis enough to take one. The person who most needs a checkpoint is the auditor who may read the log and nothing else, and requiring a management permission would put it in the hands of the only people it is meant to be evidence against.- Asking twice with nothing written in between returns the same checkpoint. Two signatures over one statement would look like two independent attestations of the same fact.
- Taking one is not itself audited - an entry would move the head the checkpoint just described, so the next call would differ, for ever.
What this does not do: prevent a rewrite, or anchor anything automatically. There is no transparency log and no third party. It turns "the log looks fine" into "the log disagrees with what you were given on the 3rd", and that depends entirely on somebody having kept the 3rd.
/system/rate-limits reports the limits in force and, per device, how many requests were allowed, how many were throttled, when it was last throttled, how many tokens it has now, how many batches each ingestion quota refused, how many events it currently stores and which quota refused it last - which separates "sending too fast" from "sending too much" from "has already accumulated too much", three problems with three different answers. The counters are durable, so they survive a restart and can be read by a scraper, the dashboard or an operator with curl. Throttling also writes an agent.rate_limited audit entry - once per episode, not once per refused request, so an attempted flood cannot turn into a flood of the audit log.
Permissions
Permissions across five roles, checked server-side on every route. The list is Permission::ALL in auth.rs; it is not counted here, because a count in prose is the sentence that goes stale first. The dashboard's hiding of controls is a convenience, never a control.
| Role | Summary |
|---|---|
owner | Everything |
admin | Everything except tenant settings, single sign-on (sso:write) and downloading copies of matched files (evidence:file, DLP-1189) |
security_analyst | Investigate, manage policies and detectors. No user management, no audit log |
auditor | Read-only including the audit log, and audit:export to take the log out as evidence (DLP-946). No writes, and no incident export |
viewer | The dashboards' counts. No incident list or detail, no ranking that names a person or an endpoint, no evidence, no audit log, no export (DLP-542) |
An API token is further limited to its explicit scopes, so a token minted by an owner is not an owner-equivalent key.
Confirming it is you (DLP-614)
Some actions need a recent proof of who is asking, on top of the permission. The permission is checked first; this second; nothing changes until both pass.
| Action key | Asked by |
|---|---|
api_token | POST /api-tokens, /api-tokens/{id}/rotate, /api-tokens/{id}/revoke; PUT /api-token-policy, the tenant's own limits on every token, and PUT /api-tokens/{id}/sources, where one may be used from (DLP-1236) |
policy_release | /policies/{id}/versions/{v}/publish when it publishes (not when it submits), /approve, /policies/{id}/rollback, /policies/{id}/enabled (either way), /policies/reorder, POST /settings/policy-release (DLP-672) |
role_change | POST /users (an invitation carries a role), /users/{id}/role, /users/{id}/enable, /users/{id}/reinvite and /users/{id}/password-reset (each issues a credential to an account, DLP-794, DLP-795), /users/{id}/unlock (lifts a failed-sign-in lock, DLP-982), DELETE /users/{id}/second-factor (removes a person's second factor, DLP-988), and defining, editing or retiring a custom role (POST /roles, PUT /roles/{id}, POST /roles/{id}/retire) |
incident_delivery | POST /integrations (adding or changing one, its address or its secret), DELETE /integrations/{id}, POST /integrations/{id}/enabled switching one off, POST /integrations/{id}/replay (DLP-932); POST /report-delivery-destinations and PATCH /report-delivery-destinations/{id} (DLP-694) |
sign_in_settings | POST and DELETE /settings/oidc -- the issuer and group mappings decide who signs in as what; POST /settings/second-factor, which decides who can still sign in without one (DLP-987); POST /settings/sign-in-mode, which decides who may still sign in with a password (DLP-994) |
data_retention | POST /settings/retention -- a shorter window is a deletion at the next pass (DLP-699); POST /legal-holds/{id}/release -- a release lets the next pass delete (DLP-703) |
signing_key | POST /system/signing-keys/{key_id}/retire -- a retired key leaves the set endpoints accept and cannot come back; POST /system/signing-keys/hardware -- an adopted key decides whose signature the fleet obeys (DLP-773) |
enrollment_token | POST /enrollment-tokens with max_uses above 1 -- a token more than one machine may use is a credential a leak turns into rogue endpoints; single-use tokens and revoking are not governed (DLP-907) |
classification_rollback | POST /classifications/{id}/rollback -- it restores an earlier rule to the whole fleet, a release by another road (DLP-798) |
subject_access | POST /subject-access/{username} -- a complete file on a named person (DLP-798) |
endpoint_uninstall | POST /agents/{id}/uninstall -- it ends that endpoint's protection (DLP-798) |
behaviour_consent | POST /settings/behaviour when it lifts the per-user consent, so analytics may name individuals; switching it off is not asked (DLP-798) |
protected_data | POST /edm/rotate-key -- starts a two-key overlap; POST /edm/key-rotation/complete -- retires the old key after dataset and endpoint convergence; DELETE /edm-datasets/{id} on an active dataset, after its reason is checked (DLP-1086) |
label_automation | POST /label-governance/rollouts/promote -- from then on files are labelled with nobody asked, and the second reviewer it needs could be a stolen session (DLP-1086) |
agent_update | POST /agents/update-manifest -- which binary every endpoint in a ring runs next (DLP-920); POST /agent-groups/{id}/follow-latest, POST /agent-groups/{id}/follow-group (DLP-1780) and POST /settings/agent-updates when they switch automatic updates on, so every later release reaches those endpoints with nobody publishing (DLP-1254); switching them off is not asked |
personal_drives | POST /settings/onedrive-discovery when it switches OneDrive discovery on -- a grant of every person's files becomes reading them (DLP-1197); switching it off is not asked |
active_time | POST /settings/behaviour/active-time when it turns measurement on or widens it to every hour -- from then on every endpoint samples which application each person has in front of them (DLP-1243); switching off or narrowing is not asked |
enforcement_bypass | POST /agents/{id}/bypass-codes -- a signed code that turns one offline endpoint's blocking into warnings for up to 72 hours (DLP-1155); POST /agent-groups/{id}/monitor-mode when it switches a group's monitor mode on, so every endpoint in it records and enforces nothing; switching it off is not asked (DLP-1230); POST /settings/test-folders when it adds a folder, so every incident raised at rest under it leaves the real queue and the risk model; removing one is not asked (DLP-1815); POST /settings/test-folders/apply, which marks the open incidents already under the folders (DLP-1825) |
file_names | POST /settings/privacy/file-paths when it chooses to keep file names, so every later event names the document whatever the server's default (DLP-1302); choosing to hash them is not asked |
people_names | POST /settings/privacy/pseudonyms when it chooses to name people again, so every name returns to every screen at once (DLP-1786); POST /settings/privacy/report-delivery when it chooses full, so every later scheduled delivery names people outside the product (DLP-2082); choosing pseudonyms or a minimised delivery is not asked |
evidence_storage | POST /settings/evidence-storage -- where sealed copies of matched files are written; every future copy goes to the target named (DLP-1188, ADR-0038) |
evidence_file | POST /incidents/{id}/evidence-files/{file_id}/download -- a whole copy of a matched file, about a named person, leaving on the reader's machine (DLP-1189) |
second_factor | POST /auth/second-factor/enrol -- a stolen session that could put its own authenticator on the account would lock the person out and keep it (DLP-986) |
detector_switch | POST /detectors/{id}/enabled and PUT /detectors/{id} when they switch a running detector off; switching one on is not asked, because it only adds detection (DLP-1086) |
backup_run | POST /system/backup/run -- queues one on-prem physical backup (full or incremental) for the operator's worker; a second is refused until an hour after the previous manual result |
Not asked, deliberately (DLP-616):
- submitting a policy version and rejecting one. Neither releases anything: a second person still decides.
- disabling an account. That is the containment step during an incident, and it should not be the slow one.
- viewing evidence. The evidence route already needs a justification and audits every read. It only returns previews redacted at the endpoint; no original value is stored, so there is no unredacted view to protect.
- revoking or deactivating an endpoint, one or all matching (
DLP-798). Containment, for the reason disabling an account is: the step taken during an incident should not be the slow one. - exporting incidents as CSV (
DLP-798). It already needs its own permission besideincidents:read, and every export is audited with every filter it used. - switching per-user behaviour consent off (
DLP-798). It protects people.
tools/step_up_documented.py fails CI when a Governed kind in domain/step_up.rs has no row in the table above, or a row names a key the code does not have.
Each route asks after its own rules. The permission comes first, then the policy lifecycle's four-eyes and state rules, the role rules, and field validation. So a request those rules refuse is refused for that reason, and does not use up a confirmation it would never have used.
- A session signed into in the last 15 minutes is proof on its own.
- An older one is refused with
403, codeauth.step_up_required, and the action key indetails[0].message. Nothing was changed; the refusal is audited asstep_up.required. POST /api/v1/auth/step-up {action, password}re-verifies the signed-in person's password under the same lockout as signing in, and grants one use of that action, on that session, for 5 minutes. Retry the original request. A grant cannot be used twice, for another action, from another session, after it expires, or after the session is signed out. Audited asstep_up.granted/step_up.failed, never with the password.- An API token cannot confirm -- there is no person to ask -- and gets the same typed answer.
POST /api/v1/auth/step-up/oidc {action}is the same confirmation for an account that signs in through single sign-on (DLP-615). It answers{authorization_url}: the tenant's provider, asked withprompt=loginandmax_age=0to authenticate the person again rather than answer from its own session. The provider returns to the ordinary callback, which grants the same one-use, one-action, one-session, five-minute grant only when the ID token'ssubis the signed-in account's subject and itsauth_timeis no earlier than the moment this was started (a minute's clock skew allowed). It then redirects to the console's/step-up-done?action=…. Otherwise nothing is granted and it redirects to/step-up-done?error=withdifferent_person,not_recent(the provider ignored the request and answered from an older sign-in) orprovider_cannot_confirm(the token carries noauth_time). No user is created or updated on this path. Audited asstep_up.granted/step_up.failedwithmethod: oidc. A password account asking here is409, and a token is refused as above.GET /api/v1/auth/mesays which to use:confirms_withispasswordwhen the account has one, otherwisesso. The sign-in answers carry the same field.feedback_address(DLP-729) is on the same answers whenDLP_FEEDBACK_ADDRESSnames a deliverable address, and absent otherwise. The console shows "Give feedback" only when it is present.
The console does this without leaving the page. A refused request opens a "Confirm it's you" dialog. The dialog confirms with a password, or with the provider in a popup that reports back to its opener on the same origin only. After a confirmation it sends the refused request once more. Cancelling leaves the refusal where it was made, and a second refusal is shown rather than asked again.
API tokens (DLP-598)
GET /api/v1/api-tokens the tenant's tokens, never a secret
GET /api/v1/api-tokens/scopes what this caller may grant
POST /api/v1/api-tokens {name, scopes, expires_in_days?} → 201, secret once
POST /api/v1/api-tokens/{id}/rotate → a new secret once; the old one fails at once
POST /api/v1/api-tokens/{id}/revoke {reason}tokens:write (Owner, Admin), and from a signed-in session only: a token is refused on every one of these routes whatever it carries, so a credential cannot mint credentials. A token acts as its creator, narrowed to its scopes, and stops when the creator's account is disabled.
- Scopes are the permissions the caller's own role holds -- never
*, nevertokens:write. Asking for more is403naming the scope, and is audited asapi_token.refused. Rotation is held to the same rule, so an Admin cannot rotate an Owner'stenant:writetoken. - Expiry is 90 days unless asked, 365 at most. There is no token that does not expire.
- The secret is in the create and rotate answers, with
Cache-Control: no-store, and nowhere else. Only its SHA-256 is stored; a lost secret is replaced by rotating, not recovered. - Rotation has no overlap: the old secret fails from the moment the new one exists. A grace period would be a window in which a leaked secret still works. For a zero-downtime change, create a second token, move the integration, then revoke the first.
- Revocation takes effect on the next request, records who and why, and revoking twice changes nothing.
- A refused token is told
401and nothing else, whatever the reason -- a message saying "revoked" would confirm to whoever holds a leaked token that it was once real. The server counts why, inmydlp_api_token_refusals_total{reason}, and logs the public id for every class butunknown(which is what scanners produce).revokedis the series to alert on: somebody still has a token that was taken away (DLP-600).
Every create, rotate and revoke is audited against api_token:<id> -- the same actor string the audit log records for every request the token itself makes, so its whole life reads as one thread -- with its public id, and never its secret. Policy publication history resolves that principal to the same current token name, human owner, scopes and last-rotation time shown by the Audit log. Approval independence is evaluated using the token owner's user id: a token cannot approve a policy version authored or submitted by its own owner. A token may submit a release, but cannot satisfy the fresh human presence required to publish or approve one even when its scopes name those permissions; a different signed-in person must complete the release. last_used_at is a time and nothing else.
Service accounts (non-human API principals)
GET /api/v1/service-accounts list metadata
GET /api/v1/service-accounts/scopes machine scopes caller may grant
GET /api/v1/service-accounts/owners minimal active-owner picker
POST /api/v1/service-accounts {name,purpose,owner_user_id,role,custom_role_id?,scopes,allowed_sources?}
GET /api/v1/service-accounts/{id} detail
PUT /api/v1/service-accounts/{id} full fields + revision + reason
GET /api/v1/service-accounts/{id}/activity last 25 relevant audit events
POST /api/v1/service-accounts/{id}/disable {reason}
POST /api/v1/service-accounts/{id}/enable {reason}
GET /api/v1/service-accounts/{id}/credentials credential metadata, never secrets
POST /api/v1/service-accounts/{id}/credentials {name,expires_in_days?} → 201, secret once
POST /api/v1/service-accounts/{id}/credentials/{credential_id}/revoke {reason}Management requires tokens:write from a fresh human session. A service credential cannot manage service accounts, users or human API tokens. It has the distinct mydlp_svc_ prefix, its own tenant-bound account, assigned role and explicit scopes; it does not act with its responsible human owner's role. The owner is a required accountability contact. Built-in service roles are Security Analyst, Auditor or Viewer; Owner and Admin are unavailable. Only the machine-suitable scopes in GET /service-accounts/scopes can be granted, and only when both the chosen role and the creator hold them. A retired custom role fails closed. The account's allowed IP ranges are enforced on every request. Tenant token policy also governs issuance and scope choice.
Credentials expire within 365 days (90 by default); only their SHA-256 hashes are stored. Create a second credential, switch the integration, then revoke the first for an overlap without a grace period on a compromised secret. Create replies carry Cache-Control: no-store; list and detail never carry the secret. Disabling an account refuses all its credentials immediately; re-enabling does not revive an individually revoked or expired one. Refused known credentials are audited by public ID and reason, never by secret. Service accounts do not acquire browser sessions or console sign-in. Their credentials currently accept GET/HEAD only: some older personal-view routes mutate data under a *:read scope, so method-level denial protects machine principals until those routes have explicit non-human attribution. The responsible owner's private views and team memberships are not inherited.
Event contract
Handled explicitly, with tests:
| Concern | Handling |
|---|---|
| Schema versioning | Every envelope carries schema_version; a major mismatch is Rejected, not dropped |
| Duplicates | UNIQUE (tenant_id, event_id); the agent derives the id deterministically |
| Out-of-order | Per-device sequence with a high-water mark kept monotonic by GREATEST |
| Clock drift | monotonic_ms alongside wall clock; drift measured at check-in; events more than 24 h in the future are rejected |
| Retry | Batch idempotency record answers a repeat without reprocessing |
| Partial batches | Per-event acks |
| Poison messages | Stored in poison_events with a reason and a truncated excerpt |
| Oversized payloads | RequestBodyLimitLayer, plus a per-batch event cap |
| Backpressure | throttle_ms in the response, honoured on top of the agent's own backoff |
Event user context carries username and upn, and file metadata carries owner_sid and owner_name separately. The SID is the stable policy and audit identity; the name is optional directory-resolution output. The server keeps these fields separate where they are stored, and persists none of them when the tenant disables privacy.collect_usernames. The same redaction is applied to rejected-event diagnostic excerpts, so an invalid event cannot bypass the setting.
user_reference (DLP-1862) is the six-character reference the endpoint printed on the notice or prompt for this decision, so that what a person quotes to support finds the incident. It names nothing about the user, the file or the rule. Ingest stores it only in its own shape - six characters from ABCDEFGHJKLMNPQRSTUVWXYZ23456789 - and stores nothing for any other value, so the column cannot carry text; the event itself is still accepted. It is returned on the incident's event timeline and matched by search.
Schema generation
Wire types live in packages/contracts and derive schemars::JsonSchema. tools/schema-gen emits JSON Schema from them:
bash
cargo run -p dlp-schema-gen # write
cargo run -p dlp-schema-gen -- --check # fail if the committed files drifted| Directory | Contracts |
|---|---|
packages/detector-schema/schema/ | endpoint-event, event-batch, event-batch-response, classification-definition, detector-definition, detector-result |
packages/policy-schema/schema/ | policy-spec, policy-bundle-payload, incident-summary, risk-score, enrollment-request, enrollment-response, check-in-request, check-in-response, agent-capabilities |
The --check half is the one that matters, and CI runs it. A generator nobody runs produces schemas that drift from the types they describe, and a stale schema is worse than none: an integrator builds against it and finds out at run time.
The dashboard additionally validates every response with Zod at runtime, so a shape mismatch surfaces as an explicit error rather than a silently wrong number on a security screen. Those Zod definitions are still written by hand and are not generated from these schemas - that gap is DOC-003's follow-on.