Heddle owns conversation-session semantics: messages, turns, leases, queued
prompts, compaction metadata, and optimistic mutation coordination. A host owns
where those records live and how an authenticated product user is scoped to
them. Compacted raw transcripts and rolling summaries use a separate
ChatArchiveRepository because their append-only lifecycle is different from
revisioned active-session records.
Use the default JSON adapters for one-machine/local-first products. For hosted
or multi-process products, inject both repositories through the
persistence.conversations capability so the completed-conversation boundary
cannot be mistaken for a session-only configuration.
For the exact boundary between local durability, completed-conversation durability, and durable in-flight execution, see the durability support matrix.
createConversationEngine uses FileChatSessionRepository by default. Point
stateRoot at a durable application-data directory and Heddle creates the
catalog and immutable revision files beneath it:
import { createConversationEngine } from '@heddleagent/runtime'
const engine = createConversationEngine({
workspaceRoot,
stateRoot: '/var/lib/my-agent',
model: 'gpt-5.4',
})To choose the catalog location explicitly:
import { join } from 'node:path'
import { createConversationEngine } from '@heddleagent/runtime'
const sessionStoragePath = join(dataDir, 'agent-sessions.catalog.json')
const engine = createConversationEngine({
workspaceRoot,
stateRoot: dataDir,
model: 'gpt-5.4',
sessionStoragePath,
})The separate sessionRepository and archiveRepository options remain for
compatibility. New hosted integrations should use the complete conversation
capability shown below.
The file adapter serializes writers across processes and uses immutable session bodies plus atomic catalog replacement. Keep the directory on a filesystem with normal local rename/locking semantics; do not put it on an eventually consistent object-store mount.
The runnable local JSON storage example
proves fresh-engine recovery and a stopped-writer backup/restore without a
model credential. In production, mount the complete stateRoot on persistent
storage, stop writers or take one atomic filesystem snapshot, and back up and
restore the whole directory. Copying only the catalog or only archive files can
produce an incomplete conversation. This posture is for one durable host; use
paired remote repositories when requests can land on different replicas.
The repository contract is record-oriented. It does not ask a database adapter to load and rewrite the entire session collection:
import {
createConversationEngine,
type ChatSessionRepository,
} from '@heddleagent/runtime'
const sessionRepository: ChatSessionRepository = {
list: async (input) => listSessionPage(input),
read: async (sessionId) => readSessionRecord(sessionId),
create: async (session) => createSessionRecord(session),
update: async (input) => compareAndSwapSession(input),
delete: async (input) => compareAndSwapSessionDelete(input),
}For a hosted service, session storage alone is not complete durability. Build the companion archive repository from the same trusted server-side identity scope, then inject the pair as one capability:
import {
ChatArchivePersistenceCodec,
createConversationEngine,
type ChatArchiveRepository,
} from '@heddleagent/runtime'
const archiveRepository: ChatArchiveRepository = {
loadManifest: async (sessionId) => {
const stored = await readArchiveManifest(sessionId)
return stored
? ChatArchivePersistenceCodec.parseManifest(stored, sessionId)
: ChatArchivePersistenceCodec.emptyManifest(sessionId)
},
readSummary: async (summaryLocator) => readArchiveSummary(summaryLocator),
append: async (input) => database.transaction(async (tx) => {
const currentValue = await tx.readArchiveManifestForUpdate(input.sessionId)
const current = currentValue
? ChatArchivePersistenceCodec.parseManifest(currentValue, input.sessionId)
: ChatArchivePersistenceCodec.emptyManifest(input.sessionId)
const archive = {
...input.archive,
path: `db://conversation-archives/${input.archive.id}/messages`,
summaryPath: `db://conversation-archives/${input.archive.id}/summary`,
}
const manifest = ChatArchivePersistenceCodec.appendArchive(current, archive)
await tx.insertArchive({
sessionId: input.sessionId,
archive,
messages: input.messages,
summary: input.summary,
})
await tx.writeArchiveManifest(input.sessionId, manifest)
return { archive, manifest }
}),
}
const engine = createConversationEngine({
workspaceRoot,
stateRoot,
model: 'gpt-5.4',
persistence: {
conversations: {
sessions: sessionRepository,
archives: archiveRepository,
},
},
})The engine exposes the resolved repositories and a non-certifying readiness
report at engine.persistence.conversations. To inspect a configuration before
constructing the engine:
import { ConversationPersistenceService } from '@heddleagent/runtime'
const readiness = ConversationPersistenceService.assess({
persistence: {
conversations: {
sessions: sessionRepository,
archives: archiveRepository,
},
},
})
if (!readiness.configurationComplete) {
throw new Error(readiness.issues.map((issue) => issue.message).join('\n'))
}
for (const check of readiness.requiredHostChecks) {
console.log(`${check.id}: ${check.description}`)
}This report confirms configuration shape and identifies the focused host checks needed for the selected durability level. It does not query the database or certify auth/RLS, scope binding, migrations, backup, load, or disaster recovery. Keep those checks in the host or maintained adapter.
append is the durability boundary: the exact messages, rolling summary, and
returned manifest must become visible in one transaction. If it rejects,
Heddle keeps the exact active transcript and does not persist compacted session
state that points at incomplete content. The legacy path and summaryPath
field names are repository-owned opaque locators; only the default file adapter
promises filesystem paths.
Remote adapters should not maintain a second validator or invent a cursor wire format. Heddle exports the database-neutral parts of the contract:
import {
ChatSessionCatalogPagination,
ChatSessionPersistenceCodec,
} from '@heddleagent/runtime'
// Fail loudly if JSON/JSONB cannot reconstruct one complete Heddle session.
const session = ChatSessionPersistenceCodec.parseRecord(row.session)
// Reuse the canonical browser-safe/indexed projection on create and update.
const catalog = ChatSessionPersistenceCodec.projectCatalogEntry(
session,
Number(row.revision),
)
ChatSessionCatalogPagination.validatePageLimit(input.limit)
const cursor = input.cursor
? ChatSessionCatalogPagination.decodeCursor(input.cursor)
: undefined
// After a query returns limit + 1 rows, encode the last included row.
const nextCursor = hasNextPage && lastIncluded
? ChatSessionCatalogPagination.encodeCursor(lastIncluded)
: undefineddecodeCursor(...) supplies (pinned, updatedAt, id) for the database
predicate shown below. Express that predicate and order in SQL; do not fetch an
entire tenant catalog merely to call the in-process comparator. Translate
database constraint/CAS failures to Heddle's exported repository errors, and
wrap record-validation failures only to add row/storage context—never continue
with a partial session.
Construct the adapter with trusted server-side identity/scope. Do not accept a
tenant or account ID from the browser and then trust it inside repository
methods. Heddle deliberately leaves product account IDs and row-level-security
fields out of ChatSession.
Use the product's existing PostgreSQL driver or ORM. A practical table stores the complete Heddle record as JSONB and duplicates only the catalog fields that need indexes:
create table agent_sessions (
tenant_id uuid not null,
id text not null,
revision bigint not null check (revision > 0),
session jsonb not null,
workspace_id text,
pinned boolean not null,
archived_at timestamptz,
updated_at timestamptz not null,
primary key (tenant_id, id)
);
create index agent_sessions_catalog_idx
on agent_sessions (
tenant_id,
pinned desc,
updated_at desc,
(id collate "C") asc
);create inserts revision 1 and translates a unique-key violation to
ChatSessionAlreadyExistsError.
update must compare and increment the revision atomically:
update agent_sessions
set session = $3,
revision = revision + 1,
workspace_id = $4,
pinned = $5,
archived_at = $6,
updated_at = $7
where tenant_id = $1
and id = $2
and revision = $8
returning session, revision;If no row is returned, query the current revision in the same transaction. A
missing row maps to undefined; a present row maps to
ChatSessionRevisionConflictError. delete follows the same expected-revision
rule. Do not silently overwrite or delete the newer record.
Catalog pages use this order:
order by pinned desc, updated_at desc, id collate "C" ascEncode the last row's (pinned, updated_at, id) as an opaque cursor. The next
page predicate must use the identical order:
where (
pinned < $cursor_pinned
or (pinned = $cursor_pinned and updated_at < $cursor_updated_at)
or (
pinned = $cursor_pinned
and updated_at = $cursor_updated_at
and id collate "C" > $cursor_id
)
)Apply tenant, workspace, and archive filters before the cursor predicate.
Keep archive content separate from the hot session row. One practical JSONB shape uses an immutable content row plus one locked manifest/head row:
create table agent_conversation_archives (
tenant_id uuid not null,
session_id text not null,
archive_id text not null,
archive_record jsonb not null,
messages jsonb not null,
summary text not null,
created_at timestamptz not null,
primary key (tenant_id, session_id, archive_id)
);
create table agent_conversation_archive_heads (
tenant_id uuid not null,
session_id text not null,
manifest jsonb not null,
updated_at timestamptz not null,
primary key (tenant_id, session_id)
);In append, create or lock the head row with SELECT ... FOR UPDATE, validate
its JSONB with ChatArchivePersistenceCodec.parseManifest, insert the immutable
archive row, and update the head in the same transaction. Enforce the composite
tenant/session key in every query. A Drizzle adapter can map the JSONB columns
to TypeScript shapes, but it must still run the Heddle codec when data crosses
the storage boundary; TypeScript generics do not validate stored JSON.
For large transcripts, replace messages jsonb with an object-store key. Write
the immutable object before committing the manifest transaction. A failed
transaction may leave an unreferenced object for a retention job to remove, but
the committed manifest must never reference a missing object.
The repository includes a complete
PostgreSQL + Drizzle example
with checked-in migrations, scope-bound session and archive repositories, the
public session conformance suite, concurrent archive verification, and recovery
through fresh connection pools and engine instances. Drizzle and pg are
example-only development dependencies; they are not Heddle runtime dependencies
or exported adapters.
Use that directory as a host implementation reference, then move the adapted files and migration ownership into your service. Do not import the example as a supported package or bypass your host's existing authentication, pool, and migration lifecycle.
Before using a custom adapter in production, run Heddle's reusable conformance suite in the adapter's own integration-test environment:
import { test } from 'node:test'
import {
ChatSessionRepositoryConformance,
type ChatSessionRepositoryConformanceHarness,
} from '@heddleagent/runtime'
const harness: ChatSessionRepositoryConformanceHarness = {
// Each call must return a new instance, already bound to this server-side
// test tenant/scope. Never trust a browser-supplied tenant identifier here.
createRepository: (scopeId) => createPostgresSessionRepository({ scopeId }),
cleanupScope: (scopeId) => deletePostgresTestScope(scopeId),
// Test infrastructure should damage the stored JSON/JSONB without deleting
// the row, so read(sessionId) still finds and rejects the addressable record.
corruptSessionRecord: ({ scopeId, sessionId }) =>
corruptPostgresSessionRecord({ scopeId, sessionId }),
}
for (const scenario of ChatSessionRepositoryConformance.createScenarios(harness)) {
test(scenario.name, scenario.run)
}The suite generates opaque UUID-shaped scopes, creates fresh repository instances when testing races and reopen behavior, and cleans every scope even when a scenario fails. It verifies:
- two writers updating the same revision cannot lose one another's changes;
- the adapter distinguishes missing, already-existing, and conflicting rows;
- cursor pages neither skip nor repeat rows when timestamps tie;
- archived/workspace filters are enforced within the authorized scope;
- a second engine/process can reopen a session and continue its messages, turns, queue, lease, and compaction state;
- storage errors reach the host as failures rather than being reported as a successful turn.
Passing the suite does not certify authentication/RLS, schema migrations,
query plans, pooling, load, process-kill recovery, backups, or disaster
recovery. Keep those checks in the host service. runAll(harness) is available
for a sequential smoke script, but registering the named callbacks with the
host's normal integration-test runner produces better diagnostics.
See the repository's local
README.md for
the exact session adapter boundary and file-layout details. The archive port's
separate ownership and atomicity rules are in its
README.md.
The session conformance suite does not certify a custom archive adapter. Keep a small set of host integration checks for transactional append, malformed manifest rejection, fresh-instance rolling-summary recovery, missing summary content, and storage-failure propagation. Add broader provider scenarios only when a real adapter exposes a portable correctness risk or a public support claim requires the evidence.