Ledger
Key Values Ledger
Reference for transactional key/value records in a Twisp ledger.
The Basics
The Key Values (KV) Ledger provides transactional key/value storage for arbitrary structured documents in the Twisp ledger database. Each record is addressed by a natural key made from (namespace, key), where namespace groups related records and key identifies one record within that namespace.
KV records are useful for application state that should live beside ledger data with the same consistency model, such as feature flags, workflow checkpoints, product configuration, integration state, or lookup documents. They are not a replacement for accounting records: money movement should still be modeled with transactions, entries, balances, and tran codes.
Like other Twisp records, KV records are versioned. Creating a record starts at version 1. Replacing the record writes a new version, preserves created, updates modified, and makes the latest version visible through current queries.
Components of KV Records
A KV record has the following fields:
- Namespace: a logical grouping key. Namespaces can be used for broad categories such as
flags,integrations, orworkflow-state. - Key: the required unique key within the namespace. The
(namespace, key)pair identifies the current record. - Description: an optional text field stored with the record. If omitted in GraphQL input, it is stored as an empty string.
- Value: a strongly-typed
Valuedocument. Scalars likeUUID,Decimal,Money,Date, andTimestampround-trip with their type intact rather than being coerced into strings or numbers. See Strong Typing for details. - Created and Modified Timestamps:
createdis preserved across updates, whilemodifiedchanges when a new version is written. - Version and Record ID:
versionidentifies the current record version and_recordIdidentifies the underlying Twisp record. - History: previous versions are available through the
historyfield onKVValue.
The KV Ledger enforces these limits:
| Field | Limit |
|---|---|
namespace | 512 UTF-8 bytes |
key | 512 UTF-8 bytes |
| persisted payload | 256 KiB, measured as description bytes plus the serialized value |
The default KV index is partitioned by namespace and each namespace has a throughput cap of 1000 Write Units/Second and 3000 Read Units/Second.
Writing Values
Use Mutation.kv.put() to create or replace a record. A put for a new (namespace, key) pair creates version 1. A put for an existing pair writes the next version.
mutation PutKv {
kv {
put(
input: {
namespace: "flags"
key: "feature-a"
description: "Crypto transfer V2 rollout"
value: { group: "a", enabled: true, rollout: 25, owner: "risk", code: "CRYPTO_TRANSFER_V2" }
}
) {
namespace
key
description
value
version
}
}
}
Use options.idempotent when retrying a write that should not create another version if the stored description and value already match the request.
mutation PutKvIdempotently {
kv {
put(
input: {
namespace: "flags"
key: "feature-a"
description: "Crypto transfer V2 rollout"
value: { group: "a", enabled: true, rollout: 25, owner: "risk", code: "CRYPTO_TRANSFER_V2" }
options: { idempotent: true }
}
) {
key
value
version
}
}
}
Strong Typing
KV values are strongly typed. The value field is a Value document, so scalars like UUID, Decimal, Money, Date, and Timestamp round-trip with their type intact instead of being coerced into strings or numbers. Reading a stored UUID back yields a UUID, not a string that happens to look like one.
The mutation below writes a record whose value.id is a UUID, then captures the stored value into the $val variable using the @export directive so the same request can feed it back into evaluate and confirm that the original UUID identity is preserved end-to-end. (@export(as: "name") assigns the decorated field's value to the named query variable, making it available to later operations in the same request.)
mutation DemonstrateValueTyping(
$id: UUID = "106c3332-08a4-4ee3-a7dd-77411882a790"
$val: Value = ""
) {
kv {
put(
input: {
namespace: "flags"
key: "type-roundtrip"
value: { id: $id }
}
) {
value @export(as: "val")
}
}
evaluate(
expressions: {
val: "document.val.id == uuid('106c3332-08a4-4ee3-a7dd-77411882a790')"
}
document: { val: $val, id: $id }
)
}
For the underlying gRPC definitions, see:
twisp.type.v1.Value: the strongly-typed value used for KV payloads.twisp.core.v1.KVValue: the KV record message.twisp.core.v1.KVService: the gRPC interface for reading and writing KV records.
Conditional Writes
Put operations can include CEL conditions. All conditions must evaluate to true before the write is applied. Conditions receive the standard request context and context.vars.existing, which is the current KV record or null. Condition names must be unique within a request.
This update only succeeds when the record already exists:
mutation UpdateKv {
kv {
put(
input: {
namespace: "flags"
key: "feature-a"
description: "Crypto transfer V2 rollout"
value: { group: "a", enabled: false, rollout: 50, owner: "risk", code: "CRYPTO_TRANSFER_V2" }
options: {
conditions: [
{
name: "must_exist"
expression: "context.vars.existing != null"
}
]
}
}
) {
namespace
key
value
version
}
}
}
Conditional writes are also useful for create-only writes:
options: {
conditions: [
{
name: "must_not_exist"
expression: "context.vars.existing == null"
}
]
}
When idempotent is combined with conditions, the conditions still run. An identical no-op write cannot silently bypass a stale precondition just because the stored value already matches the request.
Reading Values
Use Query.kv() to read the current record by (namespace, key). If no current record exists, the field returns null.
query GetKv {
kv(namespace: "flags", key: "feature-a") {
namespace
key
description
value
version
history(first: 10) {
nodes {
key
version
value
}
}
}
}
The history connection on KVValue returns versions newest-first.
Listing Values
Use Query.kvs() to list KV records through an index.
The built-in namespace index lists records in one namespace. It requires where.namespace.eq.
query ListKvsByNamespace {
kvs(
index: { name: Namespace, sort: ASC }
where: { namespace: { eq: "flags" } }
first: 10
) {
nodes {
key
description
value
version
}
}
}
Use a custom index when the read pattern is not organized by namespace. Custom KV indexes are created with on: KV and queried with index: { name: Custom }.
mutation CreateKvGroupLookupIndex {
schema {
createIndex(
input: {
name: "group_lookup"
on: KV
unique: false
partition: [{ alias: "group", value: "string(document.value.group)" }]
sort: [{ alias: "key", value: "string(document.key)", sort: ASC }]
constraints: { hasGroup: "has(document.value.group)" }
}
) {
name
on
unique
}
}
}
query ListKvsByCustomIndex {
kvs(
index: { name: Custom, sort: ASC }
where: {
custom: {
index: "group_lookup"
partition: [{ alias: "group", value: { eq: "a" } }]
sort: [{ alias: "key", value: { gte: "" } }]
}
}
first: 10
) {
nodes {
key
description
value
version
}
}
}
Deleting Values
Use Mutation.kv.delete() to delete the current record for a (namespace, key) pair. The mutation returns the deleted record when one existed, otherwise it returns null.
mutation DeleteKv {
kv {
delete(namespace: "flags", key: "feature-a") {
namespace
key
description
value
}
}
}
After deletion, Query.kv() returns null for that (namespace, key), and namespace or custom-index lists no longer include the deleted record.
Real World Examples
Feature Flag Rollout
Because KV records share the same consistency model and read path as the rest of the ledger, they make a natural place to keep small operational knobs that need to be consulted inside the same request as a write. A common pattern is to use a KV record as a feature flag that controls which code a transaction posts under, so a new code can be rolled out gradually without redeploying.
The flag itself is a normal KV record. Its value carries the rollout percentage and the candidate code, exactly matching the shape used in the Writing Values example:
{
"group": "a",
"enabled": true,
"rollout": 25,
"owner": "risk",
"code": "CRYPTO_TRANSFER_V2"
}
The mutation below reads the flag inside the same request that posts the transaction. The @export directive evaluates a CEL expression against the stored value: when a uniformly distributed roll falls under document.rollout, the new code from the flag is exported into $code; otherwise the caller-supplied default is kept. The postTransaction call then uses whichever code was selected.
# posts using `CRYPTO_TRANSFER_V1` or `CRYPTO_TRANSFER_V2` based on flag.
mutation PostTransactionFromFeatureFlag(
$ns: String = "flags"
$key: String = "feature-a"
$transactionId: UUID = "23872efc-39c6-11f1-a5bd-069b540ea27b"
$code: String = "CRYPTO_TRANSFER_V1"
$params: JSON = "{}"
) {
queries {
kv(namespace: $ns, key: $key) {
value
@export(
as: "code"
cel: "rand.Intn(100) < document.rollout ? document.code : context.vars.code"
)
}
}
postTransaction(
input: {
transactionId: $transactionId
tranCode: $code
params: $params
}
) {
transactionId
}
}
This pattern works because the KV read, the CEL evaluation, and the transaction post all happen inside one transactional request: there is no window in which the flag could change between the lookup and the post, and the rollout decision is recorded in the same audit trail as the transaction it produced. Updating the rollout percentage or the target code is a single Mutation.kv.put() away, and pairing it with the Conditional Writes pattern keeps concurrent edits to the flag safe.
Tokenized Card Vault
KV records are also useful for storing typed references - like a card token paired with its expiration - under a per-customer namespace. Addressing the record as customer001.cards:card001 gives the rest of the system a stable handle to look the card up by, without standing up a bespoke table for each kind of reference. Because Value is strongly typed, the UUID token and Date expiration are stored and read back as their native types instead of being flattened into strings.
The write is an ordinary put. The variables flow straight into value with their declared types:
mutation WriteCardData(
$cardNamespace: String! = "customer001.cards"
$cardKey: String! = "card001"
$cardToken: UUID! = "8f3b2a01-1c4d-4e7a-9b62-2c71f3d44a01"
$cardExpiration: Date! = "2026-01-01"
) {
kv {
put(
input: {
namespace: $cardNamespace
key: $cardKey
value: {
cardToken: $cardToken
cardExpiration: $cardExpiration
}
}
) {
namespace
key
}
}
}
Reading the card back and using it inside the same request follows the same shape as the Feature Flag Rollout example. Aliasing the value field lets the same selection be exported twice with different cel projections, so each typed field lands in its own variable:
mutation UseCardData(
$cardNamespace: String! = "customer001.cards"
$cardKey: String! = "card001"
$transactionId: UUID! = "8a4ec2ee-9d3e-4f1a-9c82-ab12cd34ef56"
$cardToken: UUID
$cardExpiration: Date
) {
queries {
kv(namespace: $cardNamespace, key: $cardKey) {
cardToken: value @export(as: "cardToken", cel: "document.cardToken")
cardExpiration: value @export(as: "cardExpiration", cel: "document.cardExpiration")
}
}
postTransaction(
input: {
transactionId: $transactionId
tranCode: "CARD_AUTH"
params: {
cardToken: $cardToken
cardExpiration: $cardExpiration
}
}
) {
transactionId
}
}
$cardToken and $cardExpiration keep their UUID and Date types end to end — from the stored value, through the CEL projection, into the postTransaction call — without any string parsing in between.
KV Operations
Use GraphQL queries and mutations to read, write, list, and delete KV records:
Query.kv(): Get the current KV record for a(namespace, key)pair.Query.kvs(): List KV records by namespace or custom index.KVValue.history(): Read the version history for a KV record.Mutation.kv.put(): Create or replace a KV record.Mutation.kv.delete(): Delete the current KV record for a(namespace, key)pair.Mutation.schema.createIndex(): Create a custom index onKV.
Further Reading
For custom index design, see Indexes.
For record versioning and history, see Versions and History.
To review the GraphQL docs for the KVValue type, see KVValue.