Data Models

Focus Deep-Dive

Core persistence type system for OpenClaw’s infrastructure layer

Every value that OpenClaw writes to disk or holds in memory conforms to one of these types. Understanding them is the fastest way to navigate the codebase.

📚 What is this & why does it matter?

Data models define the shape of everything persisted to disk or held in memory across the store layer. Getting the types right prevents runtime errors, makes the codebase self-documenting, and ensures that every reader of a JSON file or SQLite row knows exactly what fields to expect and what invariants hold.

💡 Real-world analogy

Think of data models like architectural blueprints for a building. Every room (data field) has a purpose, every door (interface) connects rooms in specific ways, and every load-bearing wall (required field) must remain in place. Change a blueprint incorrectly and walls don’t align—rename a field without updating consumers and the app crashes at runtime.

Contents

Overview

Primary role

Define the canonical TypeScript interfaces and types for every persistent artifact in the store layer.

Main inputs

Raw JSON from disk, SQLite rows, environment variables, and in-memory state from upstream modules.

Key decisions

Versioned schemas (version: 1) enable forward-compatible migrations. Optional fields use ? to distinguish missing from empty.

Outputs / side effects

Typed objects consumed by query builders, migration scripts, delivery queues, and session managers throughout the infra layer.

erDiagram
    DeviceAuthStore ||--o{ DeviceAuthEntry : contains
    DeviceAuthStore {
        number version
        string deviceId
        record tokens
    }
    DeviceAuthEntry {
        string tokenKey
        unknown tokenValue
    }
    QueuedDeliveryPayload ||--|{ QueuedDelivery : extends
    QueuedDeliveryPayload {
        string channel
        string to
        string accountId
        array payloads
        string threadId
        string replyToId
        boolean bestEffort
        boolean gifPlayback
        boolean forceDocument
        boolean silent
        boolean mirror
    }
    QueuedDelivery {
        string id
        number enqueuedAt
        number retryCount
        number lastAttemptAt
        string lastError
    }
    SessionEntry {
        string sessionId
        number updatedAt
        string sessionFile
        string channel
        string chatType
        string execHost
        string modelOverride
        number inputTokens
        number outputTokens
        string spawnedBy
        number spawnDepth
        string subagentRole
    }
    ExecApprovalsFile ||--o{ ExecApprovalRequest : tracks
    ExecApprovalsFile {
        number version
        string socket
        object defaults
        object agents
    }
    ExecApprovalRequest {
        string id
        object request
        number createdAtMs
        number expiresAtMs
    }
    ExecApprovalResolved {
        string id
        string decision
        string resolvedBy
        number ts
    }

The ER diagram above shows how the six core model families relate. DeviceAuthStore aggregates token entries, QueuedDelivery extends the payload envelope with retry metadata, and the approvals file tracks pending and resolved execution requests.

Decision Points

DecisionWhat controls itWhy it matters
Schema version checkversion field on DeviceAuthStore / ExecApprovalsFileAllows the migration layer to detect stale formats and upgrade them before any field access occurs.
Optional vs. required fieldsTypeScript ? modifier on interface membersDistinguishes “not yet set” from “explicitly empty,” preventing null-pointer bugs in downstream consumers.
Retry gatingretryCount and lastAttemptAt on QueuedDeliveryDelivery queue uses these to implement exponential back-off without an external scheduler.
Approval expiryexpiresAtMs on ExecApprovalRequestStale approval requests are garbage-collected, preventing orphan records from accumulating.

How It Works

Every model in the store layer follows the same lifecycle: define a TypeScript interface, export it, and consume it in exactly one reader function and one writer function. This ensures a single source of truth for every field name and type.

Device Auth Store

The DeviceAuthStore interface wraps all device-level credentials into a single versioned record. The tokens map uses Record<string, unknown> to allow heterogeneous token formats across providers.

/** Versioned device credential schema */
interface DeviceAuthStore {
  version: 1;
  deviceId: string;
  tokens: Record<string, unknown>;
}

/** Individual token entry with CRUD helpers */
interface DeviceAuthEntry {
  tokenKey: string;
  tokenValue: unknown;
}

function loadDeviceAuthToken(
  store: DeviceAuthStore,
  key: string
): DeviceAuthEntry | undefined {
  const value = store.tokens[key];
  if (value === undefined) return undefined;
  return { tokenKey: key, tokenValue: value };
}

function storeDeviceAuthToken(
  store: DeviceAuthStore,
  key: string,
  value: unknown
): void {
  store.tokens[key] = value;
}

function clearDeviceAuthToken(
  store: DeviceAuthStore,
  key: string
): boolean {
  if (!(key in store.tokens)) return false;
  delete store.tokens[key];
  return true;
}
Key detail: The version: 1 literal type ensures that code handling a DeviceAuthStore object will not compile if handed a future schema version without an explicit migration step.

Delivery Queue Models

The delivery system uses a two-layer type hierarchy. QueuedDeliveryPayload captures the intent (what to deliver, where, and how), while QueuedDelivery extends it with operational metadata used by the retry engine.

interface QueuedDeliveryPayload {
  channel: string;
  to: string;
  accountId?: string;
  payloads: unknown[];
  threadId?: string;
  replyToId?: string;
  bestEffort?: boolean;
  gifPlayback?: boolean;
  forceDocument?: boolean;
  silent?: boolean;
  mirror?: boolean;
}

interface QueuedDelivery extends QueuedDeliveryPayload {
  id: string;
  enqueuedAt: number;
  retryCount: number;
  lastAttemptAt?: number;
  lastError?: string;
}
Retry semantics: When retryCount exceeds the configured maximum, the delivery is moved to a dead-letter directory rather than deleted, preserving the payload for manual inspection.

Session Entry

Each active or historical session is represented by a SessionEntry record. The store layer persists these to a JSON5-formatted session store file, keyed by sessionId.

interface SessionEntry {
  sessionId: string;
  updatedAt: number;
  sessionFile?: string;
  channel?: string;
  chatType?: string;
  execHost?: string;
  modelOverride?: string;
  inputTokens?: number;
  outputTokens?: number;
  spawnedBy?: string;
  spawnDepth?: number;
  subagentRole?: string;
}

Execution Approvals

The approvals subsystem tracks pending and resolved execution requests. ExecApprovalsFile is the top-level container persisted to disk, while request and resolution records are indexed by id.

Utility Types

Two small but critical utility types round out the model layer:

TypeFieldsPurpose
ArchiveExtractLimitsmaxArchiveBytes?, maxEntries?, maxExtractedBytes?, maxEntryBytes?Safety constraints passed to the archive extraction pipeline to prevent zip-bomb attacks.
SafeOpenResulthandle: FileHandle, realPath: string, stat: StatsReturned by openFileWithinRoot() after path validation, symlink rejection, and size checks.
SystemPresencehost?, ip?, version?, platform?, text, tsBroadcast record used by the presence heartbeat system to announce node status.
HeartbeatEventPayloadts, status, to?, channel?, indicatorType?Event tracking payload emitted on every heartbeat tick for monitoring dashboards.

Validation Pipeline

flowchart TD
    A["Raw JSON from disk"] --> B["JSON.parse / JSON5.parse"]
    B --> C{"version field present?"}
    C -- Yes --> D["Match version to schema"]
    C -- No --> E["Reject: invalid format"]
    D --> F{"Schema matches current?"}
    F -- Yes --> G["Zod validation pass"]
    F -- No --> H["Run migration pipeline"]
    H --> G
    G --> I{"All required fields valid?"}
    I -- Yes --> J["Return typed object"]
    I -- No --> K["Return validation error"]
    E --> K

The validation pipeline runs every time a model is loaded from disk. Raw JSON is parsed, version-checked, optionally migrated, then validated through a Zod schema. Only fully validated objects are returned to callers; everything else surfaces as a structured error.

Design benefit: Because validation is centralized in the load path, no downstream consumer ever needs to check for missing or malformed fields—the type system guarantees correctness after the load boundary.

Annotated Code Walkthrough

Walk through how the approval models are defined and consumed end-to-end.

Define the top-level approvals container with a version discriminator and optional configuration sections:

interface ExecApprovalsFile {
  version: 1;
  socket?: string;
  defaults?: Record<string, unknown>;
  agents?: Record<string, unknown>;
}

interface ExecApprovalRequest {
  id: string;
  request: ExecApprovalRequestPayload;
  createdAtMs: number;
  expiresAtMs: number;
}

interface ExecApprovalResolved {
  id: string;
  decision: "approved" | "denied";
  resolvedBy?: string;
  ts: number;
}

The load function reads the approvals file, validates its version, and returns a typed result or null:

async function loadApprovalsFile(
  filePath: string
): Promise<ExecApprovalsFile | null> {
  const raw = await readJsonFile<unknown>(filePath);
  if (!raw || typeof raw !== "object") return null;
  const obj = raw as Record<string, unknown>;
  if (obj.version !== 1) return null;
  return obj as ExecApprovalsFile;
}
Guard clause: The version !== 1 check ensures that if a future version introduces breaking changes, the current code fails fast rather than silently misinterpreting fields.

Presence and heartbeat records are emitted by the broadcast system and consumed by monitoring dashboards:

interface SystemPresence {
  host?: string;
  ip?: string;
  version?: string;
  platform?: string;
  text: string;
  ts: number;
}

interface HeartbeatEventPayload {
  ts: number;
  status: string;
  to?: string;
  channel?: string;
  indicatorType?: string;
}

Edge Cases & Error Handling

ScenarioBehaviorCode Location
Missing version fieldLoader returns null; caller logs warning and creates a fresh default storestore/device-auth.ts:42
Unknown token key in DeviceAuthStoreloadDeviceAuthToken() returns undefined; no error thrownstore/device-auth.ts:58
retryCount exceeds maxDelivery moved to dead-letter queue with lastError preservedstore/delivery-queue.ts:91
Expired approval requestGarbage collector removes records where Date.now() > expiresAtMsstore/exec-approvals.ts:115
Archive exceeds maxArchiveBytesExtraction aborted immediately; partial files cleaned upstore/archive.ts:67

Configuration

Knob / ParameterDefaultDescription
maxArchiveBytes100 * 1024 * 1024Upper bound on compressed archive size before extraction is refused.
maxEntries10000Maximum number of entries to extract from a single archive.
maxExtractedBytes500 * 1024 * 1024Cumulative size limit for all extracted content.
maxEntryBytes50 * 1024 * 1024Size limit for any single extracted file.

See also:

Directory Map

Source files for this topic
src/infra/store/
├── device-auth.ts             ← DeviceAuthStore & DeviceAuthEntry types + CRUD helpers
├── delivery-queue.ts          ← QueuedDeliveryPayload, QueuedDelivery, enqueue/ack/fail
├── session-store.ts           ← SessionEntry type & JSON5 session store I/O
├── exec-approvals.ts          ← ExecApprovalsFile, ExecApprovalRequest, ExecApprovalResolved
├── archive.ts                 ← ArchiveExtractLimits type & extraction pipeline
├── fs-safe.ts                 ← SafeOpenResult type & validated file handle helpers
├── presence.ts                ← SystemPresence broadcast record
└── heartbeat.ts               ← HeartbeatEventPayload event tracking type