Skip to content

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.