Skip to content

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.

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).

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.

  1. 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.
  2. 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.
  3. Authenticate. The Authorization header 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.
  4. Gate. The caller must reach the app under its access rule (Organizations, teams & access); a caller who does not gets 404 (or 401 if 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.
  5. 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.
  6. Confirm methods. If the app lists the method in confirmMethods, the call needs the account holder’s consent (below).
  7. Route. Node::call gives 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 answering 503.
  8. 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).
  9. Answer. The node that took the request returns the result with a receipt signed by its attested receipt key.

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}|key for an app’s own calls, public|key otherwise.
  • 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.

A method listed in the app’s confirmMethods (#[app::confirm]) runs only with a fresh passkey assertion over that exact call:

  1. The first intents request answers 412 CONFIRM_REQUIRED with a challenge id, the challenge, the relying party id and the account’s credential ids. The challenge is SHA-256("calimero.confirm.v1" | app | 0 | method | 0 | SHA-256(args) | nonce).
  2. The client has the person approve it with a passkey, and sends the same call again with confirm { challengeId, assertion }.
  3. 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).
  4. 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.

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).

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.