Action
Dual-citizen atom. Mutates Context and yields a result. The deliberate hybrid that opens the door CQS closes.
Properties
- Mutation: declares at least one mutation slot (like Command).
- Children: anything that yields (Queries, Actions, Refs). No Commands or Flows - they yield nothing.
- Yields: one value (Scalar) or N values (Stream); see sub-shapes below.
Sub-shapes
| Sub-shape | Yields | Source | Examples |
|---|---|---|---|
| ScalarAction | one value | a single mutate-and-return | Pop, Swap, Take, Create |
| StreamAction | N values | a single mutate-and-yield-many | Drain, DeleteReturning, Consume |
ScalarAction is the common case: one mutation that also returns. StreamAction is its stream-shaped twin: one mutation that yields many - drain a queue, DELETE ... RETURNING over a predicate, consume-and-remove a channel. Either way the children supply the value(s) and address(es); the Action writes and yields. Action mirrors Query's scalar/stream split rather than stopping at scalar.
Forms
Pop(list_ref) # remove and yield last item (Scalar)
Swap(ref, value_q) # write value, yield prior value (Scalar)
Create(target_ref, payload_q) # create entry, yield its id (Scalar)
Drain(queue_ref) # remove every item, yield them as a stream (Stream)
DeleteReturning(coll_ref, pred) # delete matching rows, yield the deleted ones (Stream)Examples
| Expression | Sub-shape | Effects | Yields |
|---|---|---|---|
Pop(queue_ref) | ScalarAction | WRITE | popped value |
Swap(counter, Literal(0)) | ScalarAction | WRITE | prior value |
Create(orders_ref, payload_q) | ScalarAction | WRITE | new order id |
Drain(queue_ref) | StreamAction | WRITE | stream of items |
DeleteReturning(table_ref, pred) | StreamAction | WRITE | stream of rows |
Composition
- As child: accepted wherever Query OR Command is accepted. Action satisfies both contracts at once: it yields (so it fits any value-needing slot) and it mutates Context (so it fits any mutator-needing slot like a Flow body). A StreamAction yields a stream, so a scalar consumer must reduce it (
First,Collect, ...) exactly as it would a StreamQuery - the cardinality matrix gates this uniformly, off cardinality alone. - As parent: Action's child slots need yields. Queries, Refs, and other Actions all yield, so all are accepted. Commands and Flows yield nothing, so they're rejected - the same yield rule that governs Q and C.
Constraints
- Mutation slot may be non-Ref. Unlike Command, an Action's WRITE position need not hold a Ref. Lacking a Ref the slot is addressless: it contributes no effect and the Action degrades to a Query on its surviving yield - a ScalarAction to a ScalarQuery, a StreamAction to a StreamQuery. A Ref in WRITE position is what makes an Action be an Action.
- Must yield. A value (ScalarAction) or a stream (StreamAction) - same as Query, that's how the dual contract holds.
- No Command / Flow children. They yield nothing; Action's value-needing slots reject them.
Why this kind
CQS is clean but real systems break it. list.pop removes and returns. POST /resource creates and returns an id. INSERT ... RETURNING writes and returns a row. Modelling each as Command-then-Query loses the atomicity: one syscall, one transaction, one network roundtrip becomes two unrelated steps with no guarantee the read sees what the write produced.
The same break happens stream-shaped. DELETE ... RETURNING over a predicate writes and returns many rows; draining a queue empties it and hands back everything that was there. These are one atomic mutate-and-yield-many, not a Flow of N ScalarActions: a Flow would force you to know N up front and would lose the single-transaction atomicity that justified Action in the first place. So the stream case earns its own atom, StreamAction - the exact argument that gives Action a scalar sub-shape gives it a stream one too.
Q and C remain the clean cases; Action covers the world's honest hybrids, in both cardinalities, without pretending they fit CQS.
On Q's contract
Allowing Action inside a Query slot relaxes Q's old promise that its subtree carries no WRITE. With Action in play, a Q-rooted subtree can contain WRITE through Action descendants. The yield-side contract is unchanged - Q still returns one value or a stream - but the effect set may include WRITEs that ride along.
This is the price of opening the door, paid uniformly across all Q slots (operand, Ref dynamic key, Flow's Q params). The effect algebra still tracks every WRITE; observability and independence read off composition_effects as usual.