# Key Values Ledger

Reference for transactional key/value records in a Twisp ledger.

Source: https://www.twisp.com/docs/reference/ledger/kv

## 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](#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:

| Field             | Limit                                                                     |
|-------------------|---------------------------------------------------------------------------|
| `namespace`       | 512 UTF-8 bytes                                                           |
| `key`             | 512 UTF-8 bytes                                                           |
| persisted payload | 256 KiB, measured as `description` bytes plus the serialized `value`      |

> **Note:**
>
> 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.

```graphql
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](#conditional-writes)).

## Strong Typing

KV values are strongly typed. The `value` field is a [`Value`](https://buf.build/twisp/api/docs/8ce263d536164f4ca8ea7311df5a5e6c%3Atwisp.type.v1#twisp.type.v1.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.)

```graphql
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`](https://buf.build/twisp/api/docs/8ce263d536164f4ca8ea7311df5a5e6c%3Atwisp.type.v1#twisp.type.v1.Value): the strongly-typed value used for KV payloads.
- [`twisp.core.v1.KVValue`](https://buf.build/twisp/api/docs/main%3Atwisp.core.v1#twisp.core.v1.KVValue): the KV record message.
- [`twisp.core.v1.KVService`](https://buf.build/twisp/api/docs/main%3Atwisp.core.v1#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](https://www.rfc-editor.org/rfc/rfc7396) 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.

```graphql
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:

```graphql
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)).

## 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.

> **Note:**
>
> **`document` vs. `value` in KV conditions**
>
> Conditions evaluate with both the record header and the payload in scope:
>
> - **`document`** is the full [`KVValue`](https://buf.build/twisp/api/docs/main%3Atwisp.core.v1#twisp.core.v1.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](#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:

```graphql
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:

```graphql
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`.

```graphql
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`.

```graphql
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 }`.

```graphql
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
    }
  }
}
```

```graphql
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`.

```graphql
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.

```graphql
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:

```graphql
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](#writing-values) example:

```json
{
  "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.

```graphql
# 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](#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](#strong-typing), 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:

```graphql
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](#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:

```graphql
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()`](/docs/reference/graphql/queries#kv): Get the current KV record for a `(namespace, key)` pair.
- [`Query.kvs()`](/docs/reference/graphql/queries#kvs): List KV records by namespace or custom index.
- [`KVValue.history()`](/docs/reference/graphql/types/object#kvvalue): Read the version history for a KV record.
- [`Mutation.kv.put()`](/docs/reference/graphql/mutations#kv-put): Create or replace a KV record.
- [`Mutation.kv.update()`](/docs/reference/graphql/mutations#kv-update): Evaluate patch expressions and merge them into an existing KV record.
- [`Mutation.kv.delete()`](/docs/reference/graphql/mutations#kv-delete): Delete the current KV record for a `(namespace, key)` pair.
- [`Mutation.schema.createIndex()`](/docs/reference/graphql/mutations#create-index): Create a custom index on `KV`.

## Further Reading

For custom index design, see [Indexes](/docs/reference/ledger/indexes).

For record versioning and history, see [Versions And History](/docs/reference/ledger/versions-and-history).

To review the GraphQL docs for the `KVValue` type, see [KVValue](/docs/reference/graphql/types/object#kvvalue).
