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. - Conditions: the CEL conditions (if any) that were required to evaluate to
truebefore this version of the record was written. Stored as a map of{name: expression}pairs for auditing. - 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
}
}
}
Every put writes a new version. To make a write idempotent or contingent on the record's current state, use conditions (see Conditional Writes).
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.
Updating Values
Use Mutation.kv.update() to evolve an existing record without sending a full replacement value. An update evaluates a patch against the existing KV value: string leaves are CEL expressions, and non-string leaves are literal patch values.
Inside the expressions input, document refers to the current KV record and value refers to the currently stored payload under document.value. This means value.rollout + 5 reads the current document.value.rollout.
The patch is merged with RFC 7396 semantics:
- Object values merge recursively.
- A
nullpatch value deletes that key from an object. - Arrays, scalars, and non-object values replace the target value.
Updates only apply to existing records. If no record exists for the (namespace, key) pair, the mutation fails with NOT_FOUND. Pass a description on the update input to replace the stored description (an empty string clears it); omit the field to leave the existing description in place.
mutation UpdateKvPatch {
kv {
update(
input: {
namespace: "flags"
key: "feature-a"
expressions: {
rollout: "value.rollout + 5"
owner: null
metadata: "{'changedBy': 'kv.update', 'tags': ['fixture', 'patch']}"
}
conditions: { current_rollout: "value.rollout == 50" }
}
) {
namespace
key
description
value
version
}
}
}
To rename a record alongside its value patch, pass description on the update input:
mutation RenameKv {
kv {
update(
input: {
namespace: "flags"
key: "feature-a"
description: "renamed flag"
expressions: { rollout: "value.rollout + 5" }
}
) {
namespace
key
description
value
version
}
}
}
Every update writes a new version, even when the expressions evaluate to the current value. Use conditions to gate the write on the record's current state (see Conditional Writes).
Conditional Writes
put, update, and delete accept CEL conditions as a map keyed by condition name, where each value is a CEL expression. All conditions must evaluate to true before the write is applied, otherwise the request fails with BAD_REQUEST. Conditions see the current state of the record — the version about to be replaced, updated, or deleted — through the document and value variables. On successful put and update, the request's conditions are persisted on the written KVValue as an audit trail of what was required at write time.
document vs. value in KV conditions
Conditions evaluate with both the record header and the payload in scope:
documentis the fullKVValue. Reach for it when you need record-level fields:document.namespace,document.key,document.description,document.created,document.modified, or the nesteddocument.value.valueis a shortcut for the payload — equivalent todocument.value, but it skips a hop. Use it when reading payload fields:value.rollout,value.enabled, and so on.
The same split applies anywhere else that evaluates CEL against a KV record, including custom index partitions and sort keys (see Listing Values).
On put and delete, when no record exists yet for the (namespace, key) pair, both document and value are null. Use document == null to gate create-only writes and document != null to require that a record already exists. On update, the record is always present because UpdateKv fails with NOT_FOUND before conditions run when the record is missing.
This put only succeeds when the record already exists:
mutation PutIfExists {
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" }
conditions: { must_exist: "document != null" }
}
) {
namespace
key
value
version
}
}
}
Conditional writes are also useful for create-only writes:
conditions: { must_not_exist: "document == null" }
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(value.group)" }]
sort: [{ alias: "key", value: "string(document.key)", sort: ASC }]
constraints: { hasGroup: "has(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
}
}
}
Delete also accepts CEL conditions that run against the current record before the delete is applied. document and value bind to the current record, or to null when no record exists — so document != null gates a delete on the record already existing, and document.value.state == 'archived' gates on a specific payload state.
mutation DeleteKvIfArchived {
kv {
delete(
namespace: "flags"
key: "feature-a"
conditions: { only_if_archived: "document.value.state == 'archived'" }
) {
namespace
key
}
}
}
After deletion, Query.kv() returns null for that (namespace, key), and namespace or custom-index lists no longer include the deleted record.
Combining KV Operations in One Request
Only top-level mutation fields are executed sequentially by GraphQL. The fields inside a single kv { ... } selection are ordinary object sub-fields and run in parallel, so they can't see each other's writes. A put followed by an update in the same kv { ... } block will race: the update queries at the same time the put runs and sees no record, and the whole transaction aborts with NOT_FOUND.
To run multiple KV operations against the same record in a single request, give each its own top-level kv { ... } selection with a field alias:
mutation PutThenUpdate {
created: kv {
put(input: { namespace: "flags", key: "feature-a", value: { rollout: 25 } }) {
version
}
}
incremented: kv {
update(
input: {
namespace: "flags"
key: "feature-a"
expressions: { rollout: "value.rollout + 5" }
}
) {
version
value
}
}
}
Because created and incremented are top-level mutation fields, GraphQL executes them in order — the put finishes writing to the shared request transaction before the update runs its lookup. Aliased sub-fields inside one kv { ... } block are only safe when the operations are independent (different records, or all inserts of distinct keys).
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.update(): Evaluate patch expressions and merge them into an existing 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.