I was working out what one of my agent actions could actually reach in my org. The action was written carefully and recently: every query marked, every DML statement explicit, the class on a current API version. I had done the homework.
Then I followed the insert one hop further and found a trigger on the target object. Not a new trigger — an old one, sitting at API version 58, written long before any of this. And I realised I did not know what mode that trigger’s own DML would run in. I assumed it would inherit the caller’s, because the caller had started the transaction and was modern and careful.
I did not want to assume, so I measured it by hand in a real Developer Edition org. What came back changed how I read every codebase since.
The sentence, first
A trigger’s DML executes in the mode of the trigger’s own API version, not the API version of the Apex action that caused the insert.
A v58 trigger fired by a v67 action does not inherit the caller’s user-mode default. It runs by its own rules, decided by a number stored on its own metadata.
Sit with that, because the consequence is uncomfortable. Your modern, carefully written action can cascade into legacy code that runs with no field-level security enforcement at all, and nothing in the calling class hints at it. No warning, no compile message, no line you could have read differently. The dangerous part of the transaction is in a file the caller never mentions.
Why an API version can decide a security question
If you are newer to the platform, this will sound odd: why does a version number affect what code is allowed to do?
Every Apex class and trigger stores an API version — the release its behaviour is pinned to. Salesforce uses it to keep old code behaving as it did when written, so a platform change does not silently break an org overnight. Usually this affects small things. Occasionally it affects a large one. At API version 67.0, the default execution mode for Apex changed: statements now default to user mode, meaning object and field permissions are enforced unless the code says otherwise. Below that version the historic default stands — system mode, where field-level security and object permissions are simply ignored.
So the version field is not housekeeping metadata. On any org holding code from more than one era, it is a security setting. And most orgs hold code from more than one era.
How I established it, and why the method matters more than my result
I did not read this in documentation and pass it on. I ran controlled experiments by hand in a Developer Edition org, and the method matters more than my result, because you can reuse it for any platform question you cannot afford to be wrong about.
Each experiment had three parts:
- A positive control. A case that must succeed if my understanding is right. If it fails, my setup is broken and nothing else I measure means anything.
- A negative control. A case that must fail. If everything passes, I have not built a test — I have built a machine that says yes.
- A discriminator. The one case where the competing explanations predict different outcomes. This is the actual experiment; the controls exist to make its result trustworthy.
Here the two competing explanations were plain. A: the trigger inherits the caller’s mode, so a v67 caller means enforcement all the way down. B: the trigger uses its own version’s default, so the old trigger enforces nothing regardless of who called it.
The discriminator was a field the running user was not permitted to write, touched by a v58 trigger, in a transaction started by a v67 action. Under A, the write is blocked. Under B, it goes through.
It went through. I would rather hand you the method than the conclusion, because the conclusion has a shelf life and the method does not.
Two more results from the same afternoon, equally counter-intuitive
While the org was set up for it, I checked two neighbouring beliefs. Both were wrong.
A class with no sharing declaration does not behave as without sharing. It inherits its caller’s context. The same class can therefore enforce sharing in one transaction and not in another, depending entirely on who called it. If you have been reading undeclared classes as “no sharing, therefore no enforcement,” you have been reading a variable as if it were a constant.
At API version 67.0, the user-mode default overrides even an explicit without sharing for plain operations. That one genuinely surprised me. The keyword sits right there in the source, saying what it has always said — and on a current version it no longer decides the outcome for those operations on its own.
Notice the shared lesson. In all three cases the answer was not in the line of code I was staring at. It was in the context: which version this file is pinned to, and who called it.
The most dangerous line in an Apex transaction is often not in the class you are reading. It is in the file that class never mentions.
What I do differently now
Four habits came out of this, none of them expensive.
- Treat the API version field as a security setting. When I review a class or trigger, I read its version before its logic. It tells me which defaults I am reading under.
- Follow the DML one hop further. A careful action that inserts a record has not finished its story at the insert. Whatever triggers fire are part of the same transaction, and they bring their own rules.
- Never rely on a default I did not write down. Where enforcement matters, I say so in the statement —
WITH USER_MODEon the query,as useron the DML — so the behaviour comes from the code rather than a number in the metadata. An explicit statement reads the same on every version. - Inventory the old triggers. Not to upgrade them all in a weekend, which is how people break orgs, but to know which exist and which objects your modern code writes to. The overlap between those two lists is your real exposure.
One honest caveat, in the spirit of the method above: platform defaults change between releases, and this behaviour is version-dependent by definition. Verify it in your own org rather than trusting my afternoon. That is the entire point of controls.
Your next step
Pick one object your newest code writes to. List every trigger on it, and beside each one write its API version.
If any sits below 67, you have found a place where careful modern code hands work to code that enforces nothing — and you found it by reading a metadata field, not by auditing logic. A cheap afternoon with an unusually high return.
If user mode is new to you, read WITH USER_MODE, explained first, with the security model basics alongside it. The beginner path lives in Foundations.