Skip to content

Data access and queries

Every other page in the Business logic section takes reading data for granted: a formula crosses record.account.owner, an automation body writes a related list. This page is where that is made explicit — how a developer, in the one typed language, gets from a record to other records, walks them, and does something with them: the work that on Salesforce is a SOQL query inside Apex. The claim this page has to earn is the one the Exchange review makes in passing — that query injection has no analogue here. It does not, but the reason is a design the platform builds, not a property it gets for free.

Two sentences, and the difference between them is the whole page:

  • What a developer sees: there is no query string to build, so there is no place to concatenate untrusted input, so the injection bug class has no author-facing surface.
  • What the platform does: a typed query is compiled to SQL by the kernel, and that compiler is a real surface — it is safe only because it lowers an allow-listed AST to a parameterized statement and refuses anything it cannot prove pure and bounded. Safety is engineered at one chokepoint the platform owns, not sprinkled across every developer who remembers to escape an apostrophe.

Both are true. “Injection has no surface” is the developer’s experience; “injection is a surface the platform closes by construction” is the honest full picture. Where the Exchange review says the first, it is describing the outcome; this page describes the mechanism.

Most reads never touch anything resembling a query. A field’s relationships are typed, so reaching a related record is property access the compiler checks:

// pure — typed navigation over the bound graph
record.account?.owner?.region ?? "Unassigned"
// pure — a bounded operation over a related list already in scope
record.line_items.filter(l => l.included).reduce((s, l) => s + l.amount, 0)

This is the everyday case, and it is entirely inside the pure tier: the dependency is statically known, it lowers into the data query, and it evaluates over the set at once. There is no query object because there is no ambiguity about which records — the graph already says.

The second way is for records the graph does not reach directly — the equivalent of a SOQL SELECT: name an object, and constrain it.

// a typed query — filter, order, and bound are expressions in the one language
const overdue = query("invoice")
.where(i => i.account_id == record.id && i.status == "open" && i.due_date < today())
.order(i => i.due_date)
.take(200);

The important word is expression. .where(...) does not take a string; it takes a predicate in the one typed language — the same pure boolean grammar a validation rule is written in. i is typed as an Invoice row, so i.due_dat is a compile error, not an empty result at run time. The query is a typed value, not text, at every stage from authoring to execution.

Why there is no author-facing injection surface

Section titled “Why there is no author-facing injection surface”

Set the two platforms side by side on the exact operation that produces the bug.

On Salesforce, a dynamic query is a string, and the unsafe way to build it is the natural one:

// the shape every SOQL-injection write-up opens with
String q = 'SELECT Id FROM Contact WHERE LastName = \'' + userInput + '\'';
List<Contact> rows = Database.query(q); // userInput can close the string literal and append clauses

Salesforce’s own guidance is a list of things the developer must choose to do: use a static query with a bind variable (WHERE LastName = :userInput), or String.escapeSingleQuotes(), or Database.queryWithBinds(), or type-cast and allow-list the input. Each works. Each is opt-in. The platform ships the injectable primitive (Database.query(String)) and asks every developer, on every dynamic query, to remember the safe form — and the ISVforce security review rejects packages precisely because they forgot.

On CAOS there is no query(String) primitive to forget the safe form of. The filter is a typed expression; the compiler parses it into an AST and lowers it to a statement whose structure comes from validated metadata (the resolved object and column identifiers) and whose values are bound parameters:

-- what the query above lowers to (schematic)
SELECT … FROM "org_7a…"."co_invoice"
WHERE account_id = $1 AND status = $2 AND due_date < $3
ORDER BY due_date LIMIT 200
-- $1,$2,$3 bound out-of-band; no author text ever reaches the statement structure

An attacker-controlled value can only ever arrive as $1, $2, $3. It can never become a clause, because the developer never wrote a clause as text — they wrote i.status == status, and status is a typed binding, not a fragment of SQL. The bug class is removed by removing the primitive that carries it, which is a stronger guarantee than removing it by discipline.

The chokepoint that makes this real is already the gate in the kernel: the push-down compiler admits only pure, analyzable AST node kinds — an effectful call, a volatile function, or an unknown identifier is refused compilation, not escaped — and it runs a static cost governor so a legal-but-ruinous query is rejected before it executes. Compiling author expressions to SQL is, in the platform’s own words, “an injection / resource-exhaustion surface, requiring an allow-listed AST and a cost governor — mandatory work, not free.” That sentence is the honest one. The developer inherits the result; the platform pays for it once.

Access is enforced by default, not by opt-in

Section titled “Access is enforced by default, not by opt-in”

There is a second thing the query string hides on Salesforce: whose permissions it runs under. Apex runs in system mode by default, seeing every row and field regardless of the running user’s access, unless the developer opts into WITH USER_MODE / WITH SECURITY_ENFORCED or with sharing. Field- and object-level security in a query is, again, a thing to remember.

A CAOS query resolves under the caller’s own access by default. The columns a predicate may reference and the rows it may return are narrowed by the same field-level security and sharing the platform enforces everywhere else, evaluated as part of compiling the query — not bolted on by a clause the author has to add. Running with elevated access is possible, but it is the declared, audited exception (a named capability on the component), the inverse of Salesforce’s default. The safe posture is the one you get by writing nothing special.

The Apex-equivalent, worked: query → iterate → do something

Section titled “The Apex-equivalent, worked: query → iterate → do something”

Here is the canonical Apex shape — read a set of children, walk them, write them back in bulk — and its CAOS form. The developer writes real code in the effectful tier, as a controller method (the same class-and-slot surface the automation board projects):

@controller("account")
export class AccountCascade {
// fires after an Account write; only when the region actually changed
@slot("update.after")
@related(["contact"]) // declared blast radius — bounds the write count
updateAfter(record: Account, prior: Account) {
if (record.region == prior.region) return;
// QUERY — typed predicate, access-enforced, compiled to a parameterized SELECT
const contacts = query("contact").where(c => c.account_id == record.id);
// ITERATE — a bounded for-each over the result set (the effectful tier permits it)
for (const c of contacts) {
c.region = record.region; // staged mutation, not a write yet
}
// WRITE — one set-based statement through the save order, not N per-row writes
save(contacts);
}
}

Read it against the Apex it replaces — for (Contact c : [SELECT … WHERE AccountId = :id]) { c.Region = …; } update contacts; — and three differences are structural, not stylistic:

  • The WHERE is typed, not concatenated. c.account_id == record.id is an expression the compiler checked; there is no string, so there is nothing to inject and record.id is a bound parameter by construction.
  • The blast radius is declared. @related(["contact"]) states, at author time, the only objects this body may write. A write outside it does not compile. That declaration is what lets the platform bound the operation before it runs — the honest replacement for Salesforce’s per-transaction DML fuses.
  • save(set) is set-based by default. There is no per-row DML statement to bulkify by hand; the natural form is already the bulk form, which is the opposite of Apex, where the natural form (DML inside the loop) is the one that blows the governor limit.

This is an architecture page, so the line matters. Verified 2026-09-24 — built today in the kernel: the one typed language and its pure tier (lexer, parser, checker, analyzer, exact-decimal evaluator), the tier contracts, the push-down allow-list, the developer-facing query(object).where(…).order(…).take(n) builder and its lowering to a parameterized read through a single allow-listed chokepoint, the static cost governor that rejects a ruinous query before it runs, and the effect executor — a procedural interpreter over an effect program with lexical scope, a per-statement fuel meter, and fail-closed capability routing, which is what gives for…of and the effect verbs somewhere to run with no ambient authority.

Verified 2026-09-24 — specified here and still not built: executing a compiled query against live data from inside an effectful body. This slice produces the query value, its SQL and the cost gate; it does not run them against real rows.

Verified 2026-09-24 — built, but not on this surface. Narrowing a query by the caller’s own field- and record-level access is a seam in the lowering, and the seam is FILLED — but not for the query(…) builder described here. The default lowering context this page’s surface uses leaves it undefined; the platform fills it from the resolved field security and record access wherever it reads on a named user’s behalf, which today means grounding reads for the AI layer and queries a job runs as its principal. So the narrowing exists and is exercised; what is missing is the builder handing you a caller to be narrowed by.

That correction is worth stating plainly, because the paragraph this replaces said the query(…) surface, its compilation to a parameterized read and the effect sandbox were all unbuilt, and named the sandbox “the softest-specified spot”. All three shipped; the page did not move with them. It is the exact failure this page’s own subject is meant to avoid, which is why the date is now attached rather than the claim merely being fixed.

Iteration exists in both tiers, with a boundary drawn by analyzability, not by vocabulary:

  • Pure tier: only collection-bounded iteration — map, filter, reduce over a finite collection already in scope. There is no while and no data-dependent for, because the cost and dependency graph must be computable before anything runs. When an algorithm needs genuine step-by-step iteration, it moves to a calc function — procedural but still pure, its termination bounded by the runtime.
  • Effectful tier: a real for…of over a query result is permitted, because effects run far less often and inside the sandbox. What replaces Salesforce’s hard per-transaction counts is a declared, previewable budget — CPU, memory, rows, outward calls — shown in the component’s dry-run before it ships, plus the related closure that bounds writes at author time. Budgets you can see beat fuses you discover in production.
Operation Salesforce Cloud Atlantis OS
Build a dynamic query Database.query(String) — concatenated text query(obj).where(expr) — a typed predicate, no string
Prevent injection Opt-in: bind vars / escapeSingleQuotes / queryWithBinds No author-facing surface; one allow-listed-AST → parameterized compiler
Enforce FLS / sharing in a read Opt-in: WITH USER_MODE, with sharing Caller’s access by default; elevation is the declared exception
Iterate + write children for(x : [SELECT…]) { … } update list; — DML-in-loop is the trap for…of a result, save(set) set-based by default; related bounds it
Analyze a query Opaque string — not statically where-used or cost-checked Typed expression — dependency-extracted, where-used, cost-governed
Language boundary Apex + SOQL + formula — three grammars One typed language; queries and effects are the same language under a contract

The escape hatch is not narrower for being safe. The effectful tier is real code in the one typed language, so it matches Apex’s expressiveness; what it removes is the class of failure — injection, unenforced access, the DML-in-a-loop cliff — that comes from the query being a string the developer is trusted to assemble correctly every single time.