# Restore durable application objects

> Save the state needed to call one registered constructor again. Keep services and connections out of the payload.

- Package: `@db3.ai/app/serialization`
- Canonical page: [https://db3.ai/docs/serialization](https://db3.ai/docs/serialization)
- Markdown: [https://db3.ai/docs/serialization.md](https://db3.ai/docs/serialization.md)
- Framework source of truth: `packages/app/src/serialization/README.md`

<a id="setup"></a>

## Register a stable contract

Configure `serializer.classes` in App options: `new App({ serializer: { classes: { "notes.export.v1": ExportRequest } } })`. The service is `app().serializer`, with an application-scoped registry. Register the same names and constructors in every producer and worker at boot.

Names are durable wire contracts, not filenames. The example uses its own state version in addition to the framework envelope version. Renaming or changing state requires an explicit stored-payload migration or discard policy.

- [Install the tools](https://db3.ai/docs/installation.md)

<a id="copy"></a>

## Copy the request and runner

This first lab uses a scalar owner ID, so it has no database dependency. It deliberately does not claim that an ID is authorization.

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/serialization/examples/. examples/
```

<a id="run"></a>

## Run it

Expect `label: "ada:25"`, `privateStateRestored: true`, wire version `1`, both rejection flags true and the repaired label. The runner round-trips through `JSON.stringify()` and `JSON.parse()` rather than passing live objects between calls.

### Run the lab

```bash
npx tsx examples/runExportSerialization.ts
```

<a id="constructor"></a>

## Make the constructor the state contract

Implement `Serializable<State>`. `toJSON()` returns the full input accepted by the one-argument constructor. Validation runs during both ordinary construction and restoration; private fields are initialized normally.

Use plain JSON values deliberately. `undefined`, dates, bigint, functions, non-finite numbers, negative zero, sparse arrays, cycles and nested application-class instances are rejected. Convert special values explicitly. `$platform` is reserved for framework reference markers.

### examples/ExportRequest.ts

```typescript
import type { Serializable } from '@db3.ai/app/serialization';

/** Durable, explicitly versioned application state for a bounded export. */
export interface ExportRequestState {
	/** Application contract version, separate from the framework envelope version. */
	version: 1;
	/** Validated scalar identity; the worker must authorize its own data reads. */
	ownerId: string;
	/** Maximum number of records requested for the export. */
	limit: number;
}

/** A registered root that validates the same state on direct and restored construction. */
export class ExportRequest implements Serializable<ExportRequestState> {
	readonly #state: ExportRequestState;
	/** Validates all state needed to recreate this request in a worker. */
	constructor(state: ExportRequestState) {
		if (!state || state.version !== 1 || typeof state.ownerId !== 'string' || !state.ownerId.trim() || !Number.isInteger(state.limit) || state.limit < 1 || state.limit > 100) throw new Error('ExportRequest requires version 1, an owner and limit 1–100.');
		this.#state = { version: 1, ownerId: state.ownerId.trim(), limit: state.limit };
	}
	/** Returns plain constructor state, never a request, connection or service. */
	toJSON(): ExportRequestState { return { ...this.#state }; }
	/** Demonstrates private state was initialized by the restored constructor. */
	label(): string { return `${this.#state.ownerId}:${this.#state.limit}`; }
}
```

<a id="restore"></a>

## Restore in another process

`serialize()` requires the exact registered root class. `deserialize()` validates the envelope, resolves registrations and calls the constructor. Registering a class only while dispatching does not register it in a worker.

The generic `deserialize<ExportRequest>()` annotates a result; it is not a separate schema or authorization check. Keep the payload’s expected registered name under application control. The registry is an allowlist, not a safe endpoint for arbitrary untrusted object execution.

### examples/runExportSerialization.ts

```typescript
import { pathToFileURL } from 'node:url';
import { SerializationError, Serializer } from '@db3.ai/app/serialization';
import { App } from '@db3.ai/app/server';
import { ExportRequest } from './ExportRequest';

/** Exercises the JSON boundary, unknown registration, invalid state and recovery. */
export async function runExportSerialization() {
	const application = new App({ serializer: { classes: { 'notes.export.v1': ExportRequest } } });
	try {
		const payload = application.serializer.serialize(new ExportRequest({ version: 1, ownerId: 'ada', limit: 25 }));
		const durable = JSON.parse(JSON.stringify(payload));
		const restored = await application.serializer.deserialize<ExportRequest>(durable);
		const worker = new Serializer();
		let unknownRejected = false;
		try { await worker.deserialize(durable); } catch (error) { if (!(error instanceof SerializationError)) throw error; unknownRejected = true; }
		worker.registry.registerClass('notes.export.v1', ExportRequest);
		let invalidStateRejected = false;
		try { await worker.deserialize({ ...durable, state: { ...durable.state, limit: 0 } }); } catch (error) { if (!(error instanceof SerializationError)) throw error; invalidStateRejected = true; }
		const repaired = await worker.deserialize<ExportRequest>(durable);
		return { label: restored.label(), privateStateRestored: restored instanceof ExportRequest, format: payload.format, wireVersion: payload.version, unknownRejected, invalidStateRejected, repaired: repaired.label() };
	} finally { await application.close(); }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runExportSerialization(), null, 2));
```

<a id="models"></a>

## Use model references when you want current data

Register model names in `serializer.models`. A clean, persisted ActiveRecord inside constructor state becomes a model name and logical primary key, not an attribute snapshot. Restoration reloads the current row through the active application database.

Unsaved, dirty, unregistered or soft-deleted records are rejected. Repeated references share a lookup and restored instance. A missing row fails restoration. If deletion should make the work a no-op, store a scalar ID and perform an optional, scoped lookup inside the worker.

Authorization still belongs to the workflow. The model-reference lookup is not an organization membership check. This first lab does not exercise model restoration; the service-owned SQL tests cover its contract.

- [ActiveRecord](https://db3.ai/docs/active-record.md)
- [Scope queries explicitly](https://db3.ai/docs/queries.md)

<a id="errors"></a>

## Handle failures without hiding them

`SerializationError` carries operation, code, path and an optional cause. `SerializationRegistryError` identifies invalid or conflicting registrations. Return safe application errors rather than exposing constructor state or stack traces.

The lab repairs a missing registration, then proves a rejected payload did not stop later valid work. Constructors should validate and initialize state; avoid irreversible side effects while restoring. Ordinary queue jobs already use their own JSON data constructor contract and do not automatically use this service.

<a id="testing"></a>

## Testing

Create a `tests/serialization` directory and save the test below as `runExportSerialization.test.ts`. It imports the example you copied into `examples/`. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.

### tests/serialization/runExportSerialization.test.ts

```typescript
import { expect, it } from 'vitest';
import { Serializer } from '@db3.ai/app/serialization';
import { ExportRequest } from '../../examples/ExportRequest';
import { runExportSerialization } from '../../examples/runExportSerialization';

it('restores private state through a JSON boundary and recovers missing worker registration', async () => {
	expect(await runExportSerialization()).toEqual({ label: 'ada:25', privateStateRestored: true, format: 'platform.serialized-object', wireVersion: 1, unknownRejected: true, invalidStateRejected: true, repaired: 'ada:25' });
});

it('validates constructor state and rejects incompatible envelope versions', async () => {
	expect(() => new ExportRequest({ version: 1, ownerId: ' ', limit: 25 })).toThrow();
	const serializer = new Serializer({ classes: { 'notes.export.v1': ExportRequest } });
	const payload = serializer.serialize(new ExportRequest({ version: 1, ownerId: 'ada', limit: 1 }));
	await expect(serializer.deserialize({ ...payload, version: 99 })).rejects.toThrow();
	expect((await serializer.deserialize<ExportRequest>(payload)).label()).toBe('ada:1');
});
```

<a id="run-tests"></a>

## Run and extend the test

Change the export limit and add a state field with constructor validation. Verify old payload handling deliberately. The copied tests need no SQL; the complete contributor service suite also includes real database restoration tests.

### Run your copied test and check types

```bash
npx vitest run tests/serialization/runExportSerialization.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

<a id="coverage"></a>

## Coverage and API

Tested here: registration, JSON durability, private-state reconstruction, invalid state/version, missing worker registration and recovery. Explained: model identity, supported values, duplicate registration, versioning, worker boot and trust boundaries. A model-restoration cookbook with missing-row recovery remains a follow-up.

- [Serialization API](https://db3.ai/docs/serialization-api.md)

## Behavioural verification
Reconstructs a registered export request through JSON, rejects an unknown worker registration and invalid state, then repairs both.
- Behaviour test: `packages/app/src/serialization/tests/examples/runExportSerialization.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- serialization --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: Node.js; the scalar-identity lab does not need SQL or a worker process.

## Related documentation
- [Queue](https://db3.ai/docs/queue-overview.md): Create durable background jobs, run named workers, understand every attempt, compose chains and batches, and recover failures through one complete service guide.
- [ActiveRecord](https://db3.ai/docs/active-record.md): Define your fields once. Create, validate, query and save records without repeating database conversion in every endpoint.
- [Serialization API reference](https://db3.ai/docs/serialization-api.md): Current emitted signatures and options for @db3.ai/app/serialization.

## Framework-owned source: `packages/app/src/serialization/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Serialization

## Runnable scalar-state example

Copy `src/serialization/examples/` from the installed package into `examples/`
and run `npx tsx examples/runExportSerialization.ts`. The registered request
crosses a real JSON boundary, initializes private state, rejects a missing
worker registration and invalid state, then recovers. It requires no SQL.
The website includes `tests/examples/runExportSerialization.test.ts` as an
exact consumer-copyable test. Model-reference behavior remains covered by the
separate service SQL tests, not this scalar-identity example.

The framework serializer reconstructs one registered root class from durable
constructor state. It produces a versioned JSON-safe envelope and restores that
object inside a bootstrapped application process.

The service is available through `app().serializer`. Its application-scoped
allowlist is available through `app().serializer.registry`.

## Application configuration

Register root classes and ActiveRecord models with explicit stable names:

```ts
import type { SerializerOptions } from '@db3.ai/app/serialization';

const serializer: SerializerOptions = {
	classes: {
		'report.request': ReportRequest,
	},
	models: {
		'scout.user': User,
		'scout.website': Website,
	},
};

new App({
	serializer,
});
```

The same entries can be registered during application boot:

```ts
app().serializer.registry.registerClass('report.request', ReportRequest);
app().serializer.registry.registerModel('scout.website', Website);
```

Registration is idempotent for the same name and constructor. Reusing a name
or constructor for a different entry throws. The names are durable wire
contracts, so stored payloads must be migrated or discarded before a name
changes.

Every producer and worker process must install the same registrations during
boot. A process-local registration performed only while dispatching cannot make
the constructor available to another queue worker.

## Root constructor contract

A registered root class implements `Serializable<TState>`. Its `toJSON()`
method returns the complete state accepted by its one-argument constructor:

```ts
import type { Serializable } from '@db3.ai/app/serialization';

interface ReportRequestState {
	website: Website;
	options: {
		includeDrafts: boolean;
	};
}

class ReportRequest implements Serializable<ReportRequestState> {
	/**
	 * Creates a report request from its complete runtime state.
	 *
	 * @param state - State used for both direct and restored construction.
	 */
	constructor(readonly state: ReportRequestState) {}

	/**
	 * Returns the state needed to call this constructor again.
	 *
	 * @returns Complete report request constructor state.
	 */
	toJSON(): ReportRequestState {
		return this.state;
	}
}
```

Serialization resolves the instance's exact registered prototype and invokes
that prototype's `toJSON()` exactly once. It does not inspect arbitrary root
properties or invoke `toJSON()` on nested values.

Deserialization validates the entire envelope, restores nested ActiveRecord
references, and then calls `new RegisteredClass(state)`. Normal constructor
validation therefore runs in both direct and restored execution, and private
fields are initialized normally.

## Constructor state

Constructor state supports ordinary JSON values:

- `null`, strings, booleans, and finite numbers;
- dense arrays; and
- plain objects through their normal enumerable string properties.

Registered ActiveRecord instances are the only non-JSON values supported
inside that state. Nested application classes, `undefined`, `bigint`, `Date`,
functions, maps, sets, custom object prototypes, sparse arrays, invalid
numbers, and cycles throw a path-aware `SerializationError`. As with ordinary
JSON, symbol and non-enumerable properties are outside the serialized shape,
and enumerable getters are read. Repeated non-cyclic plain objects are copied
and do not preserve object identity.

The `$platform` object key is reserved for framework reference markers. Convert
optional or specialist runtime values to an intentional JSON representation in
the root class's `toJSON()` result.

## ActiveRecord references

A registered, clean, persisted ActiveRecord is stored as a model key and
logical primary key rather than as an attribute snapshot:

```json
{
	"$platform": "active-record",
	"model": "scout.website",
	"id": "01J..."
}
```

Serialization rejects a record that is unregistered, unsaved, dirty, missing a
supported string or safe-integer primary key, or already soft deleted. Dirty
checks include in-place changes to structured field values.

Deserialization resolves the registered model and calls `findOrFail()` through
the active application database. The restored object therefore contains the
current stored row, not a stale attribute snapshot. Repeated references to the
same model and primary key share one lookup and restored instance. Errors
distinguish a missing row from another database restoration failure.

If a missing record should be a valid no-op for a workflow, store its scalar id
as ordinary constructor state and perform that optional lookup in the workflow.

## Envelope and validation

The wire shape is explicit and versioned:

```json
{
	"format": "platform.serialized-object",
	"version": 1,
	"name": "report.request",
	"state": {}
}
```

The complete envelope is validated before any database query or root
constructor call.

Use a real JSON boundary when verifying durable values:

```ts
const payload = app().serializer.serialize(value);
const durablePayload = JSON.parse(JSON.stringify(payload));
const restored = await app().serializer.deserialize(durablePayload);
```

## Current queued-agent example

Scout's `BaseAgent` implements the root contract by returning its constructor
context from `toJSON()`. `ArticleGenerationAgent` accepts and validates one
context object containing `User`, `Website`, `ContentPlanItem`, and the
generation trigger.

When that agent is queued, `BaseAgent.queue()` serializes the concrete agent
before creating the tracked run. An unregistered agent therefore fails before
creating conversation or request rows. The resulting `AgentRunJob` data
contains:

- the versioned agent envelope;
- the message, model, trace, conversation, and AI request identifiers; and
- small lifecycle `metadata`, such as `contentPlanItemId`, that failure
  observers need without reconstructing the agent.

In the worker, `AgentRunJob` deserializes the envelope. The serializer reloads
the three ActiveRecord references, invokes the registered
`ArticleGenerationAgent` constructor, and the job calls `runQueued()` on that
fresh instance. The agent's constructor is now the single context contract;
there is no parallel factory that manually maps stored ids back into a
different runtime shape.

This is an explicit integration at the queued-agent boundary. Ordinary
`QueueableJob` classes continue to use their existing JSON `data` constructor
round trip.
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/serialization` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
