Skip to content

kv-registry

kv-registry is a shared key/value registry with one simple rule: the member that first writes a key owns it. Only the owner can update or delete the key. Every current entry can be proved against the state root.

The machine id is exactly kv-registry. A chain id such as registry-chain names one ledger that uses it.

Machine idkv-registry
Maturitystable
Commands[0, key, value] PUT; [1, key, h''] DELETE (canonical CBOR)
Settingmachines.kv-registry.value-format: raw (default), utf8 or cbor
Statekey bytes → [owner, value], where owner is the 32-byte member key
Proof subjectregistry-entry-v1: coordinate key (hex); claims owner-equals, value-digest-equals
Result codesKV_NOT_OWNER
Eventskv-registry.entry-put.v1, kv-registry.entry-deleted.v1, in composites only
Who owns a keyIllustrative data

What you're seeing: two members write the same key. Every message here is final; only the ones that pass the ownership rule change state. Switch to the verifier’s view to see what a proof shows.

  1. A puts Member A submits PUT supplier-42 = active. In the final block the key has no entry, so the machine writes [A, "active"]. A owns the key.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    supplier-42Aactive
  2. B puts Member B submits PUT supplier-42 = suspended. Admission does not check ownership, so B gets 202 and the message is finalized. In the block, the entry is owned by A, so the decision is KV_NOT_OWNER: nothing is written.
    • fails: The key has no entry, or the sender owns it KV_NOT_OWNER
    kv-registry state (unchanged)
    KeyOwnerValue
    supplier-42Aactive
  3. A updates Member A submits PUT supplier-42 = suspended. A owns the entry, so the value is replaced.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    supplier-42Asuspended
  4. A deletes Member A submits DELETE supplier-42. The owner may delete, so the entry is removed. A proof of the key is now an exclusion proof with no value.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    empty
  5. B puts again Member B submits PUT supplier-42 = active. The key has no entry, so B becomes the owner. This is not a transfer: the key was released and B created a new entry.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    supplier-42Bactive

What if: B deletes A’s key

The ownership rule applies to DELETE as well as PUT.

  1. A puts Member A submits PUT supplier-42 = active. In the final block the key has no entry, so the machine writes [A, "active"]. A owns the key.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    supplier-42Aactive
  2. B deletes Member B submits DELETE supplier-42. The entry exists and A owns it, so the decision is KV_NOT_OWNER. The entry stays.
    • fails: The key has no entry, or the sender owns it KV_NOT_OWNER
    kv-registry state (unchanged)
    KeyOwnerValue
    supplier-42Aactive

What if: Delete a missing key

Deleting a key that has no entry is allowed and does nothing.

  1. Nothing stored The registry has no entry for supplier-42.
    kv-registry state
    KeyOwnerValue
    empty
  2. B deletes Member B submits DELETE supplier-42. No entry means no owner to check, and nothing to remove: the plan is empty and no event is produced.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    empty

What if: Inside a composite

In a declarative composite the same rule rejects the whole cascade instead of recording a no-op.

  1. A puts Member A submits PUT supplier-42 = active. In the final block the key has no entry, so the machine writes [A, "active"]. A owns the key.
    • passes: The key has no entry, or the sender owns it
    kv-registry state
    KeyOwnerValue
    supplier-42Aactive
  2. B’s cascade fails A binding derives a PUT on supplier-42 from a command that member B sent. The derived command keeps B’s authority, so the registry returns KV_NOT_OWNER and the cascade is rejected: none of its business writes are kept.
    • fails: The key has no entry, or the sender owns it KV_NOT_OWNER
    kv-registry state (unchanged)
    KeyOwnerValue
    supplier-42Aactive
  • member node
  • deterministic state machine
  • verifier
  • state changed
  • no effect

In short:

  • A PUT on an absent key creates [sender, value].
  • A PUT or DELETE by the owner applies. By anyone else it is KV_NOT_OWNER.
  • A DELETE of an absent key does nothing.
  • On a standalone chain KV_NOT_OWNER is a final no-op. Inside a declarative composite it rejects the whole cascade instead.
  • Events are emitted only in composites, and only when state changes.

Before answering 202, the receiving member decodes the body. It refuses the body with HTTP 400 and the code APPLICATION_REJECTED unless, in this order:

  1. the body is one canonical CBOR array of three items;
  2. the key has 1 to 256 bytes;
  3. the operation is 0 or 1, and a DELETE carries an empty value;
  4. a PUT value is not empty and matches value-format.

Admission does not check ownership: that needs the state of the block that finally includes the command.

From command to bytesIllustrative data

What you're seeing: one kv-registry command from encoding to state. Try a “What if” to see which admission rule refuses a bad body, in the order the decoder checks it.

  1. Write the command A kv-registry PUT is a CBOR array: operation 0, the key bytes, and the value bytes. The machine reads only the body; the topic is a label.
    python3 docs/appchain/tutorials/tools/stdlib_command.py kv-registry put supplier-42 --value-text active
  2. Encode 83 opens an array of three items. 00 is operation 0. 4b starts an 11-byte string, the key. 46 starts a 6-byte string, the value. Stock contracts accept only this shortest, canonical form.
  3. Admit The receiving member decodes the body against the next block. Every rule passes, so it answers 202 and gossips the message.
    • passes: One canonical CBOR array of three items
    • passes: Key has 1 to 256 bytes
    • passes: Operation is 0 (PUT) or 1 (DELETE), and a DELETE carries an empty value
    • passes: A PUT value is not empty and matches value-format
  4. Apply In the final block every member runs the same decision: the key has no entry, so the sender becomes its owner. Only now has state changed.
    • passes: The key has no entry, or the sender owns it
    kv-registry state after the block
    KeyValue
    supplier-42[member 1 key, "active"]

What if: A DELETE with a value

Operation 1 must carry an empty value. The helper always sends one; this body was written by hand.

  1. Write the command A DELETE of supplier-42 that also carries the value x.
  2. Refused at admission The decoder rejects a DELETE whose value is not empty. Admission rejections keep only a safe code, so REST answers 400 with APPLICATION_REJECTED. Nothing is pooled.
    • passes: One canonical CBOR array of three items
    • passes: Key has 1 to 256 bytes
    • fails: Operation is 0 (PUT) or 1 (DELETE), and a DELETE carries an empty value APPLICATION_REJECTED
    • not reached: A PUT value is not empty and matches value-format

What if: A 300-byte key

Stock keys are bounded so that every entry can be proved through the standard proof endpoint.

  1. Write the command A PUT whose key is 300 bytes long.
  2. Refused at admission Keys must have 1 to 256 bytes. The member refuses the body before it reaches a block.
    • passes: One canonical CBOR array of three items
    • fails: Key has 1 to 256 bytes APPLICATION_REJECTED
    • not reached: Operation is 0 (PUT) or 1 (DELETE), and a DELETE carries an empty value
    • not reached: A PUT value is not empty and matches value-format

What if: Non-canonical CBOR

The same values in a longer encoding are different bytes, so they are refused.

  1. Write the command The PUT again, but operation 0 is written as 18 00, a one-byte integer, instead of 00.
  2. Refused at admission The decoder re-encodes what it read and compares the bytes. 18 00 is not the shortest form of 0, so the body is not canonical.
    • fails: One canonical CBOR array of three items APPLICATION_REJECTED
    • not reached: Key has 1 to 256 bytes
    • not reached: Operation is 0 (PUT) or 1 (DELETE), and a DELETE carries an empty value
    • not reached: A PUT value is not empty and matches value-format

What if: Amount 0 (balances)

Every stock machine uses the same strict decoder. Here a balances MINT carries a zero amount.

  1. Write the command A balances MINT [0, "alice", 0]. The helper refuses it (“amount must be positive”), so this body was written by hand.
  2. Refused at admission The balances decoder requires a positive amount, so the receiving member refuses the body.
    • passes: One canonical CBOR array of three items
    • passes: Operation is 0 (MINT) or 1 (TRANSFER)
    • passes: Account is text, and b/<account> fits in 256 bytes
    • fails: Amount is positive APPLICATION_REJECTED
  • your application
  • member node
  • deterministic state machine
  • refused

Use kv-registry when you need a provable current value and “the first writer owns this key” is the right authorization:

  • allow and deny lists;
  • product, asset, credential or schema metadata;
  • DID documents or current document pointers;
  • shared configuration with one owner per record.

Choose authenticated-map, a composite, or a plugin when ownership must move under governance, several parties must approve an update, values need domain rules, or authority belongs to a business actor rather than a member node.

The stock local cluster hosts registry-chain with value-format: utf8. From the top-level directory of the extracted release:

Terminal window
./yano.sh appchain cluster start 3
./yano.sh appchain cluster kv registry-chain set supplier-42 active --node 1

Node 1 now owns supplier-42. A write through node 2 becomes final but cannot change the entry:

Terminal window
./yano.sh appchain cluster kv registry-chain set supplier-42 suspended --node 2

The owner can update or delete it:

Terminal window
./yano.sh appchain cluster kv registry-chain set supplier-42 suspended --node 1
./yano.sh appchain cluster kv registry-chain del supplier-42 --node 1

Tutorial 2 walks through the same steps with proofs.

REST takes the command bytes as bodyHex. Encode them with the tutorial helper, from the top-level directory of the release or a source checkout:

Terminal window
TOOL=docs/appchain/tutorials/tools/stdlib_command.py
PUT_HEX=$(python3 "$TOOL" kv-registry put supplier-42 --value-text active)
DELETE_HEX=$(python3 "$TOOL" kv-registry delete supplier-42)

Submit the PUT through node 1, so node 1 becomes the owner:

Terminal window
curl -sS -X POST \
http://127.0.0.1:7071/api/v1/app-chain/chains/registry-chain/messages \
-H 'Content-Type: application/json' \
-d "{\"topic\":\"kv-registry.command.v1\",\"bodyHex\":\"$PUT_HEX\"}" | jq .

For a binary value, use --value-hex 010203ff instead of --value-text; it needs a chain whose value-format is raw. Send the DELETE through the same member.

The topic is a label. kv-registry ignores it, and it does not create a namespace: the key bytes alone identify the entry. Use prefixed keys such as suppliers/acme, or separate chains when membership should also differ.

implementation "org.yanoproject.x:yano-x-client:${yanoXVersion}"
import org.yanoproject.x.client.AppChainClient;
import org.yanoproject.x.client.StdlibAppChainClient;
import java.nio.charset.StandardCharsets;
var ownerClient = AppChainClient.builder("http://127.0.0.1:7071/api/v1")
.chainId("registry-chain")
// .apiKey("secret")
.build();
var registry = new StdlibAppChainClient(ownerClient);
byte[] key = "supplier-42".getBytes(StandardCharsets.UTF_8);
var submitted = registry.kvPut(key, "active".getBytes(StandardCharsets.UTF_8));
System.out.println(submitted.messageId());
registry.kvDelete(key); // later, through the same member

The node signs a REST submission, not your client object. A client pointed at port 7072 uses node 2’s member key and cannot update a key that node 1 owns.

The state key is the key bytes themselves:

Terminal window
KEY_HEX=$(python3 -c 'print("supplier-42".encode().hex())')
curl -sS \
"http://127.0.0.1:7070/api/v1/app-chain/chains/registry-chain/state/proof/$KEY_HEX" \
| jq '{committedHeight, stateRoot, presence, valueHex}'

valueHex is canonical CBOR [ownerPublicKey, value]. After a delete, presence is ABSENT and the proof is an exclusion proof with no value.

The typed proof subject checks a claim for you. This one asks whether the current value is active; the digest is Blake2b-256 of the value bytes:

Terminal window
DIGEST=$(python3 -c 'import hashlib; print(hashlib.blake2b(b"active", digest_size=32).hexdigest())')
curl -sS -X POST \
"http://127.0.0.1:7070/api/v1/app-chain/chains/registry-chain/proof-subjects/registry-entry-v1/proof" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg key "$KEY_HEX" --arg d "$DIGEST" \
'{coordinates:{key:$key}, view:"latest",
claim:{claimId:"value-digest-equals",operands:{expected:$d}}, includeEvidence:false}')" \
| jq '{presence:.proof.presence, fact:.fact, satisfied:.claimResult.satisfied}'

From Java, kvEntry fetches the proof, checks it, and decodes the entry:

var entry = new StdlibAppChainClient(ownerClient).kvEntry(key).orElseThrow().value();
byte[] owner = entry.owner();
byte[] value = entry.value();

The single-argument StdlibAppChainClient constructor checks only that the proof matches the root in the same response. For independent verification, pass a TrustedRootResolver that supplies a root from pinned finality or a Cardano anchor.

registry-chain in the stock cluster file shows a complete entry, including the three state-identity settings:

yano:
app-chain:
chains[1]:
chain-id: "registry-chain"
state-machine: kv-registry
state:
commitment-profile: mpf-blake2b256-v1
format-fingerprint: 91ee14091200f1e24659112d640e877e9177779dcc81dd06117f013e9190082b
genesis-id: cd4f0bac8a0d8c7fd5700510eae8c69256172814b219e9e3b1a914fc89c358b3
membership:
mode: governed
machines:
kv-registry:
value-format: utf8

A new chain needs its own genesis id; a generated project gets one from appchain render.

value-formatA PUT value must be
raw (default)any non-empty bytes
utf8valid UTF-8
cborone bounded, well-formed CBOR item

The format is a consensus setting. Every member must use the same value, and changing it on an existing chain needs a governed profile activation or a new chain.

  • Stable keys give idempotency. Repeated owner PUTs replace one current entry. Earlier values remain only in block history; the proof shows the latest one.
  • Ownership is deliberately simple. There is no transfer, expiry, multi-signature update or administrator override. Delete-and-recreate lets the next writer take the key, so it is not a safe transfer protocol.
  • Values are application data. utf8 and cbor check structure, not a schema. Every member sees the bytes; encrypt confidential values before submitting them.

A binding’s delete command still uses the positional wire [1, keyBytes, emptyBytes]. Its mapping needs both key and value; give value an empty byte literal, not empty text:

bindings:
- id: delete-record
from: {component: requests, event: kv-registry.entry-put.v1}
to:
component: records
command: delete
map:
key: {field: key}
value: {literal: {bytesHex: ''}}

Here requests and records are kv-registry component instances. The derived command retains the source sender’s authority, so that sender must own the target entry. A non-empty value is malformed for delete; it rejects the derived command and rolls back the cascade’s business writes.

In a declarative composite, admission rules can read this machine’s entries (ADR-031.4, see admission rules):

  • Value view (namespace "", key: the entry key): owner, value, valueLength, and valueText when value-format is utf8. A value over 4096 bytes exposes only valueLength.
  • Post-state facts, after an approved command: existed (the key had an entry before the command) and, for a put, valueLength.