Config manifest
The content-addressable snapshot of the runtime substrate — which plugins, prompts, nodes, channels, and filters were active — written once per session and referenced by every event-log row.
The config manifest records which substrate ran. A
fingerprint binds a turn's inputs to a hash;
the manifest binds the substrate that processed them — the plugin
set, the system prompts, the graph node bodies, the channel
definitions, the post-stage filters. One row per distinct
substrate, content-addressable, written to harness_config_manifest.
Every event-log row carries a manifest_hash
foreign reference to that row. So a full replay of any historical
session needs exactly two things: the event stream and the
manifest hash that was active when each event fired. That tuple is
self-contained — no live runtime, no ambient git HEAD, no
deploy-record lookup.
Rolling out additively
The harness_config_manifest table and the
harness_event_log.manifest_hash column land as additive schema
files — manifest_hash is nullable during rollout so rows written
before the manifest substrate stay valid. Existing sessions keep
working; new sessions stamp the reference. The cutover to a
required column is a follow-up migration after every write path
stamps the hash. See Schema for the bundle file.
Why it exists
Determinism says a turn replays from its record
without drift against the same package version and the same input.
"Same package version" is the load-bearing phrase — but a
deployment's effective substrate is more than the npm version. It
includes the plugins you registered, the prompt templates you
shipped, the graph nodes your build composed, the channels and
filters in play. Two deployments on the same @pleach/core
version can run different substrates.
The fingerprint doesn't capture that. It binds the turn input. The manifest captures the rest, so the replay contract becomes precise: same manifest, same input, same output.
The five surfaces
The manifest is a snapshot of five substrate axes. Each is content-hashed independently, then rolled up into one top-level hash.
| Surface | Column | What it captures |
|---|---|---|
| Plugins | plugin_manifest | The registered plugin set and the hooks each contributes |
| Prompts | prompt_manifest | The system-prompt facets and templates in effect |
| Nodes | node_manifest | The graph node bodies (by content hash) and their stage assignment |
| Channels | channel_manifest | The channel (state) definitions and their reducers |
| Filters | filter_manifest | The post-stage filter declarations |
Each surface gets its own child hash (plugin_hash,
prompt_hash, …). Per-surface hashes are what make the manifest
dedup well: in a multi-tenant deployment most tenants load the
same plugin set, so the plugin_manifest blob is shared across
their manifest rows even when their prompts differ.
Current coverage is partial. In the shipped cut, only the plugin
surface carries real content. The prompt, channel, and filter builders
return empty, and every node bodyHash is a 64-zero placeholder — so
prompt/channel/filter/node drift is not hash-captured yet. The
runtime warns once per process ([ConfigManifest] Manifest content is partial) when it writes a partial manifest. The table shape and write
site are stable; the remaining surface content lands in a later slice.
Treat manifest equality as "same plugin set," not "same substrate,"
until then.
The table
CREATE TABLE harness_config_manifest (
manifest_hash TEXT PRIMARY KEY, -- top-level Merkle roll-up
plugin_hash TEXT NOT NULL, -- per-surface child hash
prompt_hash TEXT NOT NULL,
node_hash TEXT NOT NULL,
channel_hash TEXT NOT NULL,
filter_hash TEXT NOT NULL,
plugin_manifest JSONB NOT NULL, -- full snapshot, not a delta
prompt_manifest JSONB NOT NULL,
node_manifest JSONB NOT NULL,
channel_manifest JSONB NOT NULL,
filter_manifest JSONB NOT NULL,
reference_count INTEGER NOT NULL DEFAULT 1, -- live event references
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id TEXT NOT NULL DEFAULT 'default'
);The primary key is the content hash, so two sessions that ran the
same substrate write the same row — the insert is
ON CONFLICT DO NOTHING. Each per-surface hash gets its own index
for the cross-tenant query shapes below, plus a tenant_id index
for per-tenant scans.
Row-level security ships the same two-policy template as the rest
of the schema bundle: a service-role
bypass for server-side adapters, and a tenant_id = current_tenant()
isolation policy for scoped clients.
Foreign reference on the event log
ALTER TABLE harness_event_log
ADD COLUMN manifest_hash TEXT -- nullable during rollout
REFERENCES harness_config_manifest (manifest_hash)
ON DELETE SET NULL
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX harness_event_log_manifest_hash_idx
ON harness_event_log (manifest_hash);The event log carries only the top-level manifest_hash; the
per-surface child hashes live on the manifest row, reached by
joining. The column is a real foreign key, but two clauses keep it
cheap and rollout-safe:
DEFERRABLE INITIALLY DEFERREDis load-bearing. A session writes its manifest fire-and-forget and writes its first event in the same transaction; deferring the constraint to commit time lets those two writes race without a transient FK violation.ON DELETE SET NULLmeans a retention GC that drops a manifest nulls the reference rather than cascading a delete into the audit trail — the event survives, it just loses its substrate pointer.
The constraint is nullable during rollout so rows written before the
manifest substrate stay valid. Two gates back the reference:
audit:config-manifest-fk-constraint-applied asserts the migration
carries every canonical clause, and
audit:config-manifest-referential-integrity asserts zero orphans
online (and the SessionRuntime wiring offline).
The hash
Each surface is serialized to canonical JSON (RFC 8785 — sorted keys, no insignificant whitespace) and hashed with SHA-256. The top-level hash is a Merkle roll-up over the five child hashes, ordered, with a versioned prefix:
manifest_hash = sha256_hex(
"pleach.manifest.v1" ␟
"plugin:" ␟ plugin_hash ␟
"prompt:" ␟ prompt_hash ␟
"node:" ␟ node_hash ␟
"channel:" ␟ channel_hash ␟
"filter:" ␟ filter_hash
)␟ is the ASCII unit separator (0x1F). This mirrors the
hash chain's pleach.c9.v1 canonicalization —
same separator, same versioned-prefix discipline, same hex output
shape. The version prefix lets a future schema add a sixth surface
without colliding with v1 manifests.
The manifest builder is a pure function of its inputs — no
Date.now(), no environment reads — so the same substrate always
produces the same hash. The audit:plugin-content-hash-stability
gate runs two consecutive builds and diffs each of the five child
hashes independently; a Date.now() leak in the prompt builder
fails prompt_hash and points at the prompt surface specifically,
not at "something in the manifest moved."
Reproducing and verifying a hash
The hash functions are exported from the @pleach/core/manifest
subpath, so a consumer can recompute a manifest_hash independently —
read a persisted ConfigManifest back, revalidate its shape, and
recompute the roll-up to confirm the row wasn't tampered with.
import {
computeConfigManifestHash, // the top-level Merkle roll-up
computePluginManifestHash, // the five per-surface child hashers
computePromptManifestHash,
computeNodeManifestHash,
computeChannelManifestHash,
computeFilterManifestHash,
validateConfigManifestShape, // parse unknown → ConfigManifest (throws on drift)
isConfigManifest, // boolean predicate form
ConfigManifestValidationError,
} from "@pleach/core/manifest";The verify flow is: read the row as unknown, validate its shape, then
recompute — a mismatch means the stored hash and the stored surfaces
disagree.
const row: unknown = await store.get(manifestHash);
const manifest = validateConfigManifestShape(row); // throws ConfigManifestValidationError on drift
const recomputed = computeConfigManifestHash(manifest);
if (recomputed !== manifest.manifestHash) {
throw new Error("config manifest tampered — recomputed hash mismatch");
}Each per-surface hasher recomputes one child hash in isolation, so a
mismatch localizes to the surface that moved (plugin_hash,
prompt_hash, …) rather than the top-level roll-up alone.
ConfigManifestMemoryStore is the in-process store for tests and the
pleach dev harness — inject it via SessionRuntimeConfig.configManifestStore
to exercise the full provenance shape with no durable backend:
import { ConfigManifestMemoryStore } from "@pleach/core/manifest";
const configManifestStore = new ConfigManifestMemoryStore();
const runtime = new SessionRuntime({ configManifestStore, userId });The manifest builder functions (buildConfigManifest /
buildAndWriteConfigManifest) that construct a manifest from a live
substrate are not re-exported from a published subpath today — only the
reproduce/verify helpers above are. The runtime writes the manifest for
you at session construction; you recompute and verify it with the
functions listed here.
Querying
The manifest turns three audit questions into index-backed queries.
Replay by config — every event under the substrate active in a session:
SELECT events.*
FROM harness_event_log events
WHERE events.manifest_hash = $1
ORDER BY events.sequence_number;Cross-tenant audit by plugin set — every event across all sessions that ran with a given plugin set, regardless of prompt, node, channel, or filter drift:
SELECT events.*
FROM harness_event_log events
JOIN harness_config_manifest manifest
ON events.manifest_hash = manifest.manifest_hash
WHERE manifest.plugin_hash = $1;Substrate reconstruction — the full substrate behind a single event, all five surfaces returned as JSONB:
SELECT manifest.*
FROM harness_config_manifest manifest
JOIN harness_event_log events
ON events.manifest_hash = manifest.manifest_hash
WHERE events.id = $1
LIMIT 1;A manifest lookup is a primary-key hit, so JOIN cost is dominated by the event-log scan, not the manifest cardinality. A typical incident window of a thousand events references only a handful of distinct manifests.
Full snapshots, not deltas
Each row stores the complete substrate, not a diff against a prior manifest. The storage cost is small — content-addressing dedups identical substrates, and per-surface hashes dedup the shared parts — and the payoff is that replay reads one row. A delta encoding would force the replay tool to walk a parent chain, which breaks the moment retention drops a parent, and it would turn the round-trip audit into a multi-step materialization. One row per manifest keeps replay offline-able.
The value is version-prefixed — parse it, never compare it
manifestHash is pleach.manifest.v<N>:<64-hex> on the wire, not a
bare <64-hex>. The schema version is already an input to the roll-up
digest, so the same config hashes differently under different schema
versions, and a bare value gave a reader no way to tell which schema
produced it.
import { parseConfigManifestHash } from "@pleach/core/manifest"
const { version, hex } = parseConfigManifestHash(manifest.manifestHash)The schema is at v4. Don't hardcode a version in a matcher.
Without the prefix, the first schema bump would make every prior
recording compare unequal, and detectReplayConfigManifestMismatch
would report a config drift that never happened on every replay of a
pre-bump turn. It parses rather than string-compares, and treats
differing schema versions as not comparable rather than as drift.
It is self-migrating: a value with no prefix is unambiguously v1 — the
only version that ever shipped bare — and the <hex> half is unchanged
for a given config, so pre-prefix and post-prefix records of the same
config still compare equal once parsed.
One accepted consequence across the upgrade
manifest_hash is a TEXT primary key in harness_config_manifest, so a
single config can key two rows across the upgrade — the bare one written
before, the prefixed one after — splitting reference_count between
them. That is one duplicate row per distinct config, not a correctness
break: retention GC deletes only at reference_count = 0, so the fork
does not make the older row collectable.
Retention
Manifests are reference-counted against the event log: a manifest survives as long as any event references it. The default floor is forever — the compliance posture is "keep the audit trail," not "expire it." Cost-sensitive tenants can opt into a per-tenant retention floor (planned) that drops manifests older than N days and referenced by no surviving event.
This couples manifest retention to whatever event-log retention you run, automatically:
| Event-log retention | Manifest retention |
|---|---|
| Forever | Forever |
| N-day TTL | Manifest drops within a GC pass of the last referencing event |
| Per-tenant policy | Manifest follows per tenant |
The audit:config-manifest-retention-completeness gate runs after
a GC pass and fails if any event references a missing manifest —
the guard against a GC racing a concurrent event write.
runRetentionGC fails closed
reference_count has been measured wrong in both directions — two
manifests holding 306 live event references sat at zero, and three with
counts of 11, 18, and 1 had none. Deleting a referenced manifest
destroys the config attribution of every event row pointing at it.
So runRetentionGC takes an optional isReferenced probe. Returning
true skips the row, and a probe that throws is also treated as
referenced: an unanswerable "is this still referenced?" must never
authorise an irreversible delete.
Two result fields report what happened:
skippedStillReferencedCount is a direct measurement of counter drift,
and unverifiedRetiredCount counts deletes made on the raw counter
alone — announced once rather than left implicit.
Existing callers are unchanged and still delete. The probe strictly narrows what can be removed; it never widens it.
One snapshot per session
The manifest is captured once, at session construction — never mid-stream. The content-addressable contract requires a stable snapshot, so a config change mid-session is out of scope: the session represents the substrate it started with. Mutating manifest-relevant state mid-session is the kind of thing the replayability contract is designed to forbid, not silently absorb.
Where it sits in the replay story
The fingerprint binds the inputs; the manifest binds the
substrate; the event-log row carries both. Replay reads the
stream, dereferences each manifest_hash, reconstructs the
substrate, and re-runs the events against it. Same manifest, same
input, same output.
Where to go next
Schema
The schema bundle this table lands in, and the RLS template it shares.
Event log
The stream whose rows carry the `manifest_hash` reference.
Determinism
The replay contract the manifest completes — same manifest, same input, same output.
Fingerprint
The input-binding half; the manifest is the substrate-binding half.
Event-log projections
GraphProjection<T> — folding harness_event_log rows into typed runtime state. The contract is stable; the shipped projections are in soak.
Stream events
Every event type executeMessage yields — message deltas, tool lifecycle, jobs, interrupts, checkpoints, sync, errors. The full catalog with payload shapes.