# state

> Distributed key-value state management with reactive change triggers — registers the `state` trigger type and the `state::*` functions.

| field | value |
|-------|-------|
| version | 0.21.4 |
| type | binary |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, i686-pc-windows-msvc, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
iii worker add state@0.21.4
```

## configuration

```yaml
- adapter:
    config:
      store_method: in_memory
    name: kv
```

## dependencies

- `configuration` @ `^0.21.6`

## readme

# state

Distributed key-value state storage with scope-based organization and
reactive change triggers. Values are addressed by a `scope` (namespace) and a
`key`, shared across every worker connected to the engine, and persisted
through a pluggable adapter (`kv` or `redis`). Callers reach the store
through six functions — `state::set`, `state::get`, `state::delete`,
`state::update`, `state::list`, `state::list_groups` — and this worker also
registers the `state` trigger type, which fires `state:created`,
`state:updated`, or `state:deleted` after every successful mutation so
downstream functions can react to data changes without polling. This worker
is the standalone migration of the engine's built-in `iii-state`.

## Install

```bash
iii worker add state
```

`iii worker add` fetches the binary, writes a config block into
`~/.iii/config.yaml`, and the engine starts the worker the next time it boots.

## Functions

| Function | Input | Returns | Fires |
|---|---|---|---|
| `state::set` | `{ scope, key, value }` (`data` accepted as an alias for `value`) | `{ old_value, new_value }` | `state:created` (new key) or `state:updated` |
| `state::get` | `{ scope, key }` | the value, or `null` | — |
| `state::delete` | `{ scope, key }` | the deleted value, or `null` | `state:deleted` (even when the key did not exist) |
| `state::update` | `{ scope, key, ops }` — ordered atomic ops: `set`, `merge`, `increment`, `decrement`, `append`, `remove` | `{ old_value, new_value, errors }` | `state:created` or `state:updated` |
| `state::list` | `{ scope }` | flat array of every value in the scope | — |
| `state::list_keys` | `{ scope }` | `{ keys }` — the keys stored in the scope, adapter order (additive; no builtin counterpart — added for the console state UI, whose per-item navigation `state::list`'s values-only shape cannot drive) | — |
| `state::list_groups` | `{}` | `{ groups }` — sorted, deduplicated scope names | — |
| `state::ui-content` | `{ path }` | `{ content, content_type }` — content function for the injected console UI (internal; see [Console UI](#console-ui)) | — |

## Console UI

The worker ships its console UI into any running console (the injectable-UI
protocol, `iii/tech-specs/2026-07-17-injectable-ui`) — three contributions,
one module each under `ui/src/`:

| Module | Slot | What it does |
|---|---|---|
| `ui/src/page/` | `host.pages` | the state-manager page (`#/ext/state-manager`): browse scopes → keys → edit one value as JSON and save it back with `state::set` |
| `ui/src/configuration/` | `host.configForms` | custom form for the `state` configuration entry in the console's workers tab (adapter picker, persistence, trigger gate, limits) — replaces the generic schema form |
| `ui/src/function-trigger-message/` | `host.functionTriggers` | how `state::set` / `state::get` triggers render in chat and the traces span view (overrides the console's built-in family for those two ids; errors and other `state::*` ids fall through) |

`ui/page.tsx` is the esbuild entry: a thin `setup(host)` composing the three
registrations plus the shared scoped stylesheet (`ui/src/lib/styles.ts`).

The page is **live**: it registers one tab-scoped `state` trigger binding
(empty config = every scope/key) whose function_id is a per-tab handler
(`iii::state-ui::events::<browserId>`), and applies the
created/updated/deleted events in place — new scopes and keys appear as
they are written, an open item editor updates in place when clean, and
unsaved edits are never clobbered (a "changed on the server" notice offers
the incoming value instead). The binding is GC'd with the tab; the `iii::`
prefix keeps the per-event invocations out of the trace feed.

- Build: esbuild over `ui/page.tsx` and `ui/styles.css` (`cd ui && pnpm
  build`; `build.rs` does this automatically) with `react` and
  `@iii-dev/console-ui` external — they resolve through the console's
  import map at runtime. Types come from the workspace-linked
  `packages/console-ui` (repo-root pnpm workspace).
- Deployment: boot registers a `console:script` trigger for `state/page.js`
  and a `console:style` trigger for `state/styles.css`, both with
  `function_id` `state::ui-content`; the console fetches, hashes, and
  serves the assets, and disposes them when this worker disconnects. The
  stylesheet is scoped under `[data-iii-ui="state"]` and mounted as a
  `<link>` (styles-before-scripts on boot, link-swap on change).
- Dev loop: run `cd ui && pnpm watch` and start the worker with
  `III_STATE_UI_WATCH=1` — it polls `ui/dist/` and re-registers a changed
  asset's trigger, hot-swapping it in every open console tab.

## Trigger type

This worker always registers the `state` trigger type. Bind a function to it
with:

| Field | Required | Default | Description |
|---|---|---|---|
| `scope` | no | any scope | Only fire for writes in this scope. |
| `key` | no | any key | Only fire for writes to this key. |
| `condition_function_id` | no | — | Function invoked first with the event; only an explicit `false` return skips the handler (null/no result passes, an error skips and logs). |

Trigger delivery is asynchronous: handlers run after the write completes and
a handler failure never rolls the write back. Duplicate trigger ids replace
the previous binding silently (builtin parity).

```typescript
iii.registerTrigger({
  type: 'state',
  function_id: 'orders::on-status-change',
  config: { scope: 'orders', key: 'status' },
})
```

The handler receives the event payload:

```json
{
  "type": "state",
  "event_type": "state:updated",
  "scope": "orders",
  "key": "status",
  "old_value": { "status": "pending" },
  "new_value": { "status": "shipped" }
}
```

`event_type` is one of `state:created`, `state:updated`, `state:deleted`;
`old_value` is `null` for created keys and `new_value` is `null` for deleted
keys.

## Configuration

| Field | Default | Description |
|---|---|---|
| `adapter` | `kv` | Storage adapter: `kv` (in-process; `store_method: in_memory` or `file_based` with `file_path` and `save_interval_ms`) or `redis` (`redis_url`, default `redis://localhost:6379`). Restart-tier: a runtime change is logged and takes effect at the next worker start. |
| `triggers_enabled` | `true` | Globally enable/disable state change-trigger fan-out. Applied live. |
| `max_value_bytes` | unset (no limit) | Reject `state::set` writes whose JSON-serialized value exceeds this many bytes (`VALUE_TOO_LARGE`). Minimum `1`. Applied live. |
| `save_interval_ms` | `5000` | Persistence flush cadence (ms) for the file-backed `kv` adapter. `100`–`3600000`. Applied live by respawning the adapter's save loop (hot-retune; no-op for in-memory/redis). |

Configuration is owned by the `configuration` worker — edit it from the
console (**Configuration → Workers → state**) or seed it once via the
worker's config block in `config.yaml` on first boot. `triggers_enabled` and
`max_value_bytes` apply on the next write, `save_interval_ms` hot-retunes
the save loop, and `adapter` takes effect on the next restart.

### Requires removing the built-in `iii-state` worker

The built-in `iii-state` worker also owns the `state` trigger type and the
`state::*` functions. Two owners of the same surface on one engine collide —
whichever registers last wins — so this worker requires `iii-state` to be
absent: omit it from the engine's `config.yaml` (a config that doesn't list
a worker won't run it).

On boot, this worker queries the engine for connected workers and refuses to
start with a clear error if `iii-state` is still active, so a stale config
fails loudly instead of silently racing the built-in worker for ownership of
`state`.

**Store migration:** if the builtin used a file-based `kv` store, point this
worker's `adapter.config.file_path` at the builtin's existing directory
(the default engine config used `./data/state_store.db`). The on-disk format
(one rkyv `.bin` file per scope) is identical, so the existing data loads
as-is — no export/import step.

## Latency

The builtin ran in-process inside the engine, so a `state::*` call cost
microseconds; as a standalone worker every call crosses the engine⇄worker
WebSocket, which puts round-trips in the low-millisecond range. That
order-of-magnitude delta is inherent to the migration and applies to every
externalized builtin. A formal benchmark was waived by project decision
(2026-07-06).

## Parity vs builtin

| Behavior | Builtin | This worker |
|---|---|---|
| Function ids | `state::set/get/delete/update/list/list_groups` | same (exact) |
| `set` input | `{scope, key, value}` (`data` alias) | same |
| Events | `state:created`/`updated`/`deleted`, payload `{type:"state", event_type, scope, key, old_value, new_value}` | same |
| Trigger config | `{scope?, key?, condition_function_id?}`; only explicit `false` blocks | same |
| Duplicate trigger id | silent replace | same |
| Trigger `metadata` | forwarded to handlers via call_with_metadata | not forwarded (iii-sdk 0.20 TriggerRequest has no metadata field; same limitation as the http worker) |
| Store adapters | kv (in_memory/file_based), redis, bridge | kv, redis (bridge not ported — see docs/adr/0001) |
| kv on-disk format | rkyv `.bin` per scope | identical — builtin data loads as-is |
| `save_interval_ms` | default 5000ms, floor 100ms, hot-retune | same |
| `max_value_bytes` | guards `set` only, `VALUE_TOO_LARGE` | same (code is the message prefix) |
| Error codes | coded `ErrorBody` (`SET_ERROR`, ...) | code as message prefix (SDK handler errors carry a message) |
| Durability | store dies with the ENGINE process | store dies with the WORKER process (ADR 0001; file_based/redis unchanged) |
| Latency | in-process µs | engine⇄worker WS round-trip (low ms) — formal benchmark waived by project decision 2026-07-06 |
| Telemetry (`track_state_*`) | engine-internal counters | none — out of scope for this migration; lands with the shared worker observability story |

## api reference

```json
{
  "functions": [
    {
      "description": "Delete a value from state",
      "metadata": {},
      "name": "state::delete",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "key": {
            "description": "Identifier for the value to delete within the scope.",
            "type": "string"
          },
          "scope": {
            "description": "Namespace that groups related keys.",
            "type": "string"
          }
        },
        "required": [
          "key",
          "scope"
        ],
        "title": "StateDeleteInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "The value that was deleted (read before delete), or null if it did not exist.",
        "title": "StateDeleteResponse",
        "type": [
          "string",
          "number",
          "boolean",
          "object",
          "array",
          "null"
        ]
      }
    },
    {
      "description": "Get a value from state",
      "metadata": {},
      "name": "state::get",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "key": {
            "description": "Identifier for the value within the scope.",
            "type": "string"
          },
          "scope": {
            "description": "Namespace that groups related keys.",
            "type": "string"
          }
        },
        "required": [
          "key",
          "scope"
        ],
        "title": "StateGetInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "The raw value stored at scope/key, or null if absent.",
        "title": "StateGetResponse",
        "type": [
          "string",
          "number",
          "boolean",
          "object",
          "array",
          "null"
        ]
      }
    },
    {
      "description": "Get a group from state",
      "metadata": {},
      "name": "state::list",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "scope": {
            "description": "Namespace whose keys should be listed as a group.",
            "type": "string"
          }
        },
        "required": [
          "scope"
        ],
        "title": "StateGetGroupInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "The values in the given scope, as a JSON array (or null on the rare serialization failure).",
        "title": "StateListResponse",
        "type": [
          "string",
          "number",
          "boolean",
          "object",
          "array",
          "null"
        ]
      }
    },
    {
      "description": "List all state groups",
      "metadata": {},
      "name": "state::list_groups",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "StateListGroupsInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "groups": {
            "items": {
              "type": "string"
            },
            "type": "array"
          }
        },
        "required": [
          "groups"
        ],
        "title": "StateListGroupsResult",
        "type": "object"
      }
    },
    {
      "description": "List the keys stored in a scope",
      "metadata": {},
      "name": "state::list_keys",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "scope": {
            "description": "Namespace whose keys should be listed as a group.",
            "type": "string"
          }
        },
        "required": [
          "scope"
        ],
        "title": "StateGetGroupInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "keys": {
            "description": "Keys stored in the scope, in the adapter's natural order.",
            "items": {
              "type": "string"
            },
            "type": "array"
          }
        },
        "required": [
          "keys"
        ],
        "title": "StateListKeysResult",
        "type": "object"
      }
    },
    {
      "description": "Internal: reload state configuration from the authoritative store on change.",
      "metadata": {},
      "name": "state::on-config-change",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ConfigChangeRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "ConfigChangeAck",
        "type": "object"
      }
    },
    {
      "description": "Set a value in state",
      "metadata": {},
      "name": "state::set",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "key": {
            "description": "Identifier for the value within the scope.",
            "type": "string"
          },
          "scope": {
            "description": "Namespace that groups related keys (e.g. `users`, `orders`).",
            "type": "string"
          },
          "value": {
            "description": "Arbitrary JSON value to store. Replaces any existing value at `scope`/`key`."
          }
        },
        "required": [
          "key",
          "scope",
          "value"
        ],
        "title": "StateSetInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "new_value": {
            "description": "The value after the update"
          },
          "old_value": {
            "description": "The value before the update (None if key didn't exist)"
          }
        },
        "required": [
          "new_value"
        ],
        "title": "StreamSetResult",
        "type": "object"
      }
    },
    {
      "description": "Serve the state worker's injected console UI assets (content function for its console:script / console:style triggers).",
      "metadata": {
        "internal": true
      },
      "name": "state::ui-content",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Input of the content function: the console asks for one asset by path.",
        "properties": {
          "path": {
            "description": "The asset path from the trigger config (e.g. `state/page.js`).",
            "type": "string"
          }
        },
        "required": [
          "path"
        ],
        "title": "UiContentInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Output of the content function.",
        "properties": {
          "content": {
            "description": "The asset source, verbatim.",
            "type": "string"
          },
          "content_type": {
            "description": "MIME type the console should serve the asset with.",
            "type": "string"
          }
        },
        "required": [
          "content",
          "content_type"
        ],
        "title": "UiContentResult",
        "type": "object"
      }
    },
    {
      "description": "Update a value in state",
      "metadata": {},
      "name": "state::update",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "MergePath": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              }
            ],
            "description": "Path target for a [`UpdateOp::Merge`] operation. Accepts either a single string (legacy / first-level field) or an array of literal segments (nested path).\n\nPath normalization rules applied by the engine: - absent / `Single(\"\")` / `Segments(vec![])` → root merge - `Single(\"foo\")` is equivalent to `Segments(vec![\"foo\".into()])` - `Segments([\"a\", \"b\", \"c\"])` walks three literal keys, never interpreting dots specially. `Segments(vec![\"a.b\".into()])` is a single literal key named `\"a.b\"`.\n\n**Variant ordering is load-bearing.** `#[serde(untagged)]` tries variants in declaration order, `Single` MUST come before `Segments` so a JSON string deserializes into `Single` rather than failing the array match first."
          },
          "UpdateOp": {
            "description": "Operations that can be performed atomically on a stream value",
            "oneOf": [
              {
                "description": "Set a value at path (overwrite)",
                "properties": {
                  "path": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "set"
                    ],
                    "type": "string"
                  },
                  "value": true
                },
                "required": [
                  "path",
                  "type"
                ],
                "type": "object"
              },
              {
                "description": "Merge object into existing value (object-only). Path may be omitted (root merge), a single first-level key, or an array of literal segments for nested merge. See [`MergePath`].",
                "properties": {
                  "path": {
                    "anyOf": [
                      {
                        "$ref": "#/definitions/MergePath"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "type": {
                    "enum": [
                      "merge"
                    ],
                    "type": "string"
                  },
                  "value": true
                },
                "required": [
                  "type",
                  "value"
                ],
                "type": "object"
              },
              {
                "description": "Increment numeric value",
                "properties": {
                  "by": {
                    "format": "int64",
                    "type": "integer"
                  },
                  "path": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "increment"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "by",
                  "path",
                  "type"
                ],
                "type": "object"
              },
              {
                "description": "Decrement numeric value",
                "properties": {
                  "by": {
                    "format": "int64",
                    "type": "integer"
                  },
                  "path": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "decrement"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "by",
                  "path",
                  "type"
                ],
                "type": "object"
              },
              {
                "description": "Append an element to an array or concatenate a string at the optional path. Path may be omitted (root append), a single first-level key, or an array of literal segments for nested append. See [`MergePath`] for the variant shape.",
                "properties": {
                  "path": {
                    "anyOf": [
                      {
                        "$ref": "#/definitions/MergePath"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "type": {
                    "enum": [
                      "append"
                    ],
                    "type": "string"
                  },
                  "value": true
                },
                "required": [
                  "type",
                  "value"
                ],
                "type": "object"
              },
              {
                "description": "Remove a field",
                "properties": {
                  "path": {
                    "type": "string"
                  },
                  "type": {
                    "enum": [
                      "remove"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "path",
                  "type"
                ],
                "type": "object"
              }
            ]
          }
        },
        "properties": {
          "key": {
            "description": "Identifier for the value to update within the scope.",
            "type": "string"
          },
          "ops": {
            "description": "Ordered list of update operations applied atomically to the existing value.",
            "items": {
              "$ref": "#/definitions/UpdateOp"
            },
            "type": "array"
          },
          "scope": {
            "description": "Namespace that groups related keys.",
            "type": "string"
          }
        },
        "required": [
          "key",
          "ops",
          "scope"
        ],
        "title": "StateUpdateInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "UpdateOpError": {
            "description": "Per-op error reported by an atomic update operation.",
            "properties": {
              "code": {
                "description": "Stable error code, e.g. `\"merge.path.too_deep\"`.",
                "type": "string"
              },
              "doc_url": {
                "description": "Optional documentation URL for this error class.",
                "type": [
                  "string",
                  "null"
                ]
              },
              "message": {
                "description": "Human-readable description with concrete numbers when applicable.",
                "type": "string"
              },
              "op_index": {
                "description": "Index of the offending op within the original `ops` array.",
                "format": "uint",
                "minimum": 0,
                "type": "integer"
              }
            },
            "required": [
              "code",
              "message",
              "op_index"
            ],
            "type": "object"
          }
        },
        "description": "Result of an atomic update operation",
        "properties": {
          "errors": {
            "description": "Errors encountered while applying ops. Successfully applied ops are still reflected in `new_value`. Field is omitted from JSON when empty for backward compatibility.",
            "items": {
              "$ref": "#/definitions/UpdateOpError"
            },
            "type": "array"
          },
          "new_value": {
            "description": "The value after the update"
          },
          "old_value": {
            "description": "The value before the update (None if key didn't exist)"
          }
        },
        "required": [
          "new_value"
        ],
        "title": "StreamUpdateResult",
        "type": "object"
      }
    }
  ],
  "triggers": [
    {
      "description": "State trigger",
      "invocation_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Trigger config schema — field-parity with the engine's StateTriggerConfig (engine/src/workers/state/trigger.rs:18-23).",
        "properties": {
          "condition_function_id": {
            "description": "Optional function evaluated per event; only an explicit `false` skips.",
            "type": [
              "string",
              "null"
            ]
          },
          "key": {
            "description": "Only fire for writes to this key (any key when unset).",
            "type": [
              "string",
              "null"
            ]
          },
          "scope": {
            "description": "Only fire for writes in this scope (any scope when unset).",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "StateTriggerSpec",
        "type": "object"
      },
      "metadata": {},
      "name": "state",
      "return_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "StateEventType": {
            "enum": [
              "state:created",
              "state:updated",
              "state:deleted"
            ],
            "type": "string"
          }
        },
        "properties": {
          "event_type": {
            "allOf": [
              {
                "$ref": "#/definitions/StateEventType"
              }
            ],
            "description": "Type of state change"
          },
          "key": {
            "description": "State key",
            "type": "string"
          },
          "new_value": {
            "description": "New value"
          },
          "old_value": {
            "description": "Previous value (null for created events)"
          },
          "scope": {
            "description": "State scope",
            "type": "string"
          },
          "type": {
            "description": "Always \"state\"",
            "type": "string"
          }
        },
        "required": [
          "event_type",
          "key",
          "new_value",
          "scope",
          "type"
        ],
        "title": "StateCallRequest",
        "type": "object"
      }
    }
  ]
}
```
