> ## Documentation Index
> Fetch the complete documentation index at: https://obversa.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Events and Artifacts

> Append small events, store large bytes beside them, and reopen the same state.

Store a run's history in two ports, small JSON events in an `EventStore`
and large bytes in an `ArtifactStore`, then reopen the same directory and
fold the same state. Use this page when you write a host or a storage
provider. For what the record is and why a stopped run carries on from it,
read [The record](/docs/concepts/record); this page stays at the storage layer
and doesn't run graph work, resume a run, list runs or manage a workspace.

The smallest use sets a policy, opens the local storage and writes one
artifact with the event that carries its reference:

```ts examples/durable-storage.ts (excerpt) {9,17-22,24} theme={null}
    const first = createLocalRunStorage(storageOptions);
    const scope = { namespace: first.record.namespace, runId };
    const stream = { namespace: first.record.namespace, streamId: runId };
    const largeBytes = encoder.encode(JSON.stringify({
      schemaVersion: 1,
      kind: 'synthetic-result',
      text: 'x'.repeat(65_536),
    }));
    const reference = await first.artifactStore.write(scope, {
      bytes: largeBytes,
      mediaType: 'application/json',
      purpose: 'synthetic-result',
      contentMode: 'state',
    });

    await first.eventStore.append(stream, 0, [{
      eventId: 'artifact-recorded-1',
      type: 'example:artifact-recorded',
      version: 1,
      timestamp: '2026-01-01T00:00:00.000Z',
      correlationId: runId,
      causationId: null,
      payload: { artifact: reference },
    }]);
```

`createLocalRunStorage` returns a live `RunStorageBinding`: one frozen
`record`, the known secret values used for write checks, an event store and
an artifact store. Every artifact operation names a namespace and run, and
every event operation names a namespace and stream. A storage binding
needs:

* **A directory**, the storage root.
* **A namespace**, 1 to 128 ASCII characters, starting with a letter or
  digit, then letters, digits, dots, underscores or hyphens. The same rule
  names streams and runs.
* **A policy**, the size limits and the secret handling below.

## Set limits

```ts examples/durable-storage.ts (excerpt) {3-6,8-12} theme={null}
const policy = {
  schemaVersion: 1,
  maxEventPayloadBytes: 8_192,
  maxAppendBatchBytes: 32_768,
  maxArtifactBytes: 131_072,
  maxTotalArtifactBytesPerRun: 1_048_576,
  retention: 'until-run-delete',
  sensitiveContent: {
    marked: 'reject',
    exact: 'reject',
    freeText: 'redact-before-hash',
  },
} as const satisfies RunStoragePolicy;
```

| Policy field | What it limits |
| - | - |
| `maxEventPayloadBytes` | One event payload. |
| `maxAppendBatchBytes` | One atomic event append. |
| `maxArtifactBytes` | One artifact after permitted free-text redaction. |
| `maxTotalArtifactBytesPerRun` | Unique admitted artifact digests for one run. |

The local stores reject a value over a limit before it becomes visible. A
second receipt for the same digest doesn't charge the artifact bytes again,
and accepted artifacts stay until `deleteRun` removes that run. Call
`preflightAppend` with the exact event batch before a run writes its
artifacts, and `preflightWrite` once with the complete same-run artifact
batch; it returns the references that later writes must issue. Neither
call changes anything or reserves a stream revision or quota, so the real
append or write checks again and can still lose a later race.

## Handle secrets

`contentMode` says how the bytes are used, and the store checks them that
way:

* **`exact` and `state`** bytes reject a configured known secret. JSON
  state also checks decoded keys and values, including
  `application/*+json`. The store never changes these bytes.
* **`free-text`** bytes can have configured known secrets replaced before
  the store calculates the digest and byte size. The replacement is checked
  again, and the write fails if it would create another configured secret.
* **`sensitive: true`** rejects the artifact instead of storing it.

The local artifact store checks receipt metadata and its exact serialised
admission record too, and the local event store checks the complete event
and segment metadata, not only the payload. The known secret values belong
in the live local-store options, never in a stored record or digest;
rotating them doesn't make committed data unreadable, and the new set
applies to later writes.

## Reopen and fold

Open a fresh binding over the same directory and the same state folds
back:

```ts examples/durable-storage.ts (excerpt) {2} theme={null}
    const firstState = await foldStoredState(first, runId);
    const reopened = createLocalRunStorage(storageOptions);
    const reopenedState = await foldStoredState(reopened, runId);
    if (JSON.stringify(firstState) !== JSON.stringify(reopenedState)) {
      throw new Error('Fresh storage binding folded a different state.');
    }
```

The record contains only safe JSON: namespace, provider identity, provider
configuration digest and resolved policy. The local provider digest binds
the resolved absolute storage root and its numeric limits, so moving the
same bytes to another root produces a different record and loading the run
fails instead of reading from a different address. The exact record is
stored with the run and checked again when the run is loaded. Two
namespaces can share one provider without reading or changing each other's
data.

The runtime rejects unknown fields in the wrappers it owns: event
envelopes, artifact references, receipts, run-start records and stored
storage records. Graph, host and workspace payloads are opaque; the runtime
keeps their JSON or bytes without reading meaning into them, so code that
owns a payload validates what it uses. The example calls
`validateArtifactReference` before it reads the reference inside its event.

Run the file with `npx tsx durable-storage.ts`. It writes one 65 KB
synthetic artifact, appends the event that carries its 194-byte reference,
reopens, folds, and runs both public conformance kits:

```json Output theme={null}
{
  "storedArtifactBytes": 65591,
  "eventPayloadBytes": 194,
  "reopenedState": {
    "eventCount": 1,
    "artifactBytes": 65591,
    "lastArtifactDigest": "sha256:45ccde9ad00cd4b72ab6c8aced15a85c3199c7ceab300942bf678ae1fa3d40cc"
  },
  "conformance": {
    "events": 10,
    "artifacts": 15
  }
}
```

The reopened binding read one event and the same artifact bytes back, with
the same digest, and both kits passed.

## Failure

* **`REVISION_CONFLICT`**: an append supplies the expected stream revision,
  and when another writer wins, the losing append gets this and no part of
  its batch becomes visible. A retry with the same bytes returns the
  revision of its earlier commit; a retry with different bytes conflicts
  again.
* **A read fails closed.** An artifact read checks that the exact receipt
  was admitted for the run, then checks the bytes against it. Missing or
  changed data fails with a typed `StorageError`. A fresh local event store
  checks segment order, revisions and checksums when it reads.
* **A crash can leave bytes behind.** Before admission, temporary or blob
  bytes that aren't readable and don't count against the quota. After
  admission, a verified, quota-charged artifact without an event reference.
  Hosts own cleanup and reconciliation; the contract doesn't promise a
  transaction across two independent ports.
* **`deleteRun` removes only the artifacts in its scope.** The event stream
  and its references stay in history, so later reads through them fail
  closed. Hosts coordinate event and artifact cleanup.

## Bring another storage provider

Implement the root `EventStore` and `ArtifactStore` ports, then pass fresh
provider instances to `runEventStoreConformance` and
`runArtifactStoreConformance`. The framework-free kits check durable
reopen, revision conflicts, namespace and run isolation, size limits,
secret handling, content addressing, provider preflight, policy identity
and read integrity. A provider only records and reopens data; the executor
reads a stored run definition and resumes an unfinished node when asked to,
so that work doesn't belong in a provider.

<Accordion title="Full file">
  ```ts examples/durable-storage.ts theme={null}
  import { mkdtemp, rm } from 'node:fs/promises';
  import { tmpdir } from 'node:os';
  import { join } from 'node:path';

  import {
    validateArtifactReference,
    type JsonObject,
    type JsonValue,
    type RunStorageBinding,
    type RunStoragePolicy,
  } from '@obversa/runtime';
  import {
    runArtifactStoreConformance,
    runEventStoreConformance,
  } from '@obversa/runtime/testing';
  import {
    createLocalArtifactStore,
    createLocalEventStore,
    createLocalRunStorage,
  } from '@obversa/runtime/storage/local';

  const encoder = new TextEncoder();
  const policy = {
    schemaVersion: 1,
    maxEventPayloadBytes: 8_192,
    maxAppendBatchBytes: 32_768,
    maxArtifactBytes: 131_072,
    maxTotalArtifactBytesPerRun: 1_048_576,
    retention: 'until-run-delete',
    sensitiveContent: {
      marked: 'reject',
      exact: 'reject',
      freeText: 'redact-before-hash',
    },
  } as const satisfies RunStoragePolicy;

  function isJsonObject(value: JsonValue): value is JsonObject {
    return value !== null && typeof value === 'object' && !Array.isArray(value);
  }

  async function foldStoredState(binding: RunStorageBinding, runId: string) {
    const stream = { namespace: binding.record.namespace, streamId: runId };
    const scope = { namespace: binding.record.namespace, runId };
    let eventCount = 0;
    let artifactBytes = 0;
    let lastArtifactDigest: string | null = null;

    for await (const event of binding.eventStore.read(stream)) {
      if (
        event.type !== 'example:artifact-recorded'
        || !isJsonObject(event.payload)
      ) {
        throw new Error(`Unexpected stored event ${event.type}.`);
      }
      const reference = validateArtifactReference(event.payload.artifact);
      const bytes = await binding.artifactStore.read(scope, reference);
      eventCount += 1;
      artifactBytes += bytes.byteLength;
      lastArtifactDigest = reference.digest;
    }

    return { eventCount, artifactBytes, lastArtifactDigest };
  }

  async function main(): Promise<void> {
    const directory = await mkdtemp(join(tmpdir(), 'obversa-storage-example-'));

    try {
      const runId = 'run-one';
      const storageOptions = {
        directory: join(directory, 'run-storage'),
        namespace: 'example-host',
        policy,
        knownSecrets: ['example-secret'],
      } as const;
      const first = createLocalRunStorage(storageOptions);
      const scope = { namespace: first.record.namespace, runId };
      const stream = { namespace: first.record.namespace, streamId: runId };
      const largeBytes = encoder.encode(JSON.stringify({
        schemaVersion: 1,
        kind: 'synthetic-result',
        text: 'x'.repeat(65_536),
      }));
      const reference = await first.artifactStore.write(scope, {
        bytes: largeBytes,
        mediaType: 'application/json',
        purpose: 'synthetic-result',
        contentMode: 'state',
      });

      await first.eventStore.append(stream, 0, [{
        eventId: 'artifact-recorded-1',
        type: 'example:artifact-recorded',
        version: 1,
        timestamp: '2026-01-01T00:00:00.000Z',
        correlationId: runId,
        causationId: null,
        payload: { artifact: reference },
      }]);

      const firstState = await foldStoredState(first, runId);
      const reopened = createLocalRunStorage(storageOptions);
      const reopenedState = await foldStoredState(reopened, runId);
      if (JSON.stringify(firstState) !== JSON.stringify(reopenedState)) {
        throw new Error('Fresh storage binding folded a different state.');
      }

      const eventConformance = await runEventStoreConformance((options) =>
        createLocalEventStore({
          root: join(directory, 'event-conformance'),
          ...options,
        }));
      const artifactConformance = await runArtifactStoreConformance((options) =>
        createLocalArtifactStore({
          root: join(directory, 'artifact-conformance'),
          ...options,
        }));
      if (!eventConformance.ok || !artifactConformance.ok) {
        throw new Error(JSON.stringify({ eventConformance, artifactConformance }));
      }

      console.log(JSON.stringify({
        storedArtifactBytes: reference.byteLength,
        eventPayloadBytes: encoder.encode(JSON.stringify({ artifact: reference })).byteLength,
        reopenedState,
        conformance: {
          events: eventConformance.cases,
          artifacts: artifactConformance.cases,
        },
      }, null, 2));
    } finally {
      await rm(directory, { recursive: true, force: true });
    }
  }

  await main();
  ```
</Accordion>

## Next steps

* [Safe node attempts](/docs/recording/node-attempts): what one engine turn
  records, and how a crash mid-attempt is reconciled.
* [The record](/docs/concepts/record): the event log as the single source of
  truth, and what a resumed run repeats.
* [Supervised local runs](/docs/driving/runner): the runner's store and how a
  killed run carries on from it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.