Small values, addressed by namespace and key, per workspace. Backed by the Realtime Database, reached only through Connect.

Use it for what a document store is bad at: a feature flag, a cached computation, a rate-limit counter, a draft nobody needs to query. Anything you want to *find* rather than *fetch* belongs in `data`.

### Namespaces are declared like collections

Same Store tab, same allowlist, same `readPermission`/`writePermission`. A `kv` namespace declares no `queryableFields` - there are no queries here, only keys.

### Keys and values

Keys are `[A-Za-z0-9_-]`, up to 64 characters. That is narrower than either store would accept on its own, and deliberately so: a `/` in a key would silently address a deeper path, and `.`, `$`, `#`, `[`, `]` are illegal in a Realtime Database key outright.

Values are JSON, up to 128 KB. Larger belongs in `data`, or in Drive.

```ts
await connect.kv.set({
  workspaceId,
  namespace: "flags",
  key: "new-billing-ui",
  value: { enabled: true, rollout: 0.25 },
});

const flag = await connect.kv.get({ workspaceId, namespace: "flags", key: "new-billing-ui" });
```

### Expiry

`ttlSeconds` sets an expiry, up to a year; omit it and the value persists. **Expiry is evaluated on read**, so an expired key reads as absent the moment it expires - there is no sweep you have to wait for, and no window where a stale value is still served.

`list` pages a namespace in key order with `cursor`. Expired entries are filtered after paging, so a page can come back shorter than `limit` while still having a `nextCursor` - treat `nextCursor` as the only signal that more exist.

Writes and deletes are audited by key, never by value.

### Declaring the namespace

Same **Store** tab in the Console as a collection, same allowlist. A `kv` namespace declares a name, a scope and the two permissions; there are no `queryableFields`, because there are no queries here - only keys.

| Field | For the examples below |
|---|---|
| Name | `cache`, `flags`, `ratelimit` |
| Scope | Per workspace |
| `readPermission` / `writePermission` | A permission id from your catalog, or `null` |

One namespace per *purpose*, not one per application. `list` pages a whole namespace, so a single `kv` namespace holding flags, counters and cached blobs together is one you can never enumerate usefully.

### Cache-aside

The pattern this store is shaped for. Note the `404`: `get` throws for an absent **or expired** key, and that throw *is* the cache miss - there is no `null` to check for.

```ts title="src/domain/report/report.use-case.ts"
import { ConnectError, type ConnectClient } from "@cosmoops/connect-sdk/server";

const TTL_SECONDS = 15 * 60;

export async function getMonthlyReport(
  connect: ConnectClient,
  workspaceId: string,
  month: string,
): Promise<Report> {
  const key = `report-${month}`;

  try {
    const hit = await connect.kv.get({ workspaceId, namespace: "cache", key });
    return Report.parse(hit.value);
  } catch (error) {
    // A miss is a 404. Anything else - a 403 on an undeclared namespace, a
    // network fault - must not be swallowed into a silent recompute.
    if (!(error instanceof ConnectError) || error.status !== 404) throw error;
  }

  const report = await computeMonthlyReport(workspaceId, month);
  await connect.kv.set({
    workspaceId,
    namespace: "cache",
    key,
    value: report,
    ttlSeconds: TTL_SECONDS,
  });
  return report;
}
```

Build keys from ids and dates, never from user input: the character class is enforced, so a `/` is refused rather than silently addressing a deeper path.

### A feature flag, read on the server

```ts
const flag = await connect.kv.get({
  workspaceId,
  namespace: "flags",
  key: "new-billing-ui",
});
const { enabled } = FlagValue.parse(flag.value);
```

This is a flag *your application* owns, per workspace. It is not the same question as "does their plan include this" - that is an entitlement, and it belongs to `useConnectFeature` / `entitlements`. A flag you can flip is a rollout; a capability you cannot is a purchase.

### A rate limit

`ttlSeconds` is what makes this a window rather than a running total: the counter stops existing when the window ends, so nothing has to reset it.

```ts
const key = `send-${userId}`;
let used = 0;
try {
  const entry = await connect.kv.get({ workspaceId, namespace: "ratelimit", key });
  used = z.number().parse(entry.value);
} catch {
  // No counter yet - this is the first send in the window.
}

if (used >= 100) throw new RateLimitedError("100 sends per hour.");

await connect.kv.set({
  workspaceId,
  namespace: "ratelimit",
  key,
  value: used + 1,
  ttlSeconds: 3600,
});
```

`set` replaces rather than increments, so two requests in the same millisecond can both read `used` and both write `used + 1`. That is acceptable for a courtesy limit and **not** for anything metering money - `billing.consumeAddon` debits atomically and is the right call there.

### Paging a namespace

```ts
let cursor: string | null = null;
do {
  const page = await connect.kv.list({ workspaceId, namespace: "flags", limit: 100, cursor });
  for (const entry of page.items) apply(entry.key, entry.value);
  cursor = page.nextCursor;
} while (cursor);
```

The loop condition is `nextCursor`, never `page.items.length`. Expired entries are filtered *after* paging, so an empty page in the middle of a namespace is ordinary.

## Endpoints

### GET `/api/v1/application/kv/{namespace}`

List a key/value namespace

**Scope** `kv:read` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Pages a namespace in key order.

Expired entries are filtered out *after* paging, so a page can come back shorter than `limit` while still having a `nextCursor`. Treat `nextCursor` as the only signal that more keys exist - never an empty page.

#### Errors
`forbidden` (403 - key missing `kv:read`, or the namespace is not declared).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `namespace` | path | yes |  |
| `workspaceId` | query | yes |  |
| `limit` | query | no |  |
| `cursor` | query | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | A page of entries |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |
| `nextCursor` | `any` | yes |  |

### GET `/api/v1/application/kv/{namespace}/{key}`

Read one key

**Scope** `kv:read` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Reads one value. An expired key reads as `404` the moment it expires - expiry is evaluated on read, so there is no sweep to wait for.

#### Errors
`not_found` (404 - absent or expired), `forbidden` (403).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `namespace` | path | yes |  |
| `key` | path | yes |  |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The entry |
| `403` | forbidden |
| `404` | not_found - absent or expired |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `key` | `string` | yes |  |
| `value` | `any` | yes |  |
| `expiresAt` | `any` | yes |  |
| `updatedAt` | `string` | yes |  |

### PUT `/api/v1/application/kv/{namespace}/{key}`

Write one key

**Scope** `kv:write` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Sets a value, replacing whatever was there.

`ttlSeconds` sets an expiry up to a year; omit it (or send `null`) and the value persists. Values are JSON up to 128 KB - larger belongs in the data store, or Drive.

#### Errors
`validation_error` (400 - value too large, or an invalid key), `forbidden` (403).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `namespace` | path | yes |  |
| `key` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `any` | no |  |
| `value` | `any` | yes |  |
| `ttlSeconds` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Written |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `key` | `string` | yes |  |
| `value` | `any` | yes |  |
| `expiresAt` | `any` | yes |  |
| `updatedAt` | `string` | yes |  |

### DELETE `/api/v1/application/kv/{namespace}/{key}`

Delete one key

**Scope** `kv:write` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Deletes a key. Idempotent - deleting an absent key succeeds.

#### Errors
`forbidden` (403 - key missing `kv:write`, or the namespace is not declared).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `namespace` | path | yes |  |
| `key` | path | yes |  |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `204` | Deleted |
| `403` | forbidden |
| `429` | rate_limited |