# @x12i/activix

Part of the [Activitix monorepo](../../README.md). Shared record types live in **`@x12i/activix-contracts@^1.3.0`** (re-exported from this package for convenience).

For **runtime optimization** of AI execution (first scenario: adaptive max completion budget), use **[`@x12i/optimixer`](../optimixer)** (2.x). Optimixer writes `optimixer:prediction` rows through your Activix client; Activix does not make optimization decisions.

Track **start → complete / fail** (and timeouts) for activities stored in **MongoDB** (via [`@x12i/xronox-store`](https://github.com/x12i/xronox-store)) or in a **local playground folder** (no database). Each record has **root-level** **`outer`** (**`input`**, **`output`**, **`metadata`**, optional **`cost`**) and optional **`inner[]`**, optional top-level **`metadata`** (orchestrator observability) and **`config`** (routing mirror), a **`runContext`** object (pass **`sessionId`** / **`jobId`** for correlation — Activix does **not** invent `sessionId` if you omit it), primary key **`activityId`**, and lifecycle fields (**`status`**, **`startTime`**, **`endTime`**, **`duration`**) that Activix sets (integrators should not send those or parallel **`createdAt`** / **`updatedAt`**).

**Docs map:** [Record fields & naming](#record-shape-where-each-value-belongs) · [`.docs/activity-structure.md`](./.docs/activity-structure.md) · [`.docs/activity-trace-schema.md`](./.docs/activity-trace-schema.md) (studio export / joins) · [`.docs/run-context-object.md`](./.docs/run-context-object.md) · **v9 consumers:** [../../.docs/MIGRATION-CONSUMERS.md](../../.docs/MIGRATION-CONSUMERS.md) · **v6:** [`.docs/MIGRATION-v6.md`](./.docs/MIGRATION-v6.md) · **v5 `runContext`:** [`.docs/MIGRATION-v5.md`](./.docs/MIGRATION-v5.md)

## Install

`@x12i/xronox-store` is a **private** dependency on npmjs. Make sure your npm auth token has access to the `@x12i` org (see `.npmrc.example`), then:

```bash
npm install @x12i/activix
```

The published package ships **`.docs/`** (same as this repo). Dependents can link or copy from **`node_modules/@x12i/activix/.docs/run-context-object.md`** when documenting their own Activix integration.

## Activation policy (important)

Activix is **explicitly enabled by config**.

- Enable Activix by constructing `new Activix(...)` with either:
  - a pre-built `store`, or
  - **`collection` / `collections`** (and usually `mongoUri` or Mongo URI env vars when you expect database storage).
- **Default `storageMode` (when omitted):** if **`mongoUri`** (or Mongo URI env vars) is set → **`database`** (no probe; fails at store init when Mongo is down). If no URI → **`local`** playground. For probe-then-fallback dev ergonomics, pass **`storageMode: 'automatic'`** explicitly.
- Set **`storageMode: 'database'`** for the same behavior as omitting mode when a URI is present. Set **`storageMode: 'local'`** for playground-only (see [Storage modes](#storage-modes)).
- **`await Activix.create(options)`** is equivalent to **`new Activix(options)`** then **`await ax.init()`** (convenience helper).
- If your app does not construct Activix, treat it as **disabled** (do not silently auto-enable from ambient env).
- If Activix is enabled, missing required connection details should fail fast at app wiring/startup.
- Collection name is **required application wiring**. Every package must pass a package-owned, hard-coded **`collection`** or **`collections`** value when constructing Activix. Do **not** pick runtime package collection names from `.env`.
- Database name is **not** accepted in constructor options. Set DB once in `.env`
  (`ACTIVIX_DB_NAME`, fallback `MONGO_AI_LOGS_DB`, then `MONGO_LOGS_DB`, then `MONGO_DB`), otherwise Activix uses `activitix`.
- **Diagnostic logs are error-only by default.** This prevents feedback loops where routine Activix diagnostics would add noise around activity-log writes. Set **`ENABLE_ACTIVIX_LOGXER=true`** to enable full console diagnostics. When enabled, level follows **`ACTIVIX_LOGS_LEVEL`** (canonical; legacy **`ACTIVIX_LOG_LEVEL`** fallback); if both are unset, the default is **`warn`**. Set **`ACTIVIX_LOGS_LEVEL=error`** to return to errors-only while diagnostics are enabled. Injected **`logger`** instances are also gated: with the enable flag off, only `logger.error` is called. Structured logging via **@x12i/logxer** is provided by **@x12i/optimixer**, not Activix.
- **Dependencies below Activix** use **their own** log switches (documented here for convenience only; each package remains authoritative). To trace persistence end-to-end, enable each layer you need (see [Diagnostic logging down the stack](#diagnostic-logging-down-the-stack)).

This keeps behavior deterministic across environments and avoids hidden coupling to process env.

## Run context at runtime (not Activix constructor config)

**`runContext` is per-run execution context**, not something you define once in **`new Activix({ … })`** or in static app configuration.

- **Constructor options** (`mongoUri`, `collection` / `collections`, `store`, index definitions, storage mode, and similar) only configure **where and how** documents are stored. They do **not** carry `sessionId`, `jobId`, `taskId`, or other correlation fields for a specific request, message, or job.
- **On each write** (`startRecord`, and any follow-up that carries run context), pass the **`runContext`** object that reflects **this** execution: usually taken from the inbound request or job envelope, optionally extended at each layer, and forwarded to downstream code. That is the object persisted under the configured **`runContext`** field (default BSON key **`runContext`**; override with **`runContextField`**). Full guide: [`.docs/run-context-object.md`](./.docs/run-context-object.md).

If you omit both `runContext.sessionId` and top-level `sessionId`, Activix **does not** invent a value—it logs a **warning** and stores the record without `sessionId`. For end-to-end correlation across services, **supply** `sessionId` (and other work-scope fields) from the true upstream of the run.

**v5 naming:** the correlation object is **`runContext`** only—there is **no** `identity` field, `identityField` config, or `findRecordsByIdentity` in the default public API (no backward-compatible aliases). Stray **`identity`** keys on writes are **not** read as the correlation envelope. v5 API summary is at the top of [`.docs/run-context-object.md`](./.docs/run-context-object.md#v5-runcontext-only-api-quick). To announce this upgrade to teams, use [`.docs/COMMUNICATING-RUNCONTEXT-V5.md`](./.docs/COMMUNICATING-RUNCONTEXT-V5.md).

## Diagnostic logging down the stack

Activix sits on **xronox-store**, which sits on **xronox**. Diagnostic output is **layered**: turning on Activix logs does **not** automatically enable store or engine logs. The table below is **reference for Activix users** only; do not treat it as instructions to modify downstream repos — pull specifics from each dependency when you need them.

| Layer | Package | Turn on | Notes |
|-------|---------|---------|--------|
| **Activix** | `@x12i/activix` | Default: errors only. Full diagnostics: **`ENABLE_ACTIVIX_LOGXER=true`**; optional **`ACTIVIX_LOGS_LEVEL`** (`error`, `warn`, `info`, `debug`, etc.) | Avoids logging-system noise about logs activity. **`isActivixDiagnosticLoggingEnabled()`** is true only when the enable flag is true and the level is not silenced. |
| **Store** | [`@x12i/xronox-store`](https://github.com/x12i/xronox-store) | `XRONOX_STORE_LOG=1` (or `true` / `yes` / `on`) | Console lines prefixed **`[xronox-store]`**. Optional `logger` on **`XronoxStore`**; see [xronox-store README — Logging](https://github.com/x12i/xronox-store/blob/main/README.md). |
| **Engine** | [`@x12i/xronox`](https://github.com/x12i/xronox) | `XRONOX_VERBOSE=1` or `XRONOX_LOGS=1` (`1`, `true`, or `yes`) | **Only** `XRONOX_*` keys; generic `DEBUG=1` does **not** enable xronox diagnostics. |

Example for a noisy trace of one persistence path:

```bash
ENABLE_ACTIVIX_LOGXER=true
ACTIVIX_LOGS_LEVEL=debug
XRONOX_STORE_LOG=1
XRONOX_VERBOSE=1
```

Expanded reference (same scope — Activix docs only) and **logs-gateway** pointers: [`.docs/logging-stack.md`](./.docs/logging-stack.md).

### Breaking change: no constructor DB override

`mongoDb` in `new Activix(...)` is intentionally unsupported.

Why:

- Prevent hidden per-component DB selection in code.
- Avoid accidental split-brain activity history (some services writing to one DB, others to another).
- Keep one operational source of truth in deployment/env config.

## Quick start (single collection)

Use this when all activity records for this `Activix` instance live in **one** MongoDB collection. Pass the collection name **once** in the constructor; you do not pass it again on `startRecord`, `completeRecord`, and the other methods.

The collection name is part of the package's source-level ownership contract. Choose a stable package-owned name such as **`my-service-activities`** and hard-code it in your Activix initialization. Environment variables are for deployment settings like Mongo URI or database name, not for deciding which package-owned activity collection a runtime package writes to.

**Production (default):** Mongo when URI is configured — no probe, fail fast if Mongo is down.

```typescript
import {
  Activix,
  activixActivityIo,
  activixOuterTier,
  type ActivixRunContext,
} from '@x12i/activix';

const ax = new Activix({
  mongoUri: process.env.MONGO_URI!,
  collection: 'my-service-activities',
});

await ax.init(); // ax.storageBackend === 'database' when Mongo is reachable
const runContext: ActivixRunContext = {
  sessionId: '550e8400-e29b-41d4-a716-446655440000', // normally the id your upstream already assigned
  jobId: 'job-123',
};
const { activityId } = await ax.startRecord({
  runContext,
  ...activixActivityIo(
    activixOuterTier({ kind: 'task', args: { id: 1 } }, null, { type: 'import' })
  ),
});
await ax.completeRecord(activityId, {
  outer: { output: { result: 'ok' } },
});
await ax.close();
```

**Always MongoDB** (no probe; same behavior as pre–automatic-default releases):

```typescript
const ax = new Activix({
  storageMode: 'database',
  mongoUri: process.env.MONGO_URI!,
  collection: 'my-service-activities',
});
```

You can pass a full config object instead of a string if you need custom primary keys, indexes, or field names:

```typescript
const ax = new Activix({
  mongoUri: process.env.MONGO_URI!,
  collection: {
    name: 'my-service-activities',
    indexes: [
      { keys: { 'runContext.jobId': 1 } },
      { keys: { 'runContext.sessionId': 1 } }, // helps findRecordsByRunContext({ sessionId })
    ],
  },
});
```

## Defaults (v6)

| Item | Default |
|------|---------|
| Primary key field | `activityId` (values like `<primaryKeyPrefix><uuid>`; default `act-<uuid>`) |
| `runContext` object | Field `runContext` (override with `runContextField`); pass **`sessionId`** / **`jobId`** when you have them (`runContext` or top-level `sessionId` — Activix warns if `sessionId` is missing) |
| `outer` / `inner` | **Root fields** (no `structure` wrapper): **`outer`** requires `input`, `output` (may be `null` until complete), `metadata`, optional `cost`; optional **`inner`** is an array of step entries with `input`, `output`, `metadata`, optional `cost`, `startedAt`, `endedAt`, optional `durationMs` |
| `autoCost` | Default **`false`**. When enabled, fills **`outer.cost`** from a valid cost on the row or from **`@x12i/ai-tools`** only when needed — see [Automatic cost](#automatic-cost-autocost) |
| Status / times | `status`, `startTime`, `endTime`, `duration` — **numbers** (Unix ms for start/end); set only by Activix |
| Mid-flight status | `statusValues.inProgress` default `'in_progress'` (`markInProgress`) |
| Abandoned / stale | `statusValues.timeout` default `'timeout'`; TTL **`staleRecordTTL`** (ms) vs **`startTime`** (`reconcileAbandonedActivities` / `markStaleRecords`) |
| Purge old rows | **`purgeOldRecords()`** — **soft-purges** (marks as `purgedAt` and hides) docs with `startTimeField` older than **`purgeRecordMaxAgeMs`** (default **7 days**); optional per-call **`olderThanMs`** |
| Optional “last heard” | `progressAtField` — set on each `markInProgress` |

`startRecord` **creates** the primary key, writes it to both the configured primary-key field and root **`activityId`**, persists the row, and returns **`activityId`**. Pass that **`activityId`** into **`completeRecord`**, **`failRecord`**, **`markInProgress`**, **`patchRecord`**, and **`getRecord`**. Deprecated **`recordId`** on the result object is the same string.

## Record shape: where each value belongs

Use this table before choosing field names in your service. Types and trace helpers live in **`@x12i/activix-contracts`** (re-exported from **`@x12i/activix`**).

| Concept | Where it lives | Example | Notes |
|---------|----------------|---------|-------|
| **Collection name** | Constructor **`collection`** / **`collections[].name`** | `'ai-gateway-activities'` | Hard-coded in **source** (not `.env`). Optimixer **`activixCollection`** must match when sharing one Activix client. |
| **`activityId`** | Root on every row | `'act-…'` | Assigned by Activix on **`startRecord`**. Join key for graph ↔ ai-tasks. **Not** stored in **`runContext`**. |
| **`runContext`** | Root envelope, per write | `{ sessionId, jobId, graphId, nodeId }` | [`.docs/run-context-object.md`](./.docs/run-context-object.md) |
| **`activityKind`** | IO / lifecycle tag | `'node:start'`, `'optimixer:prediction'` | Often `outer.input.kind` or `outer.metadata.kind`. Export: **`trace.activityKind`**. |
| **`activityType`** | Domain label | `'synthesis'`, `'task'` | **`outer.metadata.type`** only — not legacy ActivityTracker routing. Export: **`trace.activityType`**. |
| **Top-level `metadata`** | Root sibling of **`outer`** | `{ modelUsed, synthesisEnabled }` | Orchestrator observability; shallow-merged on updates; **not** hoisted into **`outer`**. |
| **`outer.metadata`** | On **`outer`** | `{ phase, provider, model, kind }` | IO-tier / gateway mirror. |
| **Top-level `config`** | Root routing mirror | `{ provider, model, maxTokens }` | Filled from **`outer.metadata`** on persist via **`materializeRecordRoutingAndBilling`**. |

### `activityKind` vs `activityType`

Do **not** use these interchangeably:

- **`activityKind`** — *which phase or IO event?* (`graph:start`, `node:start`, `optimixer:prediction`). Activix resolves warnings/playground labels in order: top-level **`kind`** → **`metadata.kind`** → **`outer.metadata.kind`** → **`outer.input.kind`**.
- **`activityType`** — *which domain bucket?* (`synthesis`, `task`, `import`). Canonical slot: **`outer.metadata.type`**.

```typescript
import { activixOuterTier, activixActivityIo } from '@x12i/activix';

await ax.startRecord({
  runContext: { sessionId: jobId, jobId, graphId: 'g1', nodeId: 'node-a' },
  ...activixActivityIo(
    activixOuterTier(
      { kind: 'node:start', payload: {} },  // outer.input
      null,
      { type: 'task', kind: 'node:start' } // outer.metadata
    )
  ),
  metadata: { modelUsed: 'openai/gpt-4o', synthesisEnabled: false },
});
```

### Top-level `metadata`, `config`, and observability

| Field | Typical use |
|-------|-------------|
| **`metadata.modelUsed`** | Model id for studio reports (diagnostics also read **`config.model`** / **`outer.metadata.model`**) |
| **`metadata.synthesisEnabled`** | Explicit boolean for synthesis on/off |
| **`metadata.*`** | Orchestrator keys (`narrixMode`, `inputStrategyKey`, …) — query `{ "metadata.<key>": value }` |
| **`config`** | Routing/billing mirror (`provider`, `model`, `maxTokens`) — safe to patch on **`failRecord`** for gateway error paths |

Legacy rows with only **`outer.metadata.type: 'synthesis'`** are treated as synthesis-enabled in export summaries when **`synthesisEnabled`** is absent.

Pure helpers: **`resolveRecordMetadata`**, **`mergeRecordMetadata`**, **`resolveRecordConfig`**, **`mergeRecordConfig`**, **`materializeRecordRoutingAndBilling`**, **`buildActivityTraceBlock`**, **`normalizeActivityTraceExport`**.

### Graph runs and ai-tasks joins

Correlation stays in **`runContext`**; inherit upstream ids, extend with your scope:

```typescript
// Graph run (no nodeId) — graphRun in getJobActivities is the row with graphId and no nodeId
runContext: { sessionId: jobId, jobId, graphId: 'g1' }

// Master skill scope (product-defined; often another row's activityId)
runContext: { sessionId: jobId, jobId, graphId: 'g1', masterSkillActivityId: 'act-master-9' }

// ai-tasks child pointing at graph node
metadata: { runTask: { metadata: { activityId: 'act-node-abc' } } }
```

**Studio join:** graph node **`trace.join.activityId`** === ai-tasks **`trace.join.parentActivityId`**. Details: [`.docs/activity-trace-schema.md`](./.docs/activity-trace-schema.md).

### Lifecycle: done, in-progress, failed, abandoned

| Intent | API |
|--------|-----|
| Success (sets `completed`, `endTime`, `duration`) | `completeRecord(activityId, updates?)` |
| Failure (sets `failed`, `error`, `endTime`, `duration`) | `failRecord(activityId, error, updates?)` |
| Still running — “last we heard” (sets `in_progress`, optional touch field) | `markInProgress(activityId, updates?)` |
| Arbitrary fields, **any** status (including after `completed` / `failed`) | `patchRecord(activityId, fields)` |

Optional per collection: **`progressAtField`** (e.g. `lastHeardAt`) — on each **`markInProgress`**, Activix sets it to `Date.now()`. Override the mid-flight label with **`statusValues.inProgress`**.

### Persistence warnings

Pass **`onPersistenceWarning`** when you want a metrics/logging hook for failed activity writes:

```typescript
const ax = new Activix({
  collection: 'my-service-activities',
  mongoUri: process.env.MONGO_URI!,
  onPersistenceWarning: (warning) => {
    metrics.increment(warning.event, {
      collection: warning.collection,
      phase: warning.phase,
    });
  },
});
```

Activix calls this hook before it rethrows or swallows a persistence error. Normal lifecycle writes (`startRecord`, `completeRecord`, `failRecord`, `markInProgress`) still reject after the warning. `patchRecord` keeps its historical non-fatal behavior by default, but **`strictPersistence: true`** makes it rethrow after warning so CI/audit-sensitive runs can fail fast.

Warning events are **`activix.record.insert_failed`** and **`activix.record.update_failed`**. The payload includes `collection`, `operation`, `phase`, primary key, and an error summary. `phase` is resolved from top-level `kind`, then `outer.metadata.kind`, then `outer.input.kind`, else `activix:record`. When xronox-store reports clone diagnostics, Activix forwards the offending path/type summary under `warning.clone`.

### Automatic cost (`autoCost`)

Opt in with **`autoCost: true`** (or an options object) on `new Activix({ … })`. On **`completeRecord`**, **`failRecord`**, **`markInProgress`**, and **`patchRecord`**, Activix can populate **`outer.cost`** when it is missing or invalid:

1. **Reuse a valid cost already on the row** — e.g. `outer.cost.usd`, `inner[].cost`, `outer.output.usage.total_cost` / `usage.cost`, or other nested cost objects (bounded scan). Integrator-provided **`outer.cost`** with a valid **`usd`** is never replaced unless you set **`overwriteOuterCost: true`**.
2. **Only if no valid cost was found**, call **`@x12i/ai-tools`** (`CostCalculator.calculateFromRecord`) when token usage and a model can be extracted. Activix checks **`needsActivixCostCalculation(record)`** first so catalog pricing is skipped when unnecessary.

**Model strings (ai-tools v5.1.0 / ai-profiles 4.4.x):** pricing accepts **`profile/choice`** keys (e.g. `cheap/default` → `google/gemma-3-4b-it`) or **concrete model ids** (e.g. `gpt-5.5`, `openai/gpt-4o`). Bare profiles (`cheap`), shortcuts (`standard`), and legacy shorthands (`gpt4o`) are **not** resolved — store the canonical form on activity rows.

Default catalog mode for Activix is **`bundledOnly: true`** (offline-friendly). Pass **`bundledOnly: false`** to allow live open-assets catalog refresh.

```typescript
const ax = new Activix({
  collection: 'my-service-activities',
  mongoUri: process.env.MONGO_URI!,
  autoCost: true,
});

await ax.completeRecord(activityId, {
  outer: {
    output: {
      usage: { prompt_tokens: 1200, completion_tokens: 400, total_cost: 0.008 },
    },
  },
});
// outer.cost.usd === 0.008 (from the response; ai-tools is not called)
```

Standalone helpers (same rules): **`applyActivixAutoCost`**, **`findValidActivixCostInRecord`**, **`needsActivixCostCalculation`**, **`resolveActivixCostForRecord`**, **`isValidActivixCost`**.

### Abandoned activities (started but never finished)

Call **`reconcileAbandonedActivities()`** on a timer (or from your job runner). It finds documents still in **`started`** whose **`startTime`** is older than a TTL and sets **`status`** to **`statusValues.timeout`** (default `'timeout'`). Default TTL is constructor **`staleRecordTTL`** (ms); override per run with `{ ttlMs }`. Same implementation as **`markStaleRecords()`** — use whichever name fits your docs.

### Purging old records (soft purge)

Call **`purgeOldRecords(options?)`** on a schedule to **soft-purge** documents whose **`startTime`** is older than a threshold (any **status**, including stuck or finished).

Activix marks matching rows with `purgedAt = Date.now()` (or your **`purgeAtField`**). When Activix creates **`XronoxStore`**, it registers the same field as xronox-store **`visibility`**, so tombstoned rows are hidden from **`getByKey`** / **`readMany`** / **`updateMany`** (i.e. **`getRecord`**, **`findRecords`**, **`findRecordsByRunContext`**, stale reconciliation, and purge matching).

- Default age: constructor **`purgeRecordMaxAgeMs`**, which defaults to **7 days** (`604800000` ms).
- Override per run: **`purgeOldRecords({ olderThanMs: 24 * 60 * 60 * 1000 })`** (example: older than 1 day).
- You can configure the marker field name per collection with `purgeAtField` (default: `purgedAt`).

```typescript
const ax = new Activix({
  mongoUri: process.env.MONGO_URI!,
  purgeRecordMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // default; omit to use 1 week
});

await ax.init();
const purged = await ax.purgeOldRecords(); // or { collection: 'other-stream', olderThanMs: ... }
```

### Migrating from v1 (`recordId` / `rec-`)

If existing collections and indexes use `recordId`, set in config:

`primaryKey: 'recordId', primaryKeyPrefix: 'rec-'`

## Cache-first reads (primary key)

When Activix creates the store, each collection’s cache is resolved with xronox-store **`resolveCacheConfig`** (defaults **`maxSize` 10000**, **`ttlMs` 0**) and your optional **`cache`** overrides. **Writes go to the cache first**, then Mongo. **`getRecord(id)`**, **`completeRecord`**, **`markInProgress`**, **`patchRecord`**, and **`failRecord`** use the store’s per-key cache when the key is hot.

**`findRecords`** / **`findRecordsByRunContext`** use **`readMany`**. By default they do **not** merge the in-memory cache; pass **`{ mergeCache: true }`** (xronox-store **1.2+**) to union matching cached rows with DB results. After a write, **`getRecord(activityId)`** is still the simplest way to read the latest row for one id in-process.

## Query by `sessionId` / `runContext` / `status`

Use **`findRecordsByRunContext(criteria, { collection?, limit?, sort? })`** to load activities from the database by:

- **`sessionId`** — matches `<runContextField>.sessionId` (default field path `runContext.sessionId`)
- **`runContext`** — extra exact matches on nested run context, e.g. `{ tenant: 'acme' }` → `runContext.tenant`
- **`status`** — exact match on your configured status field (e.g. `'started'`, `'completed'`)

Criteria are combined with **AND**. You must pass at least one of: a non-empty `sessionId`, at least one `runContext` property, or a non-empty `status`. If top-level `sessionId` and `runContext.sessionId` both appear, they must be the same string or Activix throws.

```typescript
// All activities in a session
const rows = await ax.findRecordsByRunContext({ sessionId: '550e8400-e29b-41d4-a716-446655440000' });

// Same, but only still-open ones
const open = await ax.findRecordsByRunContext({
  sessionId: '550e8400-e29b-41d4-a716-446655440000',
  status: 'started',
});

// By custom run-context fields (uses configured runContextField name, default `runContext`)
const forTenant = await ax.findRecordsByRunContext({
  runContext: { tenant: 'acme', plan: 'pro' },
  status: 'in_progress',
});
```

For ad hoc Mongo shapes, use **`findRecords(rawFilter, …)`** instead. Consider an index on `runContext.sessionId` (and other queried run-context keys) in **`indexes`** on the collection config.

Exported type: **`FindByRunContextCriteria`**.

## Runtime observability query client

Activix is the official owner of activity queryability for runtime observability. Packages that expose debug-only `runtimeObjects` should expose their package-owned Activix instance by reference as `runtimeObjects.activixClient`; they should not build local Mongo adapters, scrape playground files, or query xronox-store internals.

Use **`getJobActivities(input)`** when a parent package, playground, or debug UI needs all Activix activity information for a runtime job:

```typescript
const result = await ax.getJobActivities({
  jobId: 'job-123',
  graphId: 'graph-main',
  nodeId: 'node-a',
  limit: 500,
});

console.log(result.jobId, result.graphRun, result.activities);
```

Shape:

```typescript
type ActivixQueryableClient = {
  getJobActivities(input: {
    jobId: string;
    graphId?: string;
    nodeId?: string;
    limit?: number;
  }): Promise<{
    jobId: string;
    graphRun?: unknown;
    activities: unknown[];
  }>;
};
```

`jobId` is required and is matched inside the configured **`runContextField`** (default **`runContext.jobId`**). Optional **`graphId`** and **`nodeId`** filter the same run-context object. Activix queries every collection configured on the instance, returns full activity rows, sorts them into timeline order, and applies `limit` after merging collections.

The configured store is the source of truth: MongoDB/xronox-store in **`database`** mode, or the playground/local store in **`local`** mode or automatic fallback. Activix passes **`mergeCache: true`** so hot in-process rows can be merged with the store result where supported, but the cache is only a freshness layer, not an in-memory-only backend.

Full guide: [`.docs/runtime-observability-querying.md`](./.docs/runtime-observability-querying.md).

**Graph-style writes** (indexes recommended on `runContext.jobId`, `runContext.graphId`, `runContext.nodeId`):

```typescript
await ax.startRecord({
  runContext: { sessionId: jobId, jobId, graphId: 'g1', nodeId: 'node-a' },
  outer: {
    input: { kind: 'node:start' },
    output: null,
    metadata: { type: 'task', kind: 'node:start' },
  },
});
```

`getJobActivities({ jobId, graphId })` returns **`graphRun`** (first matching graph-level row) plus **`activities`** sorted in timeline order.

## Run context contract for gateway integrations

This is the same **runtime** **`runContext`** object described in [Run context at runtime (not Activix constructor config)](#run-context-at-runtime-not-activix-constructor-config); gateway docs spell out the envelope for AI-shaped stacks.

For teams integrating through `@athenices/ai-gateway`, use the shared run-context envelope in:

- [`.docs/run-context-object.md`](./.docs/run-context-object.md) — what `runContext` is, layers, `instance`, examples, mistakes, v5 API, graphs/hooks appendix
- [`.docs/session-id-usage.md`](./.docs/session-id-usage.md) — happy-path `sessionId` for a full run; nested layers should not replace an id they received; entry/direct-call behavior is product-defined
- [`.docs/activity-structure.md`](./.docs/activity-structure.md) — root **`outer`** / **`inner[]`** I/O (including optional **`outer.cost`** / **`inner[].cost`**)

Recommended shape for cross-service correlation (see the doc for hierarchy vs optional executor):

```ts
runContext: {
  sessionId: string,
  // Work scope: add as you go deeper (examples — names are product-defined)
  jobId?: string,
  taskId?: string,
  stepId?: string,
  skillId?: string,
  // Optional: which executor ran the work (agent / worker / replica)
  instance?: {
    instanceId: string,
    type: string,
  },
}
```

Activix does **not** mint `sessionId` for you — pass it from the job/request upstream when you need correlation. Gateway-facing systems should preserve the full **`runContext`** object end-to-end.

## Multiple collections

Use this when one service writes to **several** activity collections (for example separate streams per domain). List each collection in `collections` and set **`defaultCollection`** to the primary stream name (explicit in source — there is no implicit “first item” default). For non-default streams, pass `collection` on the method call.

```typescript
const ax = new Activix({
  mongoUri: process.env.MONGO_URI!,
  collections: [
    { name: 'workflow-runs' },
    { name: 'import-jobs' },
  ],
  defaultCollection: 'workflow-runs',
});

await ax.startRecord({ step: 1 }); // uses workflow-runs
await ax.startRecord({ file: 'a.csv' }, { collection: 'import-jobs' });
```

Collection names always come from **your application code** (`collection` or `collections` in the constructor), so each app owns its own table names.

## Jobs collection (job metadata)

If you want a dedicated collection for **job-level metadata** (separate from activity rows), you must **wire it in** when constructing `Activix` by adding it to the constructor’s **`collections`** array (recommended name: **`jobs`**) with **primary key `jobId`**, and set **`jobsCollection`** to that same name (or pass `{ collection: 'jobs' }` on every job helper call). Activix does not assume a default jobs collection name.

Then use `startJob()` / `endJob()`:

```typescript
import { Activix } from '@x12i/activix';

const ax = new Activix({
  mongoUri: process.env.MONGO_URI!,
  collections: [
    { name: 'my-service-activities' }, // your activity stream(s)
    { name: 'jobs', primaryKey: 'jobId' }, // job metadata
  ],
  defaultCollection: 'my-service-activities',
  jobsCollection: 'jobs',
});

await ax.init();

await ax.startJob({
  jobId: 'job-123',
  description: 'Import customers from CRM',
  startedAt: Date.now(),
  identityObject: {
    source: {
      sourceType: 'mongo',
      identifier: { property: '_id', value: '66f0...' },
      objectType: 'customer',
    },
  },
  // optional correlation envelope (stored under the collection’s configured `runContextField`)
  runContext: { sessionId: '...', jobId: 'job-123' },
});

await ax.endJob({ jobId: 'job-123', endedAt: Date.now() });
```

Fetch job metadata or list/search recent jobs:

```typescript
const job = await ax.getJob('job-123'); // ActivixJobRecord | null
const jobs = await ax.listJobs({ limit: 50, searchText: 'customers' });
```

Fetch job metadata + all activity rows for that job (across every configured activity collection):

```typescript
const bundle = await ax.getJobBundle({ jobId: 'job-123' });
// bundle.job uses constructor jobsCollection (or pass { jobsCollection: 'jobs' } here)
// bundle.activities -> activity rows matched by runContext.jobId across all collections
```

Types exported by this package:

- `ActivixJobRecord` — `{ jobId, description, startedAt, endedAt?, status: 'in-progress' | 'ended', identityObject, runContext? }`
- `ActivixJobStatus`

## Collection legend / registry (`activix-collections`)

When Activix **creates** the store (you do not pass a custom `store`), it registers **`activix-collections`** automatically if it is not already in your **`collections`** list (**primary key `collectionName`**). After **`await init()`**, it inserts **one legend document per configured collection** (including the registry collection itself) when a row is missing. Rows use **`diagnostics.owner`** (recommended: npm package id) or **`collectionRegistry.owner`** as the owner stamp and a short auto-generated **`about`** line (override the template via **`collectionRegistry.aboutTemplate`**; use **`{name}`** as the collection-name placeholder).

Set **`collectionRegistry: false`** to disable both behaviors (used by the **`activix`** CLI). Custom **`store`** integrators must define the registry collection on the store and on Activix themselves if they want the same behavior.

If you need **rich** legend metadata (**`friendlyName`**, **`tags`**, custom **`about`**) for specific collections, list those names in **`collectionRegistry.skipAutoInsertForCollections`** and call **`initializeCollection(...)`** after **`init()`** for each — **`initializeCollection`** is insert-only and does nothing when a row already exists.

```typescript
const ax = new Activix({
  mongoUri: process.env.MONGO_URI!,
  diagnostics: { owner: '@my-scope/my-service', component: 'worker' },
  collections: [
    { name: 'my-service-activities' },
    { name: 'jobs', primaryKey: 'jobId' },
  ],
  defaultCollection: 'my-service-activities',
  jobsCollection: 'jobs',
  collectionRegistry: {
    skipAutoInsertForCollections: ['jobs'],
  },
});

await ax.init();

await ax.initializeCollection({
  collectionName: 'jobs',
  friendlyName: 'Jobs',
  tags: ['jobs'],
  about: 'Job metadata records (one doc per jobId)',
  owner: { package: '@my-scope/my-service', component: 'worker' },
});
```

Use `tags` to control whether callers query **ALL** activity collections or just a subset:

- **ALL (activities)**: tag every activity stream with `activities`, then use `listActivityCollections()` (this returns *only* legends tagged `activities`).
- **Tag subsets**: add tags like `['activities','ai','gateway']` and filter by tags (still always constrained to `activities`).

Example (Activities UI selects only activity streams, optionally narrowed):

```typescript
const activityCollections = await ax.listActivityCollections();
// Narrow to AI gateway activity streams only:
const gatewayCollections = await ax.listActivityCollections({ tags: ['gateway'] });
// Or search by legend text:
const searched = await ax.listActivityCollections({ searchText: 'gateway' });
```

Fetch legend data and configured collections:

```typescript
const configured = ax.listConfiguredCollections(); // [{ name, primaryKey }, ...]
const legend = await ax.getCollectionLegend('jobs');
const legends = await ax.listCollectionLegends({ limit: 100, searchText: 'job', tags: ['jobs'] });
```

One-time backfill for an existing database (creates missing legend rows only):

```bash
npm run legend:backfill
```

Types exported by this package:

- `ActivixCollectionLegendRecord`
- `ActivixCollectionLegendOwner`
- `ActivixCollectionTrackingState` — `'track' | 'off'`

### Collection tracking state — who saves what?

Each row in **`activix-collections`** describes one MongoDB collection (name, owner, `about`, …) and may include **`state`**, which controls whether Activix **persists activity rows** for that collection.

```
Integrator package                    Activix                         MongoDB
─────────────────                    ───────                         ───────
startRecord / completeRecord /  →    reads legend.state (cached)  →  insert/update
failRecord / patchRecord / …         track → write                   (or skip when off)
                                     off   → skip write, same API
```

#### Rule for integrator packages

**Always call Activix** on every operation (`startRecord`, `completeRecord`, `failRecord`, `patchRecord`, `markInProgress`, `startJob`, `endJob`, …). **Do not** read `state` or branch in application code — let Activix decide whether the row reaches Mongo. When `state` is **`off`**, the same methods still run and return the same result shapes; only storage is skipped (in-memory for the current process).

#### Legend `state` values

| Value | Meaning |
|-------|---------|
| **`track`** (default) | Activix persists activity rows for that collection |
| **`off`** | Activix skips database writes for that collection |

New legend rows get **`state: 'track'`** on insert when Activix creates them. **Legacy rows without `state`** in Mongo are treated as **`track`** (same as missing/`null`/unknown values).

#### How Activix reads `state` (cache + TTL)

Before each write, Activix resolves the collection's legend **`state`**. Results are **cached per collection** so Activix does not query **`activix-collections`** on every `startRecord`. The cache is refreshed when:

| Trigger | When it applies |
|---------|-----------------|
| **TTL expiry** | Default **600 s** since the last read for that collection |
| **`setCollectionTrackingState()`** | Immediately after a programmatic toggle (updates DB + cache) |
| **`refreshCollectionTrackingStates()`** | When you call it after an **external** change (Mongo UI, another service) |

TTL resolution (first match wins):

1. **`collectionTrackingStateRefreshIntervalSec`** — Activix constructor option (seconds)
2. **`ACTIVIX_COLLECTION_TRACKING_STATE_TTL_SEC`** — environment (seconds)
3. **`600`**

Set TTL to **`0`** to re-read legend `state` on every write (no cache). Helper: `resolveActivixCollectionTrackingStateTtlSec()`.

#### Changing `state`

**Programmatic (recommended for ops / studio):**

```typescript
await ax.setCollectionTrackingState('ai-activities', 'off');
// Integrators keep calling startRecord / completeRecord — nothing is written to Mongo
await ax.setCollectionTrackingState('ai-activities', 'track');
```

**At registration** (insert-only; ignored if the legend row already exists):

```typescript
await ax.initializeCollection({
  collectionName: 'my-service-activities',
  about: 'Workflow activity stream',
  owner: { package: '@my-scope/my-service' },
  state: 'track',
});
```

**External edit** (Mongo UI, migration script, another Activix instance) — tell **this** instance to re-read:

```typescript
// One collection
const states = await ax.refreshCollectionTrackingStates({ collection: 'ai-activities' });
// states['ai-activities'] === 'track' | 'off'

// Every configured activity collection (excludes the legend registry itself)
const all = await ax.refreshCollectionTrackingStates();
```

Without `refreshCollectionTrackingStates()`, external changes take effect only after the **TTL** (default 10 minutes) or process restart.

#### Component-level manager (Graphs Studio / runtime objects)

For operator UIs that toggle tracking **per runtime package** before a graph run, use **`createActivixTrackingManager(activix)`** and expose it as **`runtimeObjects.activixTrackingManager`** (see [`.docs/runtime-observability-querying.md`](.docs/runtime-observability-querying.md)).

```typescript
import { createActivixTrackingManager, composeActivixTrackingManagers } from '@x12i/activix';

const activixTrackingManager = createActivixTrackingManager(ax);

const targets = await activixTrackingManager.listTrackingTargets();
// componentId from legend owner.component ?? owner.package

await activixTrackingManager.setTrackingState({
  componentId: '@exellix/graph-engine',
  state: 'off',
});
```

Parent packages compose child managers with **`composeActivixTrackingManagers([...])`** so each package keeps ownership of its collections. Types: **`ActivixTrackingManager`**, **`ActivixTrackingTarget`**, **`PackageRuntimeObjects`** (`@x12i/activix-contracts`).

## Unique indexes and nulls

MongoDB unique indexes treat **`null` like any other value**. If a field (e.g. `activityId`) is indexed unique, do not persist `null` for it. Activix now generates the key before insert and writes it to both the configured primary-key field and root `activityId` so writes do not reach Mongo with `activityId: null`. **`runContext.sessionId`** is set only when you pass it (in `runContext` or as top-level `sessionId`); otherwise it is omitted. Prefer **sparse** unique indexes for optional fields, or omit the field instead of setting `null`.

## Database name

When Activix creates the store for you, the MongoDB **database** name is resolved from environment only:

1. `process.env.ACTIVIX_DB_NAME`
2. `process.env.MONGO_AI_LOGS_DB`
3. `process.env.MONGO_LOGS_DB`
4. `process.env.MONGO_DB`
5. the string `activitix`

You can use the same resolution in your own code with `resolveActivixLogsDatabaseName()` (exported from this package).

Collection tracking **`state`** TTL uses **`ACTIVIX_COLLECTION_TRACKING_STATE_TTL_SEC`** (default **600**). See [Collection tracking state](#collection-tracking-state--who-saves-what).

### MongoDB URI for connection tests and default (automatic) `init()`

For **`testActivixMongoConnection()`** and for the **automatic** path inside **`await init()`** when you omit `mongoUri`, the URI is resolved by **`resolveActivixMongoUriFromEnv()`**:

1. `process.env.MONGO_LOGS_URI`  
2. then `process.env.MONGO_URI`

There is no silent fallback to a default URI: if both are unset and you do not pass `mongoUri`, the health check returns **`{ ok: false, reason: '…' }`** and **automatic** / default **`init()`** falls back to **local playground** storage.

## MongoDB connection check (single attempt)

Use **`testActivixMongoConnection(options?)`** to verify connectivity **once** (no retry loop): open a client, **`ping`** the target database, close. Returns:

- **`{ ok: true }`** if the ping succeeds  
- **`{ ok: false, reason: string }`** if the URI is missing, the driver fails to load, or connect/ping throws  

Options (all optional):

| Option | Role |
|--------|------|
| `mongoUri` | Connection string; else env via `resolveActivixMongoUriFromEnv()` |
| `mongoDb` | Database name for `ping`; else `resolveActivixLogsDatabaseName()` |
| `serverSelectionTimeoutMS` | Server selection / connect timeout for that single attempt (default **5000**) |

This package depends on the **`mongodb`** driver for this probe (also used indirectly by the xronox stack).

```typescript
import {
  testActivixMongoConnection,
  resolveActivixMongoUriFromEnv,
  resolveActivixLogsDatabaseName,
} from '@x12i/activix';

const check = await testActivixMongoConnection({
  mongoUri: process.env.MONGO_URI,
  mongoDb: resolveActivixLogsDatabaseName(),
});

if (!check.ok) {
  console.error('MongoDB not usable:', check.reason);
}
```

## Activity persistence verification (no `mongodb` import in your app)

For smoke tests, CI, and operators, use Activix-owned helpers so gateway and application repos do not depend on the **`mongodb`** driver directly. Resolution matches Activix for URI via **`resolveActivixMongoUriFromEnv()`** and database via **`resolveActivixLogsDatabaseName()`**. **`resolveActivixPersistenceTarget`** and the snapshot helpers require a **`collection`** string in options (package-owned name in code). **`resolveActivixActivitiesCollectionName()`** is for CLI/smoke scripts only: it reads **`ACTIVIX_COLLECTION`** or **`MONGO_LOGS_COLLECTION`** and **throws** if neither is set (no default name).

To trace **live writes** through Activix → xronox-store → xronox, enable diagnostics **per layer** (separate env vars); see [Diagnostic logging down the stack](#diagnostic-logging-down-the-stack) and [`.docs/logging-stack.md`](./.docs/logging-stack.md).

**API** (read-only; opens a short-lived client per call):

| Export | Role |
|--------|------|
| **`countActivixActivitiesInMongo(options)`** | Document count ( **`options.collection`** required ) |
| **`getActivixLatestActivitySummariesInMongo(limit, options)`** | Latest **`limit`** rows by **`startTime`** descending |
| **`getActivixActivityPersistenceSnapshotInMongo(limit, options)`** | **`{ count, latest }`** in one connection |
| **`summarizeActivixActivityForDiagnostics(doc, options?)`** | Pure helper: same summary shape from any in-memory / exported document |
| **`resolveActivixPersistenceTarget(options)`** | Resolved URI, DB name, **`collection`**, and field names (**`collection`** required) |

By default, counts and queries **exclude** tombstoned rows (**`purgedAt`** non-null), consistent with typical Activix visibility. Pass **`{ purgeAtField: false }`** to include them.

Each summary includes **`activityId`**, **`runContext.jobId`** when present, **`status`**, **`provider`**, **`model`** (prefers **`metadata.modelUsed`** → **`metadata.model`** → **`config.model`** → **`outer.metadata.model`**), **`maxTokens`**, token usage flags, **`costUsd`**, **`hasResponse`**, **`startTime`**, **`endTime`**. Override field names with the same options Activix uses (**`runContextField`**, **`primaryKeyField`**) if your collection config differs from defaults. Legacy rows with nested **`structure.outer`** are still summarized when **`outer`** is absent at the root.

For studio-oriented exports, use **`buildActivityTraceBlock`** / directory export (below) — same resolution rules, stable **`trace.observability`** shape.

```typescript
import { getActivixActivityPersistenceSnapshotInMongo } from '@x12i/activix';

const { count, latest } = await getActivixActivityPersistenceSnapshotInMongo(5, {
  collection: 'my-package-activities',
});
console.log({ count, latest });
```

**CLI** (after `npm install`; binary **`activix`**):

```bash
npx activix verify --collection my-activities
npx activix verify --limit 10 --json --collection my-activities
# Or set ACTIVIX_COLLECTION or MONGO_LOGS_COLLECTION instead of --collection
npx activix verify --mongo-uri "$MONGO_URI" --include-tombstoned --collection my-activities
```

## Directory export (studio / support bundles)

Write JSON snapshots under a folder (playground-style layout):

```typescript
const { targetDir, totalRecords, byCollection } = await ax.exportAllRecordsToDirectory('./export-out');

// Optional: only recent rows
await ax.exportRecordsWithinKeepLastHoursToDirectory('./export-recent', 24);
```

Layout:

- **`export-manifest.json`** — counts, `exportedAt`, optional `keepLastHours`
- **`collections/<collectionName>/records/<activityId>.json`** — one file per row

By default each file includes the **persisted document** plus a normalized **`trace`** block for reporting (does not change Mongo):

```json
{
  "activityId": "act-node-abc",
  "runContext": { "jobId": "job-1", "graphId": "g1", "nodeId": "node-a" },
  "outer": { "input": {}, "output": {}, "metadata": { "type": "task" } },
  "trace": {
    "activixCollection": "graph-engine-activities",
    "activityKind": "node:start",
    "activityType": "task",
    "correlation": { "sessionId": "job-1", "jobId": "job-1", "graphId": "g1", "nodeId": "node-a" },
    "join": { "activityId": "act-node-abc" },
    "observability": { "modelUsed": "openai/gpt-4o", "synthesisEnabled": false }
  }
}
```

- **`traceShape: false`** — raw rows only (no `trace` block)
- **`mergeCache`**, **`includeHidden`** — same as **`findRecords`**

Spec: [`.docs/activity-trace-schema.md`](./.docs/activity-trace-schema.md). CLI: `npx activix export …` (see `npx activix --help`).

## Storage modes

How to set **`storageMode`** (all optional except you must pick a valid combination with **`store`**):

| Value | When to use |
|-------|-------------|
| *(omit)* | **Context-aware default (v9+):** URI present (option or env) → **`database`**; no URI → **`local`** playground. No silent fallback when Mongo is configured but down. |
| **`'automatic'`** | **Explicit dev/CI opt-in.** On **`await init()`**, run **`testActivixMongoConnection()`** once; Mongo reachable → **`XronoxStore`**; else → playground (**`'local'`**). **`storageBackend`** is **`'pending'`** until **`init()`** finishes. |
| **`'database'`** | **Always MongoDB** — no probe; construct **`XronoxStore`** immediately. Same as omitting mode when a URI is available. |
| **`'local'`** | **Always playground** folder — no MongoDB (see **`playground`** options). |

**Custom `store`:** **`storageMode`** is ignored for picking the implementation; Activix uses your store. **`storageBackend`** is **`'local'`** for **`ActivixPlaygroundStore`** / **`ActivixMemoryStore`**, otherwise **`'database'`**. Do not pass **`storageMode: 'local'`** together with a custom **`store`**.

| `storageBackend` | Meaning |
|------------------|---------|
| **`'pending'`** | Automatic mode, **`init()`** not completed yet. |
| **`'database'`** | MongoDB (`XronoxStore` or compatible custom store). |
| **`'local'`** | Playground folder or in-memory custom store. |

### `Activix.create()` (construct + `init`)

```typescript
import { Activix } from '@x12i/activix';

// Same as: const ax = new Activix({ ... }); await ax.init(); return ax;
const ax = await Activix.create({
  mongoUri: process.env.MONGO_URI,
  collection: 'my-service-activities',
  logger: console,
  playground: { outputDir: 'playground', runId: 'my-run-id' },
});
// ax.storageBackend === 'database' or 'local' (never 'pending' here)
```

### Explicit `storageMode` examples

```typescript
// Dev / CI: probe once on init, fall back to playground when Mongo is down
new Activix({ storageMode: 'automatic', collection: 'activities', mongoUri: process.env.MONGO_URI });

// Production: omit storageMode when mongoUri (or env URI) is set — same as database
new Activix({ collection: 'activities', mongoUri: process.env.MONGO_URI! });

// Mongo only (explicit)
new Activix({ storageMode: 'database', collection: 'activities', mongoUri: process.env.MONGO_URI! });

// Playground only
new Activix({ storageMode: 'local', collection: 'activities', playground: { outputDir: 'playground' } });
```

### Repeated Mongo init logs across packages

Initialization is per **Activix instance**. In **`storageMode: 'automatic'`**, each instance performs:

1. one Mongo connectivity probe (`testActivixMongoConnection`)
2. one real store initialization (`store.init`)

If your architecture creates multiple Activix instances (for example different packages or workers), repeated probe/init logs are expected and do not mean one instance is double-initializing. Treat this as an optimization concern only when instance creation frequency becomes expensive or side effects appear.

You can pass optional diagnostics metadata to make logs self-identifying:

```ts
new Activix({
  collection: 'activities',
  mongoUri: process.env.MONGO_URI,
  diagnostics: {
    owner: '@woroces/worox-graph',
    component: 'ai-tasks-client',
    instanceLabel: 'graph-executor',
    workerId: process.pid.toString(),
  },
});
```

### Troubleshooting repeated init logs

1. Enable Activix and store diagnostics (`ENABLE_ACTIVIX_LOGXER=true`, `ACTIVIX_LOGS_LEVEL=debug`, `XRONOX_STORE_LOG=1`).
2. Group logs by `activixInstanceId`.
3. Verify each instance shows one `activix.init.mongo_probe.start` + one `activix.init.store_init.start`.
4. If repeated logs share the same `activixInstanceId` with rising `initCallCount`, inspect caller lifecycle.
5. If repeated logs have different `activixInstanceId` values, behavior is expected for multi-instance architecture.

### Local playground layout

With **`storageMode: 'local'`** (or after automatic fallback), Activix uses **`ActivixPlaygroundStore`** under **`playground.outputDir`** (default **`"playground"`**). Optional **`playground.runId`** appears in the Markdown header.

Typical artifacts:

- **`report.md`** — human-readable activity timeline (also written on **`close()`**)  
- **`activities.jsonl`** — one JSON line per persisted mutation  
- **`collections/<name>/records/<pk>.json`** — snapshot per primary key  
- **`meta.json`** — metadata including dispatch sequence for numbered sidecars  
- **`NN-<activityKey>-request.json` / `-response.json`** — when rows carry playground work shapes (`work:request` / `fullRequest`, `work:response` / `fullOutput`, etc.)

Helpers on **`Activix`** when using built-in playground storage:

- **`getPlaygroundStore()`** — `ActivixPlaygroundStore | null`  
- **`getPlaygroundMarkdown()`** — current timeline as Markdown, or `null`  
- **`writePlaygroundReport(targetPath?)`** — write **`report.md`** (default: under the playground root); returns absolute path or `null`  

For in-memory-only testing without the filesystem, you can still construct a custom **`ActivixMemoryStore`** and pass **`store`** (see [Pre-built store](#pre-built-store)).

## Pre-built store

If you already have a `XronoxStore`, pass `store` (and usually `skipStoreInit: true` if it is already initialized). **Use the same `primaryKey` / `primaryKeyPrefix` in both the store’s `collections` and Activix’s `collection` / `collections` config** so inserts and reads agree.

For soft-purge behavior to match Activix, each store collection must set **`visibility`** with **`field`** equal to Activix’s **`purgeAtField`** (default **`purgedAt`**) and the same mode (default **`hiddenIfNonNull`**). Otherwise Activix will still write the purge timestamp, but **`getRecord` / `findRecords`** will not hide those rows unless the store applies visibility.

## Documentation

| Topic | Doc |
|-------|-----|
| **I/O tiers (`outer` / `inner`, metadata vs config)** | [`.docs/activity-structure.md`](./.docs/activity-structure.md) |
| **Studio export, `trace` block, graph ↔ ai-tasks joins** | [`.docs/activity-trace-schema.md`](./.docs/activity-trace-schema.md) |
| **`runContext` layers, `masterSkillActivityId`, graphs** | [`.docs/run-context-object.md`](./.docs/run-context-object.md) |
| **`sessionId` ownership** | [`.docs/session-id-usage.md`](./.docs/session-id-usage.md) |
| **Debug / `getJobActivities`** | [`.docs/runtime-observability-querying.md`](./.docs/runtime-observability-querying.md) |
| **Contributor / CI** | [`.docs/README.md`](./.docs/README.md) |
| **Full specification** | [`.docs/activix.spec.md`](./.docs/activix.spec.md) |
| **Monorepo consumers (Activix 8 + Optimixer 2)** | [../../.docs/MIGRATION-CONSUMERS.md](../../.docs/MIGRATION-CONSUMERS.md) |
| **Optimixer (runtime optimization)** | [../optimixer/README.md](../optimixer/README.md) |

## Scripts

- `npm run build` — ESM + CJS outputs  
- `npm test` — main lifecycle integration tests (MongoDB + `.env`)  
- `npm run test:single` — single-`collection` constructor mode  
- `npm run test:options` — database name resolution and constructor validation (no MongoDB)  
- `npm run test:local` — playground **`storageMode: 'local'`** (temp dir; no MongoDB)  
- `npm run test:patch` / `test:stale` / `test:gaps` / `test:spy` / `test:purge` — focused integration suites  
- `npm run test:all` — everything above  

Tests compile to **`.tests-out/`** as ESM and import **`dist/index.js`**. See [`.docs/build-and-test.md`](./.docs/build-and-test.md).

## CI

After this tree is the Git repo root, GitHub Actions runs [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) (build; enable integration tests when `NODE_AUTH_TOKEN`, Mongo secrets, and a DB are configured).

## License

Athenix License
