Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RFD-0043 — Query and mutation surface

Discussion Opened 2026-05-17

Question

How do query and mutation bodies read and write state? This RFD specifies the grammar RFD-0040 commits to: the select / insert / update / delete / with / emit / scenario verbs, the shape grammar shared between reads and writes, the body shapes for the four operator surfaces, and the migration path from today’s require / do / retract / emit / return clause structure.

Context

Today’s mutation bodies use five clause names (require, do, retract, emit, return). The audit (RFD-0040 Context) showed four implicit-writes footguns the surface admits — let x: T = expr silently persisting, path = value reading like assignment, emit Event { … } routing invisibly to one of three sinks, derive-vs-persisted ambiguity. A team contributor compared the result to COBOL.

The fix is not patching the existing clause structure; it is replacing it. RFD-0040 D4 commits to four explicit write verbs (insert, update, delete, emit) and a no-implicit-writes rule. RFD-0040 D2 commits to four surface keywords (fn, derive, query, mutation) projecting one rule primitive. This RFD specifies the body grammar that operationalizes both.

The query syntax is informed by three external languages:

  • EdgeQL (EdgeDB) for the shape grammar — Lease { id, monthly_rent, tenant: { name } } reused identically on reads, writes, and computed projections.
  • Cypher / GQL (ISO/IEC 39075:2024) for graph-pattern traversal in the rare case where multi-hop patterns are the clearest reading.
  • Datomic / Datalog for with-bindings as the universal procedural-composition mechanism.

The result is closer to EdgeQL than to any of the others, with two divergences: Argon has concepts and traits (not object types with links), and Argon’s tier system constrains which forms admit where. This RFD makes both divergences explicit.

Decision

D1 — Four body shapes, one grammar surface

The four operator keywords each get a body shape:

KeywordBody shape
fn= expr (single-expression shorthand) or { stmt; … [trailing-expr] } — trailing expression is the implicit return value
derive:- { atom, atom, … }
query{ stmt; … [trailing-expr] } — same implicit-return shape as fn
mutation{ stmt; … [return expr]? } — explicit return only; bodies without return have unit return type

Statements (stmt) inside { } bodies are one of:

  • with name := expr; — let-binding, transaction-local, never persists.
  • let name = expr; — equivalent to with name := expr;. let is admitted as a synonym for readability when the binding is a single line.
  • select <shape> [from <source>] [where <pred>] [order by …] [group by …] [limit N]; — read; returns a set or scalar. from is permitted only when the projection shape introduces local bindings that need disambiguation (the group by case, see D5); otherwise the target concept is the first non-keyword token after select (D3).
  • insert <Concept> { <field> = <expr>, … }; — persistent write (mutation only).
  • update <pattern> set { <field> <op> <expr>, … } [where <pred>]; — persistent write (mutation only), where <op> is one of = (replace), += (add to a multi-cardinality field), -= (remove from a multi-cardinality field), or := (replace a multi-cardinality field wholesale). See D7 for the field-cardinality interaction (RFD-0039 collection semantics). The where clause is optional when the target is a bound instance variable (a single specific instance); it is required when the target is a concept name (to avoid unintentional bulk update — see D7).
  • delete <pattern> [where <pred>]; — persistent retract (mutation only). Same optionality rule as update.
  • emit (<sink>: <Event> { <field> = <expr>, … }); — externally-effecting write (mutation only).
  • scenario { stmt; … }; — hypothetical write block (query only).
  • if <expr> { stmt; … } else { stmt; … }; — conditional execution.
  • for <name> in <expr> { stmt; … }; — iteration over a set.
  • return <expr>; — explicit value-returning exit. May appear in fn, query, and mutation bodies. In fn and query bodies, return is optional: a body’s trailing non-;-terminated expression (typically a select) is the implicit return value. OE0839 fires only when a fn body has neither an implicit-return trailing expression nor an explicit return statement. mutation bodies without return have unit return type.

derive bodies are different: a comma-separated list of rule atoms following :- { … }. They do not admit let, insert, select, etc.

D2 — The shape grammar (read = write = compute)

The same shape syntax projects on reads, payloads on writes, and field declarations on computed values.

// Read projection
select Lease {
    id,
    monthly_rent,
    end_date,
    tenant: { name, email },              // nested projection
    rent_per_day := .monthly_rent / 30,   // computed field
}
where .status = Active

// Write payload — same shape
insert Lease {
    id = next_lease_id(),
    monthly_rent = 2400,
    end_date = 2027-12-31,
    tenant = (select Person where .email = "alice@example.com" limit 1),
    property = (insert Property { … }),   // subquery / nested insert
}

Shape rules:

  • field (bare name) — project the field by name; the result has a field of the same name.
  • field: { … } — nested projection; the result includes a sub-shape.
  • name := expr — computed field; expr evaluates in the scope of the parent record (.foo refers to the parent’s fields).
  • field = expr (in insert/update payloads) — assignment.

.field resolves to the field of the record currently in scope (EdgeQL convention). Inside select Lease { … }, .monthly_rent is the lease’s monthly_rent. Inside nested tenant: { … }, .name is the tenant’s name.

D3 — select without from

The target concept is the first non-keyword token after select. There is no from.

select Lease where .status = Active           // returns [Lease]
select count(Lease) where .status = Active    // returns Nat
select Lease.monthly_rent                     // returns [Money] (all leases' rents)
select sum(Lease.monthly_rent)                // returns Money

Aggregates over derived sets use with-binding:

with active := select Lease where .status = Active;
select sum(active.monthly_rent)

A query body’s final select is the return value. Multiple select statements before the final one are admitted but bound (use with to name them).

D4 — Where clauses

where <pred> filters. The predicate language is the same predicate language used in rule bodies (RFD-0005 graduated admission) — boolean expressions over field paths, equality / comparison operators, in / not in for membership, and / or / not for composition, exists(...) for existential bounds.

select Lease
where .status = Active
  and .monthly_rent > 2000
  and .tenant in (select Person where .priority = High)

Predicates compose with rule atoms via the existing graduated admission (RFD-0005) — query and derive bodies admit tier:closure by default; method calls on collections (RFD-0039) and higher-tier expressions are admitted in query bodies, not derive bodies.

D5 — order by, group by, limit

SQL-shaped, applied after where:

select Lease { id, monthly_rent }
where .status = Active
order by .monthly_rent desc
limit 10

group by:

select {
    tenant := .tenant,
    total := sum(.monthly_rent),
    count := count(*),
}
from Lease                                    // exception: group-by needs explicit source
where .status = Active
group by .tenant

(The from keyword reappears only in group by clauses where the source needs to be named explicitly because the projection shape has its own bindings. This is the one exception to D3’s no-from rule.)

D6 — Insert

insert Lease {
    id = next_lease_id(),
    monthly_rent = 2400,
    tenant = t,
    property = p,
    start_date = now(),
    status = Pending,
}

The shape lists field-value pairs. Field types must match the concept’s declared field types; type errors fire OE0830.

insert returns the freshly-created instance. Capture with with:

with l := insert Lease { … };
// l is the new lease; chained statements can reference it
emit (audit: LeaseSignedEvent { lease = l, at = now() });
return l;

insert admits subqueries in any value position:

insert Lease {
    tenant = (select Person where .email = email_address limit 1),
    property = (insert Property { … }),   // nested insert composes
    ...
}

Bulk insert from a set:

for record in incoming_batch {
    insert Lease {
        tenant = record.tenant,
        monthly_rent = record.amount,
        ...
    };
}

D7 — Update

update <pattern> set { <field> = <expr>, … } [where <pred>]

The pattern is the concept (or a with-bound name); set { … } carries the assignments; where filters which instances to update.

The where clause is optional when the target is a bound instance variable (a single specific instance — there’s nothing to filter), and required when the target is a concept name (to avoid unintentional bulk update across every instance of the concept). The grammar in D1 admits this with [where <pred>]; the elaborator enforces the requirement on concept-name targets and emits a diagnostic (TBD) if a bare update Lease set { … } appears without a where clause. The pay_rent example in D12 uses a bound variable (update p set { processed = true }) so the where clause is omitted by design.

update Lease
set { monthly_rent = .monthly_rent * 1.05 }
where .status = Active and .start_date < (now() - 365.days)

.field on the right-hand side refers to the current row’s pre-update value. The post-update value is what’s set.

Updating fields of cardinality > 1 uses += / -= / := (RFD-0039 collection semantics):

update Building
set {
    units += new_unit,                        // add to set
    tenants -= former_tenant,                 // remove from set
    rent_band := Range::new(1000, 2500),      // replace
}
where .id = building_id

Updates return the count of affected instances. Capture with with if needed; otherwise the value is discarded.

D8 — Delete

delete <pattern> [where <pred>]

Retracts every matching instance. The where clause follows the same optionality rule as update (D7): optional when the target is a bound instance variable (a single specific instance), required when the target is a bare concept name (to prevent unintentional bulk deletion). Cascading deletion follows the cardinality declarations on relations: if Lease.tenant : Person is mandatory, deleting a Person referenced by a Lease fires OE0832 DanglingReference (the user must explicitly delete the lease first, or the cascade rule must be declared via decorator).

delete Lease where .status = Terminated and .end_date < (now() - 7.years)

Like update, delete returns a count of affected instances.

D9 — Emit

emit writes to an external sink. The sink is a syntactically required prefix.

emit (audit: LeaseSignedEvent { lease = l, at = now() });
emit (hitl: ReviewRequest { document = d, reviewer = next_available() });
emit (notify: SubscriptionRenewed { user = u, plan = p });

Sinks (audit, hitl, notify) are declared by the kernel-runtime configuration. The compiler validates the event type against the sink’s declared schema (e.g., audit accepts AuditRecord subtypes; hitl accepts HitlTask subtypes; notify accepts Notification subtypes).

User-extensible sinks are admitted via pub sink <name>: <EventConcept> declarations at module level; the kernel routes them through configured channels (typed pubsub, external queue, etc.).

emit does not return a value. Emissions happen at transaction commit; if the transaction rolls back, the emission is suppressed.

D10 — Scenario blocks

scenario { … } introduces a hypothetical-write block inside a query body. On entry to the block, a kernel fork (RFD-0023) is created with the query’s current state as its base. Writes inside the block apply to the fork. On block close (};), the fork’s state replaces the query’s current state for the remainder of the query body — subsequent with-bindings and select statements read from the fork. The fork is discarded when the query body completes (the query returns or implicit-returns); writes never propagate to persistent storage.

pub query rent_after_proposed_increase(percent: Real) -> Money {
    scenario {
        update Lease
        set { monthly_rent = .monthly_rent * (1 + percent / 100) }
        where .status = Active;
    };
    with active := select Lease where .status = Active;
    select sum(active.monthly_rent)
}

Multiple scenario blocks in the same query body are chained: each scenario forks from the state including all previous scenarios’ writes (the writes accumulate sequentially within the query’s transient state). To branch from a known earlier point, factor the shared setup into a with-binding before any scenario begins. Nested scenarios are a syntax error (OE0836).

Scenario blocks do not appear in mutation bodies. A real transaction is already a fork that commits or rolls back; nested hypotheticals inside a real transaction have no clear use case in v1.

D11 — Pattern syntax for multi-hop traversal

Most queries traverse one hop and project. Multi-hop patterns admit Cypher-style syntax when clearest:

select p1
where match (p1: Person)-[:married_to]->(p2: Person)-[:works_at]->(c: Company)
  and c.name = "Acme"

The match form admits:

  • Node patterns (name: ConceptType) or (name) (untyped).
  • Edge patterns -[:relation_name]-> (directed) or -[:relation_name]- (undirected if the relation declares symmetry).
  • Variable-length edges -[:related_to*1..5]->.
  • In-pattern predicates (p: Person where .age > 18).

The match clause lowers to a join over the relation graph. It is not a separate grammar — it’s one form of predicate inside where, sharing the underlying machinery with the existing rule-body atom grammar.

For most domain code, plain select Concept where ... is clearer than match. The match form is reserved for the cases where the multi-hop structure is the question being asked.

D12 — Return

return <expr> is the final statement in query and (optionally) mutation bodies. In query, the return expression is the result of the query; if omitted, the final select statement’s result is returned (implicit return). In mutation, return is optional; if omitted, the mutation returns Unit (no value).

pub query active_count() -> Nat {
    select count(Lease) where .status = Active     // implicit return
}

pub query named_leases(name: Text) -> [Lease] {
    with t := (select Person where .name = name limit 1);
    return select Lease where .tenant = t           // explicit return
}

pub mutation create_lease(...) -> Lease {
    with l := insert Lease { ... };
    emit (audit: ...);
    return l;
}

pub mutation pay_rent(p: Payment) {
    update p set { processed = true };
    emit (notify: ...);
    // no return; returns Unit
}

D13 — Diagnostic codes

  • OE0830 FieldTypeMismatch — insert/update field-value type doesn’t match the concept’s declared field type.
  • OE0831 RequiredFieldOmitted — insert misses a required field.
  • OE0832 DanglingReference — delete leaves a mandatory reference unsatisfied; cascade decorator required or explicit delete chain.
  • OE0833 UpdateOfReadOnlyField — update targets a field declared @[readonly].
  • OE0834 WriteVerbInQueryBodyinsert / update / delete / emit outside a scenario block in a query body.
  • OE0835 ScenarioInMutationscenario { } appears in a mutation body.
  • OE0836 NestedScenarioscenario { scenario { … } }.
  • OE0837 UnknownEmitSinkemit (foo: ...) where foo is not a declared sink.
  • OE0838 EmitTypeMismatch — event payload type doesn’t match the sink’s accepted concept.
  • OE0839 MissingReturnfn body without an implicit-return final expression and without an explicit return statement.
  • OE0840 ImpureCallFromPureContext — purity-ladder violation (per RFD-0040 D3); e.g., fn calling mutation.
  • OE0841 SelectInDeriveBodyselect statement in a derive body (use rule atoms instead).
  • OE0842 GroupByWithoutFromgroup by clause without explicit from source.
  • OE0843 OrderByOnUnorderedorder by on a field whose type lacks Ord.

D14 — Migration mapping

The mechanical rewrites the ox migrate tool performs:

TodayTomorrow
mutation foo() { require { body } do { … } emit Event { … } return expr }mutation foo() { /* require body lowers to where + early return */; … emit (sink: Event { … }); return expr; }
let x: T = expr inside do { }with x := insert T { … } (or let x = expr if T was a type annotation on a local)
let x = expr inside do { } (local)let x = expr or with x := expr
path = value inside do { }update <root> set { <subpath> = value } where ...
retract { path: value where … }delete <root> where ... and <subpath> = value
emit Event { … } (unknown sink)emit (audit: Event { … }) for AuditRecord subtypes; emit (hitl: Event { … }) for HitlTask subtypes; emit (notify: Event { … }) for Notification subtypes. The tool inspects the event’s metatype to pick the sink.
query foo(...) :- body => headquery foo(...) -> T { select <head> where <body> }
compute foo(...) = exprfn foo(...) = expr
compute foo(...) { body { ... } }fn foo(...) { ... }
compute foo(...) { impl rust("...") }fn foo(...) impl rust("...")

Items with multiple plausible rewrites (e.g., the require clause may be a precondition, a constraint, or a guard depending on use) require manual review. The migration tool flags every site without a unique-rewrite and provides a // MANUAL REVIEW: ... comment with the alternatives.

D15 — Worked migration: lease-story

argon/examples/lease-story/packages/story-lease/src/mutations.ar today contains the record_security_deposit mutation. Old form (illustrative):

pub mutation record_security_deposit(
    deposit: Payment,
    lease: Lease,
) {
    require { deposit.amount > 0 and deposit.purpose = SecurityDeposit }
    do {
        let receipt: DepositReceipt = make_receipt(deposit, lease);
        lease.deposit_held = deposit.amount;
        lease.deposit_status = Held;
    }
    emit DepositReceived { lease: lease, receipt: receipt }
    return receipt
}

New form:

pub mutation record_security_deposit(
    deposit: Payment,
    lease: Lease,
) -> DepositReceipt? {
    // Precondition becomes an early-return guard. The mutation returns
    // Optional[DepositReceipt] (RFD-0039); precondition failure yields None,
    // captured in the audit log for diagnosis.
    if not (deposit.amount > 0 and deposit.purpose = SecurityDeposit) {
        emit (audit: DepositRejected {
            deposit = deposit,
            reason = "invalid amount or purpose",
            at = now(),
        });
        return None;
    };

    with receipt := insert DepositReceipt {
        deposit_id = deposit.id,
        lease_id = lease.id,
        amount = deposit.amount,
        received_at = now(),
    };

    update lease
    set {
        deposit_held = deposit.amount,
        deposit_status = Held,
    }
    where .id = lease.id;

    emit (audit: DepositReceived {
        lease = lease,
        receipt = receipt,
        at = now(),
    });

    return Some(receipt);
}

What changed:

  • require becomes an if-guard with explicit early return. The return type uses Optional[DepositReceipt] (sugar DepositReceipt?) from RFD-0039 — a substrate primitive already shipped. Precondition failure returns None; the audit emit captures the failure reason for diagnosis. A richer Result[T, E] substrate is future work (separate RFD); Optional is sufficient for the canonical migration pattern.
  • do { let x: T = expr } becomes with x := insert T { … }.
  • path = value becomes update set { field = value } where ….
  • emit Event { … } becomes emit (audit: Event { … }).
  • return expr becomes return expr; (statement-terminated).
  • The mutation’s return type is now explicit (-> DepositReceipt?).

D16 — Tier ceilings per body

Per RFD-0040 D2 and RFD-0004 (decidability ladder):

BodyTier ceilingNotes
fntier:expressivehigher-order operations admitted (collection methods, comprehensions)
derivetier:closurestructural + closure admitted; expressive operations rejected in rule atoms (OE2408)
querytier:expressiveshape grammar + aggregations admitted; cross-tier closures via with-bindings
mutationtier:expressivesame as query, plus write verbs

Bodies can opt up via #dec(<tier>) directives (existing RFD-0004 surface). Bodies that exceed the directive’s tier produce diagnostic OE0850 TierCeilingViolation.

Rationale

EdgeQL’s shape grammar is reused identically on reads and writes. This is the highest-leverage decision in the grammar — one shape grammar for projection, payload, and computed fields. Three operations for the price of one syntax. Modelers learn shapes once, use them everywhere.

select without from. The target concept is the first non-keyword token after select. SQL’s from adds noise without information; the target’s name already names the source. The only exception is group by, where the projection shape’s bindings need disambiguation from the source.

Statements over clauses. The current surface’s require / do / retract / emit / return clauses are positional and named — a reader has to scan for the clause, find its braces, and then read the contents. Statements with explicit verbs are linear: read top to bottom, every statement is self-describing.

The four write verbs cover everything. insert, update, delete, emit. Every persistent state change uses one. A reader greps a body for these four words and finds every write. The audit’s three implicit-write footguns disappear by construction.

with is the only let-binding form for procedural composition. let name = expr is admitted as a synonym for single-line readability, but conceptually the binding mechanism is one: introduce a name, bind it to a value, use it in subsequent statements. The proliferation of clause-named bindings in the current surface (do { let … }, require { let … }, emit { let … }) collapses.

Scenario blocks for hypotheticals, not query-can-write. A query declaration without scenario is statically guaranteed read-only. With scenario { … }, the hypothetical writes are visible at the call site. The graduation matches the implicit-writes principle: writes are visible everywhere they happen.

Cypher-style match reserved for multi-hop, not promoted to the default. Most domain queries traverse one hop. EdgeQL-style select Concept where .field = … reads clearer for those cases. The match form is a graduated admission within the existing rule-body atom language — same machinery, more verbose syntax for when multi-hop is the question.

Group-by needs from; otherwise no. The exception preserves consistency where the projection shape has bindings (tenant := .tenant, total := sum(.monthly_rent)) that need to disambiguate which concept they project from. Everywhere else, the first token after select is the source.

Bulk operations via for-loops. The alternative — SQL-style insert ... select ... syntax — adds a special form. A for loop with insert inside is more uniform: same syntax for “do this one thing for each of these things” everywhere. The kernel’s transaction semantics fold the loop’s inserts into one atomic commit.

Returns are statements, not clauses. return expr; at the end of a body, terminated by ;. Implicit return (final select statement) is admitted for single-statement query bodies to reduce ceremony.

Aggregations are functions, not keywords. count(...), sum(...), min(...), max(...), avg(...) are stdlib fn declarations. The grammar admits them as ordinary function calls. The compiler can recognize the canonical aggregation forms for query-plan optimization, but the surface keeps them as functions.

Consequences

Compiler. New parser entry points for select, insert, update, delete, emit, scenario, match, for, if, with. New elaboration phase phase_mutation_lowering that lowers the verbs to CoreMutationOp IR atoms (extending the existing Let / FieldUpdate atoms with Insert, UpdateSet, Delete, Emit variants).

Kernel. The MutationEvaluator in crates/nous/src/mutation/evaluator.rs reorganizes: the per-clause sequence (require → retract → do → emit → return) becomes per-statement, with each statement lowered to one or more AssertionRequest / EventRequest calls in declaration order. The atomic-commit semantics (all-or-nothing per mutation) are preserved.

Sink declarations. New module-level surface: pub sink <name>: <EventConcept>. The stdlib ships three default sinks (audit, hitl, notify); kernels can register more via the existing emit-routing API.

The wire types. oxc-protocol extends CoreMutationOp with the new variants. SDK regeneration via oxc-codegen is mechanical.

Predicate-grammar consolidation. RFD-0005’s “one grammar, multiple contexts” architecture stays. The predicate language used in where clauses, :- { … } derive bodies, match patterns, and constraint refinements is the same — three sub-grammars get unified at the implementation level (the framing-issue the team contributor flagged). The same lexer and parser entry points serve all four positions; the tier ceilings differ per context.

Migration tool. ox migrate performs the mechanical rewrites in D14. Items requiring manual review get // MANUAL REVIEW: ... comments with alternatives. The tool runs in two modes: --check (lists sites without modifying) and --apply (rewrites in place).

Book chapter. ch02-08-queries-and-mutations.md replaces the current chapter on compute/mutation. Covers select, the shape grammar, insert/update/delete, with-bindings, emit with sinks, scenario blocks, the match form for multi-hop, the four-body tier matrix.

Examples. All .ar files using the old clause grammar migrate via ox migrate. The lease-story is the canonical worked migration (D15); the sharpe-ontology/ story branches follow.

Old syntax compatibility. The old grammar parses under --edition=0.4 for one minor version after argon 1.0 lands. argon 1.1 removes it. The two grammars share the same AST after lowering, so cross-edition packages can compose without recompilation of the dependency. This matches the deprecation timeline in RFD-0040.

Historical lineage

  • RFD-0005 (one grammar, multiple contexts) — committed. The predicate-graduation architecture this RFD operationalizes. where, :- {…}, match, refinement bodies share the same predicate grammar with different tier ceilings.
  • RFD-0007 (first-class queries, mutations, provenance) — committed. Queries and mutations remain first-class items; the clause structure changes but the provenance machinery is preserved.
  • RFD-0023 (kernel API v2) — committed. The fork machinery used by scenario blocks is shipped.
  • RFD-0033 (test mutate statements) — discussion. Compatible; mutate in test blocks is a call to a mutation declaration; the test admits the new body grammar.
  • RFD-0039 (collections and collection operators) — committed. The shape grammar uses RFD-0039’s collection methods (+=, -=, :=) for cardinality > 1 fields.
  • RFD-0040 (substrate atoms and the explicit-writes principle) — discussion. Commits to the four explicit verbs (D4), the four operator surfaces (D2), the call-purity ladder (D3); this RFD specifies the body grammar.
  • RFD-0041 (traits) — discussion. Trait methods declared as mutation admit the new body grammar; trait bounds compose with the purity ladder per RFD-0040 D3.

This RFD supersedes RFD-0007’s body-clause structure (require / do / retract / emit / return) while preserving RFD-0007’s first-class-item commitment and the provenance machinery. The clause names are replaced by statements; the first-class status is not.

References

  • EdgeQL — https://docs.geldata.com/reference/edgeql
  • GQL / ISO/IEC 39075:2024 — https://www.iso.org/standard/76120.html
  • Cypher 25 — https://neo4j.com/docs/cypher-manual/current/
  • Datomic Datalog — https://docs.datomic.com/query/query-data-reference.html