The write path
A write is a call: POST /apps/{app}/intents { method, args, idempotencyKey?, confirm? }.
This page follows one from the moment a node opens it to the answer the client
gets back.
Any node takes any request
Section titled “Any node takes any request”A client may talk to any node of the cluster. Every node holds the system group, so it can authenticate the caller and find the app; if it is not the app’s leader, it forwards the call to a node that is. Clients reach a node in one of three ways, and all three end up in the same handlers:
- A program (
calimero-client, mero-js, calimero-client-py,meroctl) verifies the node’s attestation and seals every request (Networking & sealed channels). - A browser page on a configured origin (
--origins) does the same through mero-js; those origins are both the passkey origins and the CORS origins. - A package’s own frontend at
{label}.{apps-domain}signs in there and gets a session bound to that package (Packages & frontends).
Step by step
Section titled “Step by step”The entry node judges the caller on system state at least as new as the system leader’s, so a credential revoked or a member removed through any node is refused here before the call reaches the app. The app’s leader is the only node that runs the WASM; the followers only apply what it appended. The answer carries a receipt signed by the entry node’s attested receipt key, so a client can later prove which node answered what at which log index.
- Open. The sealed envelope wraps the whole router: the node opens the request and routes the inner one afresh, so a sealed request meets exactly the handlers and limits a direct one would.
- Name the app.
{app}may be the app id or its context id (hex(SHA-256(app_id))); the node maps one to the other. The app record is read from the system state, waiting briefly if it was just created through another node. - Authenticate. The
Authorizationheader holds a session token or an API key (cal_…). Either is judged on fresh system state: the node first catches its system replica up to the leader’s applied index, so a session revoked or a key deleted through another node is refused here at once. - Gate. The caller must reach the app under its access rule
(Organizations, teams & access); a
caller who does not gets
404(or401if they sent no credential), so the app’s existence is not disclosed. An API key must not be read-only for a write, and must be scoped to this app and method if it is scoped at all. A session opened on a package’s frontend reaches only that package’s apps. - Views called as calls. If the bundle’s ABI declares the method
#[app::view], the call runs as a strong view instead: it reads the latest state, writes nothing, and may search. - Confirm methods. If the app lists the method in
confirmMethods, the call needs the account holder’s consent (below). - Route.
Node::callgives the call an idempotency key if it has none, then runs it locally if this node leads the app’s group, or forwards it to the leader over the peer sealed channel. A forward waits at most 5 seconds, then tries whoever leads now; the loop rides out elections for a few seconds before answering503. - Run and append on the leader (Application execution,
Replication).
For a client’s call the leader reads the caller’s organization standing on
fresh system state first: it waits until its own system replica has applied
the index the entry node’s round named (
CallRequest::sys_index), which covers every system write committed before the request arrived, so the call costs one read-index round, not two (One round per request). - Answer. The node that took the request returns the result with a receipt signed by its attested receipt key.
Idempotency
Section titled “Idempotency”An idempotency key makes a call safe to retry. The leader checks it inside the proposal, against the app’s applied state: if the same caller already ran a call with that key, the stored answer is replayed and nothing is appended.
- Keys are scoped to the caller:
account|key,app:{id}|keyfor an app’s own calls,public|keyotherwise. - A client key is remembered for 24 hours by the log’s own clock (the leader’s timestamp in the entry). The newest 10 000 client records are kept whatever the clock says, so a leader whose clock jumps ahead cannot forget acknowledged calls; at most 1 000 000 are kept at all.
- A call without a key gets a node key (
node:…) before routing, remembered 10 minutes (the newest 1 000 always), so a retried forward of a call that did commit replays instead of running twice. - A call that hit a conflict records no key, so its rerun is a real run.
Confirm methods
Section titled “Confirm methods”A method listed in the app’s confirmMethods (#[app::confirm]) runs only
with a fresh passkey assertion over that exact call:
- The first
intentsrequest answers412 CONFIRM_REQUIREDwith a challenge id, the challenge, the relying party id and the account’s credential ids. The challenge isSHA-256("calimero.confirm.v1" | app | 0 | method | 0 | SHA-256(args) | nonce). - The client has the person approve it with a passkey, and sends the same call
again with
confirm { challengeId, assertion }. - The node checks the challenge was issued for this app, method, arguments and account, that it has not expired (5 minutes) or been used, and that the assertion is valid with user verification (a PIN or biometric, not just a touch).
- The assertion travels in the entry as
consent, so the log records what was approved and by whom.
A challenge lives in the memory of the node that issued it, under an id that
names that node; a retry that reaches another node takes it from the issuer
(internal/challenge/take), so it is used once wherever it is answered. An API key can carry a person’s assertion only if it was
created with allowConfirm; it cannot produce one. A confirm method called
through /views is refused with 412: as a view its writes would be dropped,
but its answer would have been handed back unconfirmed.
Answers and errors
Section titled “Answers and errors”| Outcome | HTTP |
|---|---|
| The app returned | 200 { index, result, events, receipt } |
| The app returned an error | 422 APP_ERROR with details.error |
| The run trapped (panic, out of gas, unknown method) | 422 TRAP |
| A read-only team member calls a method that is not a view | 403 |
| The call kept racing leader changes | 503; retry with the same idempotency key |
| No leader could be reached | 503 UNAVAILABLE |
| A system write (account, passkey, org, node) did not commit in 10 s | 503 UNAVAILABLE naming the system group’s leader, voters, learners and lagging members; it may still apply |
| The caller does not reach the app | 404 (or 401 without a credential) |
The index is the entry’s log index. A client that then reads from a replica
can pass it as minIndex to read its own write (Reads).
Governance writes take a different log
Section titled “Governance writes take a different log”Creating an organization, adding a member, installing a package or changing an
app’s access are not app calls: the API builds a SystemCmd and appends it to
the system group, whose state machine checks the
actor’s authority when it applies.