Exceptions
An exception is one specific, rule-triggered finding on a device or client — "no EDR agent on this laptop," "this device is billed but nothing has seen it in 30 days," and so on. Ledger re-evaluates every rule after each sync.
The 15 built-in rules
| Code | Severity | Category | What it means |
|---|---|---|---|
MISSING_EDR |
Critical | Security | No EDR agent has checked in on this non-server Windows or macOS device in 14 days (checked for devices seen in the last 30 days), even though the client uses an EDR product. |
MISSING_RMM |
High | Security | The device is seen by EDR or Intune but has no RMM agent at all. |
EDR_STALE |
High | Security | The EDR agent has gone silent, but the RMM agent still sees the device alive — the agent died, not the machine. |
MISSING_MDM |
Medium | Security | A Windows device at a client that uses Intune is not enrolled in it. |
MISSING_DNS_FILTER |
Medium | Security | A device seen by RMM has no DNS-filter roaming client, at a client that uses DNS filtering. Inactive today: Ledger has no DNS-filter connector, so this rule does not fire. |
NOT_BILLED |
High | Billing | A managed device (RMM and EDR, both active) has no PSA agreement line covering it — a direct revenue leak. |
BILLED_NOT_MANAGED |
High | Billing | An agreement line is billing for this device, but no agent has seen it live anywhere in 30 days — an over-billing / client-trust risk. |
BILLING_COUNT_MISMATCH |
Medium | Billing | For count-only clients (pattern B), the number of managed devices doesn't match the billed quantity. |
RMM_STALE |
Medium | Hygiene | The RMM agent has stopped checking in for 30+ days — decommissioned, or worth investigating. |
DUPLICATE_INTUNE |
Low | Hygiene | Two or more Intune records resolved to the same device — reimage/re-enrollment residue. |
DUPLICATE_ENTRA |
Low | Hygiene | Same failure mode as DUPLICATE_INTUNE, for Entra device objects. |
ORPHANED_AGENT |
Medium | Hygiene | A device seen by exactly one source, first seen 30+ days ago — likely leftover from a migration. |
NO_CLIENT_MAPPING |
Medium | Hygiene | The device can't be attributed to any client yet — this blocks every other rule for that device until it's mapped. |
UNUSABLE_IDENTITY |
Info | Hygiene | Every identity key this device carries (serial, SMBIOS UUID, MachineGuid, MAC) failed matching — explains why reconciliation couldn't place it. |
WARRANTY_EXPIRED |
Low | Lifecycle | Device's tracked warranty (from an MSP-uploaded warranty CSV) has expired. |
Warranty expiry (CSV upload)
WARRANTY_EXPIRED works from warranty dates you give Ledger — it doesn't
read them from any connected source. You upload them as a CSV, one client
at a time.
Where. Open the client's summary page and use the Warranty expiry section beside the Export and Print buttons: Upload warranty CSV to choose a file, or Download CSV template for a file that already has the header row. The section never appears in the printed report.
The file. A header row, then one row per device, with three columns:
| Column | Required | What it holds |
|---|---|---|
serial |
Yes | The device's serial number. |
warranty_expires_at |
Yes | The date the warranty ends, written YYYY-MM-DD (for example 2027-01-31). |
purchase_date |
No | The purchase date, written YYYY-MM-DD. Leave it blank if you don't have it. |
Column names can be in any order and any letter case, and other columns
are ignored. Quoted values, Windows line endings and blank lines are fine,
so a file saved from a spreadsheet as CSV is read correctly as long as it
separates values with commas (a semicolon- or tab-separated export is not
read) and its date columns are written YYYY-MM-DD. If the header has no
serial or no warranty_expires_at column, the page says so and nothing
is sent. Dates must be written YYYY-MM-DD: a row with 01/31/2027,
2027-1-31 or 20270131 is rejected, because Ledger doesn't guess a date
format.
Limits. A file can hold up to 20,000 rows and be at most 5 MB; the page refuses a larger file and sends nothing. Each value in a row is at most 1,024 characters, and a serial is at most 256 characters after it has been normalized (see Re-uploading).
What gets rejected. A bad row rejects only itself; the rest of the
file is imported. A row is rejected when its serial is missing or is a
placeholder — a manufacturer's default string such as N/A or 12345,
one Ledger ignores as meaningless —
or when its warranty date is missing or isn't YYYY-MM-DD, or its purchase
date is filled in but isn't. If one serial appears more than once in a file,
the last valid row for it is used and every other row for that serial is
rejected (an earlier valid row because a later valid one replaces it, any
invalid row for its own reason).
When the upload finishes the page shows "N imported, M rejected",
followed by the first five reasons and "and K more" if there are more.
A row number in a reason counts the data rows under the header, not
counting blank lines, and a reason never repeats the value you uploaded.
Re-uploading. Uploading a serial again updates its record; it never
creates a second one. Serials are compared after removing control
characters, trimming, collapsing repeated spaces and upper-casing, so
sn-123 and SN-123 are the same serial.
Matching is per client. A record applies to the devices of the client you uploaded it for and to no one else's: a serial uploaded for one client never applies to another client's device, even when the serial is the same. A device with no uploaded record — or with no serial, or no client — never fires the rule. Ledger doesn't invent an expiry date.
When it fires. Only once the expiry date is in the past. A warranty that ends today has not expired yet, and counts as expired from the next day (dates are compared with today's date in UTC). The exception is Low severity and Lifecycle category, unless you've retuned your own copy of the rule.
When a change takes effect. An upload doesn't evaluate anything itself. Its dates are used the next time Ledger evaluates your rules, which happens after each sync.
Custom rules
Every built-in rule can be retuned, and your own rules can be added, over
/api/exception-rules*. (The /rules screen this API drives is covered
in The /rules screen, below.)
Create. POST /api/exception-rules takes a full rule document — a
code (your own choosing; it can't reuse one of the 15 built-in codes),
name, severity, category, and a definition (scope + condition
+ message, the same shape as every built-in) — and creates a rule that
is yours alone.
Validate before you save. POST /api/exception-rules/validate checks
a rule document without creating anything — useful while you're still
drafting one. A valid document returns {"valid": true}; an invalid one
returns HTTP 422 with {"detail": "..."} naming exactly which part is
wrong — the same shape every other error from this API uses.
Preview before you commit. POST /api/exception-rules/preview runs a
candidate rule against your real, current devices and clients and shows
you which of them would fire — without creating, saving, or changing
anything. Nothing is written anywhere; run it as many times as you like
while you refine a rule. If the category the rule reads is currently
stale for you (see below), the preview says so instead of silently
reporting "no matches."
Editing a built-in never changes it for anyone but you. PATCH
/api/exception-rules/{id} on one of the 15 built-ins never rewrites the
shared definition — it creates your own private copy ("shadow") that
Ledger evaluates instead of the built-in, from then on, for your tenant
only. You can freely retune a built-in's severity, category, name, or
whether it's enabled at all. You cannot change the trigger condition
of ORPHANED_AGENT, NO_CLIENT_MAPPING, UNUSABLE_IDENTITY, the three
billing-mismatch rules, or WARRANTY_EXPIRED — those can still be
retuned on every other field.
Deleting a rule. DELETE /api/exception-rules/{id} removes one of
your own rules (a built-in itself can never be deleted — edit or
disable your shadow copy of it instead). A rule that has ever actually
fired keeps its history even after you stop using it, so Ledger refuses
to delete a rule with any exception history attached; disable it instead
(PATCH with is_enabled: false) if you no longer want it evaluated.
An edited or newly-created rule doesn't wait for the next scheduled sync to take effect — Ledger re-evaluates it right away.
The /rules screen
Everything above is the API; this is the screen it drives.
Getting there. The home screen has a Rules link alongside Exceptions, Billing reconciliation, and the other screens.
The rule list. /rules is a table — code, name, severity, category,
a Built-in or Custom badge, and an Enabled checkbox you can
flip directly from the list (this is the same toggle described above;
flipping it on a built-in creates your shadow copy the same way the API
call does), and, for a shadowed rule whose definition no longer matches
its current built-in, a Drifted from built-in badge. Each row also has a View
JSON action that expands the rule's raw definition read-only, and an
Edit link that opens the builder below. Category at the top of
the screen narrows which rows
are shown; it filters the already-loaded list rather than making a new
request, since the underlying API always returns your full rule set in
one call. A New rule button opens the same builder with a blank
form.
The rule builder (/rules/new, or /rules/:id/edit). Scope
(Device OS family, Is server, Device last seen within (days),
Client has source type(s), Client has source category/ies) and the
rest of the rule (Code, Name, Severity, Category, Message) are
always plain form fields — none of that part of a rule nests. The condition — the
part of a rule that decides whether it fires — is guided for the three
flat shapes (exists, not_exists, count_gte): pick a kind, fill in
its fields, done. Choosing all or any instead switches that one
section to a JSON text box, pre-filled with {"all": []} or {"any":
[]} as a starting point, because a boolean composition of conditions is
the one part of the DSL that can nest arbitrarily and a form can't
reasonably chase that recursion. You
can paste any valid condition into that box, not only a nested one — the
condition of a built-in rule such as MISSING_EDR among them, if you want to see the
shape a built-in actually uses. Whichever way you built it, the
Create rule/Save changes button always checks the definition
with Ledger before writing anything, and if something's wrong the exact
reason appears next to the section it's about (the rule's identity, its
scope, its condition, or its message) — never a generic "something went
wrong."
Rules you can't rewrite the condition of. A handful of built-ins have a fixed trigger condition (the list is in the API section above). Opening one of these in the builder shows a grayed-out condition section with a short explanation instead of fields to edit — name, severity, category, and enabled state are still yours to change, and saving those does not touch the condition at all.
The preview panel. While you build or edit a rule, a Preview panel below the form shows which of your current devices and clients would match it, as you go — it debounces (it doesn't re-check on every keystroke) and always re-checks with Ledger before asking what would match. It carries its own reminder: "This does not save or create any exception — preview only." Nothing you see there is written anywhere; close the tab without saving and nothing happened. A result of "no matches" and a result of "this rule doesn't parse" look different on purpose — one means the rule is fine but nothing currently qualifies (which may be exactly right), the other means the rule itself needs fixing first.
Why a rule doesn't fire for every client
Security and billing rules only ever fire for a client that actually
uses the relevant source category — Ledger never invents a MISSING_EDR
finding for a client that has no EDR product connected at all. Which
categories are "in scope" for a client is inferred from what its other
devices are observed by.
If a client's source data for a category is stale beyond that source's own sync cadence, any rule that reads that category is skipped for that run rather than firing a false exception — the run instead carries a staleness label so you know why a rule silently sat out that cycle.
Suppressing an exception
Every exception can be suppressed, one at a time or in bulk, with a required reason (at least 3 characters):
- Permanently — this specific finding never resurfaces on its own.
- For N days (1–365) — it resurfaces automatically once the window passes, with the stored reason still attached so you can see why it was suppressed in the first place.
Suppression is never silent: the reason and who suppressed it stay visible on the exception, both while it's suppressed and after it resurfaces or resolves.
Creating a PSA ticket
Turn an exception into a work item in your PSA with one click, or — from the bulk action bar above the table, once you've selected some rows — up to 25 at once. The ticket's title is the fired rule's own name; its description is either the rule's own description, when it has one, or otherwise the exception's own rendered detail message.
Creating a ticket never changes the exception's own status. Filing a PSA ticket and managing the exception itself (acknowledging it, suppressing it, letting it resolve) are separate actions in Ledger — doing one never does the other. See Tickets's "Lifecycle rules" section for the other half of that same independence — what happens (nothing) on either side when a linked ticket resolves or closes on the PSA's own side.
A failed create is never retried automatically — no PSA here guarantees it will reject a duplicate attempt, so an automatic retry risks opening a second real ticket. Once the underlying problem is fixed (an expired credential, an unreachable PSA), click create again.
This is a write feature: it only does anything once write features are enabled for your account and you've added a ticket-creation connection. See Tickets for the full data model, the one-click/bulk flow, per-client defaults, and how a ticket shows up in the CSV/XLSX export.