Two classes in the same org. The same SOQL query, character for character. The same user running both. One returned a field that user may not see. The other did not.
I had expected to spend an afternoon confirming something I had read. Instead I spent it building controlled experiments by hand in a Developer Edition org, because I was about to make a security claim and did not want to rest it on a sentence I had read somewhere. What I found is small, entirely documented, and almost never accounted for in the orgs I look at.
The only difference between those two classes was a number in a metadata file.
The version stamp nobody looks at
Every Apex class carries an API version. It is set when the class is created, it sits in the metadata beside the code, and here is the part that catches people: it does not change when your org upgrades.
Salesforce upgrades your org three times a year. Your classes do not come along: a class written in 2022 keeps its 2022 stamp until a human changes it. That is by design — it stops a seasonal release quietly altering code that was working perfectly — and it is why the situation I am describing can exist at all.
So a mature org is not running one version of Apex. It is running a sediment of them, in the order things were written.
What changed at 67.0
For most of the platform’s history, plain SOQL and DML in Apex ran in system mode. In system mode, object permissions (CRUD — create, read, update, delete) and field-level security are not enforced by the Apex layer. Your code asks for a field; it gets the field. Whether the running user could see it on a screen is not part of the transaction.
That default was not an oversight. A trigger stamping an audit field must write it even for users who cannot edit it by hand, and plenty of good code depends on that.
From API version 67.0 onward the default flipped: user mode became the default for plain operations, so CRUD and field-level security are enforced unless the statement says otherwise.
Put those two facts together and you get the sentence to leave with:
One org can hold two classes running the identical query with different effective access, purely because of the version stamped on each file.
Nothing in the code tells you which behaviour you are getting. The query looks the same; so does the class declaration. The difference lives in metadata, invisible to every review that only reads code.
The without sharing surprise
There is a sharper edge to this, and it is the finding that most changed how I read a class.
Sharing — which records a user can see — is a separate dial from CRUD and field-level security. You set it with a class declaration: with sharing, without sharing, inherited sharing. For years, without sharing meant what it says.
At v67, the user-mode default overrides even an explicit without sharing for plain operations.
Read that twice, because of what it does to your habits. The declaration at the top of the class is no longer the last word on how a plain query behaves. To know what a statement does you need the declaration, the version, and the statement itself — and two of those three are things most of us never look at.
And it cuts both ways: a class deliberately written to run without sharing may not behave as its author intended once it is stamped at 67.0 or above. This is not only a story about code being less safe than you thought — it is a story about code doing something other than what it says.
Why this lands hardest on an agent
This is why the post sits in the Agentforce category rather than Foundations.
An agent action is Apex. When you give an agent the ability to look something up or create a record, what you have given it is a method — usually an @InvocableMethod on a class somebody wrote at some point. The agent’s reach is the union of what those methods touch.
Now consider how agent actions get assembled. Almost nobody writes them all at once. You expose a method that already existed. You add another written by a colleague two years ago. You write a fresh one this month. Each arrives with its own version stamp — and its own security default.
So the honest description of a typical agent is: capabilities assembled from code written across several years, in which enforcement varies from action to action for reasons unrelated to what the actions do. That is not hypothetical; it is what happens when a platform default changes and old files keep their stamps.
How I established this, and why by hand
A moment on method, because for a beginner this is the transferable lesson.
I did not establish any of this by reading documentation. I ran controlled experiments by hand in a real Developer Edition org, and for each behaviour I built three things: a positive control, an operation I expected to succeed, so a failure tells me my rig is broken rather than the platform; a negative control, one I expected to be blocked, where a success tells me the same; and a discriminator, the one case where the competing explanations disagree. Everything else is consistent with both stories; only the discriminator settles anything.
That is more work than a release note. It is also the difference between knowing how the platform behaves in your org and knowing what a sentence said.
Stop relying on the default, in either direction
The fix does not require you to touch every version stamp in the org. Say what you mean in the statement itself:
// explicit — and independent of the class's API version
List<Contact> people = [SELECT Id, Name, Email FROM Contact
WHERE AccountId = :accountId WITH USER_MODE];
WITH USER_MODE and WITH SYSTEM_MODE (and as user / as system on DML) make the intent local, visible in review, and immune to the file’s stamp. Where code genuinely needs system mode, saying so is better than inheriting it — an accident becomes a decision somebody can defend.
Then do the inventory. List your Apex classes with their API versions, starting with those exposed as agent actions: the smallest set with the largest reach.
If you would rather not do that by hand, the static analyser I wrote to measure exactly this is MIT open source at github.com/aksumustafa1625/agent-blast-radius. It reads your metadata and reports what each action class can touch — no credits, no model calls, just code reading configuration.
Your next step
Open one agent action in an org you have access to. Note its API version. Then look at every SOQL and DML statement inside and ask whether the mode is stated or merely inherited.
If it is inherited, you know something you did not this morning: what that action can reach depends on a number nobody chose on purpose. Make it explicit, and it never will again.