Skip to content
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING:** `checkpointPolicy` now defaults to `{ every: 64 }` instead of
being disabled. Auto-checkpointing is what bounds replay depth, so leaving it
off unless a caller opted in made unbounded growth the default: a graph opened
without a policy replayed every patch since its last explicit checkpoint on
each materialize, forever, and reads paid for it. Observed in practice at 262
unreplayed patches, where a single read spawned 5,267 Git subprocesses and the
backlog grew by two commits per write with no upper bound.

An omitted policy now takes the default. `checkpointPolicy: null` remains the
explicit opt-out and is unchanged, so callers that genuinely want no
compaction keep it by asking for it rather than by forgetting.

This alters observable write behaviour for any consumer that never supplied a
policy: such graphs will begin writing checkpoint commits
once their replay depth reaches or exceeds 64 patches. State hashes are
unaffected — a checkpoint is a snapshot, not a semantic change.

## [19.0.2] - 2026-07-29

### Release notes
Expand Down
5 changes: 3 additions & 2 deletions src/domain/RuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
type RuntimeHostOpenInput,
type RuntimeHostConstructionOptions,
} from './warp/RuntimeHostBoot.ts';
import type CheckpointPolicy from './warp/CheckpointPolicy.ts';

import type { NeighborEdge } from '../ports/NeighborProviderPort.ts';

Expand Down Expand Up @@ -210,7 +211,7 @@ export default class RuntimeHost {
_patchesSinceGC: number;
_patchesSinceCheckpoint: number;
_maxObservedLamport: number;
_checkpointPolicy: { every: number } | null;
_checkpointPolicy: CheckpointPolicy | null;
_checkpointing: boolean;
_autoMaterialize: boolean;
traverse: LogicalTraversal;
Expand Down Expand Up @@ -321,7 +322,7 @@ export default class RuntimeHost {
this._patchesSinceGC = 0;
this._patchesSinceCheckpoint = 0;
this._maxObservedLamport = 0;
this._checkpointPolicy = checkpointPolicy || null;
this._checkpointPolicy = checkpointPolicy;
this._checkpointing = false;
this._autoMaterialize = autoMaterialize;
this._materializedGraph = null;
Expand Down
4 changes: 3 additions & 1 deletion src/domain/WarpGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import type { ExternalizationPolicy } from './types/ExternalizationPolicy.ts';
import type { GCPolicyConfig } from './services/GCPolicy.ts';
import type RuntimeStorageProviderPort from '../ports/RuntimeStorageProviderPort.ts';
import type TrustCryptoPort from '../ports/TrustCryptoPort.ts';
import type CheckpointPolicy from './warp/CheckpointPolicy.ts';
import type { CheckpointPolicyConfig } from './warp/CheckpointPolicy.ts';

// ---------------------------------------------------------------------------
// WarpGraph — frozen capability bag, organized by architectural moment
Expand Down Expand Up @@ -143,7 +145,7 @@ export interface WarpGraphDeps {
// Governing policy
readonly trust?: { mode?: TrustMode; pin?: string | null };
readonly gcPolicy?: GCPolicyConfig;
readonly checkpointPolicy?: { every: number };
readonly checkpointPolicy?: CheckpointPolicyConfig | CheckpointPolicy | null;
readonly onDeleteWithData?: 'reject' | 'cascade' | 'warn';
readonly autoMaterialize?: boolean;

Expand Down
23 changes: 15 additions & 8 deletions src/domain/services/controllers/ForkController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type AssetStoragePort from '../../../ports/AssetStoragePort.ts';
import type GCPolicy from '../GCPolicy.ts';
import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts';
import type PatchJournalPort from '../../../ports/PatchJournalPort.ts';
import type CheckpointPolicy from '../../warp/CheckpointPolicy.ts';

const HEX_CHARS = '0123456789abcdef';
type ForkRuntimeOpenOptions = RuntimeHostOpenOptions;
Expand All @@ -49,7 +50,7 @@ type ForkHost = {
_runtimeStorage: RuntimeStorageProviderPort;
_graphName: string;
_gcPolicy: GCPolicy;
_checkpointPolicy: { every: number } | null;
_checkpointPolicy: CheckpointPolicy | null;
_autoMaterialize: boolean;
_onDeleteWithData: 'reject' | 'cascade' | 'warn';
_logger: LoggerPort | null;
Expand Down Expand Up @@ -123,10 +124,13 @@ export default class ForkController {
forkName ?? `${host._graphName}-fork-${randomSuffix()}`;
try {
validateGraphName(resolvedForkName);
} catch (err) {
throw new ForkError(`Invalid fork name: ${(err as Error).message}`, {
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
throw new ForkError(`Invalid fork name: ${error.message}`, {
code: 'E_FORK_NAME_INVALID',
context: { forkName: resolvedForkName, originalError: (err as Error).message },
context: { forkName: resolvedForkName, originalError: error.message },
});
}

Expand All @@ -142,10 +146,13 @@ export default class ForkController {
const resolvedForkWriterId = (forkWriterId !== undefined && forkWriterId !== null && forkWriterId !== '') ? forkWriterId : generateWriterId();
try {
validateWriterId(resolvedForkWriterId);
} catch (err) {
throw new ForkError(`Invalid fork writer ID: ${(err as Error).message}`, {
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
throw new ForkError(`Invalid fork writer ID: ${error.message}`, {
code: 'E_FORK_WRITER_ID_INVALID',
context: { forkWriterId: resolvedForkWriterId, originalError: (err as Error).message },
context: { forkWriterId: resolvedForkWriterId, originalError: error.message },
});
}

Expand All @@ -161,7 +168,7 @@ export default class ForkController {
graphName: resolvedForkName,
writerId: resolvedForkWriterId,
gcPolicy: host._gcPolicy,
...(host._checkpointPolicy ? { checkpointPolicy: host._checkpointPolicy } : {}),
checkpointPolicy: host._checkpointPolicy,
autoMaterialize: host._autoMaterialize,
onDeleteWithData: host._onDeleteWithData,
...(host._logger ? { logger: host._logger } : {}),
Expand Down
7 changes: 4 additions & 3 deletions src/domain/services/controllers/detachedOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { NormalizedTrustConfig } from '../../runtimeHelpers.ts';
import type GCPolicy from '../GCPolicy.ts';
import type { DetachedGraphInternalReadSurface } from '../../capabilities/DetachedGraphFactory.ts';
import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts';
import type CheckpointPolicy from '../../warp/CheckpointPolicy.ts';

export type DetachedOpenOptions = {
persistence: CorePersistence;
Expand All @@ -24,7 +25,7 @@ export type DetachedOpenOptions = {
crypto: CryptoPort;
codec: CodecPort;
audit: false;
checkpointPolicy?: { every: number };
checkpointPolicy?: CheckpointPolicy | null;
logger?: LoggerPort;
trust?: NormalizedTrustConfig;
};
Expand All @@ -37,7 +38,7 @@ export type DetachedOpenHost = {
_graphName: string;
_writerId: string;
_gcPolicy: GCPolicy;
_checkpointPolicy: { every: number } | null;
_checkpointPolicy: CheckpointPolicy | null;
_logger: LoggerPort | null;
_trustConfig: NormalizedTrustConfig;
_onDeleteWithData: 'reject' | 'cascade' | 'warn';
Expand All @@ -61,7 +62,7 @@ function coreOptions(graph: DetachedOpenHost): DetachedOpenOptions {
}

function addReadPolicy(opts: DetachedOpenOptions, g: DetachedOpenHost): void {
if (g._checkpointPolicy) { opts.checkpointPolicy = g._checkpointPolicy; }
opts.checkpointPolicy = g._checkpointPolicy;
if (g._logger) { opts.logger = g._logger; }
}

Expand Down
42 changes: 42 additions & 0 deletions src/domain/warp/CheckpointPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import WarpError from '../errors/WarpError.ts';

export type CheckpointPolicyConfig = {
readonly every: number;
};

const DEFAULT_CHECKPOINT_INTERVAL = 64;

/** Immutable, validated cadence for automatic replay checkpoints. */
export default class CheckpointPolicy {
readonly every: number;

constructor(every: number) {
if (!Number.isInteger(every) || every <= 0) {
throw new WarpError(
'checkpointPolicy.every must be a positive integer',
'E_CHECKPOINT_POLICY_EVERY',
);
}
this.every = every;
Object.freeze(this);
}

static readonly DEFAULT: CheckpointPolicy = new CheckpointPolicy(
DEFAULT_CHECKPOINT_INTERVAL,
);

static from(
value: CheckpointPolicyConfig | CheckpointPolicy,
): CheckpointPolicy {
if (value instanceof CheckpointPolicy) {
return value;
}
if (typeof value !== 'object' || value === null) {
throw new WarpError(
'checkpointPolicy must be an object with { every: number }',
'E_CHECKPOINT_POLICY_TYPE',
);
}
return new CheckpointPolicy(value.every);
}
}
48 changes: 30 additions & 18 deletions src/domain/warp/RuntimeHostBoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type { EffectPipeline } from '../services/EffectPipeline.ts';
import type { ExternalizationPolicy } from '../types/ExternalizationPolicy.ts';
import GCPolicy, { type GCPolicyConfig } from '../services/GCPolicy.ts';
import type { MaterializeSessionOpener } from '../services/controllers/MaterializeSessionBridge.ts';
import CheckpointPolicy, { type CheckpointPolicyConfig } from './CheckpointPolicy.ts';

type DeletePolicy = 'reject' | 'cascade' | 'warn';
const VALID_DELETE_POLICIES: ReadonlyArray<DeletePolicy> = ['reject', 'cascade', 'warn'];
Expand All @@ -54,7 +55,7 @@ export type RuntimeHostConstructionOptions = {
graphName: string;
writerId: string;
gcPolicy?: GCPolicyConfig | GCPolicy;
checkpointPolicy?: { every: number };
checkpointPolicy: CheckpointPolicy | null;
autoMaterialize?: boolean;
onDeleteWithData?: DeletePolicy;
logger?: LoggerPort;
Expand Down Expand Up @@ -89,7 +90,7 @@ export type RuntimeHostOpenOptions = {
graphName: string;
writerId: string;
gcPolicy?: GCPolicyConfig | GCPolicy;
checkpointPolicy?: { every: number } | null;
checkpointPolicy?: CheckpointPolicyConfig | CheckpointPolicy | null;
autoMaterialize?: boolean;
onDeleteWithData?: DeletePolicy;
logger?: LoggerPort;
Expand All @@ -113,7 +114,7 @@ export class WarpOpenOptions {
readonly graphName: string;
readonly writerId: string;
readonly gcPolicy: GCPolicyConfig | GCPolicy;
readonly checkpointPolicy?: { every: number };
readonly checkpointPolicy: CheckpointPolicy | null;
readonly autoMaterialize?: boolean;
readonly onDeleteWithData?: DeletePolicy;
readonly logger?: LoggerPort;
Expand Down Expand Up @@ -148,10 +149,7 @@ export class WarpOpenOptions {
if (options.codec !== undefined) { this.codec = options.codec; }
if (options.trustCrypto !== undefined) { this.trustCrypto = options.trustCrypto; }

const checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy);
if (checkpointPolicy !== undefined) {
this.checkpointPolicy = checkpointPolicy;
}
this.checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy);
if (options.autoMaterialize !== undefined) {
this.autoMaterialize = normalizeBooleanOption(
options.autoMaterialize,
Expand Down Expand Up @@ -202,6 +200,18 @@ export class WarpOpenOptions {

export type RuntimeHostOpenInput = RuntimeHostOpenOptions | WarpOpenOptions;

/**
* Auto-checkpoint cadence applied when a caller supplies no `checkpointPolicy`.
*
* `every` is compared against the replay depth reported by a materialize (the
* patch count since the last checkpoint), not against writes performed by the
* current process, so short-lived callers still compact once the backlog
* crosses the threshold.
*
* Pass `checkpointPolicy: null` to disable auto-checkpointing entirely.
*/
export const DEFAULT_CHECKPOINT_POLICY: CheckpointPolicy = CheckpointPolicy.DEFAULT;

function normalizeBooleanOption(value: boolean, label: string, code: string): boolean {
if (typeof value !== 'boolean') {
throw new WarpError(`${label} must be a boolean`, code);
Expand All @@ -210,18 +220,20 @@ function normalizeBooleanOption(value: boolean, label: string, code: string): bo
}

function normalizeCheckpointPolicy(
checkpointPolicy: { every: number } | null | undefined,
): { every: number } | undefined {
if (checkpointPolicy === null || checkpointPolicy === undefined) {
return undefined;
}
if (typeof checkpointPolicy !== 'object') {
throw new WarpError('checkpointPolicy must be an object with { every: number }', 'E_CHECKPOINT_POLICY_TYPE');
checkpointPolicy: CheckpointPolicyConfig | CheckpointPolicy | null | undefined,
): CheckpointPolicy | null {
// An omitted policy takes the default. Auto-checkpointing is what bounds
// replay depth, so leaving it off by default made every caller that never
// supplied a policy replay its entire patch history on each materialize,
// growing without limit. `null` remains the explicit opt-out for callers
// that genuinely want no compaction.
if (checkpointPolicy === undefined) {
return DEFAULT_CHECKPOINT_POLICY;
}
if (!Number.isInteger(checkpointPolicy.every) || checkpointPolicy.every <= 0) {
throw new WarpError('checkpointPolicy.every must be a positive integer', 'E_CHECKPOINT_POLICY_EVERY');
if (checkpointPolicy === null) {
return null;
}
return Object.freeze({ every: checkpointPolicy.every });
return CheckpointPolicy.from(checkpointPolicy);
}

function snapshotGCPolicy(value: GCPolicyConfig | GCPolicy | undefined): GCPolicyConfig | GCPolicy {
Expand Down Expand Up @@ -371,7 +383,7 @@ export async function resolveRuntimeHostConstructionOptions(
graphName,
writerId,
gcPolicy,
...(checkpointPolicy !== undefined ? { checkpointPolicy } : {}),
checkpointPolicy,
...(autoMaterialize !== undefined ? { autoMaterialize } : {}),
...(onDeleteWithData !== undefined ? { onDeleteWithData } : {}),
...(logger !== undefined ? { logger } : {}),
Expand Down
3 changes: 2 additions & 1 deletion src/domain/warp/RuntimeHostProduct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
RuntimeHostOpenInput as RuntimeHostBootOpenInput,
RuntimeHostOpenOptions as RuntimeHostBootOpenOptions,
} from './RuntimeHostBoot.ts';
import type CheckpointPolicy from './CheckpointPolicy.ts';
import { openRuntimeHost } from '../RuntimeHost.ts';

export type RuntimeCapabilitySurface =
Expand Down Expand Up @@ -143,7 +144,7 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & {
_patchesSinceGC: number;
_patchesSinceCheckpoint: number;
_maxObservedLamport: number;
_checkpointPolicy: { every: number } | null;
_checkpointPolicy: CheckpointPolicy | null;
_autoMaterialize: boolean;
_assetStorage: AssetStoragePort;
_checkpointStore: CheckpointStorePort;
Expand Down
17 changes: 17 additions & 0 deletions test/unit/CheckpointPolicyChangelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const CHANGELOG_PATH = fileURLToPath(
new URL('../../CHANGELOG.md', import.meta.url),
);

describe('checkpoint policy changelog', () => {
it('describes the default threshold as inclusive', () => {
const changelog = readFileSync(CHANGELOG_PATH, 'utf8');

expect(changelog).toContain(
'once their replay depth reaches or exceeds 64 patches',
);
});
});
Loading
Loading