Apex Quiddity Explained: What 23 Entry Points Report
Shared Apex gets called from more places than anyone plans for. The same trigger handler runs when someone saves a record on a Lightning page, when an integration loads ten thousand rows overnight, and when a batch job tidies up afterwards. Sooner or later someone wants that code to behave differently depending on which of those is happening, and that’s usually when Quiddity comes up.
Quiddity is the category Salesforce assigns to an Apex transaction based on how it started. You can read it with System.Request.getCurrent().getQuiddity(), which returns a System.Quiddity value such as AURA, QUEUEABLE or BULK_API. That gives shared code a way to choose how to deliver work, such as paying newly approved expense claims straight away or leaving a bulk import for an overnight sweep. It also gives your logs a label for the kind of transaction that did the work. The enum names alone don’t tell you which category each real route will produce.
So I tested it. I deployed a small probe to a Winter ’27 Developer Edition org and called it from 23 entry points: the Lightning user interface, Visualforce, the application programming interfaces (APIs), flows, an Agentforce agent, all four kinds of asynchronous Apex, and three kinds of test run. A few of the results break patterns you’ll find in published examples. Batch Apex reports BATCH_APEX in start() and a different value in execute(). Code that runs when someone saves a record on a Lightning page doesn’t see AURA. And the same invocable action reports different values on two paths of one record-triggered flow.
This post explains what Quiddity describes and where it’s useful, then uses those results to work through tests, security guards, routing and logging. It assumes you’re comfortable with triggers and asynchronous Apex; if you’d like a refresher first, Triggers, Limits & Bulk Patterns covers trigger handlers and bulk processing, and Asynchronous Apex covers Queueables, finalizers, Batch Apex and Scheduled Apex.
⚡ TL;DR
Section titled “⚡ TL;DR”- Salesforce assigns Quiddity automatically to categorise how the current Apex transaction started. Use it to decide how shared code delivers work, such as paying now or leaving a bulk load for a sweep, and to label log entries, but keep the business rules the same on every route. Different callers can share a value, so it doesn’t identify the caller or establish trust.
- In Batch Apex,
start()reportsBATCH_APEX, andexecute()andfinish()reportBATCH_CHUNK_SERIALfor a QueryLocator batch orBATCH_ACSfor an Iterable batch.System.isBatch()returns true in all three. - When someone saves a record on a Lightning record page, the trigger sees
SYNCHRONOUS, the same value as an integration’s Representational State Transfer (REST) API insert. In my tests,AURAappeared only when a Lightning Web Component called Apex. - Calling an invocable method doesn’t automatically produce
INVOCABLE_ACTION. In a record-triggered flow, the immediate path kept the save’s value; the Run Asynchronously path reportedINVOCABLE_ACTIONin its own transaction. Screen flows, agents and the REST actions endpoint reportINVOCABLE_ACTIONtoo, so the value doesn’t mean background work. - Inside a test, everything reports the test run’s value, including Queueables, finalizers and batches run by
Test.stopTest(). Pass the value in to test those branches. System.Quiddity.values()returned 32 values where the reference documents 29, so everyswitchon Quiddity needs awhen else.- Separate async transactions can share a request ID, so log the job ID alongside it.
🧭 What Quiddity Is
Section titled “🧭 What Quiddity Is”Salesforce assigns a Quiddity value automatically when an Apex transaction starts. The value is a member of the System.Quiddity enum, which lists categories such as anonymous Apex (ANONYMOUS), Queueable Apex (QUEUEABLE) and Bulk API (BULK_API). An enum is a named set of values, so your code can compare the value with System.Quiddity.BULK_API or use it in a switch. You read it, together with the request ID, from the current System.Request:
System.Request request = System.Request.getCurrent();System.Quiddity origin = request.getQuiddity();String requestId = request.getRequestId();The value belongs to the transaction, not to the code that reads it. Every class that runs in the same transaction sees the same value, whether it’s a trigger handler, a utility class or an invocable action. Call that class from a different route and it can see a different value. Later in this post, one invocable action reports SYNCHRONOUS when a record-triggered flow calls it during a save, and INVOCABLE_ACTION when the same flow calls it from its Run Asynchronously path. Keep that in mind and most of the results below make sense.
Several entry points can share a category. In the tests below, a record-page edit and an integration’s standard REST API insert both reported SYNCHRONOUS. You would need another signal to distinguish those two callers.
🧩 Where Quiddity Earns Its Place
Section titled “🧩 Where Quiddity Earns Its Place”Quiddity is useful when the transaction’s origin should change how shared code carries out its work, such as whether a step runs straight away or later, or how much work it takes on in one transaction. One example runs through this post: a trigger that normally enqueues a payment job for newly approved expense claims, but leaves approvals from a Bulk API load for a nightly sweep. Both routes submit eligible claims for payment; the sweep controls how much is sent to the finance system at a time. The routing example below builds that decision.
You can use Quiddity to change when work happens or how it’s grouped, but keep the rules that decide whether it should happen the same on every route. In the claims example, approval status, required data and protection against duplicate payments still matter whichever route approved the claim. A Quiddity check shouldn’t make an otherwise ineligible claim payable, or let one caller bypass checks that another must pass.
It also helps you investigate shared code after it has run. A log entry that includes Quiddity can tell you whether an error happened during a Bulk API load, a Queueable or a test run. Add the request ID and job ID to connect that category to the work you’re investigating.
Choose the context check by the question you need answered. Quiddity sits alongside the other Apex context APIs:
| What you want to know | Signal to use |
|---|---|
| How did this transaction start? | Quiddity, including BULK_API for a Bulk API load and TRANSACTION_FINALIZER_QUEUEABLE for a finalizer |
| Am I running inside Batch Apex? | System.isBatch(), which covers all three batch methods |
| Is there room for another Queueable job? | Compare Limits.getQueueableJobs() with Limits.getLimitQueueableJobs() |
| Which records changed, and was this an insert or update? | Trigger context, such as Trigger.new, Trigger.oldMap and Trigger.operationType |
| Which behaviour has the caller requested? | Intent passed explicitly from an entry point you control |
| Can this user access the data, and is the input safe? | Sharing, user mode, validation and bind variables |
The Limits methods report capacity in the current transaction; the trigger context variables describe the record operation. Neither question needs a list of Quiddity values. Likewise, a Lightning Web Component (LWC) controller or an Apex REST class you own can pass its intended behaviour directly. Quiddity earns its place in shared handlers where the transaction’s category is useful but the caller hasn’t supplied that intent.
🔬 How I Tested It
Section titled “🔬 How I Tested It”The enum descriptions say what each value is meant to cover. I wanted to know which value real routes produce, so I put together the smallest probe that could answer that. One Apex method reads the Quiddity and saves a record holding:
- the entry point, as a label passed in by the caller
- the Quiddity, read by a class saved at API 68.0
- the same transaction’s Quiddity, read by a second class saved at API 59.0, because Salesforce versioned a change to the default value at API 60.0
- the request ID, the results of
System.isBatch(),isQueueable(),isFuture()andisScheduled(), and the running user
The heart of the probe is one short method writing to a custom object, Quiddity_Probe__c, with a text field for each value. Trimmed to three fields, it looks like this:
public static String record(String entryPoint) { System.Request request = System.Request.getCurrent(); String quiddity = request.getQuiddity().name();
Quiddity_Probe__c probe = new Quiddity_Probe__c( Entry_Point__c = entryPoint, Quiddity__c = quiddity, Request_Id__c = request.getRequestId() ); // System mode lets any caller record a row, including an agent's user. // That's fine for a throwaway probe, and wrong for real data. insert as system probe; return quiddity;}I then called that method from each entry point: anonymous Apex, an Account trigger fired by record saves, an LWC, a Visualforce page, an Apex REST class, the standard REST API and Bulk API 2.0, the REST actions endpoint, a screen flow, a record-triggered flow, an Agentforce Employee agent, Queueables with and without a Transaction Finalizer, a @future method, Scheduled Apex, two kinds of batch, and the same test class run three ways. One query over the saved records produced most of the results table. Test runs roll their data back, so each test failed on purpose and reported what it saw in its failure message instead.
📊 What Each Entry Point Reported
Section titled “📊 What Each Entry Point Reported”Here are the results, grouped by the value each route reported. The API 59.0 class saw exactly the same value as the API 68.0 class on every row, so the table shows one column.
| Entry point | Quiddity |
|---|---|
| Anonymous Apex, and a trigger it fired | ANONYMOUS |
| Record created or edited on a Lightning record page (trigger) | SYNCHRONOUS |
| Record inserted through the standard REST API (trigger) | SYNCHRONOUS |
| Record-triggered flow, immediate path | The save’s value (here SYNCHRONOUS) |
| Record inserted through Bulk API 2.0 (trigger) | BULK_API |
Lightning Web Component calling an @AuraEnabled method |
AURA |
| Visualforce page action | VF |
| Apex REST class, GET and POST | REST |
| Screen flow Apex action, from Debug and from Run | INVOCABLE_ACTION |
| Record-triggered flow, Run Asynchronously path | INVOCABLE_ACTION |
| Agentforce Employee agent action, in Agentforce Builder preview | INVOCABLE_ACTION |
| REST actions endpoint | INVOCABLE_ACTION |
Queueable execute(), with or without a finalizer |
QUEUEABLE |
| Transaction Finalizer | TRANSACTION_FINALIZER_QUEUEABLE |
@future method |
FUTURE |
Schedulable execute() |
SCHEDULED |
Batch start() |
BATCH_APEX |
QueryLocator batch execute() and finish() |
BATCH_CHUNK_SERIAL |
Iterable batch execute() and finish() |
BATCH_ACS |
| Unit tests run synchronously, asynchronously, and in a deploy validation | RUNTEST_SYNC, RUNTEST_ASYNC, RUNTEST_DEPLOY |
| Asynchronous work started inside a test | The test run’s value |
Some rows combine related entry points, such as anonymous Apex and the trigger it fired, which is how 23 entry points fit in 21 rows. No route reported UNDEFINED. The sections below go through the results that matter most when you’re writing code.
🧱 Batch Apex changes value after start()
Section titled “🧱 Batch Apex changes value after start()”This is the result most likely to break code that’s already running. BATCH_APEX sounds as if it covers Batch Apex, but it only appeared in the start() method. The execute() chunks and finish() reported a different value, and which value depended on what start() returned:
| Batch type | start() |
execute() |
finish() |
|---|---|---|---|
| QueryLocator | BATCH_APEX |
BATCH_CHUNK_SERIAL |
BATCH_CHUNK_SERIAL |
| Iterable | BATCH_APEX |
BATCH_ACS |
BATCH_ACS |
So a check for System.Quiddity.BATCH_APEX is false for nearly all the work a batch job does, and switching a batch from a QueryLocator to an Iterable changes what its execute() method sees. The reference doesn’t make this obvious either: it describes BATCH_ACS as Batch Apex driven by an API query cursor, which doesn’t sound like an ordinary Iterable batch.
System.isBatch() returned true in all six methods. If your question is “am I running inside Batch Apex?”, System.isBatch() is the simpler and safer check. If you’re switching on Quiddity for other reasons, consider listing all three batch values together.
🔍 Lightning record saves run as SYNCHRONOUS
Section titled “🔍 Lightning record saves run as SYNCHRONOUS”It’s easy to read AURA as “a user is working in Lightning”. In practice it’s narrower than that. An LWC calling an @AuraEnabled method reported AURA, but creating and editing a record on a standard Lightning record page fired the trigger with SYNCHRONOUS.
The trigger reported SYNCHRONOUS again when an integration inserted a record through the standard REST API. It didn’t report REST, which is the value for an Apex REST class, not for the REST API in general. In the Apex Execution event log, SYNCHRONOUS appears as the code R, which the Apex Execution event type describes as the catch-all for transactions that aren’t classified anywhere else. That’s how it behaved here: a user’s edit and an integration’s insert both landed in it, so trigger code can’t use Quiddity to tell a person editing a record from an integration loading one.
If your logic genuinely needs to know that a person made a change, Quiddity won’t tell you. Pass that intent explicitly from the route you control, such as an LWC controller method or a dedicated invocable action.
🤝 Invocable methods inherit their transaction’s Quiddity
Section titled “🤝 Invocable methods inherit their transaction’s Quiddity”I called the same invocable action five ways, and four of them reported INVOCABLE_ACTION: a screen flow, an Agentforce Employee agent in Builder preview, the REST actions endpoint and a record-triggered flow’s Run Asynchronously path. Only the last of those runs in the background. The other three are synchronous: a person waits on the flow screen while the action runs, the agent waits for its result before replying, and the API client waits for the response. So INVOCABLE_ACTION covers a person, an agent, an API client and background work, and it can’t tell you which of them called the action.
The fifth call didn’t report INVOCABLE_ACTION. After an Account insert through the standard REST API, the same record-triggered flow called the action from its immediate path as well as its Run Asynchronously path, and the action reported a different value on each:
On the immediate path, the action ran inside the save’s transaction, reported the save’s SYNCHRONOUS value and shared the Account trigger’s request ID. On the Run Asynchronously path, it ran in a separate transaction with a different request ID and reported INVOCABLE_ACTION. The action’s code was the same in both cases; the transaction it ran in determined its Quiddity. That held across all five calls: the action reported INVOCABLE_ACTION unless it ran inside a transaction that a record save had already started.
There is a second limit to keep in mind. On the flow’s Run Asynchronously path, System.isQueueable(), isFuture(), isBatch() and isScheduled() all returned false, and Quiddity reported the same value as the screen flow. So none of those five checks could tell the code it was running in the background. If your logic needs to distinguish the callers or flow paths, use separate actions or pass that intent explicitly.
🔄 Async jobs report their own route
Section titled “🔄 Async jobs report their own route”Each asynchronous job starts a new transaction, and it reports how that transaction started rather than how its parent did. A Queueable enqueued from Apex REST reported QUEUEABLE, not REST. A @future method reported FUTURE, and Scheduled Apex reported SCHEDULED.
Transaction Finalizers are the case to watch. A Queueable’s execute() reported QUEUEABLE both before and after it called System.attachFinalizer(), even though the reference describes TRANSACTION_FINALIZER_QUEUEABLE as a Queueable with finalizers attached. The finalizer itself ran in its own transaction and reported TRANSACTION_FINALIZER_QUEUEABLE, and inside it System.isQueueable() returned false.
That matters for code that runs in a finalizer, such as the retry logic in the reimbursement job: a check for QUEUEABLE or a call to System.isQueueable() won’t recognise where it’s running. A limit check can go wrong too: the Transaction Finalizers page says a finalizer runs with synchronous governor limits except for heap size, @future calls and System.enqueueJob, which use the asynchronous limits, so code that reads a false System.isQueueable() as permission to enqueue 50 jobs can enqueue only one.
🧪 Tests report the test run, even for async work
Section titled “🧪 Tests report the test run, even for async work”I ran the same test class three ways: synchronously, asynchronously, and inside a deploy validation. Each test enqueued real asynchronous work and ran it with Test.stopTest(). Everything reported the test run’s value (RUNTEST_SYNC, RUNTEST_ASYNC or RUNTEST_DEPLOY), including the Queueable, its finalizer, all three batch methods and the @future method.
Two things follow from that:
- Running the real job in a test won’t reach the code you wrote for
QUEUEABLEor a batch value. The job reports the test run’s Quiddity value instead, so your code takes whichever path handles that value, often thewhen else. A passing test run doesn’t prove those paths work. - To test that code, pass the value in. Move the decision into a method that takes a
System.Quiddityparameter, then call it from the test with the value you want, such asQUEUEABLEorBULK_API. The routing example below does this with a@TestVisiblemethod, which takes the result ofSystem.isBatch()as a second parameter for the same reason.
📜 The org knows more values than the docs list
Section titled “📜 The org knows more values than the docs list”System.Quiddity.values() returned 32 values in my org, while the Winter ’27 reference lists 29. The three extras each have their own short code, and the two I referenced directly, UNDEFINED and IOT, compile:
| Value | Short code | Notes |
|---|---|---|
UNDEFINED |
UD |
The default since Spring ’24 for events without a more specific value |
IOT |
I |
Shares its short code with INVOCABLE_ACTION |
TRANSACTION_SECURITY_POLICY |
TSP |
Not documented anywhere I could find |
UNDEFINED comes from a Spring ’24 change. In that release, Salesforce made “undefined” the default for events without a more descriptive value, replacing the old synchronous catch-all. The change is versioned at API 60.0. In my tests nothing reported it, and the class saved at API 59.0 saw the same value as the API 68.0 class on every route, including the ones that reported SYNCHRONOUS.
You probably won’t meet UNDEFINED on everyday routes, but your code still has to cope with it, and with whatever value the next release adds. The Apex documentation on switch statements recommends a when else block for exactly this reason: it handles enum values you didn’t anticipate.
🔒 Quiddity Can’t Tell You Who to Trust
Section titled “🔒 Quiddity Can’t Tell You Who to Trust”Kevin Poorman’s 2021 post on the Salesforce Developers Blog, You Shall NOT Pass, lists several uses for Quiddity, and its worked example is a security guard that stops a risky method from running where it might receive untrusted input. That method builds dynamic SOQL (Salesforce Object Query Language) queries, and the guard lives on as the QuiddityGuard class in Salesforce’s Apex Recipes sample app. QuiddityGuard keeps a list of trusted values (SYNCHRONOUS, QUEUEABLE, BATCH_APEX and the three test values), and the guarded method returns an empty list when the current value isn’t on it.
That was a reasonable instinct in 2021, when Apex database operations ran in system mode by default and enforcing object and field permissions was the developer’s job, as it still is in code saved below API 67.0. Set against these results, though, the trusted list has two kinds of problem. The first is trust: the list lets through input it’s meant to stop.
- Untrusted input can arrive under a trusted value. A Queueable enqueued by an Apex REST class reported
QUEUEABLE, notREST. Apex Recipes listsRESTas untrusted andQUEUEABLEas trusted, so if the Queueable passes data from the Apex REST request to the guarded method, the guard lets it through. SYNCHRONOUScovers both user and integration saves. The Apex Recipes comment says the trusted list leaves out “user situations where users could be injecting malicious data”, but trigger code sawSYNCHRONOUSwhen a user edited a record on a Lightning page. It saw the same value when an external system inserted a record through the REST API, so the guard would let both through.
The second is coverage: the list refuses work it should allow, and hides the refusal.
- Most batch work is refused, and so are finalizers.
BATCH_APEXis on the list, butexecute()andfinish()reportBATCH_CHUNK_SERIALorBATCH_ACS, so the guarded query builder would return an empty list inside every batch chunk.TRANSACTION_FINALIZER_QUEUEABLEisn’t on the list either, so a Queueable passes the guard but its finalizer doesn’t. - The same trigger code gets different answers. A Bulk API 2.0 load reports
BULK_API, which isn’t on the trusted list, so the guarded query would return results when records arrive through the REST API and an empty list when they arrive through Bulk API. - A refusal looks like no data, and a test of the real batch won’t show it. An empty list reads as “no matching records” to the caller. The three test values are on the trusted list, and a batch run by
Test.stopTest()reports the test run’s value, so the test gets results while production gets an empty list from every chunk. Only a test that sets the Quiddity itself sees the refusal, which is whatQuiddityGuard’s@TestVisibletestQuiddityOverridefield is for.
Adding the missing values and throwing an exception on refusal would fix the coverage problems, but not the trust problems: a list of Quiddity values can’t tell you where the input came from.
To be fair to the original, the Apex Recipes code calls its trusted list non-exhaustive, and the 2021 post paired the guard with object and field security checks rather than offering it as a replacement for them. The platform has also moved on since. From API 67.0, database operations default to user mode, and WITH SECURITY_ENFORCED, which the 2021 example appended to its query, is no longer supported in Apex SOQL. The Apex Recipes query builder itself now passes AccessLevel.USER_MODE.
User mode decides what a query can return by enforcing the running user’s sharing, object permissions and field-level security. Bind variables, and an allowlist for anything that has to be dynamic, stop input from changing the query itself. Security and SOQL covers the API 67.0 defaults, and Dynamic SOQL covers building queries safely.
A Quiddity guard can limit which routes reach a method, but it can’t replace those controls. If you keep one as an extra layer, allow only the routes you’ve tested, refuse everything else, and throw an exception rather than returning an empty result, so that a refusal is visible.
🔧 A Routing Decision That Survives New Values
Section titled “🔧 A Routing Decision That Survives New Values”The expense-claim example from Where Quiddity Earns Its Place comes back here as code: Quiddity decides when the payment work runs, and the payment rules stay the same. In the reimbursement build in Asynchronous Apex, ExpenseClaimTrigger enqueues one ClaimReimbursementJob for the claims approved in each trigger invocation. When the trigger runs inside Batch Apex, it skips the enqueue and leaves those claims for the nightly ClaimReimbursementRetryBatch sweep. It uses System.isBatch() for that check, which is the right signal for the question it asks.
Now suppose finance starts approving claims with a Bulk API 2.0 import. Every 200-record chunk fires the trigger and starts its own payment job, so a large import becomes a burst of payment jobs against the finance system. You’d rather those claims waited for the sweep, which works through them one chunk at a time. System.isBatch() can’t see a Bulk API load (it was false in my test), but the trigger’s Quiddity can: BULK_API.
Keep that choice in one small class, ClaimPaymentTiming. The trigger asks whether to pay now, and this class answers from two signals: System.isBatch() for Batch Apex, and Quiddity for a Bulk API load. If another handler needs the same choice, it calls payNow() too, rather than repeating both checks. The reimbursement job can keep its existing checks for claims that are still approved and unpaid.
/** * Decides whether newly approved expense claims are paid straight away or left * for the nightly ClaimReimbursementRetryBatch sweep. */public inherited sharing class ClaimPaymentTiming { public static Boolean payNow() { return payNowFor( System.Request.getCurrent().getQuiddity(), System.isBatch() ); }
@TestVisible private static Boolean payNowFor(System.Quiddity origin, Boolean inBatchApex) { // Every Batch Apex method waits for the sweep, whichever value it reports. if (inBatchApex) { return false; } switch on origin { // System.isBatch() is false during a Bulk API load, so check it here. when BULK_API { return false; } // Everything else pays now, including UNDEFINED and any value a // later release adds. The trigger's Limits check and the sweep // cover anything that can't be paid straight away. when else { return true; } } }}Use the fully qualified name System.Quiddity in the routing class and its tests. A custom class called Quiddity in your org would shadow the system enum, and Nebula Logger keeps an empty class with that name in its tests purely to catch unqualified references.
The trigger’s guard then calls ClaimPaymentTiming.payNow() in place of !System.isBatch(); the rest of the trigger is unchanged:
if (!newlyApproved.isEmpty() && ClaimPaymentTiming.payNow() && Limits.getQueueableJobs() < Limits.getLimitQueueableJobs()) { System.enqueueJob(new ClaimReimbursementJob(newlyApproved));}There are a few important details in this example:
- Batch Apex keeps its
System.isBatch()check. It returned true in all six batch methods, whichever value each one reported, so the check doesn’t depend on knowing every batch value. A switch that listed onlyBATCH_APEXwould quietly start paying claims approved inside a batch’sexecute(). Quiddity only has to add whatSystem.isBatch()can’t see:BULK_API. when elsepays now, on purpose. Anything not listed, includingUNDEFINED, the undocumented values and whatever a future release adds, takes the normal path. That’s the safe default for this decision: the trigger’sLimitscheck still stops an enqueue when there’s no room, and any claim that isn’t paid straight away staysApprovedfor the sweep. A guard likeQuiddityGuardshould default the other way and refuse what it doesn’t recognise. Decide the default by asking what a wrong answer costs.- The decision takes both signals as parameters.
payNowFor()doesn’t read the platform, so a test can hand it any combination. That matters because a test can’t produceBULK_APIor a batch value any other way.
The tests then cover every branch without running a batch or a bulk load:
@IsTestprivate class ClaimPaymentTimingTest { @IsTest static void batchApexWaitsForTheSweep() { List<System.Quiddity> batchOrigins = new List<System.Quiddity>{ System.Quiddity.BATCH_APEX, System.Quiddity.BATCH_CHUNK_SERIAL, System.Quiddity.BATCH_ACS }; for (System.Quiddity origin : batchOrigins) { Assert.isFalse( ClaimPaymentTiming.payNowFor(origin, true), origin.name() + ' should leave claims for the sweep' ); } }
@IsTest static void bulkApiLoadsWaitForTheSweep() { Assert.isFalse( ClaimPaymentTiming.payNowFor(System.Quiddity.BULK_API, false), 'BULK_API should leave claims for the sweep' ); }
@IsTest static void everyOtherRoutePaysNow() { List<System.Quiddity> payNowOrigins = new List<System.Quiddity>{ System.Quiddity.SYNCHRONOUS, System.Quiddity.AURA, System.Quiddity.INVOCABLE_ACTION, System.Quiddity.UNDEFINED }; for (System.Quiddity origin : payNowOrigins) { Assert.isTrue( ClaimPaymentTiming.payNowFor(origin, false), origin.name() + ' should pay straight away' ); } }}Including UNDEFINED in everyOtherRoutePaysNow records the default as a decision, so a later change to when else breaks a test instead of slipping through. The Asynchronous Apex chapter’s existing trigger tests keep working too: they run as RUNTEST_SYNC, RUNTEST_ASYNC or, in a deploy validation, RUNTEST_DEPLOY, all of which land in when else, so approving a claim still enqueues the job they check.
To confirm the routing in your own org, start with the normal path: approve a test claim on its record page and check that a ClaimReimbursementJob appears in Setup > Apex Jobs.
Then approve a second test claim through Bulk API 2.0. Put its record ID and the new status in a CSV file:
Id,Status__c<record ID of a second test claim>,ApprovedRun the update with --wait, so the command waits up to ten minutes for the job to finish:
sf data update bulk --sobject Expense_Claim__c --file approve-claim.csv --wait 10The command’s summary should show one successful record and no failed records. If the wait runs out first, the command prints a job ID instead; pass it to sf data update resume --job-id to wait for the result. Only then check Setup > Apex Jobs: no new ClaimReimbursementJob should appear, and the claim should stay Approved for the sweep.
📈 Logging Quiddity with the Request ID
Section titled “📈 Logging Quiddity with the Request ID”Logging is where Quiddity helps after the code has run. The value on its own tells you how a transaction started. If your code made a decision from it, log that decision too, because it’s what someone investigating a delay or a failure needs to know.
In the payment example, record whether the trigger enqueued a payment job, left the claims for the sweep because ClaimPaymentTiming.payNow() returned false (a Bulk API load or Batch Apex), or left them because the transaction had no Queueable capacity. Include the claim IDs, Quiddity and request ID, plus the job ID when a job was created. A BULK_API label alone doesn’t tell support why a claim is still waiting, and a claim left for the sweep has no payment job to write a log entry of its own.
When a payment job does run, log its own Quiddity, request ID and job ID as well. You need the job ID because the request ID turned out to be shared:
| Transaction | Quiddity | Request ID |
|---|---|---|
| Queueable, no finalizer | QUEUEABLE |
SLB:9d03…9923 |
| Queueable with a finalizer | QUEUEABLE |
SLB:9d03…9923 |
| That Queueable’s finalizer | TRANSACTION_FINALIZER_QUEUEABLE |
SLB:9d03…9923 |
@future method |
FUTURE |
SLB:9d03…9923 |
Those are four separate transactions: two Queueables and a @future method enqueued by one anonymous Apex script, plus the finalizer that one of the Queueables attached. All four reported the same request ID, which was different from the script’s own. Three execute() chunks of the QueryLocator batch shared a request ID as well. The Request class reference only says the ID represents a transaction but may not be unique everywhere, so treat it as a way to group related work rather than a key for one transaction.
Adding the job ID takes a few lines near the top of the job. In the reimbursement build, ClaimReimbursementJob.execute() would gain this:
public void execute(QueueableContext context) { System.Request request = System.Request.getCurrent(); System.debug(LoggingLevel.INFO, 'ClaimReimbursementJob ' + context.getJobId() + ' | ' + request.getQuiddity().name() + ' | request ' + request.getRequestId());
// The rest of the job is unchanged.}System.debug keeps the example short; in production, send the same three values to your logging framework. In a finalizer, FinalizerContext.getAsyncApexJobId() returns the parent job’s ID. In Batch Apex, Database.BatchableContext.getJobId() returns the batch job’s ID and getChildJobId() returns the ID of the chunk being processed; log the chunk ID too, because the QueryLocator chunks above shared a request ID. If you use Nebula Logger, it already records the request ID and the Quiddity name on every entry, in a field labelled System Mode.
If your org licenses Event Monitoring, you can match the values you’ve logged against Salesforce’s own record: the Request class reference says the request ID and Quiddity match the values in the Apex Execution event log. The log stores each value as a short code, such as L for AURA, and you can look the code up with System.getQuiddityShortCode(value). The reference shows that method without the static keyword, but it compiles and runs as a static call.
For agent calls, the Apex Execution event log also has BOT_IDENTIFIER, BOT_SESSION_IDENTIFIER and PLANNER_IDENTIFIER fields. Those let you investigate the agent behind a call afterwards, even though Quiddity reported the same INVOCABLE_ACTION value as other callers.
IOT and INVOCABLE_ACTION share the code I, so the two look identical in that log. Apex Execution isn’t among the event types included at no extra cost, so check your licence before you plan around it; the values you log yourself are the ones you control.
🎯 Final Thoughts
Section titled “🎯 Final Thoughts”Quiddity makes the transaction’s origin available to shared Apex. It earns its place in shared handlers, such as triggers and invocable actions, where that origin should change how the work is carried out but the caller hasn’t said what it wants. When you own the entry point, such as an LWC controller or an Apex REST class, pass that intent explicitly instead.
The payment trigger’s choice between a job now and a sweep later is one example. Keep the payment rules consistent across both routes, and let the rest of the code ask ClaimPaymentTiming whether to pay now rather than interpret enum values itself. Leave trust decisions to sharing, user mode and validation; a Quiddity value a guard trusts can still carry untrusted input.
The 23 tested entry points show why those decisions need evidence. Callers can share a value, batch methods can have different values, and tests substitute the test run’s value. Keep the mapping in one place, test it with supplied values, choose the fallback deliberately, and log the decision alongside the IDs that let you trace the work.
The values can change between releases, so recheck the ones your code depends on whenever a new release reaches your org. A probe like the one in How I Tested It turns that check into a single query.
If you’d like to see the trigger, Queueable, finalizer and sweep that the routing example changes, the reimbursement build in Asynchronous Apex puts them together step by step.
