Commands and decisions
A command is a request to do something. It is declared on exactly one capability (ATLAS001), validated against its input shape, and decided by a pure function.
.command('CancelOrder', { access: 'user', input: t.object({ orderId: t.string, reason: t.string }), reads: { order: { from: 'orders', key: (input) => input.orderId } }, decide({ input, state, actor }, d) { const order = state.order; if (order === null) return d.reject('order-not-found', { orderId: input.orderId }); // Ownership is checked against the record, not the actor: only the customer, staff or a policy (SYSTEM) may cancel. if (order.customerId !== actor.id && !actor.roles.some((r) => r === 'admin' || r === 'system')) return d.reject('not-order-owner', { orderId: input.orderId }); if (order.status === 'cancelled') return d.reject('order-already-cancelled', { orderId: input.orderId }); const lines = order.lines.map(({ sku, quantity }) => ({ sku, quantity })); return d.accept({ write: [d.write('orders', input.orderId, { ...order, status: 'cancelled', cancelReason: input.reason })], emit: [d.fact('OrderCancelled', { orderId: input.orderId, reason: input.reason, lines })], }); }, examples: [/* ... */],})- A read’s
keyreturns one key (state valueT | null) or an array of keys (state valueRecord<key, T | null>), e.g. one stock row per order line. - A key receives the actor:
key: (input, { actor }) => actor.idreads “my record” without an id in the payload. - Reads are chained: a key gets
(trigger, { actor, state }), wherestateholds the reads declared before it. Returningnullor[]reads nothing:request: { from: 'requests', key: (_, { state }) => state.user?.pendingId ?? null }. A key computed fromstatethat returns many keys needs a return type:key: (_, { state }): string[] => .... - A foreign read (a collection another capability owns) declares a
shapewith only the fields it uses;atlas checkverifies the owner provides them. - Every loaded record is version-checked at commit: a write to it in between is a conflict, and the decision runs again.
Unchecked record reads
Section titled “Unchecked record reads”A read marked checked: false loads the record without the version check:
reads: { stock: { from: 'stock', key: (input) => input.variantId, checked: false },}Use it only for an advisory check whose authority lives elsewhere, such as a stock pre-check at checkout while the reservation reaction decides for real. Never for a record the decision writes, or for a rule that must hold at commit (owner checks, limits). In a delivery batch an unchecked load stays out of the batch cache, so a checked read of the same record still expects its version.
Effects
Section titled “Effects”d.accept({ ... }) takes:
| Effect | Helper | Meaning |
|---|---|---|
write |
d.write(collection, key, value) |
Upsert an owned record. |
remove |
d.remove(collection, key) |
Delete an owned record atomically; it reads as null afterwards. Examples expect removes: ['drafts/d1']. |
emit |
d.fact(type, payload) |
Append a fact to the outbox, in the same transaction. |
send |
d.send(type, payload) |
Send a command to its owner, through the outbox. |
schedule |
d.schedule(type, payload, at) |
Deliver a command later (see Reactions and timers). |
Writing or removing a collection you do not own is refused (ATLAS017, and effects-invalid at runtime).
Rejections
Section titled “Rejections”d.reject('order-not-found', { orderId }): a constant kebab-case code plus params; reason is the rendered form for logs (ATLAS015). Framework codes:
| Code | Meaning |
|---|---|
unknown-command |
No capability handles the type. |
invalid-input |
The input does not parse; the issue names the exact path. |
unauthenticated / forbidden |
Access, checked before reads and decide. |
conflict |
Retries exhausted after version conflicts; params { attempts }. |
effects-invalid |
The effects break a rule (a collection not owned, a sensitive field in a fact). |
decide-failed |
decide threw. Params are {}; the trace reason has the message. |
store-rejected |
The database refuses these exact bytes (e.g. \u0000 in a string); params.code is the SQLSTATE. HTTP 422. |
store-failed |
The store threw (connection lost). HTTP 503. |
internal-error |
The adapter’s boundary: something else threw. HTTP 500. |
A command may declare rejects: ['code', ...] for codes a shared helper produces that no example shows; they are merged into atlas schema, and ATLAS015 checks the file rejects with each.
Examples
Section titled “Examples”examples: [ { name: 'places a new order for the actor and computes the total', actor: ada, state: { existing: null }, input: { orderId: 'o1', lines: [{ sku: 'mug', quantity: 2, unitPriceCents: 900 }] }, expect: { emits: ['OrderPlaced'], writes: ['orders/o1'], facts: [{ type: 'OrderPlaced', customerId: 'c1', totalCents: 1800 }] }, }, { name: 'rejects an anonymous caller', actor: null, input: { orderId: 'o1', lines: [] }, expect: { rejects: 'unauthenticated' } },]- Example
stateis partial: missing reads default tonull. expect.rejectsis the exact rejection code.- An example that sets
actor(evennull) enforces access as on dispatch; withoutactorit runs asSYSTEMand access is not exercised. - Every command needs an accepting example (ATLAS009 error) and should have a rejecting one (ATLAS009 warning).
Concurrency
Section titled “Concurrency”Every read carries a version. A stale commit is retried by re-running the pure decision after a jittered exponential wait: createRuntime({ retry: { attempts: 5, baseMs: 10, maxMs: 200 } }) are the defaults since 0.5.0. The last conflict answers conflict { attempts }.