feat(memory): retain transcript policy lineage

This commit is contained in:
Galin Iliev
2026-08-14 00:26:12 -07:00
parent a352d639f7
commit 0d20184587
22 changed files with 3367 additions and 148 deletions
+23 -8
View File
@@ -1359,23 +1359,38 @@ Add:
Phase 2B is complete only when all of the following are demonstrated:
- [ ] Every readable user, assistant, tool-result, summary, checkpoint, and
- [x] Every readable user, assistant, tool-result, summary, checkpoint, and
system event has an atomic, evaluable policy companion row.
- [ ] Every scoped exposure can be mapped to the durable events, policy-set
- [x] Every scoped exposure can be mapped to the durable events, policy-set
revision, delivery audience, and run exposure revision it influenced.
- [ ] Stable policy IDs are revalidated against current active revisions and
- [x] Stable policy IDs are revalidated against current active revisions and
revocation epochs; captured historical allows are not permanent grants.
- [ ] Reset, rollover, fork, rewind, checkpoint restore, archive, export, and
- [x] Reset, rollover, fork, rewind, checkpoint restore, archive, export, and
confirmed import preserve subject and policy lineage exactly.
- [ ] Missing, invalid, stale, or authorization-pending labels exclude events
- [x] Missing, invalid, stale, or authorization-pending labels exclude events
from replay, search, compaction, export, and derivation.
- [ ] Authorization is never reconstructed from session-key shape, transcript
- [x] Authorization is never reconstructed from session-key shape, transcript
JSON, rendered prompt text, or `InputProvenance`.
- [ ] No plugin call or async work occurs inside the transcript commit
- [x] No plugin call or async work occurs inside the transcript commit
transaction.
- [ ] Atomic-write, transition, policy-revision, revoke-race, session-rebound,
- [x] Atomic-write, transition, policy-revision, revoke-race, session-rebound,
and legacy-unlabeled transcript tests pass.
Proof (2026-08-14): policy companion/evidence and current-policy tests in
`src/config/sessions/session-transcript-memory-policy.test.ts`; archive/export
and confirmed-import rollback tests in
`src/config/sessions/session-accessor.sqlite-archive.worker.test.ts`; canonical
archive parsing in `src/config/sessions/session-transcript-policy-archive.test.ts`;
checkpoint and parent-fork lifecycle coverage in
`src/config/sessions/session-accessor.conformance.test.ts` and
`src/config/sessions/session-accessor.parent-fork.test.ts`; durable actor and
delegation ledger coverage in `src/plugins/memory-run-exposure-ledger.test.ts`;
and enforced archive-ingestion quarantine in
`extensions/memory-core/src/session-ingestion.test.ts`. Blacksmith Testbox
`tbx_01kzzhjsz3x1fsrrrm035egekp` passed 104 runtime-config and 10 plugin-ledger
tests for these files. `src/config/sessions/session-transcript-memory-policy.ts`
keeps policy evaluation and all plugin work outside the synchronous transaction.
### Phase 2B rollback
Disable transcript recall, compaction, flush, dreaming, and derivation for the
@@ -1,7 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const isLegacyMemorySurfaceDisabled = vi.hoisted(() => vi.fn(() => false));
vi.mock("openclaw/plugin-sdk/memory-core-host-runtime-core", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/memory-core-host-runtime-core")>()),
isLegacyMemorySurfaceDisabled,
}));
import {
foreignSessionIngestionSource,
scanSessionIngestionSource,
@@ -11,6 +18,8 @@ import {
const tempDirs: string[] = [];
afterEach(async () => {
isLegacyMemorySurfaceDisabled.mockReset();
isLegacyMemorySurfaceDisabled.mockReturnValue(false);
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
@@ -27,6 +36,28 @@ describe("session ingestion", () => {
expect(source?.scope).toBe("main:foo.jsonl");
});
it("excludes unconfirmed archive artifacts from cut-over derivation", () => {
isLegacyMemorySurfaceDisabled.mockReturnValue(true);
expect(
sessionIngestionSourceFromCorpus({
agentId: "main",
artifactKind: "archive-artifact",
sessionFile: path.join(os.tmpdir(), "archived.jsonl.deleted.2026-08-14"),
sessionId: "archived",
sessionKind: "interactive",
}),
).toBeNull();
});
it("rejects caller-supplied archive files after cut-over with a visible transfer outcome", () => {
isLegacyMemorySurfaceDisabled.mockReturnValue(true);
expect(() => foreignSessionIngestionSource("main", "/tmp/archived.jsonl")).toThrow(
"confirmed transcript import is available",
);
});
it("verifies backfill content despite an unchanged size and mtime", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-ingestion-"));
tempDirs.push(dir);
@@ -9,6 +9,7 @@ import {
statSessionEntrySync,
type SessionTranscriptCorpusEntry,
} from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
import { isLegacyMemorySurfaceDisabled } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
@@ -91,6 +92,12 @@ function sessionPathFromCorpus(entry: SessionTranscriptCorpusEntry): string {
export function sessionIngestionSourceFromCorpus(
entry: SessionTranscriptCorpusEntry,
): SessionIngestionSource | null {
// Archive JSONL has no confirmed transfer package yet. In cut-over mode it
// cannot carry a currently revalidated transcript policy, so deny it before
// any parser turns the bytes into derivable memory.
if (entry.artifactKind === "archive-artifact" && isLegacyMemorySurfaceDisabled(entry.agentId)) {
return null;
}
const sessionPath = sessionPathFromCorpus(entry);
if (entry.sessionKind !== "interactive") {
return null;
@@ -133,6 +140,11 @@ export function foreignSessionIngestionSource(
agentId: string,
archiveFile: string,
): SessionIngestionSource {
if (isLegacyMemorySurfaceDisabled(agentId)) {
throw new Error(
"Archive-file session backfill is unavailable after scoped-memory cutover; retain the archive until a confirmed transcript import is available.",
);
}
const absolutePath = path.resolve(archiveFile);
const normalizedPath = absolutePath.replaceAll("\\", "/");
return {
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { recordAcpParentStreamEvents } from "../../agents/subagents/spawn/acp-parent-stream-store.sqlite.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import { resetMemoryIsolationCutoverForTest } from "../../plugins/memory-cutover.js";
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
import {
closeOpenClawAgentDatabasesForTest,
@@ -23,6 +24,8 @@ import {
replaceSessionEntry,
} from "./session-accessor.js";
import { materializeSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js";
import { materializeTranscriptArchiveInWorker } from "./session-accessor.sqlite-archive.worker.js";
import { importConfirmedSqliteTranscriptPolicyArchive } from "./session-accessor.sqlite-import.js";
import {
deleteMaterializedSessionStatePlans,
planSessionStateDeleteIfUnreferenced,
@@ -30,6 +33,14 @@ import {
import { touchTranscriptMutationInTransaction } from "./session-accessor.sqlite-transcript-state.js";
import { replaceTranscriptEvents } from "./session-accessor.sqlite-transcript-write.js";
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
import {
readAuthorizedTranscriptEventSeqs,
resetTranscriptMemoryPolicyForTest,
} from "./session-transcript-memory-policy.js";
import {
parseTranscriptPolicyArchive,
restoreConfirmedTranscriptPolicyArchiveInTransaction,
} from "./session-transcript-policy-archive.js";
type TestTranscriptEvent = {
id: string;
@@ -46,6 +57,7 @@ describe("SQLite transcript archive worker", () => {
});
afterEach(() => {
resetMemoryIsolationCutoverForTest();
closeOpenClawAgentDatabasesForTest();
fs.rmSync(tempDir, { recursive: true, force: true });
});
@@ -221,6 +233,213 @@ describe("SQLite transcript archive worker", () => {
).toMatchObject({ published_at: expect.any(Number) });
});
it("keeps legacy archives raw JSONL and exports enforced events with immutable companions", async () => {
const legacySessionId = "legacy-policy-archive-session";
const legacySessionKey = "agent:main:legacy-policy-archive";
const legacyEvent = createTranscriptEvent(legacySessionId, "legacy archive bytes");
await replaceSessionEntry(
{ sessionKey: legacySessionKey, storePath },
{ sessionId: legacySessionId, updatedAt: Date.now() },
);
await replaceTranscriptEvents(
{ sessionKey: legacySessionKey, sessionId: legacySessionId, storePath },
[legacyEvent],
);
const database = openLifecycleTestDatabase(storePath);
const legacyArchive = materializeTranscriptArchiveInWorker(
planArchiveWorker(database, path.dirname(storePath), legacySessionId),
);
expect(readMaterializedArchiveLines(legacyArchive.archive)).toEqual([
JSON.stringify(legacyEvent),
]);
const sessionId = "enforced-policy-archive-session";
const sessionKey = "agent:main:enforced-policy-archive";
const event = createTranscriptEvent(sessionId, "audited archive bytes");
await replaceSessionEntry({ sessionKey, storePath }, { sessionId, updatedAt: Date.now() });
await replaceTranscriptEvents({ sessionKey, sessionId, storePath }, [event]);
const { eventSeq } = seedEnforcedArchivePolicy(database, { sessionId });
const archive = materializeTranscriptArchiveInWorker(
planArchiveWorker(database, path.dirname(storePath), sessionId),
);
const lines = readMaterializedArchiveLines(archive.archive);
expect(lines).toHaveLength(2);
expect(lines[0]).toBe(JSON.stringify(event));
expect(JSON.parse(lines[1] ?? "")).toMatchObject({
type: "openclaw.memory-policy-archive-v1",
version: 1,
agentId: "main",
sessionId,
eventSeq,
subject: expect.objectContaining({
sessionKey,
sessionIdentityRevision: expect.any(String),
subjectRevision: expect.any(String),
}),
policy: expect.objectContaining({
runExposureSetId: "archive-exposure-set",
sourcePolicySetId: "archive-policy-set",
}),
detail: expect.objectContaining({
sourceEventSeq: eventSeq,
sourceSessionId: sessionId,
}),
});
const parsed = parseTranscriptPolicyArchive(readMaterializedArchiveContent(archive.archive));
expect(parsed).toBeDefined();
if (!parsed) {
throw new Error("expected a confirmed archive envelope");
}
database.db.exec(/* sqlite-allow-raw: fixture resets a just-restored pending companion. */ `
DELETE FROM transcript_event_memory_policy_details
WHERE session_id = '${sessionId}';
UPDATE transcript_event_memory_policies
SET authorization_status = 'pending',
source_policy_set_id = NULL,
run_exposure_set_id = NULL,
run_exposure_revision = NULL,
delivery_audiences_json = NULL,
session_identity_revision = NULL,
subject_revision = NULL,
run_id = NULL,
context_fingerprint = NULL
WHERE session_id = '${sessionId}';
`);
runOpenClawAgentWriteTransaction(
(transactionDatabase) =>
restoreConfirmedTranscriptPolicyArchiveInTransaction({
archive: parsed,
database: transactionDatabase,
sessionId,
sessionKey,
}),
{ agentId: database.agentId, path: database.path },
);
expect(readAuthorizedTranscriptEventSeqs(database.db, sessionId)).toEqual(new Set([eventSeq]));
expect(readMaterializedArchiveLines(archive.archive)).toHaveLength(2);
});
it("restores a confirmed archive only through its original immutable session subject", async () => {
const sessionId = "confirmed-policy-archive-session";
const sessionKey = "agent:main:confirmed-policy-archive";
const event = createTranscriptEvent(sessionId, "confirmed archive restore bytes");
await replaceSessionEntry({ sessionKey, storePath }, { sessionId, updatedAt: Date.now() });
await replaceTranscriptEvents({ sessionKey, sessionId, storePath }, [event]);
const database = openLifecycleTestDatabase(storePath);
const { eventSeq } = seedEnforcedArchivePolicy(database, { sessionId });
const archive = materializeTranscriptArchiveInWorker(
planArchiveWorker(database, path.dirname(storePath), sessionId),
);
const archiveContent = readMaterializedArchiveContent(archive.archive);
const sourceSnapshot = database.db
.prepare(
`SELECT session_identity_revision, subject_revision
FROM session_memory_subject_snapshots
WHERE session_id = ?`,
)
.get(sessionId);
// Lifecycle deletion removes rows owned by the old session window while the
// immutable subject snapshot remains available for a confirmed same-agent restore.
database.db.exec(/* sqlite-allow-raw: fixture simulates post-archive lifecycle reclamation. */ `
DELETE FROM session_nodes WHERE session_key = '${sessionKey}';
DELETE FROM session_windows WHERE session_id = '${sessionId}';
`);
expect(
database.db
.prepare("SELECT COUNT(*) AS count FROM session_memory_subjects WHERE session_key = ?")
.get(sessionKey),
).toEqual({ count: 1 });
await expect(
importConfirmedSqliteTranscriptPolicyArchive({
agentId: database.agentId,
archiveContent,
entry: { sessionId, updatedAt: Date.now() },
sessionKey,
storePath,
}),
).resolves.toEqual({ sessionId, sessionKey, transcriptEvents: 1 });
expect(readAuthorizedTranscriptEventSeqs(database.db, sessionId)).toEqual(new Set([eventSeq]));
expect(
database.db
.prepare(
`SELECT session_identity_revision, subject_revision
FROM session_memory_subject_snapshots
WHERE session_id = ?`,
)
.get(sessionId),
).toEqual(sourceSnapshot);
});
it("rolls a confirmed archive import back when the retained policy is no longer current", async () => {
const sessionId = "revoked-confirmed-policy-archive-session";
const sessionKey = "agent:main:revoked-confirmed-policy-archive";
await replaceSessionEntry({ sessionKey, storePath }, { sessionId, updatedAt: Date.now() });
await replaceTranscriptEvents({ sessionKey, sessionId, storePath }, [
createTranscriptEvent(sessionId, "revoked confirmed archive restore bytes"),
]);
const database = openLifecycleTestDatabase(storePath);
seedEnforcedArchivePolicy(database, { sessionId });
const archive = materializeTranscriptArchiveInWorker(
planArchiveWorker(database, path.dirname(storePath), sessionId),
);
const archiveContent = readMaterializedArchiveContent(archive.archive);
database.db
.exec(/* sqlite-allow-raw: fixture revokes after archive confirmation materializes. */ `
DELETE FROM session_nodes WHERE session_key = '${sessionKey}';
DELETE FROM session_windows WHERE session_id = '${sessionId}';
DROP TRIGGER memory_resource_revisions_immutable_fields;
UPDATE memory_resource_revisions SET expires_at = 1 WHERE revision_id = 'archive-resource-revision';
`);
await expect(
importConfirmedSqliteTranscriptPolicyArchive({
agentId: database.agentId,
archiveContent,
entry: { sessionId, updatedAt: Date.now() },
sessionKey,
storePath,
}),
).rejects.toThrow("confirmed transcript archive policy is no longer authorized");
expect(
database.db
.prepare("SELECT COUNT(*) AS count FROM session_nodes WHERE session_key = ?")
.get(sessionKey),
).toEqual({ count: 0 });
expect(
database.db
.prepare("SELECT COUNT(*) AS count FROM transcript_events WHERE session_id = ?")
.get(sessionId),
).toEqual({ count: 0 });
});
it.each([
["missing companion", {}],
["expired exposed resource", { expiresAt: 1 }],
])("fails closed for an enforced archive with %s", async (_name, options) => {
const sessionId = `blocked-policy-archive-${_name.replaceAll(" ", "-")}`;
const sessionKey = `agent:main:${sessionId}`;
await replaceSessionEntry({ sessionKey, storePath }, { sessionId, updatedAt: Date.now() });
await replaceTranscriptEvents({ sessionKey, sessionId, storePath }, [
createTranscriptEvent(sessionId, "must remain in the source database"),
]);
const database = openLifecycleTestDatabase(storePath);
seedEnforcedArchivePolicy(database, {
sessionId,
includeDetail: _name !== "missing companion",
...options,
});
expect(() =>
materializeTranscriptArchiveInWorker(
planArchiveWorker(database, path.dirname(storePath), sessionId),
),
).toThrow(`Unauthorized transcript policy archive event for ${sessionId}`);
});
it("archives a logical agent transcript through the exact database's physical owner", async () => {
const sharedDatabasePath = path.join(tempDir, "shared.sqlite");
const mainSessionId = "shared-physical-owner-main-session";
@@ -682,6 +901,19 @@ function readArchiveLines(archivePath: string | undefined): string[] {
.split("\n");
}
function readMaterializedArchiveContent(
archive: NonNullable<ReturnType<typeof materializeTranscriptArchiveInWorker>["archive"]>,
): string {
expect(archive).toBeTruthy();
return decodeSessionArchiveBytes(archive.bytes, archive.encoding === "zstd");
}
function readMaterializedArchiveLines(
archive: NonNullable<ReturnType<typeof materializeTranscriptArchiveInWorker>["archive"]>,
): string[] {
return readMaterializedArchiveContent(archive).trim().split("\n");
}
function sha256(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
@@ -697,6 +929,116 @@ function openLifecycleTestDatabase(storePath: string) {
});
}
function seedEnforcedArchivePolicy(
database: ReturnType<typeof openLifecycleTestDatabase>,
params: { sessionId: string; includeDetail?: boolean; expiresAt?: number },
): { eventSeq: number } {
const session = database.db
.prepare(
`SELECT session_identity_revision, session_key, subject_revision
FROM session_memory_subject_snapshots
WHERE session_id = ?`,
)
.get(params.sessionId) as
| { session_identity_revision: string; session_key: string; subject_revision: string }
| undefined;
if (!session) {
throw new Error(`expected session subject snapshot for ${params.sessionId}`);
}
const event = database.db
.prepare(
`SELECT seq
FROM transcript_events
WHERE session_id = ?
ORDER BY seq ASC
LIMIT 1`,
)
.get(params.sessionId) as { seq: number } | undefined;
if (!event) {
throw new Error(`expected transcript event for ${params.sessionId}`);
}
database.db.exec(/* sqlite-allow-raw: fixture establishes one evaluable archive lineage. */ `
INSERT INTO memory_migrations
(migration_id, source_kind, source_hash, phase, classification_json, plan_hash,
verified_at, cutover_at, updated_at)
VALUES ('archive-cutover', 'test', 'archive-source', 'cutover', '{}', 'archive-plan', 1, 1, 1);
INSERT INTO memory_policies
(policy_id, agent_id, current_revision_id, revocation_epoch, lifecycle_state, created_at, updated_at)
VALUES ('archive-policy', 'main', 'archive-policy-revision', 0, 'active', 1, 1);
INSERT INTO memory_policy_revisions
(revision_id, policy_id, revision_number, revocation_epoch, lifecycle_state,
actor_kind, actor_id, reason, created_at)
VALUES ('archive-policy-revision', 'archive-policy', 1, 0, 'active', 'human', 'alice', 'fixture', 1);
INSERT INTO memory_policy_sets
(policy_set_id, agent_id, memory_policy_revision, member_policy_set_ids_json, created_at)
VALUES ('archive-policy-set', 'main', 'archive-policy-revision', '["plugin-policy-set"]', 1);
INSERT INTO memory_policy_set_members
(policy_set_id, policy_id, expected_revision_id, expected_revocation_epoch,
audience_intersection_json, retention_state, created_at)
VALUES ('archive-policy-set', 'archive-policy', 'archive-policy-revision', 0,
'[{"id":"alice","kind":"user"}]', 'retained', 1);
INSERT INTO memory_storage_roots
(storage_root_id, agent_id, backend_kind, opaque_locator, path_key_version, path_key,
authority_kind, authority_owner_id, default_capabilities_json, lifecycle_state, created_at, updated_at)
VALUES ('archive-root', 'main', 'builtin', 'builtin:v1:archive', 1,
's1_archive_fixture_path_key_000', 'user', 'alice', '["read"]', 'active', 1, 1);
INSERT INTO memory_stores
(store_id, agent_id, storage_root_id, policy_id, scope_kind, audience_kind, audience_id,
lifecycle_state, created_at, updated_at)
VALUES ('archive-store', 'main', 'archive-root', 'archive-policy', 'user', 'user', 'alice', 'active', 1, 1);
INSERT INTO memory_resources
(resource_id, agent_id, store_id, logical_locator, source, created_at)
VALUES ('archive-resource', 'main', 'archive-store', 'memory/archive.md', 'memory', 1);
INSERT INTO memory_resource_revisions
(revision_id, resource_id, revision_number, artifact_locator, content_hash, content_bytes,
policy_revision_id, policy_revocation_epoch, source_policy_set_id, lifecycle_state,
actor_kind, actor_id, expires_at, created_at, activated_at, retired_at)
VALUES ('archive-resource-revision', 'archive-resource', 1, 'archive.md', 'archive', 7,
'archive-policy-revision', 0, 'plugin-policy-set', 'active', 'human', 'alice',
${params.expiresAt ?? "NULL"}, 1, 1, NULL);
INSERT INTO memory_run_exposures
(exposure_set_id, agent_id, run_id, context_fingerprint, plan_id, revision_number,
previous_exposure_set_id, source_policy_set_ids_json, effective_source_policy_set_id,
exposed_resource_revisions_json, exposure_receipt_ids_json, egress_receipt_ids_json,
delivery_audiences_json, delivery_revision, egress_registry_revision, created_at)
VALUES ('archive-exposure-set', 'main', 'archive-run', 'archive-context', 'archive-plan', 1,
NULL, '["plugin-policy-set"]', 'archive-policy-set',
'["archive-resource-revision"]', '["archive-exposure"]', '["archive-egress"]',
'[{"id":"alice","kind":"user"}]', 'delivery-1', 'egress-1', 1);
INSERT INTO memory_run_exposure_resources
(exposure_set_id, resource_revision_id, policy_set_id, created_at)
VALUES ('archive-exposure-set', 'archive-resource-revision', 'archive-policy-set', 1);
`);
database.db
.prepare(
`INSERT INTO transcript_event_memory_policies
(session_id, event_seq, authorization_status, source_policy_set_id, run_exposure_set_id,
run_exposure_revision, delivery_audiences_json, session_identity_revision,
subject_revision, run_id, context_fingerprint, created_at)
VALUES (?, ?, 'authorized', 'archive-policy-set', 'archive-exposure-set', 1,
'[{"id":"alice","kind":"user"}]', ?, ?, 'archive-run', 'archive-context', 1)`,
)
.run(params.sessionId, event.seq, session.session_identity_revision, session.subject_revision);
if (params.includeDetail !== false) {
database.db
.prepare(
`INSERT INTO transcript_event_memory_policy_details
(session_id, event_seq, actor_evidence_json, delegation_snapshot_json,
exposed_resource_revisions_json, exposure_receipt_ids_json, egress_receipt_ids_json,
normalized_audience_intersection_json, finalized_delivery_audiences_json, retention_state,
source_session_id, source_event_seq, created_at)
VALUES (?, ?, '{"version":1}', '{"kind":"none","version":1}',
'["archive-resource-revision"]', '["archive-exposure"]', '["archive-egress"]',
'[{"id":"alice","kind":"user"}]', '[{"id":"alice","kind":"user"}]',
'retained', ?, ?, 1)`,
)
.run(params.sessionId, event.seq, params.sessionId, event.seq);
}
resetMemoryIsolationCutoverForTest();
resetTranscriptMemoryPolicyForTest(database.db);
return { eventSeq: event.seq };
}
function planArchiveWorker(
database: ReturnType<typeof openLifecycleTestDatabase>,
archiveDirectory: string,
@@ -25,9 +25,56 @@ import { serializeJsonlLines } from "./transcript-jsonl.js";
type TranscriptArchiveDatabase = Pick<
OpenClawAgentKyselyDatabase,
"session_transcript_archives" | "transcript_events"
| "session_transcript_archives"
| "session_memory_subject_snapshots"
| "transcript_events"
| "transcript_event_memory_policies"
| "transcript_event_memory_policy_details"
| "transcript_event_memory_policy_transitions"
>;
const TRANSCRIPT_MEMORY_POLICY_ARCHIVE_RECORD_TYPE = "openclaw.memory-policy-archive-v1";
type TranscriptMemoryPolicyArchiveRecord = Readonly<{
agentId: string;
type: typeof TRANSCRIPT_MEMORY_POLICY_ARCHIVE_RECORD_TYPE;
version: 1;
sessionId: string;
eventSeq: number;
subject: Readonly<{
sessionKey: string;
sessionIdentityRevision: string;
subjectRevision: string;
}>;
policy: Readonly<{
contextFingerprint: string;
deliveryAudiencesJson: string;
runExposureRevision: number;
runExposureSetId: string;
runId: string;
sourcePolicySetId: string;
}>;
detail: Readonly<{
actorEvidenceJson: string;
delegationSnapshotJson: string;
egressReceiptIdsJson: string;
exposedResourceRevisionsJson: string;
exposureReceiptIdsJson: string;
finalizedDeliveryAudiencesJson: string;
normalizedAudienceIntersectionJson: string;
sourceEventSeq: number;
sourceSessionId: string;
}>;
transition?: Readonly<{
sourceEventSeq: number;
sourceSessionId: string;
sourceSessionIdentityRevision: string;
subjectRevision: string;
targetSessionIdentityRevision: string;
kind: string;
}>;
}>;
function isSqliteTranscriptArchiveWorkerData(value: unknown): boolean {
return (
Boolean(value) &&
@@ -137,6 +184,7 @@ function parseWorkerPlans(value: unknown): TranscriptArchiveWorkerPlan[] | undef
function readTranscriptArchiveContent(
database: import("node:sqlite").DatabaseSync,
agentId: string,
sessionId: string,
): string {
const db = getNodeSqliteKysely<TranscriptArchiveDatabase>(database);
@@ -151,11 +199,169 @@ function readTranscriptArchiveContent(
// An archive is an export surface. Once cut over, a raw row without a
// current companion label must not become a durable bypass of the replay fence.
const authorizedSeqs = readAuthorizedTranscriptEventSeqs(database, sessionId);
return serializeJsonlLines(
(authorizedSeqs ? lines.filter((row) => authorizedSeqs.has(row.seq)) : lines).map(
(row) => row.event_json,
),
if (!authorizedSeqs) {
return serializeJsonlLines(lines.map((row) => row.event_json));
}
const authorizedRows = lines.filter((row) => authorizedSeqs.has(row.seq));
if (authorizedRows.length !== lines.length) {
// Deletion must not turn a pending, stale, or revoked event into either a
// durable raw bypass or silent data loss. Keep the source rows until an
// explicit repair/confirmed import establishes their lineage.
throw new Error(`Unauthorized transcript policy archive event for ${sessionId}`);
}
const records = readTranscriptMemoryPolicyArchiveRecords(
database,
agentId,
sessionId,
authorizedSeqs,
);
if (records.size !== authorizedRows.length) {
// Policy-enforced archives are later import candidates. Refuse to emit any
// raw event whose immutable companion cannot travel with its lineage.
throw new Error(`Missing transcript policy archive companion for ${sessionId}`);
}
return serializeJsonlLines(
authorizedRows.flatMap((row) => {
const record = records.get(row.seq);
if (!record) {
throw new Error(`Missing transcript policy archive record for ${sessionId}:${row.seq}`);
}
return [row.event_json, JSON.stringify(record)];
}),
);
}
function readTranscriptMemoryPolicyArchiveRecords(
database: import("node:sqlite").DatabaseSync,
agentId: string,
sessionId: string,
authorizedSeqs: ReadonlySet<number>,
): ReadonlyMap<number, TranscriptMemoryPolicyArchiveRecord> {
const db = getNodeSqliteKysely<TranscriptArchiveDatabase>(database);
const rows = executeSqliteQuerySync(
database,
db
.selectFrom("transcript_event_memory_policies as policy")
.innerJoin("transcript_event_memory_policy_details as detail", (join) =>
join
.onRef("detail.session_id", "=", "policy.session_id")
.onRef("detail.event_seq", "=", "policy.event_seq"),
)
.innerJoin(
"session_memory_subject_snapshots as subject",
"subject.session_id",
"policy.session_id",
)
.leftJoin("transcript_event_memory_policy_transitions as transition", (join) =>
join
.onRef("transition.session_id", "=", "policy.session_id")
.onRef("transition.event_seq", "=", "policy.event_seq"),
)
.select([
"policy.event_seq",
"policy.context_fingerprint",
"policy.delivery_audiences_json",
"policy.run_exposure_revision",
"policy.run_exposure_set_id",
"policy.run_id",
"policy.source_policy_set_id",
"subject.session_identity_revision",
"subject.session_key",
"subject.subject_revision",
"detail.actor_evidence_json",
"detail.delegation_snapshot_json",
"detail.egress_receipt_ids_json",
"detail.exposed_resource_revisions_json",
"detail.exposure_receipt_ids_json",
"detail.finalized_delivery_audiences_json",
"detail.normalized_audience_intersection_json",
"detail.source_event_seq",
"detail.source_session_id",
"transition.source_event_seq as transition_source_event_seq",
"transition.source_session_id as transition_source_session_id",
"transition.source_session_identity_revision as transition_source_session_identity_revision",
"transition.subject_revision as transition_subject_revision",
"transition.target_session_identity_revision as transition_target_session_identity_revision",
"transition.transition_kind",
])
.where("policy.session_id", "=", sessionId)
.where("policy.authorization_status", "=", "authorized")
.where("detail.retention_state", "=", "retained"),
).rows;
const records = new Map<number, TranscriptMemoryPolicyArchiveRecord>();
for (const row of rows) {
if (
!authorizedSeqs.has(row.event_seq) ||
row.context_fingerprint === null ||
row.delivery_audiences_json === null ||
row.run_exposure_revision === null ||
row.run_exposure_set_id === null ||
row.run_id === null ||
row.source_policy_set_id === null ||
row.source_event_seq === null ||
row.source_session_id === null
) {
continue;
}
const hasTransition = row.transition_source_session_id !== null;
if (
hasTransition &&
(row.transition_source_event_seq === null ||
row.transition_source_session_identity_revision === null ||
row.transition_subject_revision === null ||
row.transition_target_session_identity_revision === null ||
row.transition_kind === null)
) {
continue;
}
records.set(
row.event_seq,
Object.freeze({
agentId,
type: TRANSCRIPT_MEMORY_POLICY_ARCHIVE_RECORD_TYPE,
version: 1,
sessionId,
eventSeq: row.event_seq,
subject: Object.freeze({
sessionKey: row.session_key,
sessionIdentityRevision: row.session_identity_revision,
subjectRevision: row.subject_revision,
}),
policy: Object.freeze({
contextFingerprint: row.context_fingerprint,
deliveryAudiencesJson: row.delivery_audiences_json,
runExposureRevision: row.run_exposure_revision,
runExposureSetId: row.run_exposure_set_id,
runId: row.run_id,
sourcePolicySetId: row.source_policy_set_id,
}),
detail: Object.freeze({
actorEvidenceJson: row.actor_evidence_json,
delegationSnapshotJson: row.delegation_snapshot_json,
egressReceiptIdsJson: row.egress_receipt_ids_json,
exposedResourceRevisionsJson: row.exposed_resource_revisions_json,
exposureReceiptIdsJson: row.exposure_receipt_ids_json,
finalizedDeliveryAudiencesJson: row.finalized_delivery_audiences_json,
normalizedAudienceIntersectionJson: row.normalized_audience_intersection_json,
sourceEventSeq: row.source_event_seq,
sourceSessionId: row.source_session_id,
}),
...(hasTransition
? {
transition: Object.freeze({
sourceEventSeq: row.transition_source_event_seq!,
sourceSessionId: row.transition_source_session_id!,
sourceSessionIdentityRevision: row.transition_source_session_identity_revision!,
subjectRevision: row.transition_subject_revision!,
targetSessionIdentityRevision: row.transition_target_session_identity_revision!,
kind: row.transition_kind!,
}),
}
: {}),
}),
);
}
return records;
}
export function materializeTranscriptArchiveInWorker(
@@ -174,7 +380,7 @@ export function materializeTranscriptArchiveInWorker(
`SQLite session state changed before archive materialization for ${plan.sessionId}`,
);
}
const content = readTranscriptArchiveContent(database.db, plan.sessionId);
const content = readTranscriptArchiveContent(database.db, plan.agentId, plan.sessionId);
database.db.exec("COMMIT"); // sqlite-allow-raw: closes the consistent read snapshot.
transactionOpen = false;
return { content, snapshot };
@@ -26,6 +26,7 @@ import {
appendTranscriptEventsInTransaction,
readTranscriptIdentityByEventId,
} from "./session-accessor.sqlite-transcript-store.js";
import { preserveTranscriptMemoryPolicyTransitionInTransaction } from "./session-transcript-memory-policy.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import {
SESSION_TOTAL_TOKENS_VERSION,
@@ -197,26 +198,32 @@ function branchSqliteCompactionCheckpointSessionInTransaction(
if (!checkpoint) {
return { status: "missing-checkpoint" };
}
let nextEntry: SessionEntry | undefined;
const forked = forkSqliteCheckpointTranscriptInTransaction(database, params.resolved, {
checkpoint,
legacySource: params.legacySource,
targetSessionKey: params.targetKey,
beforeAppend: ({ sessionId, totalTokens }) => {
const label = currentEntry.label?.trim()
? `${currentEntry.label.trim()} (checkpoint)`
: "Checkpoint branch";
nextEntry = cloneSqliteCheckpointSessionEntry({
currentEntry,
label,
nextSessionId: sessionId,
parentSessionKey: params.parentSessionKey,
totalTokens,
});
writeSessionEntry(database, params.targetKey, nextEntry);
},
});
if (forked.status !== "created") {
return forked;
}
const label = currentEntry.label?.trim()
? `${currentEntry.label.trim()} (checkpoint)`
: "Checkpoint branch";
const nextEntry = cloneSqliteCheckpointSessionEntry({
currentEntry,
label,
nextSessionId: forked.sessionId,
parentSessionKey: params.parentSessionKey,
totalTokens: forked.totalTokens,
});
writeSessionEntry(database, params.targetKey, nextEntry);
if (!nextEntry) {
return { status: "failed" };
}
return {
status: "created",
key: params.targetKey,
@@ -253,22 +260,28 @@ function restoreSqliteCompactionCheckpointSessionInTransaction(
if (!checkpoint) {
return { status: "missing-checkpoint" };
}
let nextEntry: SessionEntry | undefined;
const restored = forkSqliteCheckpointTranscriptInTransaction(database, params.resolved, {
checkpoint,
legacySource: params.legacySource,
targetSessionKey: params.targetKey,
beforeAppend: ({ sessionId, totalTokens }) => {
nextEntry = cloneSqliteCheckpointSessionEntry({
currentEntry,
nextSessionId: sessionId,
preserveCompactionCheckpoints: true,
totalTokens,
});
writeSessionEntry(database, params.targetKey, nextEntry);
},
});
if (restored.status !== "created") {
return restored;
}
const nextEntry = cloneSqliteCheckpointSessionEntry({
currentEntry,
nextSessionId: restored.sessionId,
preserveCompactionCheckpoints: true,
totalTokens: restored.totalTokens,
});
writeSessionEntry(database, params.targetKey, nextEntry);
if (!nextEntry) {
return { status: "failed" };
}
return {
status: "created",
key: params.targetKey,
@@ -282,6 +295,7 @@ function forkSqliteCheckpointTranscriptInTransaction(
resolved: ResolvedSqliteScope,
params: {
checkpoint: SessionCompactionCheckpoint;
beforeAppend?: (transcript: { sessionId: string; totalTokens?: number }) => void;
legacySource?: SqliteCompactionCheckpointLegacySource;
targetSessionKey: string;
},
@@ -331,6 +345,10 @@ function forkSqliteCheckpointTranscriptInTransaction(
const sessionFile = formatSqliteSessionReferenceForScope(targetScope);
const selectedEvents = selected?.rows ?? legacySource?.events ?? [];
const totalTokens = selected?.source.totalTokens ?? legacySource?.totalTokens;
params.beforeAppend?.({
sessionId,
...(typeof totalTokens === "number" ? { totalTokens } : {}),
});
appendTranscriptEventsInTransaction(database, targetScope, [
createSessionTranscriptHeader({
cwd: readTranscriptHeaderCwd(selectedEvents),
@@ -338,6 +356,14 @@ function forkSqliteCheckpointTranscriptInTransaction(
}),
...selectedEvents.filter((event) => !isSessionTranscriptHeader(event)),
]);
if (selected) {
preserveTranscriptMemoryPolicyTransitionInTransaction({
database,
sourceSessionId: selected.source.sessionId,
targetSessionId: sessionId,
transitionKind: "checkpoint",
});
}
return {
status: "created",
sessionId,
@@ -1,7 +1,9 @@
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import type { DB as OpenClawAgentDatabaseSchema } from "../../state/openclaw-agent-db.generated.js";
import {
runOpenClawAgentWriteTransaction,
type OpenClawAgentDatabase,
@@ -26,8 +28,17 @@ import {
} from "./session-accessor.sqlite-transcript-state.js";
import { appendTranscriptEventInTransaction } from "./session-accessor.sqlite-transcript-store.js";
import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js";
import {
parseTranscriptPolicyArchive,
restoreConfirmedTranscriptPolicyArchiveInTransaction,
} from "./session-transcript-policy-archive.js";
import type { SessionEntry } from "./types.js";
type ConfirmedTranscriptImportDatabase = Pick<
OpenClawAgentDatabaseSchema,
"session_memory_subject_snapshots" | "session_memory_subjects" | "transcript_events"
>;
/** Internal doctor/migration import target for one legacy session row. */
type SqliteSessionImportRowsParams = {
allowMalformedRowRepair?: boolean;
@@ -53,6 +64,150 @@ type SqliteSessionImportRowsResult = {
transcriptEvents: number;
};
type ConfirmedSqliteTranscriptImportParams = {
agentId?: string;
archiveContent: string;
entry: SessionEntry;
env?: NodeJS.ProcessEnv;
sessionKey: string;
storePath?: string;
};
type ConfirmedSqliteTranscriptImportResult = {
sessionId: string;
sessionKey: string;
transcriptEvents: number;
};
/**
* Internal owner flow for an operator-confirmed archive only. Legacy Doctor
* import stays quarantined; callers must authenticate confirmation before this
* API is reached and must not expose it as a model/runtime import surface.
*/
export async function importConfirmedSqliteTranscriptPolicyArchive(
params: ConfirmedSqliteTranscriptImportParams,
): Promise<ConfirmedSqliteTranscriptImportResult> {
// Archive parsing is deliberately outside the synchronous write transaction.
const archive = parseTranscriptPolicyArchive(params.archiveContent);
if (!archive) {
throw new Error("confirmed transcript archive is invalid or legacy JSONL");
}
const resolved = resolveSqliteScope({
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.env ? { env: params.env } : {}),
sessionKey: params.sessionKey,
...(params.storePath ? { storePath: params.storePath } : {}),
});
if (
archive.agentId !== resolved.agentId ||
archive.sessionId !== params.entry.sessionId ||
archive.sessionKey !== resolved.sessionKey
) {
throw new Error("confirmed transcript archive target mismatch");
}
return await runExclusiveSqliteSessionWrite(resolved, async () => {
let transcriptEvents = 0;
runOpenClawAgentWriteTransaction((database) => {
const db = getNodeSqliteKysely<ConfirmedTranscriptImportDatabase>(database.db);
const currentEntry = readExactSessionEntryRowForCanonicalRepair(
database,
resolved.sessionKey,
)?.entry;
if (currentEntry && currentEntry.sessionId !== archive.sessionId) {
throw new Error("confirmed transcript archive would replace an active session");
}
const subject = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_memory_subjects")
.select("subject_revision")
.where("session_key", "=", resolved.sessionKey)
.limit(1),
);
if (!subject || subject.subject_revision !== archive.subjectRevision) {
throw new Error("confirmed transcript archive subject is unavailable");
}
const snapshotBeforeRestore = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_memory_subject_snapshots")
.select(["session_identity_revision", "session_key", "subject_revision"])
.where("session_id", "=", archive.sessionId)
.limit(1),
);
if (
snapshotBeforeRestore &&
(snapshotBeforeRestore.session_key !== archive.sessionKey ||
snapshotBeforeRestore.subject_revision !== archive.subjectRevision ||
snapshotBeforeRestore.session_identity_revision !== archive.sessionIdentityRevision)
) {
throw new Error("confirmed transcript archive immutable snapshot conflicts");
}
const existingEvents = executeSqliteQuerySync(
database.db,
db
.selectFrom("transcript_events")
.select("seq")
.where("session_id", "=", archive.sessionId),
).rows;
if (existingEvents.length > 0) {
throw new Error("confirmed transcript archive target already has transcript events");
}
writeSessionEntry(database, resolved.sessionKey, params.entry, {
previousEntry: currentEntry ?? null,
});
// writeSessionEntry snapshots the surviving immutable subject. Replace only
// that just-created snapshot with the archive's proven generation identity.
if (!snapshotBeforeRestore) {
executeSqliteQuerySync(
database.db,
db
.deleteFrom("session_memory_subject_snapshots")
.where("session_id", "=", archive.sessionId),
);
executeSqliteQuerySync(
database.db,
db.insertInto("session_memory_subject_snapshots").values({
session_id: archive.sessionId,
session_key: archive.sessionKey,
subject_revision: archive.subjectRevision,
session_identity_revision: archive.sessionIdentityRevision,
created_at: Date.now(),
}),
);
}
const transcriptScope = { ...resolved, sessionId: archive.sessionId };
for (const archived of archive.events) {
if (
!appendTranscriptEventInTransaction(database, transcriptScope, archived.event, {
scheduleProjectionReconcile: false,
touchMutation: false,
})
) {
throw new Error("confirmed transcript archive event append conflict");
}
transcriptEvents += 1;
}
restoreConfirmedTranscriptPolicyArchiveInTransaction({
archive,
database,
sessionId: archive.sessionId,
sessionKey: archive.sessionKey,
});
reconcileSessionTranscriptIndexInTransaction(database.db, archive.sessionId);
touchTranscriptMutationInTransaction(database, archive.sessionId);
publishSessionEntryCacheInvalidation(database);
}, toDatabaseOptions(resolved));
return {
sessionId: archive.sessionId,
sessionKey: resolved.sessionKey,
transcriptEvents,
};
});
}
function prepareSqliteSessionImport(params: SqliteSessionImportRowsParams) {
if (params.readExactTranscriptRows && params.readTranscriptEvents) {
throw new Error("SQLite session import accepts only one transcript row source");
@@ -39,6 +39,7 @@ import type {
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { inheritSessionSelection } from "./session-entry-selection.js";
import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js";
import { preserveTranscriptMemoryPolicyTransitionInTransaction } from "./session-transcript-memory-policy.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import {
isSessionTranscriptLeafControl,
@@ -325,13 +326,6 @@ function mutateSqliteSessionAtMessageInTransaction(
targetId: params.mode === "switch" ? params.entryId : (cut?.parentId ?? null),
},
];
appendTranscriptEventsInTransaction(database, targetScope, nextEvents);
if (params.mode !== "fork") {
reconcileSessionTranscriptIndexInTransaction(database.db, nextSessionId);
}
// Rotating transcript identity fences stale live managers: later snapshot-replace writes
// target the old session and cannot erase this leaf repoint from the active session.
const nextEntry = {
...cloneMessageCutSessionEntry({
currentEntry,
@@ -350,7 +344,23 @@ function mutateSqliteSessionAtMessageInTransaction(
? buildSessionCreationStamp(params.creation)
: {}),
};
// The new identity snapshot must exist before copied events can receive
// transition provenance. If the write fails, the enclosing transaction leaves
// the old session untouched rather than treating raw replay as authorized.
writeSessionEntry(database, params.targetKey, nextEntry);
appendTranscriptEventsInTransaction(database, targetScope, nextEvents);
preserveTranscriptMemoryPolicyTransitionInTransaction({
database,
sourceSessionId: currentEntry.sessionId,
targetSessionId: nextSessionId,
transitionKind: params.mode,
});
if (params.mode !== "fork") {
reconcileSessionTranscriptIndexInTransaction(database.db, nextSessionId);
}
// Rotating transcript identity fences stale live managers: later snapshot-replace writes
// target the old session and cannot erase this leaf repoint from the active session.
return {
status: "created",
key: params.targetKey,
@@ -48,6 +48,7 @@ import {
} from "./session-accessor.sqlite-scope.js";
import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js";
import { preserveSqliteSameKeySessionRolloverLineage } from "./session-entry-lineage.js";
import { preserveTranscriptMemoryPolicyTransitionInTransaction } from "./session-transcript-memory-policy.js";
import type { InternalSessionEntry, SessionEntry } from "./types.js";
import { mergeSessionEntry, resolveFreshSessionTotalTokens } from "./types.js";
@@ -120,6 +121,7 @@ export async function forkSessionTranscriptFromParent(
params.commitGuard?.();
writeSqliteForkedChildTranscriptInTransaction(database, targetScope, {
parentSessionFile,
sourceSessionId: params.parentEntry.sessionId,
source,
});
}, toDatabaseOptions(target));
@@ -211,56 +213,65 @@ export async function forkSessionEntryFromParentTarget(
result = { status: "missing-entry" };
return;
}
let next: SessionEntry | undefined;
const fork = forkSqliteParentTranscriptInTransaction(writeDatabase, resolved, {
parentEntry: freshParent,
parentSessionKey: parentTarget.canonicalKey,
targetSessionKey: sessionTarget.canonicalKey,
beforeAppend: (transcript) => {
const patch = params.patch?.({
decision,
entry: cloneSessionEntry(freshBase),
fork: transcript,
parentEntry: cloneSessionEntry(freshParent),
});
const forkIdentityPatch: Partial<InternalSessionEntry> = {
...patch,
forkSource: {
sessionKey: parentTarget.canonicalKey,
sessionId: freshParent.sessionId,
},
forkedFromParent: true,
lifecycleRunId: undefined,
sessionId: transcript.sessionId,
totalTokens: undefined,
totalTokensFresh: false,
totalTokensVersion: undefined,
};
next = mergeSessionEntry(freshBase, forkIdentityPatch);
previousIdentity = readSessionIdentitySnapshot(writeDatabase, sessionTarget.storeKeys);
// Establish the target identity before copied events are appended. The
// transition helper will retain only matching current source lineage.
writeSessionEntry(writeDatabase, sessionTarget.canonicalKey, next, {
previousEntry: freshBase,
});
rehomeSessionWindows(writeDatabase, sessionTarget.canonicalKey, sessionTarget.storeKeys);
deleteLegacySessionEntryRows(
writeDatabase,
sessionTarget.storeKeys,
sessionTarget.canonicalKey,
{ rehomeMembers: freshBase.sessionId === next.sessionId },
);
maintenancePlans.push(
applySessionEntryMaintenance(writeDatabase, {
activeSessionKey: sessionTarget.canonicalKey,
archiveDirectory: resolveSqliteTranscriptArchiveDirectory(resolved),
skipMaintenance: true,
storePath: params.storePath,
}),
);
currentIdentity = readSessionIdentitySnapshot(writeDatabase, sessionTarget.storeKeys);
},
});
if (fork.status !== "created") {
result =
fork.status === "missing-parent" ? { status: "missing-parent" } : { status: "failed" };
return;
}
const patch = params.patch?.({
decision,
entry: cloneSessionEntry(freshBase),
fork: fork.transcript,
parentEntry: cloneSessionEntry(freshParent),
});
const forkIdentityPatch: Partial<InternalSessionEntry> = {
...patch,
forkSource: {
sessionKey: parentTarget.canonicalKey,
sessionId: freshParent.sessionId,
},
forkedFromParent: true,
lifecycleRunId: undefined,
sessionId: fork.transcript.sessionId,
totalTokens: undefined,
totalTokensFresh: false,
totalTokensVersion: undefined,
};
const next = mergeSessionEntry(freshBase, forkIdentityPatch);
previousIdentity = readSessionIdentitySnapshot(writeDatabase, sessionTarget.storeKeys);
writeSessionEntry(writeDatabase, sessionTarget.canonicalKey, next, {
previousEntry: freshBase,
});
rehomeSessionWindows(writeDatabase, sessionTarget.canonicalKey, sessionTarget.storeKeys);
deleteLegacySessionEntryRows(
writeDatabase,
sessionTarget.storeKeys,
sessionTarget.canonicalKey,
{ rehomeMembers: freshBase.sessionId === next.sessionId },
);
maintenancePlans.push(
applySessionEntryMaintenance(writeDatabase, {
activeSessionKey: sessionTarget.canonicalKey,
archiveDirectory: resolveSqliteTranscriptArchiveDirectory(resolved),
skipMaintenance: true,
storePath: params.storePath,
}),
);
currentIdentity = readSessionIdentitySnapshot(writeDatabase, sessionTarget.storeKeys);
if (!next) {
result = { status: "failed" };
return;
}
result = {
status: "forked",
decision,
@@ -291,6 +302,12 @@ async function persistSqliteParentForkSkipPatch(params: {
previous: params.entry,
sessionKey: params.sessionTarget.canonicalKey,
});
// A fork skipped before allocating a child has no transcript identity to
// snapshot. Keep its caller-visible patch in memory; persisting an empty
// session id would create invalid lifecycle provenance.
if (!next.sessionId?.trim()) {
return cloneSessionEntry(next);
}
const maintenancePlans: SessionEntryMaintenancePlan[] = [];
let previousIdentity = new Map<string, SessionEntry>();
let currentIdentity = new Map<string, SessionEntry>();
@@ -357,6 +374,7 @@ function forkSqliteParentTranscriptInTransaction(
forkFrom?: "last-completed";
targetSessionId?: string;
targetSessionKey: string;
beforeAppend?: (transcript: { sessionFile: string; sessionId: string }) => void;
},
): ForkSessionFromParentTranscriptResult {
if (!params.parentEntry.sessionId) {
@@ -385,16 +403,16 @@ function forkSqliteParentTranscriptInTransaction(
sessionKey: normalizeSqliteSessionKey(params.parentSessionKey),
});
const sessionFile = formatSqliteSessionReferenceForScope(targetScope);
const transcript = { sessionFile, sessionId };
params.beforeAppend?.(transcript);
writeSqliteForkedChildTranscriptInTransaction(database, targetScope, {
parentSessionFile,
sourceSessionId: params.parentEntry.sessionId,
source,
});
return {
status: "created",
transcript: {
sessionFile,
sessionId,
},
transcript,
};
}
@@ -421,6 +439,7 @@ function writeSqliteForkedChildTranscriptInTransaction(
targetScope: ResolvedTranscriptScope,
params: {
parentSessionFile: string;
sourceSessionId: string;
source: ParentForkSourceTranscript;
},
): void {
@@ -433,4 +452,10 @@ function writeSqliteForkedChildTranscriptInTransaction(
targetSessionId: targetScope.sessionId,
}),
);
preserveTranscriptMemoryPolicyTransitionInTransaction({
database,
sourceSessionId: params.sourceSessionId,
targetSessionId: targetScope.sessionId,
transitionKind: "parent-fork",
});
}
@@ -34,7 +34,11 @@ import {
indexAppendedTranscriptEventInTransaction,
reconcileSessionTranscriptIndexInTransaction,
} from "./session-transcript-index.js";
import { recordTranscriptMemoryPolicyInTransaction } from "./session-transcript-memory-policy.js";
import {
readPreservedTranscriptMemoryPoliciesInTransaction,
recordTranscriptMemoryPolicyInTransaction,
type PreservedTranscriptMemoryPolicy,
} from "./session-transcript-memory-policy.js";
import { startSessionTranscriptIndexReconcile } from "./session-transcript-reconcile.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import { resolveVisibleTranscriptAppendParentId } from "./transcript-visible-events.js";
@@ -191,6 +195,8 @@ function appendTranscriptEventRowInTransaction(
seq: number,
state: { seenEventIds: Set<string>; seenMessageIdempotencyKeys: Set<string> },
createdAtOverride?: number,
inheritedMemoryPolicy?: PreservedTranscriptMemoryPolicy,
replacementMemoryPolicy?: boolean,
): boolean {
const persistedEvent = canonicalizeTranscriptEventMedia(event);
const db = getSessionKysely(database.db);
@@ -214,6 +220,8 @@ function appendTranscriptEventRowInTransaction(
sessionKey: scope.sessionKey,
eventSeq: seq,
createdAt,
...(inheritedMemoryPolicy ? { inherited: inheritedMemoryPolicy } : {}),
...(replacementMemoryPolicy ? { replacement: true } : {}),
});
indexAppendedTranscriptEventInTransaction(database.db, {
sessionId: scope.sessionId,
@@ -337,6 +345,10 @@ export function replaceSqliteTranscriptEventsInTransaction(
? readTranscriptMutationStateInTransaction(database, resolved.sessionId).updatedAt
: undefined;
const previousGeneration = readTranscriptGenerationInTransaction(database, resolved.sessionId);
const preservedPolicies = readPreservedTranscriptMemoryPoliciesInTransaction(
database,
resolved.sessionId,
);
const deleted = deleteTranscriptEventsInTransaction(database, resolved.sessionId);
if (events.length === 0) {
if (deleted || previousGeneration) {
@@ -361,6 +373,12 @@ export function replaceSqliteTranscriptEventsInTransaction(
const seenEventIds = new Set<string>();
const seenMessageIdempotencyKeys = new Set<string>();
for (const [eventIndex, event] of events.entries()) {
const persistedEvent = canonicalizeTranscriptEventMedia(event);
const preservedForEvent = preservedPolicies.get(JSON.stringify(persistedEvent));
// Consume one matching source row per replacement row. Duplicate event
// payloads are distinguishable only by their durable source queue; a new
// duplicate has no queue entry and is intentionally pending.
const inheritedMemoryPolicy = preservedForEvent?.shift();
if (
appendTranscriptEventRowInTransaction(
database,
@@ -372,6 +390,8 @@ export function replaceSqliteTranscriptEventsInTransaction(
seenMessageIdempotencyKeys,
},
options.createdAtByIndex?.[eventIndex],
inheritedMemoryPolicy,
true,
)
) {
seq += 1;
@@ -5,10 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { runDoctorMemoryIsolation } from "../../commands/doctor-memory-isolation.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resetMemoryIsolationCutoverForTest } from "../../plugins/memory-cutover.js";
import { persistMemoryRunExposureBeforeContentInDatabase } from "../../plugins/memory-run-exposure-ledger.js";
import {
persistMemoryRunExposureBeforeContentInDatabase,
readDurableMemoryRunExposure,
} from "../../plugins/memory-run-exposure-ledger.js";
import {
clearMemoryRunExposureForTest,
recordMemoryRunExposure,
prepareMemoryRunExposure,
} from "../../plugins/memory-run-exposure.js";
import { createCurrentMemorySessionContext } from "../../state/memory-session-subject.js";
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
@@ -18,20 +21,23 @@ import {
runOpenClawAgentWriteTransaction,
} from "../../state/openclaw-agent-db.js";
import { readSessionTranscriptMessageEvents } from "./session-accessor.sqlite-active-events.js";
import { materializeSqliteSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js";
import { materializeSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js";
import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js";
import { planSqliteSessionStateDeleteIfUnreferenced } from "./session-accessor.sqlite-lifecycle-state.js";
import { planSessionStateDeleteIfUnreferenced } from "./session-accessor.sqlite-lifecycle-state.js";
import {
loadLatestSqliteAssistantText,
loadSqliteTranscriptEventsSync,
loadSqliteTranscriptTailEventsSync,
loadLatestAssistantText,
loadTranscriptEventsSync,
loadTranscriptTailEventsSync,
} from "./session-accessor.sqlite-read.js";
import { readActiveTranscriptAppendParentId } from "./session-accessor.sqlite-transcript-store.js";
import {
appendSqliteTranscriptMessage,
trimSqliteTranscriptForManualCompact,
appendTranscriptEvent,
appendTranscriptMessage,
replaceTranscriptEvents,
trimTranscriptForManualCompact,
} from "./session-accessor.sqlite-transcript-write.js";
import {
preserveTranscriptMemoryPolicyTransitionInTransaction,
readAuthorizedTranscriptEventSeqs,
resetTranscriptMemoryPolicyForTest,
} from "./session-transcript-memory-policy.js";
@@ -80,16 +86,75 @@ function markCutOver(env: NodeJS.ProcessEnv) {
VALUES (?, ?, ?, ?, 1)`,
)
.run(SESSION_ID, SESSION_KEY, SUBJECT_REVISION, SESSION_IDENTITY_REVISION);
// The transcript companion resolves stable policy identity from the exposed
// resource revision. Test receipts intentionally stay opaque plugin payloads.
database.db.exec(/* sqlite-allow-raw: test fixture establishes durable policy lineage. */ `
INSERT INTO memory_storage_roots
(storage_root_id, agent_id, backend_kind, opaque_locator, path_key_version, path_key,
authority_kind, authority_owner_id, default_capabilities_json, lifecycle_state, created_at, updated_at)
VALUES ('root-1', 'main', 'builtin', 'builtin:v1:test', 1, 's1_test_fixture_path_key_000',
'user', 'alice', '["read"]', 'active', 1, 1);
INSERT INTO memory_policies
(policy_id, agent_id, current_revision_id, revocation_epoch, lifecycle_state, created_at, updated_at)
VALUES ('policy-1', 'main', 'policy-revision-1', 0, 'active', 1, 1);
INSERT INTO memory_policy_revisions
(revision_id, policy_id, revision_number, revocation_epoch, lifecycle_state,
actor_kind, actor_id, reason, created_at)
VALUES ('policy-revision-1', 'policy-1', 1, 0, 'active', 'human', 'alice', 'fixture', 1);
INSERT INTO memory_stores
(store_id, agent_id, storage_root_id, policy_id, scope_kind, audience_kind, audience_id,
lifecycle_state, created_at, updated_at)
VALUES ('store-1', 'main', 'root-1', 'policy-1', 'user', 'user', 'alice', 'active', 1, 1);
INSERT INTO memory_resources
(resource_id, agent_id, store_id, logical_locator, source, created_at)
VALUES ('resource-1', 'main', 'store-1', 'memory/fixture.md', 'memory', 1);
INSERT INTO memory_resource_revisions
(revision_id, resource_id, revision_number, artifact_locator, content_hash, content_bytes,
policy_revision_id, policy_revocation_epoch, source_policy_set_id, lifecycle_state,
actor_kind, actor_id, expires_at, created_at, activated_at, retired_at)
VALUES ('resource-revision-1', 'resource-1', 1, 'fixture.md', 'fixture', 7,
'policy-revision-1', 0, 'plugin-policy-set-1', 'active', 'human', 'alice', NULL, 1, 1, NULL);
`);
resetTranscriptMemoryPolicyForTest(database.db);
return database;
}
function seedTranscriptPolicyFixture(database: OpenClawAgentDatabase): void {
database.db.exec(/* sqlite-allow-raw: test fixture establishes durable policy lineage. */ `
INSERT INTO memory_storage_roots
(storage_root_id, agent_id, backend_kind, opaque_locator, path_key_version, path_key,
authority_kind, authority_owner_id, default_capabilities_json, lifecycle_state, created_at, updated_at)
VALUES ('root-shadow', 'main', 'builtin', 'builtin:v1:shadow', 1, 's1_shadow_fixture_path_key_000',
'user', 'alice', '["read"]', 'active', 1, 1);
INSERT INTO memory_policies
(policy_id, agent_id, current_revision_id, revocation_epoch, lifecycle_state, created_at, updated_at)
VALUES ('policy-shadow', 'main', 'policy-shadow-revision-1', 0, 'active', 1, 1);
INSERT INTO memory_policy_revisions
(revision_id, policy_id, revision_number, revocation_epoch, lifecycle_state,
actor_kind, actor_id, reason, created_at)
VALUES ('policy-shadow-revision-1', 'policy-shadow', 1, 0, 'active', 'human', 'alice', 'fixture', 1);
INSERT INTO memory_stores
(store_id, agent_id, storage_root_id, policy_id, scope_kind, audience_kind, audience_id,
lifecycle_state, created_at, updated_at)
VALUES ('store-shadow', 'main', 'root-shadow', 'policy-shadow', 'agent', 'agent', 'main', 'active', 1, 1);
INSERT INTO memory_resources
(resource_id, agent_id, store_id, logical_locator, source, created_at)
VALUES ('resource-shadow', 'main', 'store-shadow', 'memory/shadow.md', 'memory', 1);
INSERT INTO memory_resource_revisions
(revision_id, resource_id, revision_number, artifact_locator, content_hash, content_bytes,
policy_revision_id, policy_revocation_epoch, source_policy_set_id, lifecycle_state,
actor_kind, actor_id, expires_at, created_at, activated_at, retired_at)
VALUES ('resource-revision-1', 'resource-shadow', 1, 'shadow.md', 'shadow', 6,
'policy-shadow-revision-1', 0, 'plugin-policy-set-1', 'active', 'human', 'alice', NULL, 1, 1, NULL);
`);
}
function recordExposure(params: {
runId: string;
subjectRevision?: string;
sessionIdentityRevision?: string;
}) {
return recordMemoryRunExposure({
return prepareMemoryRunExposure({
agentId: AGENT_ID,
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
@@ -106,6 +171,16 @@ function recordExposure(params: {
egressRegistryRevision: "egress-registry-revision-1",
sessionIdentityRevision: params.sessionIdentityRevision ?? SESSION_IDENTITY_REVISION,
subjectRevision: params.subjectRevision ?? SUBJECT_REVISION,
actorEvidence: {
version: 1,
kind: "principal",
actorKind: "human",
principalId: "alice",
assurance: "gateway-profile",
evidenceRevision: "actor-revision-1",
},
delegationSnapshot: { version: 1, kind: "none" },
hostFactsRevision: "host-facts-1",
});
}
@@ -132,13 +207,60 @@ async function appendWithRun(params: { env: NodeJS.ProcessEnv; runId: string; te
withTranscriptWrite: async (run) => await run(),
},
async () => {
await appendSqliteTranscriptMessage(scope(params.env), {
await appendTranscriptMessage(scope(params.env), {
message: { role: "assistant", content: [{ type: "text", text: params.text }] },
});
},
);
}
function copyPendingTranscriptForTransition(params: {
database: OpenClawAgentDatabase;
sourceSessionId?: string;
subjectRevision?: string;
targetSessionId: string;
}): void {
const sourceSessionId = params.sourceSessionId ?? SESSION_ID;
const targetSessionKey = `${SESSION_KEY}:transition:${params.targetSessionId}`;
const subjectRevision = params.subjectRevision ?? SUBJECT_REVISION;
params.database.db
.prepare(
`INSERT INTO session_memory_subjects
(session_key, subject_kind, binding_id, principal_id, subject_revision, created_at)
VALUES (?, 'user', 'binding-transition', 'alice', ?, 1)`,
)
.run(targetSessionKey, subjectRevision);
writeSessionEntry(params.database, targetSessionKey, {
sessionId: params.targetSessionId,
updatedAt: 1,
});
const sourceRows = params.database.db
.prepare(
`SELECT created_at, event_json, seq
FROM transcript_events
WHERE session_id = ?
ORDER BY seq ASC`,
)
.all(sourceSessionId) as Array<{ created_at: number; event_json: string; seq: number }>;
for (const row of sourceRows) {
params.database.db
.prepare(
`INSERT INTO transcript_events (session_id, seq, event_json, created_at)
VALUES (?, ?, ?, ?)`,
)
.run(params.targetSessionId, row.seq, row.event_json, row.created_at);
params.database.db
.prepare(
`INSERT INTO transcript_event_memory_policies
(session_id, event_seq, authorization_status, source_policy_set_id, run_exposure_set_id,
run_exposure_revision, delivery_audiences_json, session_identity_revision,
subject_revision, run_id, context_fingerprint, created_at)
VALUES (?, ?, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?)`,
)
.run(params.targetSessionId, row.seq, row.created_at);
}
}
afterEach(() => {
clearMemoryRunExposureForTest();
resetMemoryIsolationCutoverForTest();
@@ -170,12 +292,13 @@ describe("transcript memory policy companions", () => {
writeSession(alice);
const database = openOpenClawAgentDatabase(options);
seedTranscriptPolicyFixture(database);
const aliceContext = createCurrentMemorySessionContext({ ...alice, options });
expect(aliceContext.kind).toBe("current");
if (aliceContext.kind !== "current") {
throw new Error("expected lifecycle-owned Alice subject context");
}
await appendSqliteTranscriptMessage(
await appendTranscriptMessage(
{ ...alice, agentId: AGENT_ID, env },
{
message: {
@@ -205,7 +328,7 @@ describe("transcript memory policy companions", () => {
}).hits,
).toEqual([]);
const shadowExposure = recordMemoryRunExposure({
const shadowExposure = prepareMemoryRunExposure({
agentId: AGENT_ID,
sessionId: alice.sessionId,
sessionKey: alice.sessionKey,
@@ -222,6 +345,16 @@ describe("transcript memory policy companions", () => {
egressRegistryRevision: "egress-registry-revision-1",
sessionIdentityRevision: aliceContext.context.sessionIdentityRevision,
subjectRevision: aliceContext.context.subjectRevision,
actorEvidence: {
version: 1,
kind: "principal",
actorKind: "human",
principalId: aliceContext.context.principalId,
assurance: "gateway-profile",
evidenceRevision: "shadow-actor-revision-1",
},
delegationSnapshot: { version: 1, kind: "none" },
hostFactsRevision: "shadow-host-facts-1",
});
expect(
persistMemoryRunExposureBeforeContentInDatabase({ database, snapshot: shadowExposure }),
@@ -237,7 +370,7 @@ describe("transcript memory policy companions", () => {
withTranscriptWrite: async (run) => await run(),
},
async () => {
await appendSqliteTranscriptMessage(
await appendTranscriptMessage(
{ ...alice, agentId: AGENT_ID, env },
{
message: {
@@ -251,7 +384,7 @@ describe("transcript memory policy companions", () => {
expect(readAuthorizedTranscriptEventSeqs(database.db, alice.sessionId)?.size).toBeGreaterThan(
0,
);
expect(loadSqliteTranscriptEventsSync({ ...alice, agentId: AGENT_ID, env })).toContainEqual(
expect(loadTranscriptEventsSync({ ...alice, agentId: AGENT_ID, env })).toContainEqual(
expect.objectContaining({
message: expect.objectContaining({
content: [{ type: "text", text: "alice scoped content" }],
@@ -270,31 +403,38 @@ describe("transcript memory policy companions", () => {
expect(createCurrentMemorySessionContext({ ...bob, options })).toEqual({
kind: "shadow-subject-mismatch",
});
await appendSqliteTranscriptMessage(
await appendTranscriptMessage(
{ ...bob, agentId: AGENT_ID, env },
{
message: { role: "assistant", content: [{ type: "text", text: "bob denied content" }] },
},
);
expect(readAuthorizedTranscriptEventSeqs(database.db, bob.sessionId)).toEqual(new Set());
expect(loadSqliteTranscriptEventsSync({ ...bob, agentId: AGENT_ID, env })).toEqual([]);
expect(loadTranscriptEventsSync({ ...bob, agentId: AGENT_ID, env })).toEqual([]);
});
it("fails closed for missing or stale run exposure while indexing only an authorized event", async () => {
const env = createEnv();
// Establish the SQLite session before the cut-over marker is written; its old row has no
// companion and must disappear as soon as the enforced policy reader is active.
await appendSqliteTranscriptMessage(scope(env), {
await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "legacy private content" }] },
});
const database = markCutOver(env);
await appendSqliteTranscriptMessage(scope(env), {
await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "missing exposure content" }] },
});
persistExposure(database, { runId: "stale-run", subjectRevision: "stale-subject-revision" });
await appendWithRun({ env, runId: "stale-run", text: "stale exposure content" });
const authorizedExposure = persistExposure(database, { runId: "authorized-run" });
expect(
readDurableMemoryRunExposure({
database,
sessionId: SESSION_ID,
runId: "authorized-run",
}),
).toMatchObject({ exposureSetId: authorizedExposure.exposureSetId });
await appendWithRun({ env, runId: "authorized-run", text: "authorized exposure content" });
const policyRows = database.db
@@ -320,21 +460,21 @@ describe("transcript memory policy companions", () => {
);
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)).toEqual(new Set([4]));
expect(loadSqliteTranscriptEventsSync(scope(env))).toEqual([
expect(loadTranscriptEventsSync(scope(env))).toEqual([
expect.objectContaining({
message: expect.objectContaining({
content: [{ type: "text", text: "authorized exposure content" }],
}),
}),
]);
expect(loadSqliteTranscriptTailEventsSync(scope(env), 2)).toEqual([
expect(loadTranscriptTailEventsSync(scope(env), 2)).toEqual([
expect.objectContaining({
message: expect.objectContaining({
content: [{ type: "text", text: "authorized exposure content" }],
}),
}),
]);
expect(loadLatestSqliteAssistantText(scope(env))).toMatchObject({
expect(loadLatestAssistantText(scope(env))).toMatchObject({
text: "authorized exposure content",
});
@@ -377,13 +517,13 @@ describe("transcript memory policy companions", () => {
},
async () => {
authorizedMessageId = (
await appendSqliteTranscriptMessage(scope(env), {
await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "authorized" }] },
})
).messageId;
},
);
const pending = await appendSqliteTranscriptMessage(scope(env), {
const pending = await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "pending" }] },
});
@@ -394,14 +534,14 @@ describe("transcript memory policy companions", () => {
it("does not pass a pending transcript event to manual compaction", async () => {
const env = createEnv();
const database = markCutOver(env);
await appendSqliteTranscriptMessage(scope(env), {
await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "pending" }] },
});
const selectRetainedLines = vi.fn(() => null);
await expect(
trimSqliteTranscriptForManualCompact(scope(env), selectRetainedLines),
).resolves.toEqual({ trimmed: false });
await expect(trimTranscriptForManualCompact(scope(env), selectRetainedLines)).resolves.toEqual({
trimmed: false,
});
expect(selectRetainedLines).not.toHaveBeenCalled();
expect(
@@ -416,8 +556,8 @@ describe("transcript memory policy companions", () => {
const database = markCutOver(env);
persistExposure(database, { runId: "authorized-run" });
database.db.exec(/* sqlite-allow-raw: test-only atomicity fault injection. */ `
CREATE TRIGGER reject_transcript_memory_policy_for_test
BEFORE INSERT ON transcript_event_memory_policies
CREATE TRIGGER reject_transcript_memory_policy_detail_for_test
BEFORE INSERT ON transcript_event_memory_policy_details
BEGIN
SELECT RAISE(ABORT, 'test companion persistence failure');
END;
@@ -430,8 +570,11 @@ describe("transcript memory policy companions", () => {
for (const table of [
"transcript_events",
"transcript_event_memory_policies",
"transcript_event_memory_policy_details",
"memory_policy_sets",
"memory_policy_set_members",
"memory_run_exposures",
"memory_run_exposure_resources",
]) {
expect(database.db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()).toEqual({
count: 0,
@@ -442,6 +585,11 @@ describe("transcript memory policy companions", () => {
expect(
database.db.prepare("SELECT COUNT(*) AS count FROM memory_preoutput_exposure_ledger").get(),
).toEqual({ count: 1 });
expect(
database.db
.prepare("SELECT COUNT(*) AS count FROM memory_preoutput_exposure_authorization_facts")
.get(),
).toEqual({ count: 1 });
});
it("replays only committed current companions after a fresh database consumer starts", async () => {
@@ -480,7 +628,7 @@ describe("transcript memory policy companions", () => {
expect(readAuthorizedTranscriptEventSeqs(fresh.db, SESSION_ID)?.size).toBe(
committedAuthorizedCount,
);
expect(loadSqliteTranscriptEventsSync(scope(env))).toContainEqual(
expect(loadTranscriptEventsSync(scope(env))).toContainEqual(
expect.objectContaining({
message: expect.objectContaining({
content: [{ type: "text", text: "committed companion content" }],
@@ -490,7 +638,7 @@ describe("transcript memory policy companions", () => {
// A later durable row without a companion is pending. A separate database
// consumer must not infer authority from the earlier committed exposure.
await appendSqliteTranscriptMessage(scope(env), {
await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "missing companion" }] },
});
closeOpenClawAgentDatabasesForTest();
@@ -498,7 +646,7 @@ describe("transcript memory policy companions", () => {
expect(readAuthorizedTranscriptEventSeqs(fresh.db, SESSION_ID)?.size).toBe(
committedAuthorizedCount,
);
expect(loadSqliteTranscriptEventsSync(scope(env))).toContainEqual(
expect(loadTranscriptEventsSync(scope(env))).toContainEqual(
expect.objectContaining({
message: expect.objectContaining({
content: [{ type: "text", text: "committed companion content" }],
@@ -519,7 +667,7 @@ describe("transcript memory policy companions", () => {
expect(readAuthorizedTranscriptEventSeqs(fresh.db, SESSION_ID)?.size).toBe(
committedAuthorizedCount,
);
expect(loadSqliteTranscriptEventsSync(scope(env))).toContainEqual(
expect(loadTranscriptEventsSync(scope(env))).toContainEqual(
expect.objectContaining({
message: expect.objectContaining({
content: [{ type: "text", text: "committed companion content" }],
@@ -539,7 +687,7 @@ describe("transcript memory policy companions", () => {
closeOpenClawAgentDatabasesForTest();
fresh = openOpenClawAgentDatabase({ agentId: AGENT_ID, env });
expect(readAuthorizedTranscriptEventSeqs(fresh.db, SESSION_ID)).toEqual(new Set());
expect(loadSqliteTranscriptEventsSync(scope(env))).toEqual([]);
expect(loadTranscriptEventsSync(scope(env))).toEqual([]);
});
it("removes a stale companion from replay, search, projections, compaction, and export", async () => {
@@ -565,16 +713,16 @@ describe("transcript memory policy companions", () => {
.run(999, SESSION_ID);
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)).toEqual(new Set());
expect(loadSqliteTranscriptEventsSync(scope(env))).toEqual([]);
expect(loadTranscriptEventsSync(scope(env))).toEqual([]);
expect(search().hits).toEqual([]);
expect(() => readSessionTranscriptMessageEvents(scope(env))).toThrow(
/projection is rebuilding/i,
);
await expect(trimSqliteTranscriptForManualCompact(scope(env), vi.fn())).resolves.toEqual({
await expect(trimTranscriptForManualCompact(scope(env), vi.fn())).resolves.toEqual({
trimmed: false,
});
const plan = planSqliteSessionStateDeleteIfUnreferenced({
const plan = planSessionStateDeleteIfUnreferenced({
archiveDirectory: path.join(roots.at(-1) ?? "", "archives"),
archiveTranscript: true,
database,
@@ -583,7 +731,317 @@ describe("transcript memory policy companions", () => {
sessionId: SESSION_ID,
});
expect(plan).not.toBeNull();
const materialized = await materializeSqliteSessionStateDeletePlans([plan!]);
expect(materialized[0]?.archivedTranscript).toBeNull();
const sourceEventCount = database.db
.prepare("SELECT COUNT(*) AS count FROM transcript_events WHERE session_id = ?")
.get(SESSION_ID);
await expect(materializeSessionStateDeletePlans([plan!])).rejects.toThrow(
`Unauthorized transcript policy archive event for ${SESSION_ID}`,
);
// Archive failure preserves the raw source rather than leaking it or deleting it.
expect(
database.db
.prepare("SELECT COUNT(*) AS count FROM transcript_events WHERE session_id = ?")
.get(SESSION_ID),
).toEqual(sourceEventCount);
});
it("records complete policy evidence for every readable transcript event class", async () => {
const env = createEnv();
const database = markCutOver(env);
persistExposure(database, { runId: "event-classes-run" });
await withOwnedSessionTranscriptWrites(
{
sessionTarget: {
agentId: AGENT_ID,
expectedWriterRunId: "event-classes-run",
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
},
withTranscriptWrite: async (run) => await run(),
},
async () => {
await appendTranscriptMessage(scope(env), {
message: { role: "user", content: [{ type: "text", text: "user event" }] },
});
await appendTranscriptMessage(scope(env), {
message: { role: "assistant", content: [{ type: "text", text: "assistant event" }] },
});
for (const type of ["tool-result", "summary", "checkpoint", "system"] as const) {
await appendTranscriptEvent(scope(env), { type, value: `${type} event` });
}
},
);
const companions = database.db
.prepare(
`SELECT policy.authorization_status, detail.actor_evidence_json, detail.delegation_snapshot_json,
detail.exposed_resource_revisions_json, detail.normalized_audience_intersection_json,
detail.finalized_delivery_audiences_json, detail.source_session_id, detail.source_event_seq
FROM transcript_event_memory_policies AS policy
JOIN transcript_event_memory_policy_details AS detail
ON detail.session_id = policy.session_id AND detail.event_seq = policy.event_seq
WHERE policy.session_id = ?
ORDER BY policy.event_seq`,
)
.all(SESSION_ID) as Array<{
actor_evidence_json: string;
authorization_status: string;
delegation_snapshot_json: string;
exposed_resource_revisions_json: string;
finalized_delivery_audiences_json: string;
normalized_audience_intersection_json: string;
source_event_seq: number;
source_session_id: string;
}>;
// Header plus six requested classes all carry one atomic, evaluable row.
expect(companions).toHaveLength(7);
expect(companions.every((companion) => companion.authorization_status === "authorized")).toBe(
true,
);
for (const companion of companions) {
expect(JSON.parse(companion.actor_evidence_json)).toMatchObject({ principalId: "alice" });
expect(JSON.parse(companion.delegation_snapshot_json)).toMatchObject({ kind: "none" });
expect(JSON.parse(companion.exposed_resource_revisions_json)).toEqual([
"resource-revision-1",
]);
expect(companion.normalized_audience_intersection_json).toBe(
companion.finalized_delivery_audiences_json,
);
expect(companion.source_session_id).toBe(SESSION_ID);
expect(companion.source_event_seq).toBeGreaterThanOrEqual(0);
}
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)?.size).toBe(7);
});
it("uses the captured trusted actor and token-free delegation rather than reconstructing session facts", async () => {
const env = createEnv();
const database = markCutOver(env);
const captured = recordExposure({ runId: "delegated-run" });
const exposure = {
...captured,
actorEvidence: {
version: 1,
kind: "principal",
actorKind: "agent",
principalId: "support-agent",
assurance: "service",
evidenceRevision: "actor-evidence-42",
},
delegationSnapshot: {
version: 1,
kind: "delegated",
rootPrincipalId: "alice",
rootContextId: "root-context-42",
parentContextId: "parent-context-42",
parentMemoryPlanId: "parent-plan-42",
capabilitySnapshotId: "capability-42",
allowedOperations: ["derive", "read"],
maximumAudiences: [
{ kind: "role", id: "writer" },
{ kind: "user", id: "alice" },
],
depth: 1,
},
hostFactsRevision: "host-facts-42",
} as typeof captured;
expect(persistMemoryRunExposureBeforeContentInDatabase({ database, snapshot: exposure })).toBe(
true,
);
await appendWithRun({ env, runId: "delegated-run", text: "delegated durable evidence" });
const row = database.db
.prepare(
`SELECT actor_evidence_json, delegation_snapshot_json
FROM transcript_event_memory_policy_details
WHERE session_id = ?
ORDER BY event_seq DESC
LIMIT 1`,
)
.get(SESSION_ID) as { actor_evidence_json: string; delegation_snapshot_json: string };
expect(JSON.parse(row.actor_evidence_json)).toEqual(exposure.actorEvidence);
expect(JSON.parse(row.delegation_snapshot_json)).toEqual(exposure.delegationSnapshot);
expect(JSON.stringify(row)).not.toContain("binding-alice");
expect(JSON.stringify(row)).not.toContain("storeCapToken");
});
it("withdraws replay eligibility when the captured stable policy revision or epoch changes", async () => {
const env = createEnv();
const database = markCutOver(env);
persistExposure(database, { runId: "policy-revision-run" });
await appendWithRun({ env, runId: "policy-revision-run", text: "revision-bound secret" });
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)?.size).toBeGreaterThan(0);
database.db.exec(/* sqlite-allow-raw: test mutates the active policy owner after capture. */ `
UPDATE memory_policy_revisions
SET lifecycle_state = 'superseded'
WHERE revision_id = 'policy-revision-1';
INSERT INTO memory_policy_revisions
(revision_id, policy_id, revision_number, revocation_epoch, lifecycle_state,
actor_kind, actor_id, reason, created_at)
VALUES ('policy-revision-2', 'policy-1', 2, 1, 'active', 'human', 'alice', 'revoked', 2);
UPDATE memory_policies
SET current_revision_id = 'policy-revision-2', revocation_epoch = 1, updated_at = 2
WHERE policy_id = 'policy-1';
`);
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)).toEqual(new Set());
expect(loadTranscriptEventsSync(scope(env))).toEqual([]);
expect(
searchSessionTranscripts({ agentId: AGENT_ID, env, query: "revision-bound secret" }).hits,
).toEqual([]);
});
it("withdraws dependent events when an exposed resource expires", async () => {
const env = createEnv();
const database = markCutOver(env);
persistExposure(database, { runId: "resource-expiry-run" });
await appendWithRun({ env, runId: "resource-expiry-run", text: "resource-bound secret" });
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)?.size).toBeGreaterThan(0);
database.db.exec(/* sqlite-allow-raw: test advances an immutable lease past expiry. */ `
DROP TRIGGER memory_resource_revisions_immutable_fields;
UPDATE memory_resource_revisions
SET expires_at = ${Date.now() - 1}
WHERE revision_id = 'resource-revision-1';
`);
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)).toEqual(new Set());
expect(loadTranscriptEventsSync(scope(env))).toEqual([]);
});
it("preserves only exact same-session replacement lineage and leaves new rows pending", async () => {
const env = createEnv();
const database = markCutOver(env);
persistExposure(database, { runId: "replace-lineage-run" });
await appendWithRun({ env, runId: "replace-lineage-run", text: "retained source" });
const original = loadTranscriptEventsSync(scope(env));
const retainedMessage = original.find(
(event) =>
typeof event === "object" &&
event !== null &&
"message" in event &&
(event as { message?: { content?: Array<{ text?: string }> } }).message?.content?.[0]
?.text === "retained source",
);
expect(retainedMessage).toBeDefined();
await replaceTranscriptEvents(scope(env), [retainedMessage!]);
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)).toEqual(new Set([0]));
expect(
database.db
.prepare(
`SELECT source_event_seq
FROM transcript_event_memory_policy_details
WHERE session_id = ? AND event_seq = 0`,
)
.get(SESSION_ID),
).toEqual({ source_event_seq: 1 });
await withOwnedSessionTranscriptWrites(
{
sessionTarget: {
agentId: AGENT_ID,
expectedWriterRunId: "replace-lineage-run",
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
},
withTranscriptWrite: async (run) => await run(),
},
async () => {
await replaceTranscriptEvents(scope(env), [
{
message: {
role: "assistant",
content: [{ type: "text", text: "new derived content" }],
},
},
]);
},
);
expect(readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID)).toEqual(new Set());
expect(
database.db
.prepare(
`SELECT authorization_status
FROM transcript_event_memory_policies
WHERE session_id = ? AND event_seq = 0`,
)
.get(SESSION_ID),
).toEqual({ authorization_status: "pending" });
});
it("preserves a cross-session transition only with matching immutable subject provenance", async () => {
const env = createEnv();
const database = markCutOver(env);
persistExposure(database, { runId: "transition-run" });
await appendWithRun({ env, runId: "transition-run", text: "transition source" });
const sourceSeqs = readAuthorizedTranscriptEventSeqs(database.db, SESSION_ID);
expect(sourceSeqs?.size).toBeGreaterThan(0);
const targetSessionId = "transition-target";
copyPendingTranscriptForTransition({
database,
targetSessionId,
});
expect(
preserveTranscriptMemoryPolicyTransitionInTransaction({
database,
sourceSessionId: SESSION_ID,
targetSessionId,
transitionKind: "fork",
}),
).toBe(sourceSeqs?.size);
expect(readAuthorizedTranscriptEventSeqs(database.db, targetSessionId)).toEqual(sourceSeqs);
expect(
database.db
.prepare(
`SELECT source_session_id, source_event_seq, transition_kind
FROM transcript_event_memory_policy_transitions
WHERE session_id = ?
ORDER BY event_seq ASC`,
)
.all(targetSessionId),
).toEqual(
[...(sourceSeqs ?? [])].map((sourceEventSeq) => ({
source_session_id: SESSION_ID,
source_event_seq: sourceEventSeq,
transition_kind: "fork",
})),
);
});
it("leaves a cross-session copy pending when its target subject differs", async () => {
const env = createEnv();
const database = markCutOver(env);
persistExposure(database, { runId: "transition-mismatch-run" });
await appendWithRun({ env, runId: "transition-mismatch-run", text: "isolated source" });
const targetSessionId = "transition-mismatched-subject";
copyPendingTranscriptForTransition({
database,
subjectRevision: "different-subject-revision",
targetSessionId,
});
expect(
preserveTranscriptMemoryPolicyTransitionInTransaction({
database,
sourceSessionId: SESSION_ID,
targetSessionId,
transitionKind: "parent-fork",
}),
).toBe(0);
expect(readAuthorizedTranscriptEventSeqs(database.db, targetSessionId)).toEqual(new Set());
expect(
database.db
.prepare(
`SELECT COUNT(*) AS count
FROM transcript_event_memory_policy_transitions
WHERE session_id = ?`,
)
.get(targetSessionId),
).toEqual({ count: 0 });
});
});
@@ -14,12 +14,64 @@ import { getOwnedSessionTranscriptWriterFence } from "./transcript-write-context
type TranscriptMemoryPolicyDatabase = Pick<
OpenClawAgentDatabaseSchema,
| "memory_policies"
| "memory_policy_revisions"
| "memory_policy_set_members"
| "memory_policy_sets"
| "memory_resource_revisions"
| "memory_run_exposure_resources"
| "memory_run_exposures"
| "session_memory_subject_snapshots"
| "transcript_events"
| "transcript_event_memory_policies"
| "transcript_event_memory_policy_details"
| "transcript_event_memory_policy_transitions"
>;
type StablePolicyMember = Readonly<{
expectedRevocationEpoch: number;
expectedRevisionId: string;
policyId: string;
}>;
type PersistedExposure = Readonly<{
deliveryAudiencesJson: string;
egressReceiptIdsJson: string;
exposedResourceRevisionsJson: string;
exposureReceiptIdsJson: string;
policySetId: string;
}>;
export type PreservedTranscriptMemoryPolicy = Readonly<{
actorEvidenceJson: string;
contextFingerprint: string;
createdAt: number;
delegationSnapshotJson: string;
deliveryAudiencesJson: string;
egressReceiptIdsJson: string;
eventSeq: number;
exposedResourceRevisionsJson: string;
exposureReceiptIdsJson: string;
finalizedDeliveryAudiencesJson: string;
normalizedAudienceIntersectionJson: string;
retentionState: "retained" | "quarantined";
runExposureRevision: number;
runExposureSetId: string;
runId: string;
sessionIdentityRevision: string;
sourceEventSeq: number;
sourcePolicySetId: string;
sourceSessionId: string;
subjectRevision: string;
}>;
export type TranscriptMemoryPolicyTransitionKind =
| "parent-fork"
| "fork"
| "rewind"
| "switch"
| "checkpoint";
const enforcementByDatabase = new WeakMap<DatabaseSync, boolean>();
function policyDatabase(db: DatabaseSync) {
@@ -55,9 +107,18 @@ function canonicalAudiences(exposure: MemoryRunExposureSnapshot): string | undef
function effectivePolicySetId(
memoryPolicyRevision: string,
sourcePolicySetIdsJson: string,
members: readonly StablePolicyMember[],
audienceIntersectionJson: string,
): string {
return `mpset1_${createHash("sha256")
.update(JSON.stringify({ memoryPolicyRevision, sourcePolicySetIdsJson }))
return `mpset2_${createHash("sha256")
.update(
JSON.stringify({
audienceIntersectionJson,
members,
memoryPolicyRevision,
sourcePolicySetIdsJson,
}),
)
.digest("base64url")}`;
}
@@ -80,15 +141,71 @@ export function isTranscriptMemoryPolicyEnforcedInDatabase(db: DatabaseSync): bo
return enforced;
}
/**
* Resolve only persisted built-in revision facts. Plugin policy payload remains opaque:
* an exposure without stable resource revisions is pending instead of being reverse-engineered.
*/
function resolveStablePolicyMembersInTransaction(params: {
database: OpenClawAgentDatabase;
exposedResourceRevisions: readonly string[];
sourcePolicySetIds: readonly string[];
}): readonly StablePolicyMember[] | undefined {
const db = policyDatabase(params.database.db);
if (params.exposedResourceRevisions.length === 0) {
return undefined;
}
const resources = executeSqliteQuerySync(
params.database.db,
db
.selectFrom("memory_resource_revisions")
.select(["policy_revision_id", "revision_id", "source_policy_set_id"])
.where("revision_id", "in", [...new Set(params.exposedResourceRevisions)]),
).rows;
if (resources.length !== new Set(params.exposedResourceRevisions).size) {
return undefined;
}
if (params.sourcePolicySetIds.length === 0) {
return undefined;
}
const sourcePolicySetIds = new Set(params.sourcePolicySetIds);
if (resources.some((resource) => !sourcePolicySetIds.has(resource.source_policy_set_id))) {
return undefined;
}
const policyRevisionIds = new Set(resources.map((resource) => resource.policy_revision_id));
const revisions = executeSqliteQuerySync(
params.database.db,
db
.selectFrom("memory_policy_revisions as revision")
.innerJoin("memory_policies as policy", "policy.policy_id", "revision.policy_id")
.select([
"policy.policy_id as policy_id",
"revision.revision_id as revision_id",
"revision.revocation_epoch as revision_revocation_epoch",
])
.where("revision.revision_id", "in", [...policyRevisionIds]),
).rows;
if (revisions.length !== policyRevisionIds.size) {
return undefined;
}
const members = revisions
.map(
(revision) =>
({
expectedRevocationEpoch: revision.revision_revocation_epoch,
expectedRevisionId: revision.revision_id,
policyId: revision.policy_id,
}) satisfies StablePolicyMember,
)
.toSorted((left, right) => left.policyId.localeCompare(right.policyId));
return new Set(members.map((member) => member.policyId)).size === members.length
? Object.freeze(members)
: undefined;
}
function persistExposureLineageInTransaction(params: {
database: OpenClawAgentDatabase;
current: MemoryRunExposureSnapshot;
}):
| Readonly<{
policySetId: string;
deliveryAudiencesJson: string;
}>
| undefined {
}): PersistedExposure | undefined {
const snapshots: MemoryRunExposureSnapshot[] = [];
const seen = new Set<string>();
let cursor: MemoryRunExposureSnapshot | undefined = params.current;
@@ -105,7 +222,7 @@ function persistExposureLineageInTransaction(params: {
cursor = cursor.previous;
}
const db = policyDatabase(params.database.db);
let currentResult: { policySetId: string; deliveryAudiencesJson: string } | undefined;
let currentResult: PersistedExposure | undefined;
for (const snapshot of snapshots.toReversed()) {
const sourcePolicySetIdsJson = canonicalStrings(snapshot.sourcePolicySetIds);
const exposedResourceRevisionsJson = canonicalStrings(snapshot.exposedResourceRevisions);
@@ -124,7 +241,20 @@ function persistExposureLineageInTransaction(params: {
) {
return undefined;
}
const policySetId = effectivePolicySetId(snapshot.memoryPolicyRevision, sourcePolicySetIdsJson);
const members = resolveStablePolicyMembersInTransaction({
database: params.database,
exposedResourceRevisions: snapshot.exposedResourceRevisions,
sourcePolicySetIds: snapshot.sourcePolicySetIds,
});
if (!members) {
return undefined;
}
const policySetId = effectivePolicySetId(
snapshot.memoryPolicyRevision,
sourcePolicySetIdsJson,
members,
deliveryAudiencesJson,
);
executeSqliteQuerySync(
params.database.db,
db
@@ -138,6 +268,23 @@ function persistExposureLineageInTransaction(params: {
})
.onConflict((conflict) => conflict.column("policy_set_id").doNothing()),
);
for (const member of members) {
executeSqliteQuerySync(
params.database.db,
db
.insertInto("memory_policy_set_members")
.values({
policy_set_id: policySetId,
policy_id: member.policyId,
expected_revision_id: member.expectedRevisionId,
expected_revocation_epoch: member.expectedRevocationEpoch,
audience_intersection_json: deliveryAudiencesJson,
retention_state: "retained",
created_at: snapshot.createdAt,
})
.onConflict((conflict) => conflict.columns(["policy_set_id", "policy_id"]).doNothing()),
);
}
executeSqliteQuerySync(
params.database.db,
db
@@ -162,8 +309,30 @@ function persistExposureLineageInTransaction(params: {
})
.onConflict((conflict) => conflict.column("exposure_set_id").doNothing()),
);
for (const resourceRevisionId of snapshot.exposedResourceRevisions) {
executeSqliteQuerySync(
params.database.db,
db
.insertInto("memory_run_exposure_resources")
.values({
exposure_set_id: snapshot.exposureSetId,
resource_revision_id: resourceRevisionId,
policy_set_id: policySetId,
created_at: snapshot.createdAt,
})
.onConflict((conflict) =>
conflict.columns(["exposure_set_id", "resource_revision_id"]).doNothing(),
),
);
}
if (snapshot === params.current) {
currentResult = { policySetId, deliveryAudiencesJson };
currentResult = {
deliveryAudiencesJson,
egressReceiptIdsJson,
exposedResourceRevisionsJson,
exposureReceiptIdsJson,
policySetId,
};
}
}
return currentResult;
@@ -196,6 +365,9 @@ export function recordTranscriptMemoryPolicyInTransaction(params: {
sessionKey: string;
eventSeq: number;
createdAt: number;
inherited?: PreservedTranscriptMemoryPolicy;
/** Replacement rows may retain only an exact durable predecessor. */
replacement?: boolean;
}): boolean {
if (!isTranscriptMemoryPolicyEnforcedInDatabase(params.database.db)) {
return true;
@@ -213,7 +385,21 @@ export function recordTranscriptMemoryPolicyInTransaction(params: {
if (existing) {
return existing.authorization_status === "authorized";
}
const runId = getOwnedSessionTranscriptWriterFence()?.expectedWriterRunId;
if (
params.inherited &&
recordInheritedTranscriptMemoryPolicyInTransaction({
...params,
inherited: params.inherited,
})
) {
return true;
}
// Rewrites must never borrow a currently active writer's exposure for new
// material. A replacement carries authorization only through exact durable
// lineage; every unmatched or invalid row remains pending.
const runId = params.replacement
? undefined
: getOwnedSessionTranscriptWriterFence()?.expectedWriterRunId;
const exposure = runId
? readDurableMemoryRunExposure({
database: params.database,
@@ -226,11 +412,15 @@ export function recordTranscriptMemoryPolicyInTransaction(params: {
isCurrentAuthorizedLabel({ database: params.database, sessionId: params.sessionId, exposure })
? persistExposureLineageInTransaction({ database: params.database, current: exposure })
: undefined;
const authorized = Boolean(exposure && persisted);
// The exposure ledger carries the trusted, pre-output actor/delegation facts.
// Session subject rows only revalidate binding; they never reconstruct an actor.
const actorEvidenceJson = exposure ? JSON.stringify(exposure.actorEvidence) : undefined;
const delegationSnapshotJson = exposure ? JSON.stringify(exposure.delegationSnapshot) : undefined;
const authorized = Boolean(exposure && persisted && actorEvidenceJson && delegationSnapshotJson);
executeSqliteQuerySync(
params.database.db,
db.insertInto("transcript_event_memory_policies").values(
authorized && exposure && persisted
authorized && exposure && persisted && actorEvidenceJson && delegationSnapshotJson
? {
session_id: params.sessionId,
event_seq: params.eventSeq,
@@ -261,9 +451,488 @@ export function recordTranscriptMemoryPolicyInTransaction(params: {
},
),
);
if (authorized && exposure && persisted && actorEvidenceJson && delegationSnapshotJson) {
executeSqliteQuerySync(
params.database.db,
db.insertInto("transcript_event_memory_policy_details").values({
session_id: params.sessionId,
event_seq: params.eventSeq,
actor_evidence_json: actorEvidenceJson,
delegation_snapshot_json: delegationSnapshotJson,
exposed_resource_revisions_json: persisted.exposedResourceRevisionsJson,
exposure_receipt_ids_json: persisted.exposureReceiptIdsJson,
egress_receipt_ids_json: persisted.egressReceiptIdsJson,
normalized_audience_intersection_json: persisted.deliveryAudiencesJson,
finalized_delivery_audiences_json: persisted.deliveryAudiencesJson,
retention_state: "retained",
source_session_id: params.sessionId,
source_event_seq: params.eventSeq,
created_at: params.createdAt,
}),
);
}
return authorized;
}
function recordInheritedTranscriptMemoryPolicyInTransaction(params: {
database: OpenClawAgentDatabase;
sessionId: string;
sessionKey: string;
eventSeq: number;
createdAt: number;
inherited: PreservedTranscriptMemoryPolicy;
}): boolean {
const inherited = params.inherited;
// A replacement may copy only an already durable, same-session companion.
// Cross-session forks have a distinct session identity and must use the
// transition owner to establish a new authorized lineage rather than replay it.
const currentSubject = executeSqliteQueryTakeFirstSync(
params.database.db,
policyDatabase(params.database.db)
.selectFrom("session_memory_subject_snapshots")
.select(["session_identity_revision", "subject_revision"])
.where("session_id", "=", params.sessionId)
.limit(1),
);
if (
inherited.retentionState !== "retained" ||
inherited.sourceSessionId !== params.sessionId ||
inherited.sessionIdentityRevision !== currentSubject?.session_identity_revision ||
inherited.subjectRevision !== currentSubject?.subject_revision
) {
return false;
}
const db = policyDatabase(params.database.db);
executeSqliteQuerySync(
params.database.db,
db.insertInto("transcript_event_memory_policies").values({
session_id: params.sessionId,
event_seq: params.eventSeq,
authorization_status: "authorized",
source_policy_set_id: inherited.sourcePolicySetId,
run_exposure_set_id: inherited.runExposureSetId,
run_exposure_revision: inherited.runExposureRevision,
delivery_audiences_json: inherited.deliveryAudiencesJson,
session_identity_revision: inherited.sessionIdentityRevision,
subject_revision: inherited.subjectRevision,
run_id: inherited.runId,
context_fingerprint: inherited.contextFingerprint,
created_at: params.createdAt,
}),
);
executeSqliteQuerySync(
params.database.db,
db.insertInto("transcript_event_memory_policy_details").values({
session_id: params.sessionId,
event_seq: params.eventSeq,
actor_evidence_json: inherited.actorEvidenceJson,
delegation_snapshot_json: inherited.delegationSnapshotJson,
exposed_resource_revisions_json: inherited.exposedResourceRevisionsJson,
exposure_receipt_ids_json: inherited.exposureReceiptIdsJson,
egress_receipt_ids_json: inherited.egressReceiptIdsJson,
normalized_audience_intersection_json: inherited.normalizedAudienceIntersectionJson,
finalized_delivery_audiences_json: inherited.finalizedDeliveryAudiencesJson,
retention_state: inherited.retentionState,
source_session_id: inherited.sourceSessionId,
source_event_seq: inherited.sourceEventSeq,
created_at: params.createdAt,
}),
);
return true;
}
/**
* Capture durable companion evidence by exact stored event bytes before a
* same-session replacement deletes old rows. Replacements can reorder or drop
* events, so a sequence number is never a safe lineage key.
*/
export function readPreservedTranscriptMemoryPoliciesInTransaction(
database: OpenClawAgentDatabase,
sessionId: string,
): Map<string, PreservedTranscriptMemoryPolicy[]> {
const rows = executeSqliteQuerySync(
database.db,
policyDatabase(database.db)
.selectFrom("transcript_event_memory_policies as policy")
.innerJoin("transcript_events as event", (join) =>
join
.onRef("event.session_id", "=", "policy.session_id")
.onRef("event.seq", "=", "policy.event_seq"),
)
.innerJoin("transcript_event_memory_policy_details as detail", (join) =>
join
.onRef("detail.session_id", "=", "policy.session_id")
.onRef("detail.event_seq", "=", "policy.event_seq"),
)
.select([
"event.event_json",
"policy.context_fingerprint",
"policy.delivery_audiences_json",
"policy.event_seq",
"policy.run_exposure_revision",
"policy.run_exposure_set_id",
"policy.run_id",
"policy.session_identity_revision",
"policy.source_policy_set_id",
"policy.subject_revision",
"detail.actor_evidence_json",
"detail.created_at",
"detail.delegation_snapshot_json",
"detail.egress_receipt_ids_json",
"detail.exposed_resource_revisions_json",
"detail.exposure_receipt_ids_json",
"detail.finalized_delivery_audiences_json",
"detail.normalized_audience_intersection_json",
"detail.retention_state",
"detail.source_event_seq",
"detail.source_session_id",
])
.where("policy.session_id", "=", sessionId)
.where("policy.authorization_status", "=", "authorized")
.orderBy("policy.event_seq", "asc"),
).rows;
const result = new Map<string, PreservedTranscriptMemoryPolicy[]>();
for (const row of rows) {
if (
row.source_policy_set_id === null ||
row.run_exposure_set_id === null ||
row.run_exposure_revision === null ||
row.delivery_audiences_json === null ||
row.session_identity_revision === null ||
row.subject_revision === null ||
row.run_id === null ||
row.context_fingerprint === null ||
(row.retention_state !== "retained" && row.retention_state !== "quarantined")
) {
continue;
}
const preserved = Object.freeze({
actorEvidenceJson: row.actor_evidence_json,
contextFingerprint: row.context_fingerprint,
createdAt: row.created_at,
delegationSnapshotJson: row.delegation_snapshot_json,
deliveryAudiencesJson: row.delivery_audiences_json,
egressReceiptIdsJson: row.egress_receipt_ids_json,
eventSeq: row.event_seq,
exposedResourceRevisionsJson: row.exposed_resource_revisions_json,
exposureReceiptIdsJson: row.exposure_receipt_ids_json,
finalizedDeliveryAudiencesJson: row.finalized_delivery_audiences_json,
normalizedAudienceIntersectionJson: row.normalized_audience_intersection_json,
retentionState: row.retention_state,
runExposureRevision: row.run_exposure_revision,
runExposureSetId: row.run_exposure_set_id,
runId: row.run_id,
sessionIdentityRevision: row.session_identity_revision,
sourceEventSeq: row.source_event_seq,
sourcePolicySetId: row.source_policy_set_id,
sourceSessionId: row.source_session_id,
subjectRevision: row.subject_revision,
} satisfies PreservedTranscriptMemoryPolicy);
const matches = result.get(row.event_json);
if (matches) {
matches.push(preserved);
} else {
result.set(row.event_json, [preserved]);
}
}
return result;
}
/**
* Copies only already-readable, exact source events into a new session identity.
* The target snapshot must exist first: a copied companion is transition lineage,
* never a replay of the source session's authorization context.
*/
export function preserveTranscriptMemoryPolicyTransitionInTransaction(params: {
database: OpenClawAgentDatabase;
sourceSessionId: string;
targetSessionId: string;
transitionKind: TranscriptMemoryPolicyTransitionKind;
}): number {
if (
params.sourceSessionId === params.targetSessionId ||
!isTranscriptMemoryPolicyEnforcedInDatabase(params.database.db)
) {
return 0;
}
const db = policyDatabase(params.database.db);
const [sourceSubject, targetSubject] = [params.sourceSessionId, params.targetSessionId].map(
(sessionId) =>
executeSqliteQueryTakeFirstSync(
params.database.db,
db
.selectFrom("session_memory_subject_snapshots")
.select(["session_identity_revision", "subject_revision"])
.where("session_id", "=", sessionId)
.limit(1),
),
);
if (
!sourceSubject ||
!targetSubject ||
sourceSubject.subject_revision !== targetSubject.subject_revision
) {
return 0;
}
const readableSourceEventSeqs = readAuthorizedTranscriptEventSeqs(
params.database.db,
params.sourceSessionId,
);
if (!readableSourceEventSeqs || readableSourceEventSeqs.size === 0) {
return 0;
}
const sourceRows = executeSqliteQuerySync(
params.database.db,
db
.selectFrom("transcript_events as event")
.innerJoin("transcript_event_memory_policies as policy", (join) =>
join
.onRef("policy.session_id", "=", "event.session_id")
.onRef("policy.event_seq", "=", "event.seq"),
)
.innerJoin("transcript_event_memory_policy_details as detail", (join) =>
join
.onRef("detail.session_id", "=", "policy.session_id")
.onRef("detail.event_seq", "=", "policy.event_seq"),
)
.select([
"event.event_json",
"policy.context_fingerprint",
"policy.delivery_audiences_json",
"policy.event_seq",
"policy.run_exposure_revision",
"policy.run_exposure_set_id",
"policy.run_id",
"policy.source_policy_set_id",
"detail.actor_evidence_json",
"detail.delegation_snapshot_json",
"detail.egress_receipt_ids_json",
"detail.exposed_resource_revisions_json",
"detail.exposure_receipt_ids_json",
"detail.finalized_delivery_audiences_json",
"detail.normalized_audience_intersection_json",
"detail.retention_state",
])
.where("event.session_id", "=", params.sourceSessionId)
.where("policy.authorization_status", "=", "authorized")
.where("detail.retention_state", "=", "retained")
.orderBy("event.seq", "asc"),
).rows;
const sourcesByEventJson = new Map<string, (typeof sourceRows)[number][]>();
for (const row of sourceRows) {
if (!readableSourceEventSeqs.has(row.event_seq)) {
continue;
}
const matches = sourcesByEventJson.get(row.event_json);
if (matches) {
matches.push(row);
} else {
sourcesByEventJson.set(row.event_json, [row]);
}
}
const targetRows = executeSqliteQuerySync(
params.database.db,
db
.selectFrom("transcript_events")
.select(["event_json", "seq"])
.where("session_id", "=", params.targetSessionId)
.orderBy("seq", "asc"),
).rows;
let preserved = 0;
for (const target of targetRows) {
const source = sourcesByEventJson.get(target.event_json)?.shift();
if (
!source ||
source.source_policy_set_id === null ||
source.run_exposure_set_id === null ||
source.run_exposure_revision === null ||
source.delivery_audiences_json === null ||
source.run_id === null ||
source.context_fingerprint === null
) {
continue;
}
const updated = executeSqliteQuerySync(
params.database.db,
db
.updateTable("transcript_event_memory_policies")
.set({
authorization_status: "authorized",
source_policy_set_id: source.source_policy_set_id,
run_exposure_set_id: source.run_exposure_set_id,
run_exposure_revision: source.run_exposure_revision,
delivery_audiences_json: source.delivery_audiences_json,
session_identity_revision: targetSubject.session_identity_revision,
subject_revision: targetSubject.subject_revision,
run_id: source.run_id,
context_fingerprint: source.context_fingerprint,
})
.where("session_id", "=", params.targetSessionId)
.where("event_seq", "=", target.seq)
.where("authorization_status", "=", "pending"),
);
if (updated.numAffectedRows !== 1n) {
continue;
}
executeSqliteQuerySync(
params.database.db,
db.insertInto("transcript_event_memory_policy_details").values({
session_id: params.targetSessionId,
event_seq: target.seq,
actor_evidence_json: source.actor_evidence_json,
delegation_snapshot_json: source.delegation_snapshot_json,
exposed_resource_revisions_json: source.exposed_resource_revisions_json,
exposure_receipt_ids_json: source.exposure_receipt_ids_json,
egress_receipt_ids_json: source.egress_receipt_ids_json,
normalized_audience_intersection_json: source.normalized_audience_intersection_json,
finalized_delivery_audiences_json: source.finalized_delivery_audiences_json,
retention_state: "retained",
source_session_id: params.sourceSessionId,
source_event_seq: source.event_seq,
created_at: Date.now(),
}),
);
executeSqliteQuerySync(
params.database.db,
db.insertInto("transcript_event_memory_policy_transitions").values({
session_id: params.targetSessionId,
event_seq: target.seq,
source_session_id: params.sourceSessionId,
source_event_seq: source.event_seq,
transition_kind: params.transitionKind,
source_session_identity_revision: sourceSubject.session_identity_revision,
target_session_identity_revision: targetSubject.session_identity_revision,
subject_revision: targetSubject.subject_revision,
created_at: Date.now(),
}),
);
preserved += 1;
}
return preserved;
}
function isCurrentPolicySetAuthorized(db: DatabaseSync, policySetId: string): boolean {
try {
const policy = policyDatabase(db);
const members = executeSqliteQuerySync(
db,
policy
.selectFrom("memory_policy_set_members")
.select([
"expected_revocation_epoch",
"expected_revision_id",
"policy_id",
"retention_state",
])
.where("policy_set_id", "=", policySetId),
).rows;
if (members.length === 0 || members.some((member) => member.retention_state !== "retained")) {
return false;
}
return members.every((member) => {
const current = executeSqliteQueryTakeFirstSync(
db,
policy
.selectFrom("memory_policies as policy")
.innerJoin(
"memory_policy_revisions as revision",
"revision.revision_id",
"policy.current_revision_id",
)
.select([
"policy.lifecycle_state",
"policy.revocation_epoch",
"revision.lifecycle_state as revision_state",
])
.where("policy.policy_id", "=", member.policy_id)
.where("policy.current_revision_id", "=", member.expected_revision_id)
.where("policy.revocation_epoch", "=", member.expected_revocation_epoch)
.limit(1),
);
return current?.lifecycle_state === "active" && current.revision_state === "active";
});
} catch {
return false;
}
}
function areExposedResourcesCurrent(db: DatabaseSync, exposureSetId: string): boolean {
try {
const policy = policyDatabase(db);
const resources = executeSqliteQuerySync(
db,
policy
.selectFrom("memory_run_exposure_resources as exposure_resource")
.innerJoin(
"memory_resource_revisions as resource",
"resource.revision_id",
"exposure_resource.resource_revision_id",
)
.select(["resource.expires_at", "resource.lifecycle_state"])
.where("exposure_resource.exposure_set_id", "=", exposureSetId),
).rows;
const now = Date.now();
return (
resources.length > 0 &&
resources.every(
(resource) =>
resource.lifecycle_state === "active" &&
(resource.expires_at === null || resource.expires_at > now),
)
);
} catch {
return false;
}
}
function isCurrentTranscriptMemoryTransition(params: {
db: DatabaseSync;
policySessionIdentityRevision: string | null;
policySubjectRevision: string | null;
transition: {
source_event_seq: number | null;
source_session_id: string | null;
source_session_identity_revision: string | null;
subject_revision: string | null;
target_session_identity_revision: string | null;
};
}): boolean {
const { transition } = params;
if (
transition.source_event_seq === null ||
transition.source_session_id === null ||
transition.source_session_identity_revision === null ||
transition.subject_revision === null ||
transition.target_session_identity_revision === null ||
params.policySessionIdentityRevision !== transition.target_session_identity_revision ||
params.policySubjectRevision !== transition.subject_revision
) {
return false;
}
const sourceSnapshot = executeSqliteQueryTakeFirstSync(
params.db,
policyDatabase(params.db)
.selectFrom("session_memory_subject_snapshots")
.select(["session_identity_revision", "subject_revision"])
.where("session_id", "=", transition.source_session_id)
.limit(1),
);
if (
!sourceSnapshot ||
sourceSnapshot.session_identity_revision !== transition.source_session_identity_revision ||
sourceSnapshot.subject_revision !== transition.subject_revision
) {
return false;
}
// A transition never grants new authority: the exact source event must still
// be readable under current policy, resource, and source-session checks.
return Boolean(
readAuthorizedTranscriptEventSeqs(params.db, transition.source_session_id)?.has(
transition.source_event_seq,
),
);
}
/** Legacy returns undefined; cut-over returns only companion-authorized raw event sequences. */
export function readAuthorizedTranscriptEventSeqs(
db: DatabaseSync,
@@ -277,6 +946,11 @@ export function readAuthorizedTranscriptEventSeqs(
db,
policyDatabase(db)
.selectFrom("transcript_event_memory_policies as policy")
.innerJoin("transcript_event_memory_policy_details as detail", (join) =>
join
.onRef("detail.session_id", "=", "policy.session_id")
.onRef("detail.event_seq", "=", "policy.event_seq"),
)
.innerJoin(
"session_memory_subject_snapshots as subject",
"subject.session_id",
@@ -292,9 +966,28 @@ export function readAuthorizedTranscriptEventSeqs(
"policy_set.policy_set_id",
"policy.source_policy_set_id",
)
.select("policy.event_seq")
.leftJoin("transcript_event_memory_policy_transitions as transition", (join) =>
join
.onRef("transition.session_id", "=", "policy.session_id")
.onRef("transition.event_seq", "=", "policy.event_seq"),
)
.select([
"policy.event_seq",
"policy.session_identity_revision as policy_session_identity_revision",
"policy.subject_revision as policy_subject_revision",
"policy.source_policy_set_id",
"policy.run_exposure_set_id",
"detail.source_event_seq",
"detail.source_session_id",
"transition.source_event_seq as transition_source_event_seq",
"transition.source_session_id as transition_source_session_id",
"transition.source_session_identity_revision",
"transition.subject_revision as transition_subject_revision",
"transition.target_session_identity_revision",
])
.where("policy.session_id", "=", sessionId)
.where("policy.authorization_status", "=", "authorized")
.where("detail.retention_state", "=", "retained")
.whereRef("subject.session_identity_revision", "=", "policy.session_identity_revision")
.whereRef("subject.subject_revision", "=", "policy.subject_revision")
.whereRef("exposure.run_id", "=", "policy.run_id")
@@ -302,9 +995,45 @@ export function readAuthorizedTranscriptEventSeqs(
.whereRef("exposure.revision_number", "=", "policy.run_exposure_revision")
.whereRef("exposure.effective_source_policy_set_id", "=", "policy.source_policy_set_id")
.whereRef("exposure.delivery_audiences_json", "=", "policy.delivery_audiences_json")
.whereRef("detail.finalized_delivery_audiences_json", "=", "policy.delivery_audiences_json")
.whereRef(
"detail.normalized_audience_intersection_json",
"=",
"policy.delivery_audiences_json",
)
.whereRef("policy_set.policy_set_id", "=", "exposure.effective_source_policy_set_id"),
).rows;
return new Set(rows.map((row) => row.event_seq));
return new Set(
rows.flatMap((row) => {
if (
!row.source_policy_set_id ||
!row.run_exposure_set_id ||
!isCurrentPolicySetAuthorized(db, row.source_policy_set_id) ||
!areExposedResourcesCurrent(db, row.run_exposure_set_id)
) {
return [];
}
const transitioned = row.transition_source_session_id !== null;
const lineageCurrent = transitioned
? isCurrentTranscriptMemoryTransition({
db,
policySessionIdentityRevision: row.policy_session_identity_revision,
policySubjectRevision: row.policy_subject_revision,
transition: {
source_event_seq: row.transition_source_event_seq,
source_session_id: row.transition_source_session_id,
source_session_identity_revision: row.source_session_identity_revision,
subject_revision: row.transition_subject_revision,
target_session_identity_revision: row.target_session_identity_revision,
},
})
: // Exact same-session replacement retains the original source sequence
// after delete-and-reappend. No transition row means this is either the
// direct event or that already-verified replacement lineage.
row.source_session_id === sessionId;
return lineageCurrent ? [row.event_seq] : [];
}),
);
} catch {
return new Set();
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { parseTranscriptPolicyArchive } from "./session-transcript-policy-archive.js";
function archive(
params: { agentId?: string; mutate?: (record: Record<string, unknown>) => void } = {},
) {
const record: Record<string, unknown> = {
agentId: params.agentId ?? "main",
type: "openclaw.memory-policy-archive-v1",
version: 1,
sessionId: "session-1",
eventSeq: 0,
subject: {
sessionKey: "agent:main:session-1",
sessionIdentityRevision: "identity-1",
subjectRevision: "subject-1",
},
policy: {
contextFingerprint: "context-1",
deliveryAudiencesJson: '[{"id":"alice","kind":"user"}]',
runExposureRevision: 1,
runExposureSetId: "exposure-1",
runId: "run-1",
sourcePolicySetId: "policy-set-1",
},
detail: {
actorEvidenceJson: '{"version":1}',
delegationSnapshotJson: '{"kind":"none","version":1}',
egressReceiptIdsJson: '["egress-1"]',
exposedResourceRevisionsJson: '["resource-1"]',
exposureReceiptIdsJson: '["receipt-1"]',
finalizedDeliveryAudiencesJson: '[{"id":"alice","kind":"user"}]',
normalizedAudienceIntersectionJson: '[{"id":"alice","kind":"user"}]',
sourceEventSeq: 0,
sourceSessionId: "session-1",
},
};
params.mutate?.(record);
return `${JSON.stringify({ id: "event-1", type: "message" })}\n${JSON.stringify(record)}\n`;
}
describe("transcript policy archive", () => {
it("accepts canonical event and companion pairs", () => {
expect(parseTranscriptPolicyArchive(archive())).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
events: [{ eventSeq: 0, eventJson: '{"id":"event-1","type":"message"}' }],
});
});
it.each([
["legacy raw JSONL", '{"id":"event-1","type":"message"}\n'],
[
"missing immutable subject key",
archive({
mutate: (record) => delete (record.subject as Record<string, unknown>).sessionKey,
}),
],
["cross-owner companion", `${archive()}${archive({ agentId: "other" })}`],
])("rejects %s", (_name, content) => {
expect(parseTranscriptPolicyArchive(content)).toBeUndefined();
});
});
@@ -0,0 +1,383 @@
// Versioned transcript-policy archive decoding for explicit confirmed-import workflows.
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import type { DB as OpenClawAgentDatabaseSchema } from "../../state/openclaw-agent-db.generated.js";
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import { readAuthorizedTranscriptEventSeqs } from "./session-transcript-memory-policy.js";
const RECORD_TYPE = "openclaw.memory-policy-archive-v1";
type TranscriptPolicyArchiveDatabase = Pick<
OpenClawAgentDatabaseSchema,
| "session_memory_subject_snapshots"
| "transcript_events"
| "transcript_event_memory_policies"
| "transcript_event_memory_policy_details"
| "transcript_event_memory_policy_transitions"
>;
export type TranscriptPolicyArchiveEvent = Readonly<{
event: unknown;
eventJson: string;
eventSeq: number;
metadata: TranscriptPolicyArchiveMetadata;
}>;
export type TranscriptPolicyArchiveMetadata = Readonly<{
agentId: string;
eventSeq: number;
sessionId: string;
subject: Readonly<{
sessionIdentityRevision: string;
sessionKey: string;
subjectRevision: string;
}>;
policy: Readonly<{
contextFingerprint: string;
deliveryAudiencesJson: string;
runExposureRevision: number;
runExposureSetId: string;
runId: string;
sourcePolicySetId: string;
}>;
detail: Readonly<{
actorEvidenceJson: string;
delegationSnapshotJson: string;
egressReceiptIdsJson: string;
exposedResourceRevisionsJson: string;
exposureReceiptIdsJson: string;
finalizedDeliveryAudiencesJson: string;
normalizedAudienceIntersectionJson: string;
sourceEventSeq: number;
sourceSessionId: string;
}>;
transition?: Readonly<{
kind: "parent-fork" | "fork" | "rewind" | "switch" | "checkpoint";
sourceEventSeq: number;
sourceSessionId: string;
sourceSessionIdentityRevision: string;
subjectRevision: string;
targetSessionIdentityRevision: string;
}>;
}>;
export type TranscriptPolicyArchive = Readonly<{
agentId: string;
events: readonly TranscriptPolicyArchiveEvent[];
sessionId: string;
sessionIdentityRevision: string;
sessionKey: string;
subjectRevision: string;
}>;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function text(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function integer(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function parseJson(line: string): unknown | undefined {
try {
const parsed = JSON.parse(line) as unknown;
return JSON.stringify(parsed) === line ? parsed : undefined;
} catch {
return undefined;
}
}
function parseMetadata(value: unknown): TranscriptPolicyArchiveMetadata | undefined {
if (!isRecord(value) || value.type !== RECORD_TYPE || value.version !== 1) {
return undefined;
}
const subject = isRecord(value.subject) ? value.subject : undefined;
const policy = isRecord(value.policy) ? value.policy : undefined;
const detail = isRecord(value.detail) ? value.detail : undefined;
const agentId = text(value.agentId);
const sessionId = text(value.sessionId);
const eventSeq = integer(value.eventSeq);
const sessionKey = text(subject?.sessionKey);
const sessionIdentityRevision = text(subject?.sessionIdentityRevision);
const subjectRevision = text(subject?.subjectRevision);
const contextFingerprint = text(policy?.contextFingerprint);
const deliveryAudiencesJson = text(policy?.deliveryAudiencesJson);
const runExposureRevision = integer(policy?.runExposureRevision);
const runExposureSetId = text(policy?.runExposureSetId);
const runId = text(policy?.runId);
const sourcePolicySetId = text(policy?.sourcePolicySetId);
const actorEvidenceJson = text(detail?.actorEvidenceJson);
const delegationSnapshotJson = text(detail?.delegationSnapshotJson);
const egressReceiptIdsJson = text(detail?.egressReceiptIdsJson);
const exposedResourceRevisionsJson = text(detail?.exposedResourceRevisionsJson);
const exposureReceiptIdsJson = text(detail?.exposureReceiptIdsJson);
const finalizedDeliveryAudiencesJson = text(detail?.finalizedDeliveryAudiencesJson);
const normalizedAudienceIntersectionJson = text(detail?.normalizedAudienceIntersectionJson);
const sourceEventSeq = integer(detail?.sourceEventSeq);
const sourceSessionId = text(detail?.sourceSessionId);
if (
!agentId ||
!sessionId ||
eventSeq === undefined ||
!sessionKey ||
!sessionIdentityRevision ||
!subjectRevision ||
!contextFingerprint ||
!deliveryAudiencesJson ||
runExposureRevision === undefined ||
!runExposureSetId ||
!runId ||
!sourcePolicySetId ||
!actorEvidenceJson ||
!delegationSnapshotJson ||
!egressReceiptIdsJson ||
!exposedResourceRevisionsJson ||
!exposureReceiptIdsJson ||
!finalizedDeliveryAudiencesJson ||
!normalizedAudienceIntersectionJson ||
sourceEventSeq === undefined ||
!sourceSessionId
) {
return undefined;
}
const transitionRecord =
value.transition === undefined
? undefined
: isRecord(value.transition)
? value.transition
: null;
if (transitionRecord === null) {
return undefined;
}
const transition = transitionRecord
? {
kind: transitionRecord.kind,
sourceEventSeq: integer(transitionRecord.sourceEventSeq),
sourceSessionId: text(transitionRecord.sourceSessionId),
sourceSessionIdentityRevision: text(transitionRecord.sourceSessionIdentityRevision),
subjectRevision: text(transitionRecord.subjectRevision),
targetSessionIdentityRevision: text(transitionRecord.targetSessionIdentityRevision),
}
: undefined;
if (
transition &&
(!["parent-fork", "fork", "rewind", "switch", "checkpoint"].includes(String(transition.kind)) ||
transition.sourceEventSeq === undefined ||
!transition.sourceSessionId ||
!transition.sourceSessionIdentityRevision ||
!transition.subjectRevision ||
!transition.targetSessionIdentityRevision)
) {
return undefined;
}
return Object.freeze({
agentId,
eventSeq,
sessionId,
subject: Object.freeze({ sessionIdentityRevision, sessionKey, subjectRevision }),
policy: Object.freeze({
contextFingerprint,
deliveryAudiencesJson,
runExposureRevision,
runExposureSetId,
runId,
sourcePolicySetId,
}),
detail: Object.freeze({
actorEvidenceJson,
delegationSnapshotJson,
egressReceiptIdsJson,
exposedResourceRevisionsJson,
exposureReceiptIdsJson,
finalizedDeliveryAudiencesJson,
normalizedAudienceIntersectionJson,
sourceEventSeq,
sourceSessionId,
}),
...(transition
? {
transition: Object.freeze(
transition as NonNullable<TranscriptPolicyArchiveMetadata["transition"]>,
),
}
: {}),
});
}
/** Parse only canonical event/companion pairs; legacy raw JSONL is never importable here. */
export function parseTranscriptPolicyArchive(content: string): TranscriptPolicyArchive | undefined {
if (!content.endsWith("\n")) {
return undefined;
}
const lines = content.slice(0, -1).split("\n");
if (lines.length === 0 || lines.length % 2 !== 0) {
return undefined;
}
const events: TranscriptPolicyArchiveEvent[] = [];
for (let index = 0; index < lines.length; index += 2) {
const eventJson = lines[index];
const metadataJson = lines[index + 1];
if (eventJson === undefined || metadataJson === undefined) {
return undefined;
}
const event = parseJson(eventJson);
const metadata = parseMetadata(parseJson(metadataJson));
if (event === undefined || !metadata || metadata.eventSeq !== events.length) {
return undefined;
}
events.push(Object.freeze({ event, eventJson, eventSeq: metadata.eventSeq, metadata }));
}
const first = events[0]?.metadata;
if (
!first ||
events.some(
({ metadata, eventSeq }) =>
metadata.agentId !== first.agentId ||
metadata.sessionId !== first.sessionId ||
metadata.subject.sessionKey !== first.subject.sessionKey ||
metadata.subject.sessionIdentityRevision !== first.subject.sessionIdentityRevision ||
metadata.subject.subjectRevision !== first.subject.subjectRevision ||
metadata.eventSeq !== eventSeq,
)
) {
return undefined;
}
return Object.freeze({
agentId: first.agentId,
events: Object.freeze(events),
sessionId: first.sessionId,
sessionIdentityRevision: first.subject.sessionIdentityRevision,
sessionKey: first.subject.sessionKey,
subjectRevision: first.subject.subjectRevision,
});
}
/**
* Restores only already-appended archive events. The caller owns entry creation
* and event insertion; this helper proves provenance before making companions
* readable, then reruns the canonical current-policy reader in the same txn.
*/
export function restoreConfirmedTranscriptPolicyArchiveInTransaction(params: {
archive: TranscriptPolicyArchive;
database: OpenClawAgentDatabase;
sessionId: string;
sessionKey: string;
}): void {
const { archive, database, sessionId, sessionKey } = params;
if (
archive.agentId !== database.agentId ||
archive.sessionId !== sessionId ||
archive.sessionKey !== sessionKey
) {
throw new Error("confirmed transcript archive owner mismatch");
}
const db = getNodeSqliteKysely<TranscriptPolicyArchiveDatabase>(database.db);
const subject = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_memory_subject_snapshots")
.select(["session_identity_revision", "session_key", "subject_revision"])
.where("session_id", "=", sessionId)
.limit(1),
);
if (
!subject ||
subject.session_key !== archive.sessionKey ||
subject.session_identity_revision !== archive.sessionIdentityRevision ||
subject.subject_revision !== archive.subjectRevision
) {
throw new Error("confirmed transcript archive subject mismatch");
}
const rows = executeSqliteQuerySync(
database.db,
db
.selectFrom("transcript_events")
.select(["event_json", "seq"])
.where("session_id", "=", sessionId)
.orderBy("seq", "asc"),
).rows;
if (
rows.length !== archive.events.length ||
rows.some(
(row, index) =>
row.seq !== archive.events[index]?.eventSeq ||
row.event_json !== archive.events[index]?.eventJson,
)
) {
throw new Error("confirmed transcript archive event mismatch");
}
for (const archived of archive.events) {
const { detail, policy, transition } = archived.metadata;
const updated = executeSqliteQuerySync(
database.db,
db
.updateTable("transcript_event_memory_policies")
.set({
authorization_status: "authorized",
context_fingerprint: policy.contextFingerprint,
delivery_audiences_json: policy.deliveryAudiencesJson,
run_exposure_revision: policy.runExposureRevision,
run_exposure_set_id: policy.runExposureSetId,
run_id: policy.runId,
session_identity_revision: archive.sessionIdentityRevision,
source_policy_set_id: policy.sourcePolicySetId,
subject_revision: archive.subjectRevision,
})
.where("session_id", "=", sessionId)
.where("event_seq", "=", archived.eventSeq)
.where("authorization_status", "=", "pending"),
);
if (updated.numAffectedRows !== 1n) {
throw new Error("confirmed transcript archive companion conflict");
}
executeSqliteQuerySync(
database.db,
db.insertInto("transcript_event_memory_policy_details").values({
session_id: sessionId,
event_seq: archived.eventSeq,
actor_evidence_json: detail.actorEvidenceJson,
delegation_snapshot_json: detail.delegationSnapshotJson,
egress_receipt_ids_json: detail.egressReceiptIdsJson,
exposed_resource_revisions_json: detail.exposedResourceRevisionsJson,
exposure_receipt_ids_json: detail.exposureReceiptIdsJson,
finalized_delivery_audiences_json: detail.finalizedDeliveryAudiencesJson,
normalized_audience_intersection_json: detail.normalizedAudienceIntersectionJson,
retention_state: "retained",
source_event_seq: detail.sourceEventSeq,
source_session_id: detail.sourceSessionId,
created_at: Date.now(),
}),
);
if (transition) {
executeSqliteQuerySync(
database.db,
db.insertInto("transcript_event_memory_policy_transitions").values({
session_id: sessionId,
event_seq: archived.eventSeq,
source_event_seq: transition.sourceEventSeq,
source_session_id: transition.sourceSessionId,
transition_kind: transition.kind,
source_session_identity_revision: transition.sourceSessionIdentityRevision,
subject_revision: transition.subjectRevision,
target_session_identity_revision: transition.targetSessionIdentityRevision,
created_at: Date.now(),
}),
);
}
}
const readable = readAuthorizedTranscriptEventSeqs(database.db, sessionId);
if (
!readable ||
readable.size !== archive.events.length ||
archive.events.some((archived) => !readable.has(archived.eventSeq))
) {
throw new Error("confirmed transcript archive policy is no longer authorized");
}
}
@@ -6,7 +6,10 @@ import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import { truncateUtf16Safe } from "../../utils.js";
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
import { listSessionsNeedingTranscriptIndexReconcile } from "./session-transcript-index.js";
import { isTranscriptMemoryPolicyEnforcedInDatabase } from "./session-transcript-memory-policy.js";
import {
isTranscriptMemoryPolicyEnforcedInDatabase,
readAuthorizedTranscriptEventSeqs,
} from "./session-transcript-memory-policy.js";
import {
isSessionTranscriptIndexReconcileRunning,
startSessionTranscriptIndexReconcile,
@@ -112,7 +115,7 @@ export function searchSessionTranscripts(params: {
// rebuilds them (indexing=true tells the caller to retry).
const statement = database.db.prepare(/* sqlite-allow-raw: FTS5 MATCH/snippet/bm25 */ `
SELECT session_windows.session_key AS session_key, session_transcript_fts.session_id AS session_id,
message_id, role, timestamp,
message_id, role, timestamp, identity.seq AS event_seq,
snippet(session_transcript_fts, 0, '', '', ' … ', 48) AS snippet,
bm25(session_transcript_fts) AS rank
FROM session_transcript_fts
@@ -129,6 +132,7 @@ export function searchSessionTranscripts(params: {
`);
const values = [toFtsQuery(query), ...sessionKeys, limit + 1];
const rows = statement.all(...values) as Array<{
event_seq: unknown;
message_id: unknown;
rank: unknown;
role: unknown;
@@ -137,16 +141,28 @@ export function searchSessionTranscripts(params: {
snippet: unknown;
timestamp: unknown;
}>;
const authorizedEventSeqsBySession = new Map<string, Set<number> | undefined>();
const hits = rows.flatMap((row): SessionTranscriptSearchHit[] => {
if (
typeof row.session_key !== "string" ||
typeof row.session_id !== "string" ||
typeof row.message_id !== "string" ||
typeof row.event_seq !== "number" ||
(row.role !== "user" && row.role !== "assistant") ||
typeof row.snippet !== "string"
) {
return [];
}
if (isTranscriptMemoryPolicyEnforcedInDatabase(database.db)) {
let authorized = authorizedEventSeqsBySession.get(row.session_id);
if (authorized === undefined && !authorizedEventSeqsBySession.has(row.session_id)) {
authorized = readAuthorizedTranscriptEventSeqs(database.db, row.session_id);
authorizedEventSeqsBySession.set(row.session_id, authorized);
}
if (!authorized?.has(row.event_seq)) {
return [];
}
}
const timestamp = typeof row.timestamp === "number" ? row.timestamp : Number(row.timestamp);
const rank = typeof row.rank === "number" ? row.rank : Number(row.rank);
return [
+6 -1
View File
@@ -29,7 +29,11 @@ import {
hydrateMemoryRunExposureFromLedger,
persistMemoryRunExposureBeforeContent,
} from "./memory-run-exposure-ledger.js";
import { prepareMemoryRunExposure, publishMemoryRunExposure } from "./memory-run-exposure.js";
import {
captureDurableMemoryAuthorizationFacts,
prepareMemoryRunExposure,
publishMemoryRunExposure,
} from "./memory-run-exposure.js";
import { resolveSelectedMemoryCapabilityRegistration } from "./memory-state.js";
import type {
MemoryPluginCapability,
@@ -284,6 +288,7 @@ function readTranscriptExposure(params: {
egressRegistryRevision: context.delivery.egressRegistryRevision,
sessionIdentityRevision: context.sessionIdentityRevision,
subjectRevision: context.subjectRevision,
...captureDurableMemoryAuthorizationFacts(context),
});
}
@@ -1,5 +1,6 @@
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ensureMemoryPreoutputExposureLedgerSchemaInTransaction } from "../state/openclaw-agent-db-schema-helpers.js";
const mocks = vi.hoisted(() => ({
database: undefined as
@@ -39,6 +40,7 @@ let database: DatabaseSync | undefined;
beforeEach(() => {
database = new DatabaseSync(":memory:");
ensureMemoryPreoutputExposureLedgerSchemaInTransaction(database);
mocks.database = {
agentId: "main",
db: database,
@@ -72,6 +74,16 @@ function prepare(sessionId: string) {
egressRegistryRevision: "egress-1",
sessionIdentityRevision: "identity-1",
subjectRevision: "subject-1",
actorEvidence: {
version: 1,
kind: "principal",
actorKind: "human",
principalId: "alice",
assurance: "gateway-profile",
evidenceRevision: "actor-revision-1",
},
delegationSnapshot: { version: 1, kind: "none" },
hostFactsRevision: "host-facts-1",
});
}
@@ -137,6 +149,116 @@ describe("memory pre-output exposure ledger", () => {
]);
});
it("persists token-free delegated facts and rehydrates them after restart", () => {
const base = prepare("session-a");
const snapshot = {
...base,
delegationSnapshot: {
version: 1,
kind: "delegated",
rootPrincipalId: "alice",
rootContextId: "root-context",
parentContextId: "parent-context",
parentMemoryPlanId: "parent-plan",
capabilitySnapshotId: "capability-snapshot",
allowedOperations: ["derive", "read"],
maximumAudiences: [
{ kind: "user", id: "alice" },
{ kind: "role", id: "writer" },
],
depth: 1,
},
} as typeof base;
expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(true);
const canonicalDelegation = {
...snapshot.delegationSnapshot,
maximumAudiences: [
{ kind: "role", id: "writer" },
{ kind: "user", id: "alice" },
],
};
const persisted = database
?.prepare(
`SELECT actor_evidence_json, delegation_snapshot_json, host_facts_revision
FROM memory_preoutput_exposure_authorization_facts
WHERE exposure_set_id = ?`,
)
.get(snapshot.exposureSetId) as {
actor_evidence_json: string;
delegation_snapshot_json: string;
host_facts_revision: string;
};
expect(JSON.parse(persisted.actor_evidence_json)).toEqual(snapshot.actorEvidence);
expect(JSON.parse(persisted.delegation_snapshot_json)).toEqual(canonicalDelegation);
expect(persisted.host_facts_revision).toBe("host-facts-1");
expect(JSON.stringify(persisted)).not.toContain("storeCapToken");
clearMemoryRunExposureForTest();
expect(
readDurableMemoryRunExposure({
database: mocks.database as never,
sessionId: "session-a",
runId: "shared-run-id",
}),
).toMatchObject({
delegationSnapshot: canonicalDelegation,
actorEvidence: snapshot.actorEvidence,
hostFactsRevision: "host-facts-1",
});
});
it("fails closed rather than accepting missing, token-bearing, or partial authorization facts", () => {
const base = prepare("session-a");
const tokenBearing = {
...base,
delegationSnapshot: {
version: 1,
kind: "none",
storeCapToken: "must-never-persist",
} as never,
};
expect(persistMemoryRunExposureBeforeContent(tokenBearing)).toBe(false);
expect(
database?.prepare("SELECT count(*) AS count FROM memory_preoutput_exposure_ledger").get(),
).toEqual({ count: 0 });
const snapshot = prepare("session-a");
expect(persistMemoryRunExposureBeforeContent(snapshot)).toBe(true);
database?.exec(/* sqlite-allow-raw: test corrupts immutable proof to assert fail-closed read. */ `
DROP TRIGGER memory_preoutput_exposure_authorization_facts_no_delete;
DELETE FROM memory_preoutput_exposure_authorization_facts
WHERE exposure_set_id = '${snapshot.exposureSetId}';
`);
expect(
readLatestDurableMemoryRunExposure({
agentId: "main",
sessionId: "session-a",
runId: "shared-run-id",
}),
).toEqual({ kind: "unavailable" });
});
it("rolls back both ledger rows when durable authorization-fact persistence fails", () => {
database?.exec(/* sqlite-allow-raw: test-only atomicity fault injection. */ `
CREATE TRIGGER reject_exposure_authorization_facts_for_test
BEFORE INSERT ON memory_preoutput_exposure_authorization_facts
BEGIN
SELECT RAISE(ABORT, 'test authorization facts failure');
END;
`);
expect(persistMemoryRunExposureBeforeContent(prepare("session-a"))).toBe(false);
for (const table of [
"memory_preoutput_exposure_ledger",
"memory_preoutput_exposure_authorization_facts",
]) {
expect(database?.prepare(`SELECT count(*) AS count FROM ${table}`).get()).toEqual({
count: 0,
});
}
});
it("fails closed on a duplicate revision without adding a partial row", () => {
const snapshot = prepare("session-a");
+222 -3
View File
@@ -1,4 +1,8 @@
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
import { logWarn } from "../logger.js";
import type { AudienceRef } from "../memory-host-sdk/host/authorization.js";
@@ -10,6 +14,8 @@ import {
import {
createMemoryRunExposureScopeId,
reconcileMemoryRunExposureWithDurableLedger,
type DurableMemoryActorEvidence,
type DurableMemoryDelegationSnapshot,
type MemoryRunExposureSnapshot,
} from "./memory-run-exposure.js";
@@ -36,6 +42,13 @@ type MemoryPreoutputExposureLedgerDatabase = {
subject_revision: string;
created_at: number;
};
memory_preoutput_exposure_authorization_facts: {
exposure_set_id: string;
actor_evidence_json: string;
delegation_snapshot_json: string;
host_facts_revision: string;
created_at: number;
};
};
type MemoryExposureLedgerDiagnostic = "hydrate-failed" | "persist-failed";
@@ -136,6 +149,160 @@ function parseCanonicalAudiences(value: string): readonly AudienceRef[] | undefi
}
}
function hasExactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).toSorted();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function canonicalActorEvidence(value: DurableMemoryActorEvidence): string | undefined {
if (value.kind === "principal") {
if (
!hasExactKeys(
value,
value.expiresAt === undefined
? ["actorKind", "assurance", "evidenceRevision", "kind", "principalId", "version"]
: [
"actorKind",
"assurance",
"evidenceRevision",
"expiresAt",
"kind",
"principalId",
"version",
],
)
) {
return undefined;
}
if (
!["human", "agent", "service", "system"].includes(value.actorKind) ||
!["gateway-profile", "adapter-attested", "oidc", "service"].includes(value.assurance) ||
!value.principalId.trim() ||
!value.evidenceRevision.trim()
) {
return undefined;
}
if (
value.expiresAt !== undefined &&
(!Number.isFinite(Date.parse(value.expiresAt)) ||
new Date(Date.parse(value.expiresAt)).toISOString() !== value.expiresAt)
) {
return undefined;
}
return JSON.stringify({
version: 1,
kind: "principal",
actorKind: value.actorKind,
principalId: value.principalId,
assurance: value.assurance,
evidenceRevision: value.evidenceRevision,
...(value.expiresAt ? { expiresAt: value.expiresAt } : {}),
});
}
if (!hasExactKeys(value, ["evidenceRevision", "kind", "transportAuditRef", "version"])) {
return undefined;
}
return value.transportAuditRef.trim() && value.evidenceRevision.trim()
? JSON.stringify({
version: 1,
kind: "unattributed",
transportAuditRef: value.transportAuditRef,
evidenceRevision: value.evidenceRevision,
})
: undefined;
}
function canonicalDelegationSnapshot(value: DurableMemoryDelegationSnapshot): string | undefined {
if (value.kind === "none") {
return hasExactKeys(value, ["kind", "version"])
? JSON.stringify({ version: 1, kind: "none" })
: undefined;
}
if (
!hasExactKeys(value, [
"allowedOperations",
"capabilitySnapshotId",
"depth",
"kind",
"maximumAudiences",
"parentContextId",
"parentMemoryPlanId",
"rootContextId",
"rootPrincipalId",
"version",
])
) {
return undefined;
}
const allowedOperations = [...new Set(value.allowedOperations)].toSorted();
const maximumAudiences = canonicalAudiences({ deliveryAudiences: value.maximumAudiences });
if (
allowedOperations.length !== value.allowedOperations.length ||
allowedOperations.some(
(operation) =>
![
"read",
"append",
"replace",
"derive",
"deposit",
"project",
"publish",
"import",
"export",
"delete",
"sync",
"status",
"policy-admin",
].includes(operation),
) ||
!maximumAudiences ||
!value.rootPrincipalId.trim() ||
!value.rootContextId.trim() ||
!value.parentContextId.trim() ||
!value.parentMemoryPlanId.trim() ||
!value.capabilitySnapshotId.trim() ||
!Number.isSafeInteger(value.depth) ||
value.depth < 0
) {
return undefined;
}
return JSON.stringify({
version: 1,
kind: "delegated",
rootPrincipalId: value.rootPrincipalId,
rootContextId: value.rootContextId,
parentContextId: value.parentContextId,
parentMemoryPlanId: value.parentMemoryPlanId,
capabilitySnapshotId: value.capabilitySnapshotId,
allowedOperations,
maximumAudiences: JSON.parse(maximumAudiences),
depth: value.depth,
});
}
function parseCanonicalActorEvidence(value: string): DurableMemoryActorEvidence | undefined {
try {
const parsed = JSON.parse(value) as DurableMemoryActorEvidence;
const canonical = canonicalActorEvidence(parsed);
return canonical === value ? Object.freeze(parsed) : undefined;
} catch {
return undefined;
}
}
function parseCanonicalDelegationSnapshot(
value: string,
): DurableMemoryDelegationSnapshot | undefined {
try {
const parsed = JSON.parse(value) as DurableMemoryDelegationSnapshot;
const canonical = canonicalDelegationSnapshot(parsed);
return canonical === value ? Object.freeze(parsed) : undefined;
} catch {
return undefined;
}
}
function isDurableSnapshot(snapshot: MemoryRunExposureSnapshot): boolean {
return Boolean(
snapshot.agentId.trim() &&
@@ -149,6 +316,7 @@ function isDurableSnapshot(snapshot: MemoryRunExposureSnapshot): boolean {
snapshot.egressRegistryRevision.trim() &&
snapshot.sessionIdentityRevision.trim() &&
snapshot.subjectRevision.trim() &&
snapshot.hostFactsRevision.trim() &&
snapshot.revisionNumber > 0 &&
snapshot.revisionNumber === (snapshot.previous?.revisionNumber ?? 0) + 1 &&
snapshot.durableRunScopeId === createMemoryRunExposureScopeId(snapshot) &&
@@ -167,6 +335,8 @@ function persistMemoryRunExposureInTransaction(params: {
exposureReceiptIdsJson: string;
egressReceiptIdsJson: string;
deliveryAudiencesJson: string;
actorEvidenceJson: string;
delegationSnapshotJson: string;
}): void {
const { database, snapshot } = params;
ensureMemoryPreoutputExposureLedgerSchemaInTransaction(database.db);
@@ -204,6 +374,22 @@ function persistMemoryRunExposureInTransaction(params: {
if (inserted.numAffectedRows !== 1n) {
throw new Error("memory exposure revision already has a durable ledger row");
}
const factsInserted = executeSqliteQuerySync(
database.db,
db
.insertInto("memory_preoutput_exposure_authorization_facts")
.values({
exposure_set_id: snapshot.exposureSetId,
actor_evidence_json: params.actorEvidenceJson,
delegation_snapshot_json: params.delegationSnapshotJson,
host_facts_revision: snapshot.hostFactsRevision,
created_at: snapshot.createdAt,
})
.onConflict((conflict) => conflict.column("exposure_set_id").doNothing()),
);
if (factsInserted.numAffectedRows !== 1n) {
throw new Error("memory exposure revision already has durable authorization facts");
}
}
/**
@@ -218,13 +404,17 @@ export function persistMemoryRunExposureBeforeContent(
const exposureReceiptIdsJson = canonicalStrings(snapshot.exposureReceiptIds);
const egressReceiptIdsJson = canonicalStrings(snapshot.egressReceiptIds);
const deliveryAudiencesJson = canonicalAudiences(snapshot);
const actorEvidenceJson = canonicalActorEvidence(snapshot.actorEvidence);
const delegationSnapshotJson = canonicalDelegationSnapshot(snapshot.delegationSnapshot);
if (
!isDurableSnapshot(snapshot) ||
!sourcePolicySetIdsJson ||
!exposedResourceRevisionsJson ||
!exposureReceiptIdsJson ||
!egressReceiptIdsJson ||
!deliveryAudiencesJson
!deliveryAudiencesJson ||
!actorEvidenceJson ||
!delegationSnapshotJson
) {
return false;
}
@@ -250,6 +440,8 @@ export function persistMemoryRunExposureBeforeContentInDatabase(params: {
const exposureReceiptIdsJson = canonicalStrings(snapshot.exposureReceiptIds);
const egressReceiptIdsJson = canonicalStrings(snapshot.egressReceiptIds);
const deliveryAudiencesJson = canonicalAudiences(snapshot);
const actorEvidenceJson = canonicalActorEvidence(snapshot.actorEvidence);
const delegationSnapshotJson = canonicalDelegationSnapshot(snapshot.delegationSnapshot);
if (
database.agentId !== snapshot.agentId ||
!isDurableSnapshot(snapshot) ||
@@ -257,7 +449,9 @@ export function persistMemoryRunExposureBeforeContentInDatabase(params: {
!exposedResourceRevisionsJson ||
!exposureReceiptIdsJson ||
!egressReceiptIdsJson ||
!deliveryAudiencesJson
!deliveryAudiencesJson ||
!actorEvidenceJson ||
!delegationSnapshotJson
) {
return false;
}
@@ -271,6 +465,8 @@ export function persistMemoryRunExposureBeforeContentInDatabase(params: {
exposureReceiptIdsJson,
egressReceiptIdsJson,
deliveryAudiencesJson,
actorEvidenceJson,
delegationSnapshotJson,
});
});
return true;
@@ -345,12 +541,32 @@ function readDurableMemoryRunExposureOrThrow(params: {
const exposureReceiptIds = parseCanonicalStrings(row.exposure_receipt_ids_json);
const egressReceiptIds = parseCanonicalStrings(row.egress_receipt_ids_json);
const deliveryAudiences = parseCanonicalAudiences(row.delivery_audiences_json);
const facts = executeSqliteQueryTakeFirstSync(
params.database.db,
db
.selectFrom("memory_preoutput_exposure_authorization_facts")
.select([
"actor_evidence_json",
"delegation_snapshot_json",
"host_facts_revision",
"created_at",
])
.where("exposure_set_id", "=", row.exposure_set_id)
.limit(1),
);
const actorEvidence = facts && parseCanonicalActorEvidence(facts.actor_evidence_json);
const delegationSnapshot =
facts && parseCanonicalDelegationSnapshot(facts.delegation_snapshot_json);
if (
!sourcePolicySetIds ||
!exposedResourceRevisions ||
!exposureReceiptIds ||
!egressReceiptIds ||
!deliveryAudiences ||
!actorEvidence ||
!delegationSnapshot ||
!facts?.host_facts_revision.trim() ||
facts.created_at !== row.created_at ||
!row.session_key.trim() ||
!row.context_fingerprint.trim() ||
!row.plan_id.trim() ||
@@ -389,6 +605,9 @@ function readDurableMemoryRunExposureOrThrow(params: {
egressRegistryRevision: row.egress_registry_revision,
sessionIdentityRevision: row.session_identity_revision,
subjectRevision: row.subject_revision,
actorEvidence,
delegationSnapshot,
hostFactsRevision: facts.host_facts_revision,
createdAt: row.created_at,
}) satisfies MemoryRunExposureSnapshot;
}
+136 -1
View File
@@ -1,5 +1,48 @@
import { createHash, randomUUID } from "node:crypto";
import type { AudienceRef } from "../memory-host-sdk/host/authorization.js";
import {
MEMORY_OPERATIONS,
type AudienceRef,
type MemoryAccessContext,
type MemoryActorEvidence,
type MemoryOperation,
} from "../memory-host-sdk/host/authorization.js";
/** Immutable, serializable actor facts captured from the trusted access context. */
export type DurableMemoryActorEvidence =
| Readonly<{
version: 1;
kind: "principal";
actorKind: "human" | "agent" | "service" | "system";
principalId: string;
assurance: "gateway-profile" | "adapter-attested" | "oidc" | "service";
evidenceRevision: string;
expiresAt?: string;
}>
| Readonly<{
version: 1;
kind: "unattributed";
transportAuditRef: string;
evidenceRevision: string;
}>;
/**
* Delegation facts needed for an audit trail. The bearer token is intentionally
* absent: durable transcript lineage proves authority without becoming authority.
*/
export type DurableMemoryDelegationSnapshot =
| Readonly<{ version: 1; kind: "none" }>
| Readonly<{
version: 1;
kind: "delegated";
rootPrincipalId: string;
rootContextId: string;
parentContextId: string;
parentMemoryPlanId: string;
capabilitySnapshotId: string;
allowedOperations: readonly MemoryOperation[];
maximumAudiences: readonly AudienceRef[];
depth: number;
}>;
export type MemoryRunExposureSnapshot = Readonly<{
exposureSetId: string;
@@ -22,6 +65,9 @@ export type MemoryRunExposureSnapshot = Readonly<{
egressRegistryRevision: string;
sessionIdentityRevision: string;
subjectRevision: string;
actorEvidence: DurableMemoryActorEvidence;
delegationSnapshot: DurableMemoryDelegationSnapshot;
hostFactsRevision: string;
createdAt: number;
}>;
@@ -66,6 +112,95 @@ function sortedAudiences(audiences: readonly AudienceRef[]): readonly AudienceRe
);
}
function requireText(value: string, label: string): string {
if (!value.trim()) {
throw new TypeError(`${label} must be non-empty`);
}
return value;
}
function canonicalIsoDate(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const time = Date.parse(value);
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
throw new TypeError("actor.expiresAt must be a canonical ISO date");
}
return value;
}
function captureActorEvidence(actor: MemoryActorEvidence): DurableMemoryActorEvidence {
if (actor.kind === "principal") {
const expiresAt = canonicalIsoDate(actor.expiresAt);
return Object.freeze({
version: 1,
kind: "principal",
actorKind: actor.actorKind,
principalId: requireText(actor.principalId, "actor.principalId"),
assurance: actor.assurance,
evidenceRevision: requireText(actor.evidenceRevision, "actor.evidenceRevision"),
...(expiresAt ? { expiresAt } : {}),
});
}
return Object.freeze({
version: 1,
kind: "unattributed",
transportAuditRef: requireText(actor.transportAuditRef, "actor.transportAuditRef"),
evidenceRevision: requireText(actor.evidenceRevision, "actor.evidenceRevision"),
});
}
function captureDelegation(
delegation: MemoryAccessContext["delegation"],
): DurableMemoryDelegationSnapshot {
if (!delegation) {
return Object.freeze({ version: 1, kind: "none" });
}
const allowedOperations = Object.freeze([...new Set(delegation.allowedOperations)].toSorted());
if (
allowedOperations.length !== delegation.allowedOperations.length ||
allowedOperations.some((operation) => !MEMORY_OPERATIONS.includes(operation))
) {
throw new TypeError("delegation.allowedOperations must be canonical");
}
const maximumAudiences = sortedAudiences(delegation.maximumAudiences);
if (maximumAudiences.length !== delegation.maximumAudiences.length) {
throw new TypeError("delegation.maximumAudiences must be canonical");
}
if (!Number.isSafeInteger(delegation.depth) || delegation.depth < 0) {
throw new TypeError("delegation.depth must be a nonnegative integer");
}
return Object.freeze({
version: 1,
kind: "delegated",
rootPrincipalId: requireText(delegation.rootPrincipalId, "delegation.rootPrincipalId"),
rootContextId: requireText(delegation.rootContextId, "delegation.rootContextId"),
parentContextId: requireText(delegation.parentContextId, "delegation.parentContextId"),
parentMemoryPlanId: requireText(delegation.parentMemoryPlanId, "delegation.parentMemoryPlanId"),
capabilitySnapshotId: requireText(
delegation.capabilitySnapshotId,
"delegation.capabilitySnapshotId",
),
allowedOperations,
maximumAudiences,
depth: delegation.depth,
});
}
/** Captures only the durable audit subset of a trusted access context. */
export function captureDurableMemoryAuthorizationFacts(context: MemoryAccessContext): Readonly<{
actorEvidence: DurableMemoryActorEvidence;
delegationSnapshot: DurableMemoryDelegationSnapshot;
hostFactsRevision: string;
}> {
return Object.freeze({
actorEvidence: captureActorEvidence(context.actor),
delegationSnapshot: captureDelegation(context.delegation),
hostFactsRevision: requireText(context.hostFactsRevision, "hostFactsRevision"),
});
}
/** Prepares an immutable run-exposure revision without publishing it to process state. */
export function prepareMemoryRunExposure(facts: MemoryRunExposureFacts): MemoryRunExposureSnapshot {
const normalizedKey = key(facts);
+67
View File
@@ -157,6 +157,14 @@ export interface MemoryAuditOutbox {
updated_at: number;
}
export interface MemoryCompactionPolicies {
compaction_policy_id: string;
created_at: number;
retention_state: string;
session_id: string;
source_policy_set_id: string;
}
export interface MemoryEmbeddingCache {
dims: number | null;
embedding: string;
@@ -275,6 +283,16 @@ export interface MemoryPolicyRevisions {
revocation_epoch: number;
}
export interface MemoryPolicySetMembers {
audience_intersection_json: string;
created_at: number;
expected_revision_id: string;
expected_revocation_epoch: number;
policy_id: string;
policy_set_id: string;
retention_state: string;
}
export interface MemoryPolicySets {
agent_id: string;
created_at: number;
@@ -306,6 +324,14 @@ export interface MemoryPreoutputExposureLedger {
subject_revision: string;
}
export interface MemoryPreoutputExposureAuthorizationFacts {
actor_evidence_json: string;
created_at: number;
delegation_snapshot_json: string;
exposure_set_id: string;
host_facts_revision: string;
}
export interface MemoryResourceRevisions {
activated_at: number | null;
actor_id: string | null;
@@ -343,6 +369,13 @@ export interface MemoryResources {
store_id: string;
}
export interface MemoryRunExposureResources {
created_at: number;
exposure_set_id: string;
policy_set_id: string;
resource_revision_id: string;
}
export interface MemoryRunExposures {
agent_id: string;
context_fingerprint: string;
@@ -762,6 +795,34 @@ export interface TranscriptEventMemoryPolicies {
subject_revision: string | null;
}
export interface TranscriptEventMemoryPolicyDetails {
actor_evidence_json: string;
created_at: number;
delegation_snapshot_json: string;
egress_receipt_ids_json: string;
event_seq: number;
exposed_resource_revisions_json: string;
exposure_receipt_ids_json: string;
finalized_delivery_audiences_json: string;
normalized_audience_intersection_json: string;
retention_state: string;
session_id: string;
source_event_seq: number;
source_session_id: string;
}
export interface TranscriptEventMemoryPolicyTransitions {
created_at: number;
event_seq: number;
session_id: string;
source_event_seq: number;
source_session_id: string;
source_session_identity_revision: string;
subject_revision: string;
target_session_identity_revision: string;
transition_kind: string;
}
export interface TranscriptEvents {
created_at: number;
event_json: string;
@@ -787,6 +848,7 @@ export interface DB {
conversations: Conversations;
heartbeat_outcomes: HeartbeatOutcomes;
memory_audit_outbox: MemoryAuditOutbox;
memory_compaction_policies: MemoryCompactionPolicies;
memory_embedding_cache: MemoryEmbeddingCache;
memory_index_chunk_provenance: MemoryIndexChunkProvenance;
memory_index_chunk_recall_metadata: MemoryIndexChunkRecallMetadata;
@@ -799,11 +861,14 @@ export interface DB {
memory_policies: MemoryPolicies;
memory_policy_entries: MemoryPolicyEntries;
memory_policy_revisions: MemoryPolicyRevisions;
memory_policy_set_members: MemoryPolicySetMembers;
memory_policy_sets: MemoryPolicySets;
memory_preoutput_exposure_authorization_facts: MemoryPreoutputExposureAuthorizationFacts;
memory_preoutput_exposure_ledger: MemoryPreoutputExposureLedger;
memory_resource_revisions: MemoryResourceRevisions;
memory_resource_subjects: MemoryResourceSubjects;
memory_resources: MemoryResources;
memory_run_exposure_resources: MemoryRunExposureResources;
memory_run_exposures: MemoryRunExposures;
memory_scoped_chunk_vectors: MemoryScopedChunkVectors;
memory_scoped_chunks: MemoryScopedChunks;
@@ -845,6 +910,8 @@ export interface DB {
trajectory_runtime_events: TrajectoryRuntimeEvents;
transcript_event_identities: TranscriptEventIdentities;
transcript_event_memory_policies: TranscriptEventMemoryPolicies;
transcript_event_memory_policy_details: TranscriptEventMemoryPolicyDetails;
transcript_event_memory_policy_transitions: TranscriptEventMemoryPolicyTransitions;
transcript_events: TranscriptEvents;
transcript_rewrite_watermarks: TranscriptRewriteWatermarks;
}
+173
View File
@@ -973,6 +973,38 @@ BEGIN
SELECT RAISE(ABORT, 'memory policy sets cannot be deleted');
END;
-- A policy-set id is an immutable retention handle, not an everlasting grant.
-- Its members retain the stable policy identity and the exact active revision
-- expected at exposure time; readers revalidate both against current policy state.
CREATE TABLE IF NOT EXISTS memory_policy_set_members (
policy_set_id TEXT NOT NULL,
policy_id TEXT NOT NULL,
expected_revision_id TEXT NOT NULL,
expected_revocation_epoch INTEGER NOT NULL CHECK (expected_revocation_epoch >= 0),
audience_intersection_json TEXT NOT NULL,
retention_state TEXT NOT NULL CHECK (retention_state IN ('retained', 'quarantined')),
created_at INTEGER NOT NULL,
PRIMARY KEY (policy_set_id, policy_id),
FOREIGN KEY (policy_set_id) REFERENCES memory_policy_sets(policy_set_id) ON DELETE RESTRICT,
FOREIGN KEY (policy_id) REFERENCES memory_policies(policy_id) ON DELETE RESTRICT,
FOREIGN KEY (expected_revision_id) REFERENCES memory_policy_revisions(revision_id) ON DELETE RESTRICT
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_policy_set_members_policy
ON memory_policy_set_members(policy_id, expected_revision_id, expected_revocation_epoch);
CREATE TRIGGER IF NOT EXISTS memory_policy_set_members_no_update
BEFORE UPDATE ON memory_policy_set_members
BEGIN
SELECT RAISE(ABORT, 'memory policy set members are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_policy_set_members_no_delete
BEFORE DELETE ON memory_policy_set_members
BEGIN
SELECT RAISE(ABORT, 'memory policy set members cannot be deleted');
END;
CREATE TABLE IF NOT EXISTS memory_run_exposures (
exposure_set_id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
@@ -1010,6 +1042,34 @@ BEGIN
SELECT RAISE(ABORT, 'memory run exposures cannot be deleted');
END;
-- Resource-to-exposure rows make revocation and expiry impact analysis a
-- durable join, rather than an opaque array scan over transcript history.
CREATE TABLE IF NOT EXISTS memory_run_exposure_resources (
exposure_set_id TEXT NOT NULL,
resource_revision_id TEXT NOT NULL,
policy_set_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (exposure_set_id, resource_revision_id),
FOREIGN KEY (exposure_set_id) REFERENCES memory_run_exposures(exposure_set_id) ON DELETE RESTRICT,
FOREIGN KEY (resource_revision_id) REFERENCES memory_resource_revisions(revision_id) ON DELETE RESTRICT,
FOREIGN KEY (policy_set_id) REFERENCES memory_policy_sets(policy_set_id) ON DELETE RESTRICT
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_run_exposure_resources_revision
ON memory_run_exposure_resources(resource_revision_id, exposure_set_id);
CREATE TRIGGER IF NOT EXISTS memory_run_exposure_resources_no_update
BEFORE UPDATE ON memory_run_exposure_resources
BEGIN
SELECT RAISE(ABORT, 'memory run exposure resources are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_run_exposure_resources_no_delete
BEFORE DELETE ON memory_run_exposure_resources
BEGIN
SELECT RAISE(ABORT, 'memory run exposure resources cannot be deleted');
END;
-- Selected-plugin content is never returned until this content-free ledger row commits.
-- It is lazy/additive so current-version databases remain compatible until first scoped read.
CREATE TABLE IF NOT EXISTS memory_preoutput_exposure_ledger (
@@ -1052,6 +1112,31 @@ BEGIN
SELECT RAISE(ABORT, 'pre-output memory exposure ledger cannot be deleted');
END;
-- Trusted host facts are captured before content release. Keep their audit-only
-- projection separate from the old ledger table so existing agent databases can
-- install it lazily without a schema-version migration.
CREATE TABLE IF NOT EXISTS memory_preoutput_exposure_authorization_facts (
exposure_set_id TEXT NOT NULL PRIMARY KEY,
actor_evidence_json TEXT NOT NULL,
delegation_snapshot_json TEXT NOT NULL,
host_facts_revision TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (exposure_set_id)
REFERENCES memory_preoutput_exposure_ledger(exposure_set_id) ON DELETE RESTRICT
) STRICT;
CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_authorization_facts_no_update
BEFORE UPDATE ON memory_preoutput_exposure_authorization_facts
BEGIN
SELECT RAISE(ABORT, 'pre-output exposure authorization facts are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_preoutput_exposure_authorization_facts_no_delete
BEFORE DELETE ON memory_preoutput_exposure_authorization_facts
BEGIN
SELECT RAISE(ABORT, 'pre-output exposure authorization facts cannot be deleted');
END;
CREATE TABLE IF NOT EXISTS transcript_event_memory_policies (
session_id TEXT NOT NULL,
event_seq INTEGER NOT NULL,
@@ -1095,6 +1180,94 @@ CREATE TABLE IF NOT EXISTS transcript_event_memory_policies (
CREATE INDEX IF NOT EXISTS idx_transcript_event_memory_policies_status
ON transcript_event_memory_policies(session_id, authorization_status, event_seq);
-- The narrow P1C row remains the hot replay filter. This companion holds the
-- full opaque retention evidence without making transcript JSON authoritative.
CREATE TABLE IF NOT EXISTS transcript_event_memory_policy_details (
session_id TEXT NOT NULL,
event_seq INTEGER NOT NULL,
actor_evidence_json TEXT NOT NULL,
delegation_snapshot_json TEXT NOT NULL,
exposed_resource_revisions_json TEXT NOT NULL,
exposure_receipt_ids_json TEXT NOT NULL,
egress_receipt_ids_json TEXT NOT NULL,
normalized_audience_intersection_json TEXT NOT NULL,
finalized_delivery_audiences_json TEXT NOT NULL,
retention_state TEXT NOT NULL CHECK (retention_state IN ('retained', 'quarantined')),
source_session_id TEXT NOT NULL,
source_event_seq INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, event_seq),
FOREIGN KEY (session_id, event_seq)
REFERENCES transcript_event_memory_policies(session_id, event_seq) ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS idx_transcript_event_memory_policy_details_source
ON transcript_event_memory_policy_details(source_session_id, source_event_seq);
CREATE TRIGGER IF NOT EXISTS transcript_event_memory_policy_details_no_update
BEFORE UPDATE ON transcript_event_memory_policy_details
BEGIN
SELECT RAISE(ABORT, 'transcript memory policy details are immutable');
END;
-- The immutable detail lives exactly as long as its transcript event. The
-- transcript owner deletes both through the foreign-key cascade during a
-- reset/rewind/replace; blocking that cascade would leave the session unable
-- to enforce its own retention lifecycle.
DROP TRIGGER IF EXISTS transcript_event_memory_policy_details_no_delete;
-- A new session identity cannot reuse a parent's direct companion. Transition
-- provenance records the exact source event and both immutable identities so
-- readers can revalidate the origin without treating copied JSON as authority.
CREATE TABLE IF NOT EXISTS transcript_event_memory_policy_transitions (
session_id TEXT NOT NULL,
event_seq INTEGER NOT NULL,
source_session_id TEXT NOT NULL,
source_event_seq INTEGER NOT NULL,
transition_kind TEXT NOT NULL CHECK (transition_kind IN (
'parent-fork', 'fork', 'rewind', 'switch', 'checkpoint'
)),
source_session_identity_revision TEXT NOT NULL,
target_session_identity_revision TEXT NOT NULL,
subject_revision TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, event_seq),
FOREIGN KEY (session_id, event_seq)
REFERENCES transcript_event_memory_policies(session_id, event_seq) ON DELETE CASCADE
) STRICT;
CREATE INDEX IF NOT EXISTS idx_transcript_event_memory_policy_transitions_source
ON transcript_event_memory_policy_transitions(source_session_id, source_event_seq);
CREATE TRIGGER IF NOT EXISTS transcript_event_memory_policy_transitions_no_update
BEFORE UPDATE ON transcript_event_memory_policy_transitions
BEGIN
SELECT RAISE(ABORT, 'transcript memory policy transitions are immutable');
END;
-- Compaction creates its own derived policy in Phase 2C. The table is owned
-- now so transitions can preserve the reference without inventing sidecars.
CREATE TABLE IF NOT EXISTS memory_compaction_policies (
compaction_policy_id TEXT NOT NULL PRIMARY KEY,
session_id TEXT NOT NULL,
source_policy_set_id TEXT NOT NULL,
retention_state TEXT NOT NULL CHECK (retention_state IN ('retained', 'quarantined')),
created_at INTEGER NOT NULL,
FOREIGN KEY (source_policy_set_id) REFERENCES memory_policy_sets(policy_set_id) ON DELETE RESTRICT
) STRICT;
CREATE TRIGGER IF NOT EXISTS memory_compaction_policies_no_update
BEFORE UPDATE ON memory_compaction_policies
BEGIN
SELECT RAISE(ABORT, 'memory compaction policies are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_compaction_policies_no_delete
BEFORE DELETE ON memory_compaction_policies
BEGIN
SELECT RAISE(ABORT, 'memory compaction policies cannot be deleted');
END;
CREATE TABLE IF NOT EXISTS standing_intents (
intent_key INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
@@ -17,9 +17,15 @@ export const AGENT_SCOPED_MEMORY_TABLES = [
"memory_audit_outbox",
"memory_migrations",
"memory_policy_sets",
"memory_policy_set_members",
"memory_run_exposures",
"memory_run_exposure_resources",
"memory_preoutput_exposure_ledger",
"memory_preoutput_exposure_authorization_facts",
"transcript_event_memory_policies",
"transcript_event_memory_policy_details",
"transcript_event_memory_policy_transitions",
"memory_compaction_policies",
] as const;
export const AGENT_SCOPED_MEMORY_FTS_TABLE = "memory_scoped_chunks_fts";