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.
The shape of the claim, stated honestly
Section titled “The shape of the claim, stated honestly”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.
Two ways to read: the graph and the query
Section titled “Two ways to read: the graph and the query”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 graphrecord.account?.owner?.region ?? "Unassigned"
// pure — a bounded operation over a related list already in scoperecord.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 languageconst 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 withString q = 'SELECT Id FROM Contact WHERE LastName = \'' + userInput + '\'';List<Contact> rows = Database.query(q); // userInput can close the string literal and append clausesSalesforce’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 < $3ORDER BY due_date LIMIT 200-- $1,$2,$3 bound out-of-band; no author text ever reaches the statement structureAn 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
WHEREis typed, not concatenated.c.account_id == record.idis an expression the compiler checked; there is no string, so there is nothing to inject andrecord.idis 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.
What is decided here versus already built
Section titled “What is decided here versus already built”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, and the one hard rule
Section titled “Iteration, and the one hard rule”Iteration exists in both tiers, with a boundary drawn by analyzability, not by vocabulary:
- Pure tier: only collection-bounded iteration —
map,filter,reduceover a finite collection already in scope. There is nowhileand no data-dependentfor, 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…ofover 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 therelatedclosure that bounds writes at author time. Budgets you can see beat fuses you discover in production.
How Salesforce differs
Section titled “How Salesforce differs”| 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.
Sources
Section titled “Sources”- The logic model and Record-triggered automation — the one typed language, the two tiers, the effect verbs,
related, and the governor budgets this page reads and writes through. - Calc functions — the pure procedural escape hatch for genuine iteration; the exact-decimal interpreter’s structural purity.
- Formula fields — pure expressions compiled to SQL by the query planner; the same push-down path a query filter uses.
- Salesforce — SOQL injection and its prevention: Trailhead: Prevent SOQL Injection Attacks with Best Practices, ISVforce: SOQL Injection Due to Insecure Database Query Construction. Apex default system-mode execution and
WITH USER_MODE/with sharingfor access enforcement.