Skip to content

Delegated Execution Client Contract

A client that holds an account root and a device key, and runs no node of its own, reaches a context through somebody else’s node. This page is the contract for that: the exact bytes it signs, the exact shapes it sends, and which parts are live today.

It exists so a client can be built against a fixed target rather than against a guess. Every field here is quoted from the code that implements it, and anything still due to change says so where it appears.

Contract Status Tracking
Discovery — where to write and as whom Live —
Warrant and delegated write Live; account_heads signed but not enforced #3933
Session — challenge and LoginStatement Live #3930
Read — an authenticated account reads a context Live #3931

Two conventions run through everything below.

Every signature in Calimero is over a 32-byte digest produced by one function, so a signature minted for one purpose can never be replayed as another. Length-prefixing each segment is what stops ("ab", "c") and ("a", "bc") hashing alike.

crates/primitives/src/identity.rs
pub fn domain_hash(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update((domain.len() as u64).to_le_bytes());
hasher.update(domain);
for part in parts {
hasher.update((part.len() as u64).to_le_bytes());
hasher.update(part);
}
hasher.finalize().into()
}

In other words: SHA256( u64le(len(domain)) ‖ domain ‖ ( u64le(len(part)) ‖ part )* ), with every length a little-endian u64.

A client implementing this in another language must reproduce it byte for byte. It is the single most likely place for a cross-language client to go wrong, because a missing length prefix still produces plausible-looking digests that never verify.

Signed structures cross the HTTP boundary as hex-encoded borsh, never as JSON. The canonical form of a signed statement is its borsh encoding; re-describing its fields as JSON would create a second spelling that could disagree with the bytes the signature covers.

Everything unsigned — request envelopes, arguments, responses — is JSON, camelCase.

Before minting a warrant a client needs two facts it cannot derive: which node to talk to, and the account and signing key the warrant must name for that node (executor and executor_key).

Which relay, from the cloud:

GET /api/cloud/me/namespaces/{namespace_id}/relays
{
"namespace_id": "…",
"relays": [
{
"peer_id": "12D3KooW…",
"relay_url": "https://node-7.example.net", // where to send the intent
"executor_account": "…", // what the warrant must name
"authorship_ready": true, // may it author here yet
"status": "active",
"assigned_at": "…", "confirmed_at": "…", "last_seen_at": "…"
}
]
}

Whether this context is open to delegation, from the node itself:

GET /admin-api/contexts/{context_id}/intents
{
"executorAccount": "…",
"executorKey": "…",
"canAuthorOnBehalf": false,
"groupId": "…",
"grantedOnGroupId": null,
"releaseBytecodeId": "…",
"releaseVersion": "1.2.0"
}

releaseBytecodeId and releaseVersion are the release this context’s group names, which a warrant pins (below). Read them here rather than from the application: the relay refuses a warrant pinning any other release.

canAuthorOnBehalf: false is the default state of every context — authorship is implied by neither membership nor admin, and is never granted implicitly. A client should read this before signing anything and, when it is false, tell the user to ask an admin of groupId for the grant rather than minting a warrant that will be refused after the nonce is already spent.

A TEE relay (RelayTee) answers true by its role, with no grant. A TEE replica (ReadOnlyTee) always answers false and no grant changes that: the namespace has to admit its TEEs in relay mode. See what the relay must be granted.

// crates/account/src/warrant.rs — live today
pub struct Warrant {
pub context: ContextId,
pub author_account: AccountId,
pub author_device_key: PublicKey,
pub executor: AccountId,
pub executor_key: PublicKey, // the one relay device that may spend it
pub release_bytecode_id: [u8; 32], // the release the relay must run
pub release_version: String, // its semver, readability only; at most 256 bytes
pub method: String, // in the clear
pub intent_hash: [u8; 32], // H(method ‖ args)
pub account_heads: Vec<[u8; 32]>, // at most 64
pub governance_floor: Vec<[u8; 32]>, // at most 64
pub nonce: u64,
pub not_after: u64,
pub signature: [u8; 64],
}

The arguments are committed to as a hash rather than carried; the method itself travels openly, so a peer can select a per-method write-set without reversing a hash against the app’s ABI:

intent_hash = domain_hash(b"calimero.warrant.intent.v1", &[method.as_bytes(), args])

And the signature covers, in this order:

signature = sign(domain_hash(b"calimero.warrant.v2", &[
context.digest(),
author_account.as_bytes(),
author_device_key, // 32 bytes
executor.as_bytes(),
executor_key, // 32 bytes
release_bytecode_id, // 32 bytes
release_version.as_bytes(), // variable length
method.as_bytes(), // variable length
intent_hash, // 32 bytes
&(account_heads.len() as u64).to_le_bytes(),
..account_heads, // 32 bytes each, in order
&(governance_floor.len() as u64).to_le_bytes(),
..governance_floor, // 32 bytes each, in order
&nonce.to_le_bytes(),
&not_after.to_le_bytes(),
]))

Each head list is preceded by its own count, and that is not decoration. domain_hash length-prefixes every part it is given, so the heads are individually unambiguous — but the two lists are adjacent, so without the counts account_heads = [a, b], governance_floor = [] would hash identically to [a], [b], and a relay could relabel which plane a head was cited from.

POST /admin-api/contexts/{context_id}/intents
{
"method": "set",
"argsJson": { "key": "a", "value": "b" },
"warrant": "<hex borsh of Warrant>",
"authorProof": "<hex borsh of AccountProof<DeviceCert>>"
}

The client supplies only its own half of the proof. The executor’s proof and signing key are attached by the node from its own credentials, and the node refuses the warrant with a 403 unless its signing key is the executor_key the warrant names. Naming one device is what makes a warrant spendable once: each relay keeps its own nonce ledger, so a warrant any device of the operator could present would be spendable once per device. A relay that re-keys voids the warrants it has not spent yet.

// 200
{
"data": {
"rootHash": "…", // the context's scope root after the run
"returns": { /* the method's own return value, or null */ }
}
}

A method that ran and returned an error is not a 200. It answers 400 with the error /jsonrpc reports for the same call: type and data are exactly the object JSON-RPC puts under error, and error is its message for clients that read only the admin API’s error shape. Nothing was committed or published, and the warrant’s nonce is unspent: a nonce is spent only when the delta applies.

// 400
{
"error": "function call error: <the method's message>",
"type": "FunctionCallError",
"data": "<the method's message>"
}

So returns: null always means the method returned nothing, never that it failed.

AccountProof<T> is the self-contained credential that ties a device key to an account:

crates/account/src/signed.rs
pub struct AccountProof<T> {
pub genesis: AccountGenesis, // hashes to the AccountId
pub chain: Vec<RootKeyHandoff>, // root rollovers, epoch 0 upward
pub statement: T, // here: DeviceCert
}

A warrant writes into a context that exists. To create one, sign a ContextCreationWarrant — the group, a 32-byte seed the node derives the context id from, the application, optional service and name, and a hash of the init arguments — and present it to a relay:

GET /admin-api/groups/{group_id}/context-intents?author={account}
POST /admin-api/groups/{group_id}/context-intents
// GET 200 — ask first; it signs nothing and spends nothing
{ "data": { "executorAccount": "…", "executorKey": "…", "groupId": "…",
"canCreateOnBehalf": true, "authorMayCreate": true } }
// POST body
{ "warrant": "<hex borsh of ContextCreationWarrant>",
"authorProof": "<hex borsh of AccountProof<DeviceCert>>",
"initArgs": { /* the JSON init() receives */ } }
// POST 200
{ "data": { "contextId": "…", "groupId": "…", "memberPublicKey": "…" } }

init_hash = domain_hash("calimero.context-creation.init.v1", [init_args]), where init_args is the JSON the node re-encodes from initArgs (compact, keys in the order sent). The signing preimage is domain_hash("calimero.context-creation-warrant.v1", parts) over, in order: group, seed, author account, author device key, executor, executor key, application id, then for each of service_name and name a one-byte presence tag (00/01) followed by its UTF-8 bytes (empty when absent), then init_hash, the u64le-counted account heads and governance floor, u64le(nonce) and u64le(not_after).

The author needs admin or CAN_CREATE_CONTEXT in the group; the relay needs only standing to act for members (a RelayTee, or CAN_AUTHOR_ON_BEHALF). The nonce is spent in the new context’s ledger, so the device’s ordinary nonce source is the right one. Refusals: 400 malformed, 403 not authorized (author, relay, expiry, arguments, replay), 404 unknown group or application, 409 the group targets a different application than the one signed. Vectors are under Test vectors.

The same pattern covers governance: adding the other person to a DM, creating a channel’s subgroup, renaming it. Sign a GovernanceWarrant over one op in its delegable form and present it with the op’s bytes:

GET /admin-api/groups/{group_id}/governance-intents
POST /admin-api/groups/{group_id}/governance-intents
// GET 200 — signs nothing, spends nothing
{ "data": { "executorAccount": "…", "executorKey": "…", "groupId": "…",
"canActOnBehalf": true } }
// POST body — every field hex borsh
{ "warrant": "<GovernanceWarrant>",
"authorProof": "<AccountProof<DeviceCert>>",
"op": "<GroupOp, or RootOp, in its delegable form>" }
// POST 200 — the group the op acted on; the new subgroup for a creation
{ "data": { "groupId": "…" } }

The warrant’s kind names the plane (0 group op, published on {group_id}; 1 root op, published on the namespace, so {group_id} is the namespace id) and scope is that group. It commits to the op, not a description of it: op_hash = domain_hash("calimero.governance-warrant.op.v1", [[kind], op_bytes]), where op_bytes is exactly the op field. The signing preimage is domain_hash("calimero.governance-warrant.v1", parts) over, in order: scope, [kind], author account, author device key, executor, executor key, op_hash, the u64le-counted account heads and governance floor, u64le(nonce) and u64le(not_after).

The delegable form is the op as you mean it, with the fields only the relay can compute cleared: a MemberRemoved/MemberLeft’s two expected hashes are zero or empty, a GroupDeleted’s cascade lists are empty, and a TargetApplicationSet’s bytecode_id is zero. The relay fills them in. For GroupCreated, pick the new subgroup’s id at random and name yourself as admin; the id is inside what you sign, so the relay keeps it.

Delegable: MemberAdded, MemberRemoved, MemberLeft, MemberRoleSet, MemberCapabilitySet, DefaultCapabilitiesSet, SubgroupVisibilitySet, group, member and context metadata, ContextDetached, context capabilities, a group’s first TargetApplicationSet; and, as root ops, GroupCreated, GroupReparented, GroupDeleted, and MemberJoinedOpen for yourself. A capability change may not grant or withdraw CAN_AUTHOR_ON_BEHALF.

TargetApplicationSet is how a namespace you founded through a relay gets its application, which a creation warrant needs (until then a creation is refused with 409). Sign target_application_id, package and version; leave bytecode_id zero, because it is the blob id of the bundle as the relay stores it. The relay resolves package@version from its registry and refuses with 403 if that bundle is a different application than the one you signed (502 if its registry has no such bundle). It is accepted only as the group’s first application: on a group that already targets one it is refused with 403, before anything is published, and every peer refuses it too. An upgrade is not something a relay carries. You need admin or MANAGE_APPLICATION in the group.

Every peer applies the op as you, so your own role and capabilities decide: MANAGE_MEMBERS to add or remove, admin to add an admin, CAN_CREATE_SUBGROUP to create a subgroup. The relay needs only standing to act for members. A relay that creates a subgroup for you is seated in it with that standing, and holds its key, so it can serve it straight away. Nonces are spent per group and author device. Refusals: 400 malformed, 403 not authorized (the reason is in the body), 404 unknown group, 409 a subgroup id that already exists. Vectors are under Test vectors.

A device key obtains a session in two calls. Implemented by #3930; enable the account_proof provider on the node to use it.

GET /auth/challenge
{
"data": {
"challenge": "<64 hex chars — 32 bytes>",
"expires_at": 1789470000 // unix seconds; 60s from issue by default
},
"error": null
}

The 32 bytes are expiry(8) ‖ nonce(8) ‖ tag(16), the tag an HMAC over the first two under a key only the node holds. So the node stores nothing at issue time — which matters, because this endpoint is unauthenticated and a per-request row would hand an anonymous caller a write amplifier.

It is single-use, and spent only after a signature over it verifies. Burning it on presentation would let anyone who can see a challenge in flight invalidate it before its holder finishes signing.

404 means the provider is not enabled on this node.

crates/account/src/login.rs
// domain calimero.auth.login.v1 · borsh · signed by the DEVICE key
pub struct LoginStatement {
pub node: PublicKey, // this node's identity, as the client pinned it
pub audience: Audience, // WebOrigin(String) | CodeSigningId(String) | Cli
pub challenge: [u8; 32],
pub session_key: PublicKey, // the ephemeral key the session speaks with
pub device_key: PublicKey, // what the signature is checked against
pub issued_at: u64,
pub expires_at: u64,
pub signature: [u8; 64],
}
signature = sign(domain_hash(b"calimero.auth.login.v1", &[
node, // 32 bytes
&audience_tag_and_bytes, // 1-byte variant tag, then the payload
&challenge, // 32 bytes
session_key, // 32 bytes
device_key, // 32 bytes
&issued_at.to_le_bytes(),
&expires_at.to_le_bytes(),
]))

The audience tag is 0 for WebOrigin, 1 for CodeSigningId, 2 for Cli, followed by the variant’s string bytes (empty for Cli). The tag is load-bearing: without it a statement minted for the web origin "x" would verify as one minted for the code-signing id "x".

The client learns the node’s identity from a pinned certificate, never from the challenge — otherwise an attacker who can answer on the node’s behalf chooses what the device signs about. That is the field’s whole purpose: a hostile relay can fetch a real challenge from the target node and serve it to a user as its own, and only node refuses the replay.

The login goes through the node’s existing token endpoint, with auth_method: "account_proof" — not a bespoke route. That is what gives a device-key session the same refresh, revocation and rate limiting every other session gets.

POST /auth/token
{
"auth_method": "account_proof",
"public_key": "<the session public key — must match the statement's session_key>",
"client_name": "<the node URL, for node-binding the token>",
"timestamp": 1789470000,
"provider_data": {
"challenge": "<64 hex chars>",
"login_statement": "<hex borsh of LoginStatement>",
"account_proof": "<hex borsh of AccountProof<DeviceCert>>"
}
}
// 200
{
"data": {
"access_token": "<JWT; subject is the ACCOUNT, not the device>",
"refresh_token": "<JWT>"
},
"error": null
}

The session is granted to the account: the JWT’s sub is the account id, which /auth/validate forwards to handlers as X-Auth-User.

Membership is not established by logging in. It is evaluated per request against the target context’s group — a relay serves several tenants, so a session must not carry a standing right to read.

Implemented by #3931.

POST /admin-api/contexts/{context_id}/query
Authorization: Bearer <session token>
{
"method": "get",
"argsJson": { "key": "a" }
}
// 200
{ "data": { "returns": { /* the method's return value */ } } }

The method runs as the caller: env::account_id() is the session’s account and env::device_id() is the certified signing key of the device that opened the session, resolved from the namespace’s bindings — the same pair a warrant for that device would carry through /intents. So a view such as “whose turn is it” or “who am I” answers for the caller, not for the relay executing the read. A session that names no device runs on the relay’s key for that half only; the account is always the caller’s.

Three refusals a client must handle distinctly, because they mean different things to a user:

Status Meaning What the client should do
401 no session, or it expired re-run the login exchange
403 the account is not a member of this context’s group tell the user; do not retry
409 the method is not read-only a bug in the client’s method choice — this is a write, mint a warrant

A read whose method returned an error answers 400 with the same body as POST .../intents gives a method error (type: "FunctionCallError", the method’s message in data), which is what /jsonrpc reports for the same call.

That last one is the one to get right. Reads are gated on the method being declared read-only, and the gate fails closed: a method that declares nothing is refused rather than guessed at. See #3932 for how that declaration is derived from the method’s receiver — which is why the gate is usable at all, and why a client can call an ordinary &self method without the app author having annotated anything.

Three things about this endpoint are worth knowing before building against it.

The account is never in the body. It comes from the session, so there is no field a caller could set to read as somebody else. A session that is not account-anchored — a node-owner or client-key token — gets 401 here, not because it lacks permission but because this route answers “what may this account see” and such a session names no account. A node owner reads through the ordinary execute path.

Membership is re-checked on every call, against the group owning the context, never cached from the session. One relay serves several tenants, so a session that carried a standing right to read would keep serving somebody after the removal op the node has already applied. Inheritance resolves, so a subgroup member reads a context they hold through a parent.

No rootHash in the response, unlike the intent reply. That field answers “did this change anything?” and for a read the answer is structurally no; returning it would invite a client to watch it for changes that can never come.

5. Inventory — what this account can reach

Section titled “5. Inventory — what this account can reach”

A client that runs no node still has to find out which namespaces and contexts it may read or write, or every id has to be handed to it out of band. Three routes answer that, all with the session token and nothing else:

GET /admin-api/namespaces → the namespaces this account is a member of
GET /admin-api/contexts → the contexts inside them (each with its groupId)
GET /admin-api/contexts/{ctx} → one context, if this account is a member

All three resolve the caller’s groups from the store on every request and return only what those admit. Membership is never read off the session: a namespace or context disappears from the listing when the removal op lands, not when the token expires. A context whose owning group cannot be resolved is excluded, not included — the listings fail closed.

GET /admin-api/contexts/{ctx} applies the same rule to a named id, so a context the listing hides cannot be reached by guessing its id: the answer is 403, and 404 means the node genuinely does not hold it.

Node-owner sessions, and nodes running with no auth guard at all, keep the node-wide view of all three — narrowing there would empty the endpoints on every single-tenant node without closing anything.

A delegated session uploads and reads blobs through a context, never by id alone. Both routes take context_id, and for a session it is required:

  • PUT /admin-api/blobs?context_id={ctx} — refused 403 unless the account is a member of ctx’s group (resolved per request), 413 over 64 MiB. The node records the blob as ctx’s.
  • GET / HEAD /admin-api/blobs/{id}?context_id={ctx} — refused 403 unless the account is a member of ctx’s group. The blob is then served only if the node associates it with ctx: it was uploaded for ctx, or fetched from ctx’s peers (a GET for a blob the relay does not hold does that fetch). Anything else is 404, the same answer as a blob nobody holds, so a member of one context cannot probe for another’s.

So an app that uploads a blob and names it to its WASM later (as blob_announce_to_context does) must pass context_id on the upload, in every session: a blob uploaded without one is held for no context, so the announce shares nothing.

1. GET /api/cloud/me/namespaces/{ns}/relays → relay_url, executor_account
2. GET /auth/challenge → challenge
3. sign LoginStatement with the DEVICE key
4. POST /auth/token (auth_method=account_proof) → session token
5. GET /admin-api/namespaces → my namespaces
GET /admin-api/contexts → my contexts, with groupId
6. GET /admin-api/contexts/{ctx}/intents → executorKey, releaseBytecodeId, canAuthorOnBehalf?
├── false → stop; ask an admin of groupId for the grant
└── true → continue
7. READ POST /admin-api/contexts/{ctx}/query (token; no warrant)
8. WRITE sign a Warrant with the DEVICE key
POST /admin-api/contexts/{ctx}/intents (token + warrant + authorProof)

Every step works today.

On a TEE relay, steps 6 and 8 can travel sealed to the relay’s attested key, so the warrant and the arguments are readable only inside its TD. Attest with POST /admin-api/tee/attest (bindTransportKey, includeCollateral), verify the quote, then send those requests through /sealed/v2. On a relay that leaves auth to a proxy (hosted nodes), the session steps (2–4) and reads (5, 7) cannot be sealed. See how a client reaches a node.

Check your implementation against these before pointing it at a node. They are asserted by the Rust tests named beside each block, so they cannot drift from the code without CI going red.

crates/account/src/tests/domain.rs::domain_hash_has_known_answers

domain parts digest
"" — af5570f5a1810b7af78caf4bc70a660f0df51e42baf91d4de5b2328de0e83dfc
"ab" ["c"] 43ee655579de01ca739b3f95c1c2d3f46d353b2c0df818064ea594506cdb2617
"a" ["bc"] 9a8acca1b6c6c0befd3fbc756aed625da998c998f7252e738c4ef061906b9b21

Start with the first row. It is SHA-256 of eight zero bytes — the u64le length of an empty domain — not SHA-256 of the empty string. If you get e3b0c442…, you are omitting the length prefixes, and nothing below will match.

The last two rows are the whole reason the prefixes exist: both hash the letters abc, and they must differ.

crates/account/src/tests/warrant_wire_fixture.rs

Inputs: context = 0x11×32, author_account = 0x22×32, executor = 0x33×32, executor_key = 0x77×32, release_bytecode_id = 0x44×32, release_version = "1.0.0", method = "set", args = {"key":"k","value":"v"} (exactly those bytes, no whitespace), account_heads = [0x55×32], governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author device secret = 0x07 seeded.

intent_hash dc066cc8524c74dc21714174009df536376e3151f5b92f0a676defde599dbae5
signing preimage f38e2c9eb7e78025f8797bb34168fe48da95a20320fc62fc2da41e3efd30607c
author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c

The full 392-byte encoding, field by field:

offset field bytes value
0 context 32 1111…11
32 author_account 32 2222…22
64 author_device_key 32 ea4a6c63…
96 executor 32 3333…33
128 executor_key 32 7777…77
160 release_bytecode_id 32 4444…44
192 release_version len 4 05000000 (5, little-endian)
196 release_version 5 312e302e30 ("1.0.0")
201 method len 4 03000000 (3, little-endian)
205 method 3 736574 ("set")
208 intent_hash 32 dc066cc8…
240 account_heads len 4 01000000 (1, little-endian)
244 account_heads[0] 32 5555…55
276 governance_floor len 4 01000000 (1, little-endian)
280 governance_floor[0] 32 6666…66
312 nonce 8 2a00000000000000 (42, little-endian)
320 not_after 8 00f1536500000000 (1700000000, little-endian)
328 signature 64 e42f753e…

v1 was pure fixed-width concatenation — no length prefixes, no tags — and v2 gives that up on purpose: release_version and method are Strings and the two head lists are Vecs, so four u32 little-endian counts now sit inside the encoding. The offsets above hold only for these inputs; a string of another length moves everything after it.

Note the two widths. The counts inside the borsh encoding are u32; the counts the signing preimage hashes (above) are u64. They are different serializations of the same numbers and a builder that reuses one for the other produces a warrant that is well-formed and verifies nowhere.

Every integer here is little-endian, in the encoding and in domain_hash alike. That is the second most common cross-language mistake after the missing length prefix.

crates/account/src/tests/creation_wire_fixture.rs

Inputs: group = 0x11×32, seed = 0x12×32, author_account = 0x22×32, executor = 0x33×32, executor_key = 0x77×32, application_id = 0x44×32, service_name absent, name = "general", init args = {"name":"general"}, account_heads = [0x55×32], governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author device secret = 0x07 seeded.

init_hash 074dc4be8c7abe685532a48947430edd0301b42f60222e715a834e723d2a055e
signing preimage 7e1126b215a794e565bb59c630cb1a36e43590c4bd8f62c6111792f77c231598
author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c
encoding 421 bytes; service_name encodes as 00, name as 01 07000000 67656e6572616c
signature 19103d73752d052f747911b4b36e423221d89d120bf6f4d32122d3c4fd1fb030
9add40f3f3b80a5e5d6fefa02de5f4d6bb914c85c5c8b10a3a0e3a0e22c08400

crates/account/src/tests/governance_wire_fixture.rs

Inputs: scope = 0x11×32, kind = 1 (root), author_account = 0x22×32, executor = 0x33×32, executor_key = 0x77×32, op bytes = 010203, account_heads = [0x55×32], governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author device secret = 0x07 seeded.

op_hash d6bc121f9fcf7b85bea94d356d620c14dd8e1ae5fa2317cbcfc2c486cf04dfb3
signing preimage b71a40cfbbe50e40d3423e8729a9ca8359e69f2762396922e6b314a971c5f720
author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c
encoding 345 bytes
signature 03ce6c011f565924099b2c776032a1cfe540d1c4c09fad0b470723f8ee76138d
1d685a20948edf4dee2901d3f9187e3363a16e3b141b0bc94553f5948becae07

Real ops, from delegated_governance_op_vectors_are_stable and delegable_target_application_set_vector_is_stable in crates/governance-types:

MemberAdded { member: 0x44×32, role: Member }
op 01 44…44 01
op_hash c48dce4ba9da20980a86832b133e7040ec67c293987c0f131140291a71041cd6 (kind 0)
MemberRemoved { member: 0x44×32 }, delegable form
op 02 44…44 00×32 00000000
TargetApplicationSet { target_application_id: 0x88×32, package: "com.example.app",
version: "1.2.3" }, delegable form (bytecode_id cleared)
op 07 00×32 88…88 0f000000 636f6d2e6578616d706c652e617070 05000000 312e322e33
op_hash 904984c8f39e4172ea8864a65511faead68baa76af18d31e1d442a0b9fcb656b (kind 0)
GroupCreated { group_id: 0x55×32, parent_id: 0x11×32, restricted: true, admin: 0x22×32 }
op 00 55…55 11…11 01 22…22
op_hash 0ae00fae1b87e3b0f285246632ebe31e2e3b687a563a1f5299be997657dfd55f (kind 1)