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:

  1. Namespace: a logical grouping key. Namespaces can be used for broad categories such as flags, integrations, or workflow-state.
  2. Key: the required unique key within the namespace. The (namespace, key) pair identifies the current record.
  3. Description: an optional text field stored with the record. If omitted in GraphQL input, it is stored as an empty string.
  4. Value: a strongly-typed Value document. Scalars like UUID, Decimal, Money, Date, and Timestamp round-trip with their type intact rather than being coerced into strings or numbers. See Strong Typing for details.
  5. Conditions: the CEL conditions (if any) that were required to evaluate to true before this version of the record was written. Stored as a map of {name: expression} pairs for auditing.
  6. Created and Modified Timestamps: created is preserved across updates, while modified changes when a new version is written.
  7. Version and Record ID: version identifies the current record version and _recordId identifies the underlying Twisp record.
  8. History: previous versions are available through the history field on KVValue.

The KV Ledger enforces these limits:

FieldLimit
namespace512 UTF-8 bytes
key512 UTF-8 bytes
persisted payload256 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:

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 null patch 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:

  • document is the full KVValue. Reach for it when you need record-level fields: document.namespace, document.key, document.description, document.created, document.modified, or the nested document.value.
  • value is a shortcut for the payload — equivalent to document.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:

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.

Previous
Journals