diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf9556a..8b73b57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
Hy Memory and MemHub are versioned and released independently.
+## Unreleased
+
+- Removed MemHub's task-scoped HyPermission store wrappers and global
+ model-content consent switch. The trusted embedded Hy Memory SDK now uses
+ its native local stores directly.
+- Existing installations ignore the retired config flag, readmit previously
+ parked pre-invocation work, and drop only the obsolete
+ `hy_node_permissions` proof table. Evidence, governed memories, model
+ profiles, SDK stores, and source/host grants are preserved.
+
## Hy Memory 2.0.2 — 2026-10-17
- Published
diff --git a/hy_memory/agent/llm_provider.py b/hy_memory/agent/llm_provider.py
index c12e995..aa28d90 100644
--- a/hy_memory/agent/llm_provider.py
+++ b/hy_memory/agent/llm_provider.py
@@ -496,11 +496,21 @@ class LLMProvider:
clients = self._clients.setdefault(loop, {})
client = clients.get(client_key)
if client is None:
+ client_options: Dict[str, Any] = {
+ "api_key": base_api_key,
+ "base_url": base_url,
+ "timeout": self._llm_config.timeout,
+ "max_retries": 0,
+ }
+ transport_factory = getattr(self.config, "http_transport_factory", None)
+ if callable(transport_factory):
+ import httpx
+
+ client_options["http_client"] = httpx.AsyncClient(
+ transport=transport_factory()
+ )
client = AsyncOpenAI(
- api_key=base_api_key,
- base_url=base_url,
- timeout=self._llm_config.timeout,
- max_retries=0,
+ **client_options,
)
clients[client_key] = client
diff --git a/hy_memory/config.py b/hy_memory/config.py
index 3d58f4e..ce36910 100644
--- a/hy_memory/config.py
+++ b/hy_memory/config.py
@@ -6,7 +6,7 @@ Agent Memory - 配置管理
import os
from dataclasses import dataclass, field
-from typing import Optional, Dict, Any, List
+from typing import Optional, Dict, Any, Callable, List
from pathlib import Path
@@ -1252,6 +1252,13 @@ class MemoryConfig:
enable_agent: bool = False
debug: bool = False
metrics_enabled: bool = True
+ # Runtime-only HTTP transport factory. MemHub uses this for request-cost
+ # budgeting without restricting the embedded SDK's local store access.
+ http_transport_factory: Optional[Callable[[], Any]] = field(
+ default=None,
+ repr=False,
+ compare=False,
+ )
def __post_init__(self):
# enable_graph 跟随 mode 走:mode=ultra 时自动启用,无需独立环境变量
diff --git a/hy_memory/core/embed_service.py b/hy_memory/core/embed_service.py
index 711fd2d..efe7955 100644
--- a/hy_memory/core/embed_service.py
+++ b/hy_memory/core/embed_service.py
@@ -356,11 +356,21 @@ class EmbedService:
key = self._client_cache_key(base_url, api_key, timeout)
client = self._client_cache.get(key)
if client is None:
+ client_options = {
+ "api_key": api_key,
+ "base_url": base_url or None,
+ "timeout": timeout,
+ "max_retries": 0,
+ }
+ transport_factory = getattr(self.config, "http_transport_factory", None)
+ if callable(transport_factory):
+ import httpx
+
+ client_options["http_client"] = httpx.AsyncClient(
+ transport=transport_factory()
+ )
client = AsyncOpenAI(
- api_key=api_key,
- base_url=base_url or None,
- timeout=timeout,
- max_retries=0,
+ **client_options,
)
self._client_cache[key] = client
return client
diff --git a/memhub/MEMORY.md b/memhub/MEMORY.md
index 7aafed9..f23fdc0 100644
--- a/memhub/MEMORY.md
+++ b/memhub/MEMORY.md
@@ -305,9 +305,9 @@ Runtime 应长期复用 client,不为每次请求重新初始化数据库和
```text
Evidence
- -> ModelProcessingPermissionLease
- -> HyPermissionTransport
- -> HyMemoryBridge.ingest / ingest_extracted / search_ids
+ -> HyMemoryBridge
+ -> embedded Hy Memory SDK
+ -> native Vector / Coding / Graph stores
-> write result verification
-> Governance
-> Recall Broker / UI
@@ -316,8 +316,11 @@ Evidence
不要在新业务模块里直接创建 `HyMemoryClient`,否则会绕过:
- 来源读取授权;
-- 模型内容处理授权;
-- 发送前与响应后的撤权复核;
+- 共享 Runtime、模型配置、API Key 安全存储与请求预算;
- Evidence 引用与作者归属;
- 写后回读验证;
- Governance 状态、版本、更正和删除级联。
+
+Hy Memory SDK 是 MemHub 的内置可信组件,可自由访问和管理本机的全部
+Hy Memory 数据。来源授权仍决定哪些宿主数据可以进入本机;宿主 Recall Grant
+仍决定哪些外部 AI 应用可以读取治理后的记忆。这两类边界不限制内置 SDK 的本地 CRUD。
diff --git a/memhub/apps/cli/scripts/verify-package-matrix.mjs b/memhub/apps/cli/scripts/verify-package-matrix.mjs
index aa3066d..d68c72d 100644
--- a/memhub/apps/cli/scripts/verify-package-matrix.mjs
+++ b/memhub/apps/cli/scripts/verify-package-matrix.mjs
@@ -32,7 +32,6 @@ const REQUIRED_COMMAND_SURFACE = [
"calls",
"import-file",
"notes",
- "permission",
"extraction",
"graph",
"models",
@@ -77,7 +76,7 @@ const REQUIRED_MARKERS = [
/**
* Sub-commands the UI-write parity work added. A package can declare the
- * parent command (`permission`, `models`) while still missing the new verb, so
+ * parent command (`models`) while still missing the new verb, so
* checking command names alone would pass a stale bundle. The marker is the
* exact literal the catalog usage string contains.
*/
@@ -88,9 +87,6 @@ const REQUIRED_SUBCOMMANDS = [
{ command: "import-file", marker: "memhub import-file discard" },
{ command: "notes", marker: "memhub notes add" },
{ command: "notes", marker: "memhub notes process" },
- { command: "permission", marker: "memhub permission status" },
- { command: "permission", marker: "memhub permission allow" },
- { command: "permission", marker: "memhub permission deny" },
{ command: "extraction", marker: "memhub extraction set" },
{ command: "graph", marker: "memhub graph width" },
{ command: "models", marker: "memhub models edit" },
@@ -153,8 +149,6 @@ const STOPPED_DESTRUCTIVE_PROBES = [
"--confirm",
],
},
- { name: "permission_allow", args: ["permission", "allow"] },
- { name: "permission_deny", args: ["permission", "deny"] },
{
name: "extraction_set_apply",
args: ["extraction", "set", "--window", "6m", "--confirm"],
diff --git a/memhub/apps/cli/src/cli.ts b/memhub/apps/cli/src/cli.ts
index fd75869..de67f80 100644
--- a/memhub/apps/cli/src/cli.ts
+++ b/memhub/apps/cli/src/cli.ts
@@ -190,13 +190,6 @@ import {
renderMemoryDetailText,
renderMemoryPageText,
} from "./memory-report.js";
-import {
- type ModelContentPermissionDocument,
- modelContentPermissionExitCode,
- readModelContentPermission,
- renderModelContentPermissionText,
- setModelContentPermission,
-} from "./model-content-permission-flow.js";
import {
executeModelProbe,
inspectModelProbePlan,
@@ -217,12 +210,10 @@ import {
executeModelProfileActivate,
executeModelProfileEdit,
type ModelWriteDocument,
- type ModelWritePlan,
modelWriteExitCode,
planModelProfileActivate,
planModelProfileEdit,
renderModelWriteText,
- validateModelWritePlan,
} from "./model-write-flows.js";
import { claimPlanAttempt } from "./operation-journal.js";
import {
@@ -2462,45 +2453,6 @@ async function runCommand(
);
return uiWriteExitCode(document);
}
- if (named === "permission") {
- const parsed = parsePermissionCommand(arguments_.slice(1));
- if (!parsed) return usage();
- const config = resolveRuntimeCliConfig(environment, process.argv[1]);
- const runtime = await inspectRuntimeStatus({
- dataDir: config.dataDir,
- probeIdentity,
- requireServiceReceipt: true,
- });
- const attached =
- runtime.state === "running"
- ? await attachClients(config.dataDir)
- : { reader: null, operations: null, failure: runtime.code };
- if (
- runtime.state !== "running" ||
- (parsed === "status" ? !attached.reader : !attached.operations)
- ) {
- const document: ModelContentPermissionDocument = {
- schema_version: 1,
- product: PRODUCT,
- state: "unknown",
- code:
- runtime.state !== "running"
- ? runtime.code
- : (attached.failure ?? "runtime_attach_failed"),
- };
- emitModelContentPermissionDocument(document, asJson);
- return runtime.state === "stopped" ? 3 : 1;
- }
- const document =
- parsed === "status"
- ? await readModelContentPermission(attached.reader!)
- : await setModelContentPermission(
- attached.operations!,
- parsed === "allow",
- );
- emitModelContentPermissionDocument(document, asJson);
- return modelContentPermissionExitCode(document);
- }
if (named === "notes" || named === "extraction" || named === "graph") {
const parsed = parseExtendedWriteCommand(
named as "notes" | "extraction" | "graph",
@@ -5124,18 +5076,6 @@ function parseHeadlessCommand(
: null;
}
-function parsePermissionCommand(
- rest: readonly string[],
-): "status" | "allow" | "deny" | null {
- if (rest.filter((item) => item === "--json").length > 1) return null;
- const tokens = rest.filter((item) => item !== "--json");
- const action = tokens[0];
- return tokens.length === 1 &&
- (action === "status" || action === "allow" || action === "deny")
- ? action
- : null;
-}
-
function unwrapPlan(value: unknown): unknown {
if (
value &&
@@ -5323,17 +5263,6 @@ function emitModelReportDocument(
);
}
-function emitModelContentPermissionDocument(
- document: ModelContentPermissionDocument,
- asJson: boolean,
-): void {
- process.stdout.write(
- asJson
- ? `${JSON.stringify(document)}\n`
- : renderModelContentPermissionText(document),
- );
-}
-
function emitSourceSyncDocument(
document: SourceSyncFlowDocument,
asJson: boolean,
diff --git a/memhub/apps/cli/src/command-catalog.ts b/memhub/apps/cli/src/command-catalog.ts
index 57827e3..f2888ff 100644
--- a/memhub/apps/cli/src/command-catalog.ts
+++ b/memhub/apps/cli/src/command-catalog.ts
@@ -493,14 +493,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
},
- {
- name: "permission",
- summary: "读取或直接设置全局模型内容外发许可。",
- usage:
- "memhub permission status [--json]\n memhub permission allow [--json]\n memhub permission deny [--json]",
- options: [JSON_OPTION],
- exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
- },
{
name: "extraction",
summary: "设置提取窗口;默认只出计划,--confirm 后才写入。",
diff --git a/memhub/apps/cli/src/command-contract.test.ts b/memhub/apps/cli/src/command-contract.test.ts
index 2c49b7e..fcdfca9 100644
--- a/memhub/apps/cli/src/command-contract.test.ts
+++ b/memhub/apps/cli/src/command-contract.test.ts
@@ -81,7 +81,6 @@ const COMMANDS = [
"calls",
"import-file",
"notes",
- "permission",
"extraction",
"graph",
"host",
@@ -259,7 +258,14 @@ describe("CLI-22 shared output contract", () => {
describe("argument validation", () => {
it("fails unknown commands without printing data or running anything", async () => {
- for (const argv of [["unknown"], ["unknown", "--help"], ["--typo"]]) {
+ for (const argv of [
+ ["unknown"],
+ ["unknown", "--help"],
+ ["--typo"],
+ ["permission", "status"],
+ ["permission", "allow"],
+ ["permission", "deny"],
+ ]) {
const streams = captureStreams();
try {
expect(await run(argv, {})).toBe(2);
diff --git a/memhub/apps/cli/src/core-client.ts b/memhub/apps/cli/src/core-client.ts
index 8fdcc46..26542c0 100644
--- a/memhub/apps/cli/src/core-client.ts
+++ b/memhub/apps/cli/src/core-client.ts
@@ -8,7 +8,6 @@ import axios from "axios";
*/
const READ_ONLY_PATHS = [
"/api/v1/call-events",
- "/api/v1/model-content-permission",
"/api/v1/home-summary",
"/api/v1/import-jobs",
"/api/v1/live-sync",
diff --git a/memhub/apps/cli/src/core-operations.ts b/memhub/apps/cli/src/core-operations.ts
index ee8da82..9424e01 100644
--- a/memhub/apps/cli/src/core-operations.ts
+++ b/memhub/apps/cli/src/core-operations.ts
@@ -30,7 +30,6 @@ const IMPORT_FILE =
const MEMORY_NOTES_PATH = "/api/v1/memory-notes";
const MEMORY_NOTE_PROCESS =
/^\/api\/v1\/memory-notes\/[A-Za-z0-9][\w.:-]{0,127}\/process$/;
-const MODEL_CONTENT_PERMISSION_PATH = "/api/v1/model-content-permission";
const EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings";
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract";
@@ -134,7 +133,6 @@ export function createCoreOperations(attachment: {
!IMPORT_FILE_COMMIT.test(path) &&
path !== MEMORY_NOTES_PATH &&
!MEMORY_NOTE_PROCESS.test(path) &&
- path !== MODEL_CONTENT_PERMISSION_PATH &&
path !== GRAPH_WIDTH_PATH &&
path !== MANUAL_EXTRACT_PATH &&
path !== ONBOARDING_COMPLETE_PATH &&
diff --git a/memhub/apps/cli/src/init-interactive.test.ts b/memhub/apps/cli/src/init-interactive.test.ts
index fafbe5d..b5a6942 100644
--- a/memhub/apps/cli/src/init-interactive.test.ts
+++ b/memhub/apps/cli/src/init-interactive.test.ts
@@ -259,7 +259,7 @@ describe("A2 dual-mode init", () => {
expect(streams.value()).toContain("处理:已开启");
});
- it("continues the TTY wizard through model probe and content permission", async () => {
+ it("continues the TTY wizard through model setup and probe", async () => {
const profileId = "22222222-2222-4222-8222-222222222222";
const profile = {
profile_id: profileId,
@@ -361,12 +361,6 @@ describe("A2 dual-mode init", () => {
};
}
if (url.endsWith("/activate")) return { status: 200, data: profile };
- if (url === "/api/v1/model-content-permission") {
- return {
- status: 200,
- data: { allowed: body.allowed },
- };
- }
if (url === "/api/v1/onboarding/complete") {
return {
status: 200,
@@ -383,7 +377,7 @@ describe("A2 dual-mode init", () => {
},
);
const prompt = extendedPrompter(
- ["", "", "", "", "", "", "", "", "n"],
+ ["", "", "", "", "", "", "", "n"],
"sk-never-print-this",
);
const streams = captureStdout();
@@ -402,7 +396,6 @@ describe("A2 dual-mode init", () => {
streams.restore();
}
expect(streams.value()).toContain("已接入来源:codex");
- expect(prompt.written.join("\n")).toContain("模型内容外发:已允许");
expect(streams.value()).toContain("首次设置完成。");
expect(`${streams.value()}\n${prompt.written.join("\n")}`).not.toContain(
"sk-never-print-this",
@@ -412,10 +405,10 @@ describe("A2 dual-mode init", () => {
expect.objectContaining({ api_key: "sk-never-print-this" }),
undefined,
);
- expect(transport.post).toHaveBeenCalledWith(
+ expect(transport.post).not.toHaveBeenCalledWith(
"/api/v1/model-content-permission",
- { allowed: true },
- undefined,
+ expect.anything(),
+ expect.anything(),
);
expect(transport.post).toHaveBeenCalledWith(
"/api/v1/onboarding/complete",
diff --git a/memhub/apps/cli/src/interactive-model-setup.test.ts b/memhub/apps/cli/src/interactive-model-setup.test.ts
index 4c6d6bf..18668a4 100644
--- a/memhub/apps/cli/src/interactive-model-setup.test.ts
+++ b/memhub/apps/cli/src/interactive-model-setup.test.ts
@@ -132,12 +132,6 @@ function clients(options: { failedProbes?: number } = {}) {
if (path.endsWith("/activate")) {
return { state: "committed" as const, data: profile(true) };
}
- if (path === "/api/v1/model-content-permission") {
- return {
- state: "committed" as const,
- data: { allowed: body.allowed },
- };
- }
return {
state: "rejected" as const,
code: "unexpected_path",
@@ -160,8 +154,8 @@ function clients(options: { failedProbes?: number } = {}) {
}
describe("interactive model setup", () => {
- it("uses App defaults, keeps the key out of output, and allows model content", async () => {
- const prompt = prompter(["", "", "", "", "", ""]);
+ it("uses App defaults and keeps the key out of output", async () => {
+ const prompt = prompter(["", "", ""]);
const api = clients();
const result = await runInteractiveModelSetup(prompt.value, api, {
@@ -170,10 +164,7 @@ describe("interactive model setup", () => {
installHosts: false,
});
- expect(result).toMatchObject({
- state: "configured",
- model_content_allowed: true,
- });
+ expect(result).toMatchObject({ state: "configured" });
expect(api.post).toHaveBeenCalledWith(
"/api/v1/model-profiles",
expect.objectContaining({
@@ -184,9 +175,10 @@ describe("interactive model setup", () => {
embedding_provider_kind: "local_onnx",
}),
);
- expect(api.post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
- allowed: true,
- });
+ expect(api.post).not.toHaveBeenCalledWith(
+ "/api/v1/model-content-permission",
+ expect.anything(),
+ );
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
});
@@ -205,7 +197,7 @@ describe("interactive model setup", () => {
});
it("removes an unverified profile before skipping after a failed probe", async () => {
- const prompt = prompter(["", "", "", "n", "0"]);
+ const prompt = prompter(["", "", "", "0"]);
const api = clients();
api.post.mockImplementation(
async (path: string, body: Record) => {
@@ -280,7 +272,7 @@ describe("interactive model setup", () => {
});
it("cleans a failed probe and lets the user re-enter the model settings", async () => {
- const prompt = prompter(["", "", "", "", "", "", "", "", "", "", ""]);
+ const prompt = prompter(["", "", "", "", "", "", ""]);
const api = clients({ failedProbes: 1 });
const result = await runInteractiveModelSetup(prompt.value, api, {
@@ -289,10 +281,7 @@ describe("interactive model setup", () => {
installHosts: false,
});
- expect(result).toMatchObject({
- state: "configured",
- model_content_allowed: true,
- });
+ expect(result).toMatchObject({ state: "configured" });
expect(api.remove).toHaveBeenCalledTimes(1);
expect(
api.post.mock.calls.filter(([path]) => path === "/api/v1/model-profiles"),
@@ -305,7 +294,7 @@ describe("interactive model setup", () => {
const prompt = prompter(["", "", ""]);
prompt.value.ask = async () => {
asked += 1;
- if (asked === 4) throw new Error("interrupted");
+ if (asked === 3) throw new Error("interrupted");
return "";
};
const api = clients();
@@ -322,7 +311,7 @@ describe("interactive model setup", () => {
});
it("installs selected detected hosts through preview and fingerprint apply", async () => {
- const prompt = prompter(["", "", "", "n", "", ""]);
+ const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -360,7 +349,6 @@ describe("interactive model setup", () => {
expect(result).toMatchObject({
state: "configured",
- model_content_allowed: false,
installed_hosts: ["codex"],
});
expect(read).toHaveBeenNthCalledWith(
@@ -373,7 +361,7 @@ describe("interactive model setup", () => {
});
it("lists and installs only hosts that are actually present", async () => {
- const prompt = prompter(["", "", "", "n", "", ""]);
+ const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -420,7 +408,7 @@ describe("interactive model setup", () => {
});
it("completes the wizard and keeps onboarding reachable when one host install fails", async () => {
- const prompt = prompter(["", "", "", "n", "", ""]);
+ const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -469,7 +457,6 @@ describe("interactive model setup", () => {
// calling the install a failure.
expect(result).toMatchObject({
state: "configured",
- model_content_allowed: false,
installed_hosts: ["codex"],
});
const summary = prompt.written.join("\n");
@@ -479,7 +466,7 @@ describe("interactive model setup", () => {
});
it("tells the user to install App-only hosts in the App instead of calling them failed", async () => {
- const prompt = prompter(["", "", "", "n", "", ""]);
+ const prompt = prompter(["", "", "", "", ""]);
const api = clients();
const read = vi
.fn()
@@ -506,7 +493,6 @@ describe("interactive model setup", () => {
// only the App can install, so nothing counts as "installed".
expect(result).toMatchObject({
state: "configured",
- model_content_allowed: false,
installed_hosts: [],
});
const summary = prompt.written.join("\n");
diff --git a/memhub/apps/cli/src/interactive-model-setup.ts b/memhub/apps/cli/src/interactive-model-setup.ts
index d831bc8..900bcd3 100644
--- a/memhub/apps/cli/src/interactive-model-setup.ts
+++ b/memhub/apps/cli/src/interactive-model-setup.ts
@@ -11,7 +11,6 @@ import {
} from "./headless-flows.js";
import { approvalFingerprint, listHosts, readHost } from "./host-flow.js";
import { parseSelection, type WizardPrompter } from "./init-wizard.js";
-import { setModelContentPermission } from "./model-content-permission-flow.js";
import { executeModelProbe, planModelProbe } from "./model-probe-flow.js";
import {
executeModelProfileActivate,
@@ -122,7 +121,6 @@ export type InteractiveModelSetupState =
| {
state: "configured";
profile: ModelProfile;
- model_content_allowed: boolean;
installed_hosts: string[];
}
| { state: "skipped" }
@@ -289,15 +287,11 @@ async function installHosts(
function writeSummary(
prompter: WizardPrompter,
profile: ModelProfile,
- modelContentAllowed: boolean,
installedHosts: readonly string[],
failedHosts: readonly { hostId: string; label: string }[],
): void {
prompter.write("配置摘要:");
prompter.write(` 模型:${profile.name} / ${profile.model}`);
- prompter.write(
- ` 模型内容外发:${modelContentAllowed ? "已允许" : "已禁止"}`,
- );
prompter.write(
` 应用接入:${installedHosts.length > 0 ? installedHosts.join("、") : "已跳过"}`,
);
@@ -353,14 +347,6 @@ export async function runInteractiveModelSetup(
prompter.write("未填写 API Key。输入 back 返回,或重新选择模型服务。");
continue;
}
- const permissionAnswer = (
- await prompter.ask("允许将对话内容发送给模型以整理记忆?[Y/n]: ")
- )
- .trim()
- .toLowerCase();
- const modelContentAllowed = !["n", "no", "否", "0"].includes(
- permissionAnswer,
- );
const hostApi = options.hostApi ?? { list: listHosts, read: readHost };
const selectedHosts = options.installHosts
? await chooseDetectedHosts(prompter, options.hostHome, hostApi)
@@ -452,13 +438,6 @@ export async function runInteractiveModelSetup(
return { state: "failed", code: "model_activate_failed" };
}
- const permission = await setModelContentPermission(
- clients.operations,
- modelContentAllowed,
- );
- if (permission.state !== "ok") {
- return { state: "failed", code: "model_content_permission_set_failed" };
- }
const { installed, failed } = await installHosts(
options.dataDir,
selectedHosts,
@@ -467,7 +446,6 @@ export async function runInteractiveModelSetup(
writeSummary(
prompter,
profile,
- modelContentAllowed,
installed,
failed.map((hostId) => ({
hostId,
@@ -477,7 +455,6 @@ export async function runInteractiveModelSetup(
return {
state: "configured",
profile,
- model_content_allowed: modelContentAllowed,
installed_hosts: installed,
};
}
diff --git a/memhub/apps/cli/src/job-flow.test.ts b/memhub/apps/cli/src/job-flow.test.ts
index 15fdbb7..3f9e0ba 100644
--- a/memhub/apps/cli/src/job-flow.test.ts
+++ b/memhub/apps/cli/src/job-flow.test.ts
@@ -180,5 +180,8 @@ describe("import-job write allow-list", () => {
await expect(
operations.post("/api/v1/import-jobs/job-1/delete", {}),
).rejects.toThrow("core_operation_path_not_allowed");
+ await expect(
+ operations.post("/api/v1/model-content-permission", { allowed: true }),
+ ).rejects.toThrow("core_operation_path_not_allowed");
});
});
diff --git a/memhub/apps/cli/src/model-content-permission-cli.test.ts b/memhub/apps/cli/src/model-content-permission-cli.test.ts
deleted file mode 100644
index 69a1196..0000000
--- a/memhub/apps/cli/src/model-content-permission-cli.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-import { run } from "./cli.js";
-
-const transport = vi.hoisted(() => ({
- get: vi.fn(),
- post: vi.fn(),
- put: vi.fn(),
- patch: vi.fn(),
- delete: vi.fn(),
-}));
-vi.mock("axios", () => ({
- default: { create: vi.fn(() => transport), get: vi.fn() },
-}));
-
-const runtime = vi.hoisted(() => ({
- inspectRuntimeStatus: vi.fn(),
- discoverRuntimeAttachment: vi.fn(),
-}));
-vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
- ...(await importOriginal()),
- ...runtime,
-}));
-
-beforeEach(() => {
- vi.clearAllMocks();
- runtime.inspectRuntimeStatus.mockResolvedValue({
- state: "running",
- code: "runtime_verified",
- instanceId: "instance-1",
- host: "127.0.0.1",
- port: 8765,
- version: "1.2.3",
- });
- runtime.discoverRuntimeAttachment.mockResolvedValue({
- instanceId: "instance-1",
- apiToken: "synthetic-runtime-token",
- host: "127.0.0.1",
- port: 8765,
- servicePid: 4242,
- health: {
- status: "ok",
- service: "one-memory-core",
- version: "1.2.3",
- instance_id: "instance-1",
- },
- });
-});
-
-describe("model content permission CLI", () => {
- it("documents only final-state permission and manual extraction commands", async () => {
- const permission = await capturedRun(["help", "permission"]);
- expect(permission.code).toBe(0);
- expect(permission.stdout).toContain("memhub permission allow");
- expect(permission.stdout).toContain("memhub permission deny");
- expect(permission.stdout).not.toMatch(
- /\b(preview|confirm|process|revoke)\b/,
- );
- expect(permission.stdout).not.toContain("revision");
- expect(permission.stdout).not.toContain("receipt");
-
- const processing = await capturedRun(["help", "processing"]);
- expect(processing.code).toBe(0);
- expect(processing.stdout).toContain("memhub processing extract");
- expect(processing.stdout).not.toMatch(/\b(plan|apply|receipt)\b/);
- expect(processing.stdout).not.toContain("backlog");
- });
-
- it("reads the final state", async () => {
- transport.get.mockResolvedValue({ status: 200, data: { allowed: true } });
-
- const result = await capturedRun(["permission", "status", "--json"]);
-
- expect(result.code).toBe(0);
- expect(JSON.parse(result.stdout)).toMatchObject({
- state: "ok",
- allowed: true,
- });
- expect(transport.get).toHaveBeenCalledWith(
- "/api/v1/model-content-permission",
- undefined,
- );
- });
-
- it.each([
- ["allow", true],
- ["deny", false],
- ] as const)("sets %s directly with only allowed", async (verb, allowed) => {
- transport.post.mockResolvedValue({ status: 200, data: { allowed } });
-
- const result = await capturedRun(["permission", verb, "--json"]);
-
- expect(result.code).toBe(0);
- expect(JSON.parse(result.stdout)).toMatchObject({ state: "ok", allowed });
- expect(transport.post).toHaveBeenCalledWith(
- "/api/v1/model-content-permission",
- { allowed },
- undefined,
- );
- });
-
- it.each(["preview", "confirm", "process", "revoke"])(
- "rejects removed %s commands before runtime access",
- async (verb) => {
- const result = await capturedRun(["permission", verb, "--json"]);
-
- expect(result.code).toBe(2);
- expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
- expect(transport.get).not.toHaveBeenCalled();
- expect(transport.post).not.toHaveBeenCalled();
- },
- );
-
- it.each(["plan", "apply", "receipt"])(
- "rejects removed processing %s commands before runtime access",
- async (verb) => {
- const result = await capturedRun(["processing", verb, "--json"]);
-
- expect(result.code).toBe(2);
- expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
- expect(transport.get).not.toHaveBeenCalled();
- expect(transport.post).not.toHaveBeenCalled();
- },
- );
-});
-
-async function capturedRun(arguments_: string[]) {
- let stdout = "";
- let stderr = "";
- const stdoutSpy = vi
- .spyOn(process.stdout, "write")
- .mockImplementation((chunk) => {
- stdout += String(chunk);
- return true;
- });
- const stderrSpy = vi
- .spyOn(process.stderr, "write")
- .mockImplementation((chunk) => {
- stderr += String(chunk);
- return true;
- });
- try {
- return {
- code: await run(arguments_, {
- ONE_MEMORY_DATA_DIR: "/synthetic/data",
- }),
- stdout,
- stderr,
- };
- } finally {
- stdoutSpy.mockRestore();
- stderrSpy.mockRestore();
- }
-}
diff --git a/memhub/apps/cli/src/model-content-permission-flow.test.ts b/memhub/apps/cli/src/model-content-permission-flow.test.ts
deleted file mode 100644
index 458e792..0000000
--- a/memhub/apps/cli/src/model-content-permission-flow.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-import type { CoreReader } from "./core-client.js";
-import type { CoreOperations } from "./core-operations.js";
-import {
- readModelContentPermission,
- setModelContentPermission,
-} from "./model-content-permission-flow.js";
-
-describe("model content permission flow", () => {
- it("reads the single final-state boolean", async () => {
- const reader = vi.fn().mockResolvedValue({
- state: "ok",
- data: { allowed: true },
- });
-
- await expect(
- readModelContentPermission(reader as CoreReader),
- ).resolves.toMatchObject({ state: "ok", allowed: true });
- expect(reader).toHaveBeenCalledWith("/api/v1/model-content-permission");
- });
-
- it.each([
- ["allow", true],
- ["deny", false],
- ] as const)(
- "sets %s directly without revision or receipt fields",
- async (_, allowed) => {
- const post = vi.fn().mockResolvedValue({
- state: "committed",
- data: { allowed },
- });
-
- await expect(
- setModelContentPermission({ post } as CoreOperations, allowed),
- ).resolves.toMatchObject({ state: "ok", allowed });
- expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
- allowed,
- });
- },
- );
-
- it("does not accept a malformed or mismatched success response", async () => {
- const malformed = vi.fn().mockResolvedValue({
- state: "committed",
- data: { allowed: true, revision: 1 },
- });
- await expect(
- setModelContentPermission({ post: malformed } as CoreOperations, true),
- ).resolves.toMatchObject({
- state: "unknown",
- code: "model_content_permission_response_invalid",
- });
-
- const mismatched = vi.fn().mockResolvedValue({
- state: "committed",
- data: { allowed: false },
- });
- await expect(
- setModelContentPermission({ post: mismatched } as CoreOperations, true),
- ).resolves.toMatchObject({
- state: "unknown",
- code: "model_content_permission_state_mismatch",
- });
- });
-});
diff --git a/memhub/apps/cli/src/model-content-permission-flow.ts b/memhub/apps/cli/src/model-content-permission-flow.ts
deleted file mode 100644
index 39a146e..0000000
--- a/memhub/apps/cli/src/model-content-permission-flow.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import type { CoreReader } from "./core-client.js";
-import type { CoreOperations } from "./core-operations.js";
-import { PRODUCT } from "./version.js";
-
-export type ModelContentPermissionDocument =
- | {
- schema_version: 1;
- product: string;
- state: "ok";
- allowed: boolean;
- }
- | {
- schema_version: 1;
- product: string;
- state: "rejected" | "unknown";
- code: string;
- };
-
-export async function readModelContentPermission(
- reader: CoreReader,
-): Promise {
- const outcome = await reader("/api/v1/model-content-permission");
- if (outcome.state !== "ok") {
- return failure("unknown", outcome.code);
- }
- return permissionState(
- outcome.data,
- "model_content_permission_response_invalid",
- );
-}
-
-export async function setModelContentPermission(
- operations: CoreOperations,
- allowed: boolean,
-): Promise {
- const outcome = await operations.post(
- "/api/v1/model-content-permission",
- { allowed },
- );
- if (outcome.state === "rejected") {
- return failure("rejected", outcome.code);
- }
- if (outcome.state === "unknown") {
- return failure("unknown", outcome.code);
- }
- const document = permissionState(
- outcome.data,
- "model_content_permission_response_invalid",
- );
- return document.state === "ok" && document.allowed !== allowed
- ? failure("unknown", "model_content_permission_state_mismatch")
- : document;
-}
-
-export function modelContentPermissionExitCode(
- document: ModelContentPermissionDocument,
-): number {
- return document.state === "ok" ? 0 : 1;
-}
-
-export function renderModelContentPermissionText(
- document: ModelContentPermissionDocument,
-): string {
- if (document.state === "ok") {
- return `模型内容外发:${document.allowed ? "允许" : "禁止"}\n`;
- }
- return `模型内容权限未更新:${document.state}(${document.code})\n`;
-}
-
-function permissionState(
- value: unknown,
- invalidCode: string,
-): ModelContentPermissionDocument {
- if (
- value === null ||
- typeof value !== "object" ||
- Array.isArray(value) ||
- Object.keys(value).length !== 1 ||
- typeof (value as Record).allowed !== "boolean"
- ) {
- return failure("unknown", invalidCode);
- }
- return {
- schema_version: 1,
- product: PRODUCT,
- state: "ok",
- allowed: (value as { allowed: boolean }).allowed,
- };
-}
-
-function failure(
- state: "rejected" | "unknown",
- code: string,
-): ModelContentPermissionDocument {
- return { schema_version: 1, product: PRODUCT, state, code };
-}
diff --git a/memhub/apps/cli/src/package-matrix.test.ts b/memhub/apps/cli/src/package-matrix.test.ts
index db76eba..4ae17f3 100644
--- a/memhub/apps/cli/src/package-matrix.test.ts
+++ b/memhub/apps/cli/src/package-matrix.test.ts
@@ -137,8 +137,8 @@ describe("package matrix harness", () => {
};
expect(document.state).toBe("verified");
const results = document.commands.stopped_destructive.results;
- // 13 destructive commands, each unknown/runtime_not_running with exit 3.
- expect(Object.keys(results)).toHaveLength(13);
+ // 11 destructive commands, each unknown/runtime_not_running with exit 3.
+ expect(Object.keys(results)).toHaveLength(11);
for (const [name, result] of Object.entries(results)) {
expect(result, `${name} did not fail closed`).toEqual({
exit: 3,
@@ -162,7 +162,7 @@ describe("package matrix harness", () => {
const shim = [
"#!/usr/bin/env node",
"const a=process.argv.slice(2);",
- "if(['calls','import-file','notes','permission','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
+ "if(['calls','import-file','notes','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
" process.exit(0);",
"}",
@@ -188,7 +188,7 @@ describe("package matrix harness", () => {
expect(failure?.state).toBe("failed");
expect(
failure?.failures.filter((item) => item.includes("did not fail closed")),
- ).toHaveLength(13);
+ ).toHaveLength(11);
});
it("can require an expected bundle SHA and reports bundle metadata", {
@@ -280,9 +280,8 @@ describe("package matrix harness", () => {
});
it("rejects a catalog that declares the parent command but not the new verb", async () => {
- // A bundle can still declare `permission`/`models` while missing
- // `permission allow` or `models activate`. Names alone would pass it; the
- // usage marker must not.
+ // A bundle can still declare `models` while missing `models activate`.
+ // Names alone would pass it; the usage marker must not.
const declared = [
"install",
"init",
@@ -292,7 +291,6 @@ describe("package matrix harness", () => {
"calls",
"import-file",
"notes",
- "permission",
"extraction",
"graph",
"models",
@@ -342,9 +340,6 @@ describe("package matrix harness", () => {
},
};
const failures = harness.validateCatalog(catalog);
- expect(failures).toContain(
- 'command "permission" is missing usage "memhub permission allow"',
- );
expect(failures).toContain(
'command "models" is missing usage "memhub models activate"',
);
diff --git a/memhub/apps/cli/src/product-command-contract.test.ts b/memhub/apps/cli/src/product-command-contract.test.ts
index 9505706..0e000b0 100644
--- a/memhub/apps/cli/src/product-command-contract.test.ts
+++ b/memhub/apps/cli/src/product-command-contract.test.ts
@@ -151,9 +151,6 @@ describe("memhub short-command contract", () => {
"memhub import-file discard",
"memhub notes add",
"memhub notes process",
- "memhub permission status",
- "memhub permission allow",
- "memhub permission deny",
"memhub models setup",
"memhub models activate",
"memhub models edit",
@@ -163,6 +160,9 @@ describe("memhub short-command contract", () => {
]) {
expect(flattened).toContain(shortForm);
}
+ expect(COMMANDS.some((command) => command.name === "permission")).toBe(
+ false,
+ );
});
it("ships a memhub bin entry pointing at a real bundle path", () => {
diff --git a/memhub/apps/cli/src/read-only-queries.test.ts b/memhub/apps/cli/src/read-only-queries.test.ts
index f078cb7..41bb129 100644
--- a/memhub/apps/cli/src/read-only-queries.test.ts
+++ b/memhub/apps/cli/src/read-only-queries.test.ts
@@ -840,6 +840,7 @@ describe("CLI-21 memory reads", () => {
expect(isReadOnlyPath("/api/v1/memories/graph")).toBe(false);
expect(isReadOnlyPath("/api/v1/memories/ask")).toBe(false);
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(true);
+ expect(isReadOnlyPath("/api/v1/model-content-permission")).toBe(false);
expect(
isReadOnlyPath(
"/api/v1/memory-mutations/123e4567-e89b-12d3-a456-426614174000",
diff --git a/memhub/apps/core/one-memory.spec b/memhub/apps/core/one-memory.spec
index 21187ac..05e00c3 100644
--- a/memhub/apps/core/one-memory.spec
+++ b/memhub/apps/core/one-memory.spec
@@ -11,7 +11,7 @@ folder_layout = layout == 'folder'
datas = [('src/one_memory/embedding_assets', 'one_memory/embedding_assets')]
binaries = []
# Loaded lazily by the API; static dependency analysis cannot discover it.
-hiddenimports = ['one_memory.hy_permission_context']
+hiddenimports = []
tmp_ret = collect_all('hy_memory')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('chromadb')
diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py
index 273f5d3..94cd6fd 100644
--- a/memhub/apps/core/src/one_memory/api.py
+++ b/memhub/apps/core/src/one_memory/api.py
@@ -20,7 +20,6 @@ from copy import copy
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from functools import partial
-from importlib import import_module
from pathlib import Path
from typing import Any, Literal, cast
from uuid import UUID
@@ -164,7 +163,6 @@ from one_memory.memory_library import (
)
from one_memory.memory_notes import MemoryNoteStore, memory_note_router
from one_memory.memory_processing import (
- MemoryBridgeProtocol,
MemoryProcessingSupervisor,
_conversation_chunks,
memory_task_timeout_seconds,
@@ -178,10 +176,8 @@ from one_memory.memory_queue import (
)
from one_memory.memory_server.config_state import ConfigStore
from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus, MemoryTaskStore
-from one_memory.model_content_routes import model_content_router
from one_memory.model_presets import seed_preset_profile
from one_memory.model_probe import ModelProber, ModelProbeResult
-from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease
from one_memory.model_profiles import (
ActiveModelProfileError,
ModelCreationOperationConflictError,
@@ -818,6 +814,13 @@ def create_app(
evidence=app.state.evidence_store,
)
app.state.memory_write_queue = memory_write_queue
+ # Builds through 1.0.2 parked pre-invocation jobs behind the
+ # retired model-content consent bit. The embedded SDK is trusted
+ # now, so make those durable jobs claimable on the first startup.
+ await asyncio.to_thread(
+ memory_write_queue.readmit_parked,
+ states=("waiting_for_permission",),
+ )
repaired_identity_failures = await asyncio.to_thread(
memory_write_queue.repair_pre_send_identity_failures
)
@@ -960,7 +963,7 @@ def create_app(
# Not fatal: without the tables every read checks exactly, as
# it does today.
app.state.memory_visibility_error = str(error)
- app.state.model_request_budget = (
+ model_request_budget = (
ModelRequestBudget(
database_path,
request_limit=runtime_settings.model_request_limit,
@@ -970,6 +973,7 @@ def create_app(
if runtime_settings.model_request_limit is not None
else None
)
+ app.state.model_request_budget = model_request_budget
app.state.memory_answerer = MemoryAnswerer(
governance=app.state.memory_governance_store,
filter_memories=app.state.source_read_access.memories,
@@ -1008,6 +1012,16 @@ def create_app(
governance=app.state.memory_governance_store,
data_dir=runtime_settings.data_dir,
extraction_settings=app.state.extraction_settings_store,
+ bridge_factory=lambda profile, api_key, data_dir: HyMemoryBridge(
+ profile=profile,
+ api_key=api_key,
+ data_dir=data_dir,
+ http_transport_factory=(
+ (lambda: BudgetedModelTransport(model_request_budget))
+ if model_request_budget is not None
+ else None
+ ),
+ ),
# `None` means "whatever the active profile allows". Pinned to
# 1 in `a59f4b5` as temporary load-shedding for an acceptance
# package, and left pinned after it: a hardcoded 1 is rule 9's
@@ -1016,10 +1030,6 @@ def create_app(
# profile concurrency 4, recall latency is unchanged while
# precipitation goes 4x.
max_concurrent_tasks=runtime_settings.precipitation_max_concurrent_tasks,
- model_content_config=app.state.config_store,
- permission_bridge_factory=lambda lease, secret, directory: _permission_context(
- app, lease, secret, directory
- ),
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
service_levels=app.state.service_levels,
)
@@ -1134,7 +1144,6 @@ def create_app(
app.state.matter_discovery.processor = app.state.memory_processing_supervisor
app.state.matter_discovery.write_intake = app.state.memory_write_intake
app.state.matter_discovery.write_queue = memory_write_queue
- app.state.processing_coordinator = None
app.state.memory_note_store = MemoryNoteStore(
app.state.evidence_store,
app.state.memory_processing_supervisor,
@@ -1446,7 +1455,6 @@ def create_app(
app.include_router(matter_router(protected))
app.include_router(memory_note_router(protected))
app.include_router(file_import_router(protected))
- app.include_router(model_content_router(protected))
app.include_router(context_router(protected))
def require_workbuddy_boundary(request: Request) -> None:
@@ -2328,7 +2336,7 @@ def create_app(
await asyncio.to_thread(
app.state.memory_task_store.park_source_for_permission,
source_id=source_id,
- error_code="model_content_permission_required",
+ error_code="hy_memory_source_revoked",
)
await _live_sync(app).stop_source(source_id)
_supervisor(app).unregister_adapter(source_id)
@@ -3894,12 +3902,7 @@ async def _authorize_answer_generation(
# The explicit question is this operation's input, not a standing permission for history.
if not question.strip() or not grounding:
raise MemoryAnswerError("memory_answer_generation_not_authorized")
- if not await asyncio.to_thread(_config_store(app).model_content_allowed):
- raise MemoryAnswerError("memory_answer_generation_not_authorized")
-
def check() -> None:
- if not _config_store(app).model_content_allowed():
- raise MemoryAnswerError("memory_answer_generation_not_authorized")
if _model_profile_store(app).active_identity() != (
profile.profile_id,
profile.revision,
@@ -3922,30 +3925,6 @@ async def _authorize_answer_generation(
await recheck()
return recheck
-
-@asynccontextmanager
-async def _permission_context(
- app: FastAPI,
- lease: ModelProcessingPermissionLease,
- secret: str | None,
- directory: Path,
-) -> AsyncIterator[MemoryBridgeProtocol]:
- module = await asyncio.to_thread(import_module, "one_memory.hy_permission_context")
- budget = cast(ModelRequestBudget | None, app.state.model_request_budget)
- source_id = lease.records[0].source_id if lease.records else "manual"
- factory = module.permission_bridge_factory(
- lambda profile, api_key, data_dir: _memory_processing_supervisor(app)._bridge(
- profile,
- api_key,
- data_dir,
- scope_key=source_id,
- ),
- **({"http_transport": BudgetedModelTransport(budget)} if budget else {}),
- )
- async with factory(lease, secret, directory) as bridge:
- yield cast(MemoryBridgeProtocol, bridge)
-
-
class _LazyASGIApp:
"""Compatibility ASGI app that is built only when a server imports it.
@@ -4723,8 +4702,6 @@ async def _run_backfill(app: FastAPI) -> None:
app.state.memory_backfill_error = None
if not app.state.settings.automatic_memory_processing:
return
- if not await asyncio.to_thread(_config_store(app).model_content_allowed):
- return
try:
queue = app.state.memory_write_queue
intake = app.state.memory_write_intake
diff --git a/memhub/apps/core/src/one_memory/hy_memory_bridge.py b/memhub/apps/core/src/one_memory/hy_memory_bridge.py
index a18e599..ac6889f 100644
--- a/memhub/apps/core/src/one_memory/hy_memory_bridge.py
+++ b/memhub/apps/core/src/one_memory/hy_memory_bridge.py
@@ -21,7 +21,6 @@ from pydantic import BaseModel, ConfigDict, Field
from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms
from one_memory.hy_lifecycle import HyLifecycle, HyLifecycleUnreadable, read_hy_lifecycle
-from one_memory.hy_permission_store import native_store_read_slot
from one_memory.hy_presence import HyPresence, confirm_deleted
from one_memory.hy_runtime_config import (
HY_MEMORY_MODE,
@@ -32,6 +31,7 @@ from one_memory.hy_runtime_config import (
diagnostic_log_level,
)
from one_memory.hy_source_context import HySourceContext, HyWriteBindings
+from one_memory.hy_store_concurrency import native_store_read_slot
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking
from one_memory.semantic_deadline import SemanticMemoryHit
@@ -376,11 +376,13 @@ class HyMemoryBridge:
api_key: str | None,
data_dir: Path,
client: HyMemoryClientProtocol | None = None,
+ http_transport_factory: Callable[[], Any] | None = None,
) -> None:
self.profile = profile
self.api_key = api_key
self.data_dir = data_dir
self._client = client
+ self._http_transport_factory = http_transport_factory
self._client_lock = asyncio.Lock()
self._write_lock = asyncio.Lock()
self._last_bindings: HyWriteBindings | None = None
@@ -427,6 +429,13 @@ class HyMemoryBridge:
)
client = await self._get_client()
guards = getattr(client, "_one_memory_write_guards", ())
+ from one_memory.hy_response_guard import HyReconcileGuard
+
+ reconcile_guards = tuple(
+ guard for guard in guards if isinstance(guard, HyReconcileGuard)
+ )
+ for guard in reconcile_guards:
+ guard.begin_write()
before = [len(guard.failures) for guard in guards]
bindings = HyWriteBindings(request.source_context) if request.source_context else None
if bindings:
@@ -500,6 +509,12 @@ class HyMemoryBridge:
if len(guard.failures) > count:
raise HyMemoryBridgeError(guard.failures[count])
result = await self._result(client, request, raw, expected_contents=None, bindings=bindings)
+ result = await _with_native_mutation_delta(
+ client,
+ result,
+ raw,
+ reconcile_guards,
+ )
if bindings:
outputs = []
for memory in result.memories:
@@ -553,6 +568,8 @@ class HyMemoryBridge:
from one_memory.hy_response_guard import HyReconcileGuard
report_guards = tuple(guard for guard in guards if isinstance(guard, HyReconcileGuard))
+ for guard in report_guards:
+ guard.begin_write()
expected_contents = frozenset(content.strip() for content in contents if content.strip())
for guard in report_guards:
guard.imported_contents = expected_contents
@@ -599,6 +616,12 @@ class HyMemoryBridge:
raw,
expected_contents=expected_contents,
)
+ result = await _with_native_mutation_delta(
+ client,
+ result,
+ raw,
+ report_guards,
+ )
diagnostic_event(
level="info",
component="hy.bridge",
@@ -1087,6 +1110,7 @@ class HyMemoryBridge:
self.profile,
api_key=self.api_key,
data_dir=self.data_dir,
+ http_transport_factory=self._http_transport_factory,
)
await _joined_thread(initialize)
@@ -1136,6 +1160,7 @@ def create_hy_memory_client(
*,
api_key: str | None,
data_dir: Path,
+ http_transport_factory: Callable[[], Any] | None = None,
) -> HyMemoryClientProtocol:
configure_hy_memory_environment(data_dir)
config = build_hy_memory_config(
@@ -1143,6 +1168,7 @@ def create_hy_memory_client(
api_key=api_key,
data_dir=data_dir,
)
+ config.http_transport_factory = http_transport_factory
reattach_legacy_vector_collection(profile, config)
mismatch = inspect_graph_width_mismatch(config)
if mismatch is not None:
@@ -1287,6 +1313,74 @@ async def _coding_result(
return HyMemoryIngestResult(success=True, memories=tuple(outputs))
+async def _with_native_mutation_delta(
+ client: HyMemoryClientProtocol,
+ result: HyMemoryIngestResult,
+ raw: Mapping[str, object],
+ reconcile_guards: Sequence[Any],
+) -> HyMemoryIngestResult:
+ """Map native SDK mutations back into MemHub governance.
+
+ Store permission wrappers used to discover this by intercepting every
+ native CRUD call. The embedded SDK now owns unrestricted local CRUD, so
+ chat targets come from the reconcile observer and coding targets come from
+ the SDK's public result document.
+ """
+
+ retired = set(result.retired_memory_ids)
+ superseded = {
+ key: tuple(values) for key, values in result.superseded_memory_ids.items()
+ }
+ chat_targets = {
+ key
+ for guard in reconcile_guards
+ for key in getattr(guard, "mutation_target_ids", ())
+ if isinstance(key, str) and key
+ }
+
+ for item in raw.get("ops", ()) if isinstance(raw.get("ops"), list) else ():
+ if not isinstance(item, Mapping):
+ continue
+ if item.get("action") == "DELETE":
+ key = item.get("target_experience_id")
+ if isinstance(key, str) and key:
+ retired.add(key)
+
+ for item in raw.get("fact_ops", ()) if isinstance(raw.get("fact_ops"), list) else ():
+ if not isinstance(item, Mapping) or item.get("action") != "SUPERSEDE":
+ continue
+ old, new = item.get("fact_id"), item.get("superseded_by")
+ if isinstance(old, str) and old:
+ retired.add(old)
+ if isinstance(new, str) and new:
+ superseded[old] = (new,)
+
+ for key in chat_targets:
+ record = await _joined_thread(_read_hy_record, client, key)
+ if record is None:
+ retired.add(key)
+ continue
+ status = str(record.get("status", "")).lower()
+ if status in {"deleted", "shadow", "superseded"}:
+ retired.add(key)
+ replacement = record.get("superseded_by")
+ if isinstance(replacement, str) and replacement:
+ superseded[key] = (replacement,)
+ elif isinstance(replacement, list):
+ values = tuple(
+ value for value in replacement if isinstance(value, str) and value
+ )
+ if values:
+ superseded[key] = values
+
+ return result.model_copy(
+ update={
+ "retired_memory_ids": tuple(sorted(retired)),
+ "superseded_memory_ids": superseded,
+ }
+ )
+
+
def _read_hy_record(
client: HyMemoryClientProtocol,
memory_id: str,
diff --git a/memhub/apps/core/src/one_memory/hy_permission_coding_store.py b/memhub/apps/core/src/one_memory/hy_permission_coding_store.py
deleted file mode 100644
index 7825946..0000000
--- a/memhub/apps/core/src/one_memory/hy_permission_coding_store.py
+++ /dev/null
@@ -1,607 +0,0 @@
-"""Operation-bound read/write capability for the pinned native HY coding store.
-
-HY's coding route writes two layers, experiences and working facts. Rows this
-lease cannot attest stay in the same backend but are invisible here, so they
-never reach a coding prompt and cannot be updated, superseded or linked.
-
-HY's reconcilers catch store exceptions and carry on, so a denial latches and
-recheck() raises it again at finish; a swallowed denial still fails the task.
-Any native method not implemented here is denied rather than passed through.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-from collections.abc import Iterator, Mapping, Sequence
-from copy import deepcopy
-from hashlib import sha256
-from types import MappingProxyType
-from typing import Any, NoReturn
-
-from hy_memory.coding.types import CodingMemory, WorkingFact # type: ignore[import-untyped]
-
-from one_memory.hy_permission_store import HyNodePermission
-from one_memory.model_processing_permission_guard import (
- ModelProcessingPermissionLease,
- permission_error,
-)
-
-_NATIVE_WRITES = frozenset(
- {
- "insert",
- "update",
- "delete",
- "insert_fact",
- "supersede_fact",
- "set_fact_derived_experiences",
- "set_experience_source_facts",
- # Trace rows are raw conversation: writes must be gated like any other.
- "append_trace",
- }
-)
-
-_TIMESTAMPS = frozenset({"created_at", "updated_at"})
-
-# SDK 2.0.2 reads `config` off the coding store while routing a coding
-# turn. The wrapper must answer it, but the native `MemoryConfig` carries
-# credentials (llm/embedder api keys, graph and cache passwords) and is a live
-# handle into the backend, so it is never handed out. Only this redacted
-# projection is exposed: credential-bearing keys collapse to None.
-_CONFIG_REDACTED_KEYS = frozenset(
- {
- "api_key",
- "password",
- "username",
- "mysql_password",
- "mysql_user",
- "extra_headers",
- "extra_body",
- }
-)
-
-
-class _RedactedConfig(Mapping[str, Any]):
- """Read-only config view: readable by key and attribute, never the native one.
-
- The SDK reaches config values both ways, so this keeps attribute access
- working. It is deliberately not the native MemoryConfig: it exposes only
- plain copied values, so no backend handle, connection or callable escapes.
- """
-
- __slots__ = ("_values",)
-
- def __init__(self, values: Mapping[str, Any]) -> None:
- object.__setattr__(self, "_values", MappingProxyType(dict(values)))
-
- def __getitem__(self, key: str) -> Any:
- return self._values[key]
-
- def __iter__(self) -> Iterator[str]:
- return iter(self._values)
-
- def __len__(self) -> int:
- return len(self._values)
-
- def __getattr__(self, name: str) -> Any:
- values = object.__getattribute__(self, "_values")
- try:
- return values[name]
- except KeyError:
- raise AttributeError(name) from None
-
- def __setattr__(self, name: str, value: Any) -> NoReturn:
- raise AttributeError("redacted config is read-only")
-
- def __repr__(self) -> str:
- return f"_RedactedConfig({sorted(self._values)})"
-
-
-def _redact_config(value: Any, depth: int = 0) -> Any:
- """Copy config data into read-only views, collapsing credentials to None.
-
- The SDK reads this both by attribute and by key, so each config section
- becomes a mapping-like view rather than a plain dict: attribute access stays
- available while the native object itself is never handed out.
- """
- if depth > 4:
- return None
- if value is None or isinstance(value, (str, int, float, bool)):
- return value
- if isinstance(value, Mapping):
- return _RedactedConfig(
- {
- key: None if key in _CONFIG_REDACTED_KEYS else _redact_config(item, depth + 1)
- for key, item in value.items()
- }
- )
- if isinstance(value, (list, tuple)):
- return [_redact_config(item, depth + 1) for item in value]
- if hasattr(value, "__dict__") and type(value).__module__.startswith("hy_memory"):
- return _redact_config(vars(value), depth + 1)
- return None
-
-
-def _config_projection(native: Any) -> Any:
- try:
- return _redact_config(native.config)
- except Exception:
- return None
-
-
-def hy_coding_fingerprint(row: CodingMemory | WorkingFact) -> str:
- """Hash the complete native row as read back, including links and timestamps."""
- if type(row) is CodingMemory:
- kind = "experience"
- elif type(row) is WorkingFact:
- kind = "fact"
- else:
- raise permission_error("hy_coding_store_row_shape")
- payload = {"kind": kind, **row.to_dict()}
- return sha256(
- json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
- ).hexdigest()
-
-
-def _row_id(row: CodingMemory | WorkingFact) -> str:
- return str(row.experience_id if type(row) is CodingMemory else row.fact_id)
-
-
-class HyPermissionCodingStore:
- """Closed, task-exclusive adapter; initialize/close never own the native store.
-
- node_permissions is the same attested ledger the chat vector store uses;
- coding rows are keyed by experience or fact id. written_nodes carries the
- whole lease footprint for each row written here, as the chat store does.
- """
-
- def __init__(
- self,
- native: Any,
- lease: ModelProcessingPermissionLease,
- *,
- agent_id: str,
- session_id: str,
- node_permissions: Mapping[str, HyNodePermission] | None = None,
- ) -> None:
- self._native = native
- self._lease = lease
- self._agent_id = agent_id
- self._session_id = session_id
- self._user_id = lease.user_id
- self._failure: str | None = None
- self._closed = False
- self._lock = asyncio.Lock()
- self._deleted: set[str] = set()
- self._written: dict[str, HyNodePermission] = {}
- self._superseded: dict[str, str] = {}
- self._evidence_ids = tuple(record.evidence_id for record in lease.records)
- self._permissions = {
- key: proof
- for key, proof in (node_permissions or {}).items()
- if set(proof.evidence_ids) <= set(self._evidence_ids)
- }
- self.recheck()
- if not agent_id or agent_id == "*" or not session_id or not self._evidence_ids:
- self._deny("scope")
-
- def __getattr__(self, name: str) -> NoReturn:
- if name.startswith("__"):
- raise AttributeError(name)
- self._deny("unsupported_operation")
-
- @property
- def written_nodes(self) -> Mapping[str, HyNodePermission]:
- self.recheck()
- return MappingProxyType(dict(self._written))
-
- @property
- def config(self) -> Any:
- """Read-only, redacted view of the native coding config.
-
- SDK 2.0.2 reads `config` when it routes a coding turn. Answering
- it is safe only as a projection: the native MemoryConfig is a live
- backend handle and carries credentials, so callers get plain values
- with every credential-bearing key collapsed to None. This carries no
- identity, permission or tenant state and is not the native object.
- """
- self._assert_open()
- return _config_projection(self._native)
-
- @property
- def retired_ids(self) -> frozenset[str]:
- """Experiences HY deleted in this operation."""
- self.recheck()
- return frozenset(self._deleted)
-
- @property
- def superseded_ids(self) -> Mapping[str, tuple[str, ...]]:
- """Facts HY superseded in this operation, with the fact that replaced each.
-
- A successor linked before it existed must have been written here by
- now; otherwise the old fact points at a row nobody attested.
- """
- self.recheck()
- if any(new not in self._written for new in self._superseded.values()):
- self._deny("link_denied")
- return MappingProxyType({old: (new,) for old, new in self._superseded.items()})
-
- def recheck(self) -> None:
- self._assert_open()
- try:
- self._lease.recheck()
- except Exception as error:
- code = str(error)
- self._failure = (
- code
- if code.startswith("model_content_") and len(code) <= 100
- else "model_content_hy_coding_store_lease_failed"
- )
- raise permission_error(self._failure) from None
-
- def _assert_open(self) -> None:
- if self._closed:
- self._deny("closed")
- if self._failure:
- raise permission_error(self._failure)
-
- async def close(self) -> None:
- async with self._lock:
- self._closed = True
- self._permissions.clear()
- self._written.clear()
-
- def _deny(self, suffix: str) -> NoReturn:
- self._failure = self._failure or f"model_content_hy_coding_store_{suffix}"
- raise permission_error(self._failure)
-
- def _scope(self, user_id: str | None) -> None:
- if user_id not in (None, self._user_id):
- self._deny("scope")
-
- async def _invoke(self, method: str, *args: Any, **kwargs: Any) -> Any:
- writing = method in _NATIVE_WRITES
- self.recheck() if writing else self._assert_open()
- try:
- result = await getattr(self._native, method)(*args, **kwargs)
- except Exception:
- self._deny("backend_failed")
- self.recheck() if writing else self._assert_open()
- return result
-
- def _visible(self, row: Any, expected: Any) -> bool:
- if row is None:
- return False
- if type(row) is not expected:
- self._deny("row_shape")
- key = _row_id(row)
- proof = self._permissions.get(key)
- if proof is None or key in self._deleted or row.user_id != self._user_id:
- return False
- if hy_coding_fingerprint(row) != proof.fingerprint:
- self._deny("row_changed")
- return True
-
- async def _experience(self, key: str) -> CodingMemory | None:
- if key not in self._permissions or key in self._deleted:
- return None
- row = await self._invoke("get_by_id", key, user_id=self._user_id)
- return deepcopy(row) if self._visible(row, CodingMemory) else None
-
- async def _fact(self, key: str) -> WorkingFact | None:
- if key not in self._permissions or key in self._deleted:
- return None
- rows = await self._invoke("get_facts", [key], user_id=self._user_id)
- row = rows[0] if rows else None
- return deepcopy(row) if self._visible(row, WorkingFact) else None
-
- def _new_row_in_scope(self, row: CodingMemory | WorkingFact) -> bool:
- return bool(
- row.user_id == self._user_id
- and row.agent_id == self._agent_id
- and row.session_id == self._session_id
- )
-
- async def _capture(
- self, key: str, kind: Any, expected: CodingMemory | WorkingFact | None = None
- ) -> None:
- row: Any
- if kind is CodingMemory:
- row = await self._invoke("get_by_id", key, user_id=self._user_id)
- else:
- rows = await self._invoke("get_facts", [key], user_id=self._user_id)
- row = rows[0] if rows else None
- if type(row) is not kind or row.user_id != self._user_id:
- self._deny("write_mismatch")
- if expected is not None:
- stored, planned = row.to_dict(), expected.to_dict()
- for field in _TIMESTAMPS:
- stored.pop(field, None)
- planned.pop(field, None)
- if stored != planned:
- self._deny("write_mismatch")
- proof = HyNodePermission(hy_coding_fingerprint(row), self._evidence_ids)
- self._permissions[key] = proof
- self._written[key] = proof
-
- async def _plan_new(self, row: CodingMemory | WorkingFact, kind: Any) -> None:
- if type(row) is not kind or not self._new_row_in_scope(row):
- self._deny("scope")
- key = _row_id(row)
- if not key or key in self._permissions or key in self._deleted:
- self._deny("scope")
- if kind is CodingMemory:
- existing = await self._invoke("get_by_id", key)
- else:
- existing = next(iter(await self._invoke("get_facts", [key])), None)
- if existing is not None:
- self._deny("scope")
-
- # Reads
-
- async def get_by_id(
- self, experience_id: str, user_id: str | None = None
- ) -> CodingMemory | None:
- async with self._lock:
- self._scope(user_id)
- return await self._experience(experience_id)
-
- async def get_many(
- self, experience_ids: list[str], user_id: str | None = None
- ) -> list[CodingMemory]:
- async with self._lock:
- self._scope(user_id)
- wanted = [
- key
- for key in dict.fromkeys(experience_ids)
- if key in self._permissions and key not in self._deleted
- ]
- if not wanted:
- return []
- rows = await self._invoke("get_many", wanted, user_id=self._user_id)
- return [deepcopy(row) for row in rows if self._visible(row, CodingMemory)]
-
- async def search_by_query_embedding(
- self,
- query_embedding: list[float],
- user_id: str,
- *,
- workspace_id: str | None = None,
- branch: str | None = None,
- top: int = 40,
- ) -> list[dict[str, Any]]:
- async with self._lock:
- self._scope(user_id)
- hits = await self._invoke(
- "search_by_query_embedding",
- deepcopy(query_embedding),
- self._user_id,
- workspace_id=workspace_id,
- branch=branch,
- top=top * 4,
- )
- result = []
- for hit in hits:
- key = hit.get("experience_id")
- if not isinstance(key, str) or await self._experience(key) is None:
- continue
- result.append(
- {
- "experience_id": key,
- "score": float(hit.get("score", 0.0)),
- "matched_key_kind": hit.get("matched_key_kind"),
- "matched_key_text": hit.get("matched_key_text"),
- }
- )
- return result[:top]
-
- async def get_fact(self, fact_id: str, user_id: str | None = None) -> WorkingFact | None:
- async with self._lock:
- self._scope(user_id)
- return await self._fact(fact_id)
-
- async def get_facts(self, fact_ids: list[str], user_id: str | None = None) -> list[WorkingFact]:
- async with self._lock:
- self._scope(user_id)
- found = [await self._fact(key) for key in dict.fromkeys(fact_ids)]
- return [row for row in found if row is not None]
-
- async def search_facts(
- self,
- query_embedding: list[float],
- user_id: str,
- *,
- workspace_id: str | None = None,
- branch: str | None = None,
- top: int = 5,
- query_text: str | None = None,
- min_score: float = 0.0,
- ) -> list[tuple[WorkingFact, float]]:
- async with self._lock:
- self._scope(user_id)
- hits = await self._invoke(
- "search_facts",
- deepcopy(query_embedding),
- self._user_id,
- workspace_id=workspace_id,
- branch=branch,
- top=top * 4,
- query_text=query_text,
- min_score=min_score,
- )
- result = []
- for row, score in hits:
- if self._visible(row, WorkingFact):
- result.append((deepcopy(row), float(score)))
- return result[:top]
-
- async def get_fact_ancestors(
- self, fact_id: str, *, user_id: str | None = None, max_depth: int = 8
- ) -> list[WorkingFact]:
- async with self._lock:
- self._scope(user_id)
- if await self._fact(fact_id) is None:
- return []
- rows = await self._invoke(
- "get_fact_ancestors", fact_id, user_id=self._user_id, max_depth=max_depth
- )
- # Oldest first; the chain is only as long as its nearest unapproved link.
- kept: list[WorkingFact] = []
- for row in reversed(rows):
- if not self._visible(row, WorkingFact):
- break
- kept.append(deepcopy(row))
- return list(reversed(kept))
-
- # Writes
-
- async def insert(self, memory: CodingMemory) -> None:
- async with self._lock:
- self.recheck()
- await self._plan_new(memory, CodingMemory)
- await self._invoke("insert", memory)
- await self._capture(memory.experience_id, CodingMemory, memory)
-
- async def update(self, memory: CodingMemory) -> None:
- async with self._lock:
- self.recheck()
- if type(memory) is not CodingMemory or not self._new_row_in_scope(memory):
- self._deny("scope")
- if await self._experience(memory.experience_id) is None:
- self._deny("target_denied")
- await self._invoke("update", memory)
- await self._capture(memory.experience_id, CodingMemory, memory)
-
- async def delete(self, experience_id: str, user_id: str) -> bool:
- async with self._lock:
- self._scope(user_id)
- if await self._experience(experience_id) is None:
- self._deny("target_denied")
- if await self._invoke("delete", experience_id, user_id=self._user_id) is not True:
- self._deny("write_failed")
- if await self._invoke("get_by_id", experience_id) is not None:
- self._deny("write_mismatch")
- self._deleted.add(experience_id)
- self._permissions.pop(experience_id, None)
- self._written.pop(experience_id, None)
- return True
-
- async def insert_fact(self, fact: WorkingFact) -> None:
- async with self._lock:
- self.recheck()
- await self._plan_new(fact, WorkingFact)
- await self._invoke("insert_fact", fact)
- await self._capture(fact.fact_id, WorkingFact, fact)
-
- async def supersede_fact(self, old_fact_id: str, new_fact_id: str) -> bool:
- async with self._lock:
- self.recheck()
- if await self._fact(old_fact_id) is None or old_fact_id == new_fact_id:
- self._deny("target_denied")
- # HY links the old fact before it inserts the new one. A successor
- # that does not exist yet may only become a row this lease writes;
- # an existing row it cannot attest stays out of reach.
- if await self._fact(new_fact_id) is None:
- if not new_fact_id or new_fact_id in self._deleted:
- self._deny("target_denied")
- if next(iter(await self._invoke("get_facts", [new_fact_id])), None) is not None:
- self._deny("target_denied")
- changed = await self._invoke("supersede_fact", old_fact_id, new_fact_id)
- await self._capture(old_fact_id, WorkingFact)
- if changed:
- self._superseded[old_fact_id] = new_fact_id
- return bool(changed)
-
- async def set_fact_derived_experiences(self, fact_id: str, experience_ids: list[str]) -> None:
- async with self._lock:
- self.recheck()
- if await self._fact(fact_id) is None:
- self._deny("target_denied")
- for key in experience_ids:
- if await self._experience(key) is None:
- self._deny("link_denied")
- await self._invoke("set_fact_derived_experiences", fact_id, list(experience_ids))
- await self._capture(fact_id, WorkingFact)
-
- async def set_experience_source_facts(self, experience_id: str, fact_ids: list[str]) -> None:
- async with self._lock:
- self.recheck()
- if await self._experience(experience_id) is None:
- self._deny("target_denied")
- for key in fact_ids:
- if await self._fact(key) is None:
- self._deny("link_denied")
- await self._invoke("set_experience_source_facts", experience_id, list(fact_ids))
- await self._capture(experience_id, CodingMemory)
-
- async def get_recent_trace_messages(
- self,
- *,
- user_id: str,
- agent_id: str = "default_agent",
- max_user_turns: int = 5,
- ) -> list[Any]:
- """Read recent raw trace rows, still bounded by this lease.
-
- Public SDK 2.0.2 asks for recent history before a coding write. The native call is
- scoped to the lease's own user and agent, and rows this lease cannot
- attest are filtered out, so unapproved conversations never reach a
- coding prompt through this path.
- """
- self._scope(user_id)
- if agent_id not in (self._agent_id, "*"):
- self._deny("scope")
- rows = await self._invoke(
- "get_recent_trace_messages",
- user_id=self._user_id,
- agent_id=self._agent_id,
- max_user_turns=max_user_turns,
- )
- return [row for row in (rows or []) if self._trace_visible(row)]
-
- async def append_trace(
- self,
- messages: Sequence[Any],
- *,
- request_id: str,
- user_id: str,
- agent_id: str = "default_agent",
- session_id: str | None = None,
- workspace_id: str | None = None,
- branch: str | None = None,
- ) -> None:
- """Append raw trace rows for this lease only.
-
- Trace rows are raw conversation, so this is a write path: the lease is
- rechecked around the call and every message must belong to the leased
- user and session. Nothing outside the lease may be recorded.
- """
- self._scope(user_id)
- if agent_id not in (self._agent_id, "*"):
- self._deny("scope")
- if session_id not in (None, self._session_id):
- self._deny("scope")
- for row in messages or []:
- if not self._trace_visible(row):
- self._deny("scope")
- await self._invoke(
- "append_trace",
- list(messages or []),
- request_id=request_id,
- user_id=self._user_id,
- agent_id=self._agent_id,
- session_id=self._session_id,
- workspace_id=workspace_id,
- branch=branch,
- )
-
- def _trace_visible(self, row: Any) -> bool:
- """A trace row may be read or written only inside this lease's scope."""
- if row is None:
- return False
- owner = getattr(row, "user_id", None)
- session = getattr(row, "session_id", None)
- return not (
- (owner is not None and owner != self._user_id)
- or (session is not None and session != self._session_id)
- )
diff --git a/memhub/apps/core/src/one_memory/hy_permission_context.py b/memhub/apps/core/src/one_memory/hy_permission_context.py
deleted file mode 100644
index 5f1f4d3..0000000
--- a/memhub/apps/core/src/one_memory/hy_permission_context.py
+++ /dev/null
@@ -1,582 +0,0 @@
-"""Task-exclusive HY wiring; provenance metadata stays beside the existing Core DB."""
-
-from __future__ import annotations
-
-import asyncio
-import json
-from collections.abc import AsyncIterator, Callable
-from contextlib import asynccontextmanager, nullcontext, suppress
-from pathlib import Path
-from typing import Any, cast
-
-import httpx
-
-from one_memory.database import take_the_write_lock_first
-from one_memory.database_access import main_database
-from one_memory.hy_memory_bridge import (
- HyMemoryBridge,
- HyMemoryBridgeError,
- HyMemoryIngestRequest,
- HyMemoryIngestResult,
- HyMemoryOutput,
- LocalHyEmbedService,
- _joined_thread,
-)
-from one_memory.hy_permission_coding_store import HyPermissionCodingStore
-from one_memory.hy_permission_store import (
- HyNodePermission,
- HyPermissionGraphStore,
- HyPermissionVectorStore,
-)
-from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
-from one_memory.hy_response_guard import HyResponseGuard
-from one_memory.hy_step_timing import HyStepTimer
-from one_memory.memory_server.migrations import hy_node_permissions_ddl
-from one_memory.memory_tasks import evidence_ref
-from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease
-
-
-class _NativeProofs:
- def __init__(self, database: Path) -> None:
- self.database = database
- self._loaded_states: dict[str, tuple[int, str]] | None = None
- with main_database(database).write() as db, db:
- # Schema owned by `memory_server.migrations` (migration 26).
- db.execute(hy_node_permissions_ddl("sqlite"))
-
- def load(self, lease: ModelProcessingPermissionLease) -> dict[str, HyNodePermission]:
- allowed = {record.evidence_id: evidence_ref(record) for record in lease.records}
- with main_database(self.database).read() as db:
- rows = db.execute(
- "SELECT p.node_id,p.fingerprint,p.evidence_json,g.revision,g.status "
- "FROM hy_node_permissions p LEFT JOIN governed_memories g ON g.memory_id=p.node_id "
- # Superseded nodes stay attested: every later head links back to them.
- "WHERE g.status IS NULL OR g.status IN ('confirmed','superseded')"
- ).fetchall()
- proofs = {}
- states = {}
- for identifier, fingerprint, evidence_json, revision, status in rows:
- refs = json.loads(evidence_json)
- if (
- not isinstance(refs, list)
- or not refs
- or not all(isinstance(ref, dict) for ref in refs)
- ):
- continue
- if all(allowed.get(ref.get("evidence_id")) == ref for ref in refs):
- proofs[identifier] = HyNodePermission(
- fingerprint, tuple(ref["evidence_id"] for ref in refs)
- )
- if revision is not None:
- states[identifier] = (revision, status)
- self._loaded_states = states
- return proofs
-
- def guard(self, values: dict[str, HyNodePermission]) -> Callable[[], None]:
- identifiers = tuple(values)
-
- def state() -> dict[str, tuple[int, str]]:
- found: dict[str, tuple[int, str]] = {}
- with main_database(self.database).read() as db:
- for start in range(0, len(identifiers), 900):
- batch = identifiers[start : start + 900]
- placeholders = ",".join("?" for _ in batch)
- for identifier, revision, status in db.execute(
- "SELECT memory_id,revision,status FROM governed_memories "
- f"WHERE memory_id IN ({placeholders})",
- batch,
- ):
- found[identifier] = (revision, status)
- return found
-
- if self._loaded_states is None:
- raise HyMemoryBridgeError("model_content_context_unverified")
- before = dict(self._loaded_states)
-
- def check() -> None:
- if state() != before:
- raise HyMemoryBridgeError("model_content_context_changed")
-
- check()
- return check
-
- def save(
- self, lease: ModelProcessingPermissionLease, values: dict[str, HyNodePermission]
- ) -> None:
- refs = {record.evidence_id: evidence_ref(record) for record in lease.records}
- with main_database(self.database).write(autocommit=True) as db:
- take_the_write_lock_first(db)
- with db:
- lease.recheck()
- db.executemany(
- "INSERT INTO hy_node_permissions VALUES (?,?,?) ON CONFLICT(node_id) "
- "DO UPDATE SET fingerprint=excluded.fingerprint,"
- "evidence_json=excluded.evidence_json",
- [
- (
- identifier,
- proof.fingerprint,
- json.dumps([refs[key] for key in proof.evidence_ids]),
- )
- for identifier, proof in values.items()
- ],
- )
-
- def refresh(
- self, lease: ModelProcessingPermissionLease, node_id: str
- ) -> HyNodePermission | None:
- del lease
- with main_database(self.database).read() as db:
- row = db.execute(
- "SELECT fingerprint,evidence_json FROM hy_node_permissions WHERE node_id=?",
- (node_id,),
- ).fetchone()
- if row is None:
- return None
- fingerprint = str(row["fingerprint"])
- refs = json.loads(row["evidence_json"])
- if (
- len(fingerprint) != 64
- or any(value not in "0123456789abcdef" for value in fingerprint)
- or not isinstance(refs, list)
- or not refs
- or not all(isinstance(ref, dict) for ref in refs)
- ):
- return None
- evidence_ids = tuple(ref.get("evidence_id") for ref in refs)
- if any(not isinstance(value, str) or not value for value in evidence_ids):
- return None
- return HyNodePermission(fingerprint, evidence_ids)
-
-
-class _BoundLease:
- def __init__(
- self,
- lease: ModelProcessingPermissionLease,
- proof_check: Callable[[], None],
- step_timer: HyStepTimer | None = None,
- ) -> None:
- self.lease = lease
- self.step_timer = step_timer
- self.user_id = lease.user_id
- self.profile = lease.profile
- self.task_id = lease.task_id
- self.records = lease.records
- self.source_context = lease.source_context
- self.proof_check = proof_check
- self.vector: HyPermissionVectorStore | None = None
- self.coding: HyPermissionCodingStore | None = None
-
- def recheck(self) -> None:
- timer = self.step_timer
- with timer.step("permission_check") if timer is not None else nullcontext():
- self.lease.recheck()
- self.proof_check()
- if self.vector is not None:
- self.vector.recheck()
- if self.coding is not None:
- self.coding.recheck()
-
-
-class _Wiring:
- def __init__(
- self,
- client: Any,
- lease: ModelProcessingPermissionLease,
- proofs_store: _NativeProofs,
- proofs: dict[str, HyNodePermission],
- agent_id: str,
- session_id: str,
- transport: httpx.AsyncBaseTransport | None,
- proof_check: Callable[[], None],
- step_timer: HyStepTimer,
- ) -> None:
- self.client, self.lease, self.proofs_store, self.proofs = (
- client,
- lease,
- proofs_store,
- proofs,
- )
- self.step_timer = step_timer
- self.agent_id, self.session_id, self.http_transport = agent_id, session_id, transport
- self.cross_evidence_scope = False
- self.proof_check = proof_check
- self.saved: list[tuple[Any, str, Any]] = []
- self.transport: HyPermissionTransport
- self.vector: HyPermissionVectorStore
- self.graph: HyPermissionGraphStore | None = None
- self.coding: HyPermissionCodingStore
- self.active = False
-
- def replace(self, target: Any, name: str, value: Any) -> None:
- self.saved.append((target, name, getattr(target, name)))
- setattr(target, name, value)
-
- async def enter(self) -> None:
- client, registry = self.client, self.client._registry
- config = client._config
- if config.llm.enable_summary or config.llm.enable_query_rewrite:
- raise HyMemoryBridgeError("model_content_context_unverified")
- bound = _BoundLease(self.lease, self.proof_check, self.step_timer)
- self.transport = await HyPermissionTransport(
- bound, http_transport=self.http_transport, step_timer=self.step_timer
- ).__aenter__()
- self.active = True
- try:
- for guard in getattr(client, "_one_memory_write_guards", ()):
- if hasattr(type(guard), "step_timer"):
- self.replace(guard, "step_timer", self.step_timer)
- system2 = await registry.get_system2_writer()
- writers = (await registry.get_writer(), system2._writer)
- native_vector = client._vector_store
- store_lease = _BoundLease(self.lease, self.proof_check, self.step_timer)
- self.vector = HyPermissionVectorStore(
- native_vector,
- cast(ModelProcessingPermissionLease, store_lease),
- agent_id=self.agent_id,
- session_id=self.session_id,
- cross_evidence_scope=self.cross_evidence_scope,
- node_permissions=self.proofs,
- refresh_permission=lambda node_id: asyncio.to_thread(
- self.proofs_store.refresh, self.lease, node_id
- ),
- step_timer=self.step_timer,
- )
- bound.vector = self.vector
- embed = client._embed_service
- if isinstance(embed, LocalHyEmbedService):
- self.transport.bind_local_embed_service(embed)
- else:
- self.transport.bind_embed_service(embed)
- for writer in writers:
- provider = writer.mem_agent.llm_provider
- self.transport.bind_provider(
- provider.provider if isinstance(provider, HyResponseGuard) else provider
- )
- reconciler = writer.reconciler
- provider = reconciler._get_llm()
- self.transport.bind_provider(
- provider.provider if isinstance(provider, HyResponseGuard) else provider
- )
- self.replace(writer, "_external_vector_store", self.vector)
- self.replace(writer, "_vector_store", self.vector)
- self.replace(writer, "_vector_store_initialized", True)
- self.replace(writer, "_cache", None)
- self.replace(client, "_vector_store", self.vector)
- native_graph = client._graph_store
- if self.cross_evidence_scope and native_graph is not None:
- self.graph = HyPermissionGraphStore(native_graph, self.proof_check)
- self.replace(client, "_graph_store", self.graph)
- else:
- self.replace(client, "_graph_store", None)
- self.replace(registry, "_shared_vector_store", self.vector)
- self.replace(
- registry,
- "_shared_graph_store",
- self.graph if self.graph is not None else None,
- )
- self.replace(registry, "_shared_cache", None)
- self.replace(system2, "_vector_store", self.vector)
- self.replace(
- system2,
- "_graph_store",
- self.graph if self.graph is not None else None,
- )
- self.replace(system2, "_cache", None)
- # Result fallback may call search; rebuild only its non-generative reader.
- self.replace(registry, "_reader", None)
- self.replace(registry, "_readers", {})
- await self._enter_coding(bound, embed)
- self.transport.assert_allowed()
- except BaseException:
- await self.exit()
- raise
-
- async def _enter_coding(self, bound: _BoundLease, embed: Any) -> None:
- """HY judges and writes the coding route itself; every seam it uses is bound here."""
- client = self.client
- # Created now, so the judge and every coding stage share one bound provider.
- writer = await client._get_coding_writer()
- stages = (writer, writer.extractor, writer.reconciler, writer.fact_reconciler)
- native_store = client._coding_store
- if any(
- stage.store is not native_store
- for stage in (writer, writer.reconciler, writer.fact_reconciler)
- ) or any(
- item.embed_service is not embed
- for item in (native_store, writer, writer.reconciler, writer.fact_reconciler)
- ):
- raise HyMemoryBridgeError("model_content_context_unverified")
- providers = {
- id(p): p for p in (client._coding_llm_provider, *(s.llm_provider for s in stages))
- }
- for provider in providers.values():
- self.transport.bind_provider(provider)
- self.coding = HyPermissionCodingStore(
- native_store,
- cast(
- ModelProcessingPermissionLease,
- _BoundLease(self.lease, self.proof_check, self.step_timer),
- ),
- agent_id=self.agent_id,
- session_id=self.session_id,
- node_permissions=self.proofs,
- )
- bound.coding = self.coding
- for stage in (writer, writer.reconciler, writer.fact_reconciler):
- self.replace(stage, "store", self.coding)
- self.replace(client, "_coding_store", self.coding)
- for stage in (writer, writer.extractor, writer.reconciler):
- self.replace(stage, "cache", None)
- # The judge logs its prompt through the client's own cache.
- self.replace(client, "_cache", None)
-
- async def finish(self) -> dict[str, HyNodePermission]:
- self.transport.assert_allowed()
- self.vector.recheck()
- self.coding.recheck()
- return {**self.vector.written_nodes, **self.coding.written_nodes}
-
- async def retired(self) -> tuple[tuple[str, ...], dict[str, tuple[str, ...]]]:
- self.transport.assert_allowed()
- return (
- tuple(sorted(self.vector.retired_node_ids | self.coding.retired_ids)),
- {**self.vector.superseded_nodes, **self.coding.superseded_ids},
- )
-
- async def exit(self) -> None:
- if not self.active:
- return
- self.active = False
- try:
- await self.transport.__aexit__(None, None, None)
- finally:
- for target, name, value in reversed(self.saved):
- setattr(target, name, value)
- self.saved.clear()
- if hasattr(self, "vector"):
- await self.vector.close()
- if self.graph is not None:
- await self.graph.close()
- if hasattr(self, "coding"):
- await self.coding.close()
-
-
-class _LeasedBridge:
- def __init__(
- self,
- bridge: HyMemoryBridge,
- lease: ModelProcessingPermissionLease,
- proofs: _NativeProofs,
- transport: httpx.AsyncBaseTransport | None,
- ) -> None:
- self.bridge, self.lease, self.proofs, self.transport = bridge, lease, proofs, transport
- self.wiring: _Wiring | None = None
- self.finished = False
- self.step_timer = HyStepTimer()
- self.request_id: str | None = None
-
- def _step(self, name: str) -> Any:
- return self.step_timer.step(name)
-
- async def _recheck(self) -> None:
- def recheck() -> None:
- with self._step("permission_check"):
- self.lease.recheck()
-
- await _joined_thread(recheck)
-
- async def _start(self, request: HyMemoryIngestRequest) -> None:
- if self.wiring is not None or self.finished:
- raise HyMemoryBridgeError("model_content_context_reused")
- if request.user_id != self.lease.user_id or set(request.evidence_ids) - {
- item.evidence_id for item in self.lease.records
- }:
- raise HyMemoryBridgeError("model_content_context_unverified")
- await self._start_operation(
- user_id=request.user_id,
- agent_id=request.agent_id,
- request_id=request.request_id,
- session_id=request.session_id,
- )
-
- async def _start_operation(
- self,
- *,
- user_id: str,
- agent_id: str,
- request_id: str,
- session_id: str,
- cross_evidence_scope: bool = False,
- ) -> None:
- if self.wiring is not None or self.finished:
- raise HyMemoryBridgeError("model_content_context_reused")
- if user_id != self.lease.user_id:
- raise HyMemoryBridgeError("model_content_context_unverified")
- self.request_id = request_id
- await self._recheck()
- client = cast(Any, await self.bridge._get_client())
- with self._step("permission_store_read"):
- known = await asyncio.to_thread(self.proofs.load, self.lease)
- proof_check = await asyncio.to_thread(self.proofs.guard, known)
- self.wiring = _Wiring(
- client,
- self.lease,
- self.proofs,
- known,
- agent_id,
- session_id,
- self.transport,
- proof_check,
- self.step_timer,
- )
- self.wiring.cross_evidence_scope = cross_evidence_scope
- await _joined_thread(client._loop_thread.run, self.wiring.enter())
-
- async def list_agent_ids(self, *, user_id: str, limit: int = 1_000) -> tuple[str, ...]:
- if user_id != self.lease.user_id or self.wiring is not None or self.finished:
- raise HyMemoryBridgeError("model_content_context_unverified")
- await self._recheck()
- result = await self.bridge.list_agent_ids(user_id=user_id, limit=limit)
- await self._recheck()
- return result
-
- async def digest(self, *, user_id: str, agent_id: str) -> dict[str, object]:
- await self._start_operation(
- user_id=user_id,
- agent_id=agent_id,
- request_id=f"digest:{self.lease.task_id}:{agent_id}",
- session_id=f"digest:{self.lease.task_id}",
- cross_evidence_scope=True,
- )
- try:
- result = dict(await self.bridge.digest(user_id=user_id, agent_id=agent_id))
- except HyMemoryBridgeError:
- await self._check()
- raise
- proofs = await self._check()
- with self._step("permission_store_write"):
- await asyncio.to_thread(self.proofs.save, self.lease, proofs)
- await self.close()
- await self._recheck()
- return result
-
- async def graph_snapshot(self, *, user_id: str, agent_id: str, limit: int = 300) -> Any:
- await self._start_operation(
- user_id=user_id,
- agent_id=agent_id,
- request_id=f"digest-graph:{self.lease.task_id}:{agent_id}",
- session_id=f"digest:{self.lease.task_id}",
- cross_evidence_scope=True,
- )
- try:
- result = await self.bridge.graph_snapshot(
- user_id=user_id,
- agent_id=agent_id,
- limit=limit,
- )
- except HyMemoryBridgeError:
- await self._check()
- raise
- await self._check()
- await self.close()
- await self._recheck()
- return result
-
- async def ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult:
- await self._start(request)
- try:
- result = await self.bridge.ingest(request)
- except HyMemoryBridgeError:
- await self._check()
- raise
- return await self._with_retired(result)
-
- async def ingest_extracted(
- self, request: HyMemoryIngestRequest, contents: tuple[str, ...]
- ) -> HyMemoryIngestResult:
- await self._start(request)
- try:
- result = await self.bridge.ingest_extracted(request, contents)
- except HyMemoryBridgeError:
- await self._check()
- raise
- return await self._with_retired(result)
-
- async def _with_retired(self, result: HyMemoryIngestResult) -> HyMemoryIngestResult:
- await self._check()
- if self.wiring is None:
- raise HyMemoryBridgeError("model_content_context_unverified")
- try:
- retired, superseded = await _joined_thread(
- self.wiring.client._loop_thread.run, self.wiring.retired()
- )
- except HyPermissionTransportError as error:
- await self._recheck()
- raise HyMemoryBridgeError(error.code) from None
- return result.model_copy(
- update={"retired_memory_ids": retired, "superseded_memory_ids": superseded}
- )
-
- async def _check(self) -> dict[str, HyNodePermission]:
- if self.wiring is None or self.finished:
- raise HyMemoryBridgeError("model_content_context_unverified")
- await self._recheck()
- try:
- return await _joined_thread(self.wiring.client._loop_thread.run, self.wiring.finish())
- except HyPermissionTransportError as error:
- await self._recheck()
- raise HyMemoryBridgeError(error.code) from None
-
- async def profile_memories(self, *, user_id: str, agent_id: str) -> tuple[HyMemoryOutput, ...]:
- if self.wiring is None or user_id != self.lease.user_id or agent_id != self.wiring.agent_id:
- raise HyMemoryBridgeError("model_content_context_unverified")
- result = await self.bridge.profile_memories(user_id=user_id, agent_id=agent_id)
- proofs = await self._check()
- with self._step("permission_store_write"):
- await asyncio.to_thread(self.proofs.save, self.lease, proofs)
- # SDK context closes before governance can update a previously known memory.
- await self.close()
- return result
-
- async def close(self) -> None:
- if self.wiring is not None and not self.finished:
- self.finished = True
- try:
- await _joined_thread(self.wiring.client._loop_thread.run, self.wiring.exit())
- finally:
- self.step_timer.emit(request_id=self.request_id, task_id=self.lease.task_id)
-
-
-def permission_bridge_factory(
- resolve_bridge: Callable[..., Any],
- *,
- http_transport: httpx.AsyncBaseTransport | None = None,
-) -> Callable[..., Any]:
- @asynccontextmanager
- async def factory(
- lease: ModelProcessingPermissionLease, secret: str | None, data_dir: Path
- ) -> AsyncIterator[Any]:
- bridge = resolve_bridge(lease.profile, secret, data_dir)
- if not isinstance(bridge, HyMemoryBridge):
- raise HyMemoryBridgeError("model_content_context_unverified")
- proofs = _NativeProofs(data_dir / "one-memory.sqlite3")
- async with bridge.exclusive_access():
- await bridge.prepare()
- bound = _LeasedBridge(bridge, lease, proofs, http_transport)
- try:
- yield bound
- except BaseException as error:
- with suppress(Exception):
- await bound.close()
- if isinstance(error, HyPermissionTransportError):
- await _joined_thread(lease.recheck)
- raise HyMemoryBridgeError(error.code) from None
- raise
- else:
- try:
- await bound.close()
- except HyPermissionTransportError as error:
- raise HyMemoryBridgeError(error.code) from None
-
- return factory
diff --git a/memhub/apps/core/src/one_memory/hy_permission_store.py b/memhub/apps/core/src/one_memory/hy_permission_store.py
deleted file mode 100644
index 8731627..0000000
--- a/memhub/apps/core/src/one_memory/hy_permission_store.py
+++ /dev/null
@@ -1,880 +0,0 @@
-"""Operation-bound read/write capability for the pinned native HY chat vector store.
-
-No semantic storage or extraction lives here. Pre-existing nodes require a full
-native-node attestation with complete approved Evidence provenance; known content
-alone cannot authorize tags, custom fields, vectors or links. Unknown nodes stay
-in the same HY backend but are not exposed to this operation.
-
-The caller must exclusively own the native backend's writes during execution:
-its API has no compare-and-swap, and a swallowed get_by_id error can look missing.
-This component cannot make that check/upsert interval atomic. Factory integration
-must replace client/registry/both writers/cached-reader references, bind physical
-LLM/embedding sends to the lease AND this denial latch, and guard publication.
-Coding rows go through hy_permission_coding_store instead; graph, entities,
-digest, sweeper and summary remain unsupported.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import math
-import struct
-import weakref
-from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
-from contextlib import asynccontextmanager, nullcontext
-from copy import deepcopy
-from dataclasses import dataclass
-from hashlib import sha256
-from types import MappingProxyType
-from typing import Any, NoReturn
-
-from hy_memory.models.memory import ( # type: ignore[import-untyped]
- MemoryLayer,
- MemoryNode,
- MemoryStatus,
-)
-
-from one_memory.hy_step_timing import HyStepTimer
-from one_memory.model_processing_permission_guard import (
- ModelProcessingPermissionLease,
- permission_error,
-)
-
-_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
- weakref.WeakKeyDictionary()
-)
-
-
-def _native_write_lock() -> asyncio.Lock:
- loop = asyncio.get_running_loop()
- lock = _WRITE_LOCKS.get(loop)
- if lock is None:
- lock = asyncio.Lock()
- _WRITE_LOCKS[loop] = lock
- return lock
-
-
-@asynccontextmanager
-async def native_store_read_slot() -> AsyncIterator[bool]:
- """Admit one native HY read only while no write is active.
-
- Chroma's native reader may retain the GIL after an asyncio timeout. Running
- it concurrently with HY's multi-step store writes has deadlocked the Core
- under sustained imports. Reads therefore share the physical store lock,
- but never wait behind a writer: host recall can immediately use the local
- governance ledger instead.
- """
- lock = _native_write_lock()
- if lock.locked():
- yield False
- return
- await lock.acquire()
- try:
- yield True
- finally:
- lock.release()
-
-
-@dataclass(frozen=True)
-class HyNodePermission:
- """Trusted caller's full-node attestation, not permission inferred from an ID."""
-
- fingerprint: str
- evidence_ids: tuple[str, ...]
-
-
-def hy_node_fingerprint(node: MemoryNode) -> str:
- """Hash the complete native serialized node, including vectors and metadata.
-
- Timestamp precision follows HY's to_dict contract (integer Unix seconds).
- Attest the node read from the backend, not an unpersisted input object.
- """
- if type(node) is not MemoryNode:
- raise permission_error("hy_store_node_shape")
- return sha256(
- json.dumps(node.to_dict(), sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
- ).hexdigest()
-
-
-_CHAT_LAYERS = frozenset(
- {
- MemoryLayer.L0_BASIC_INFO,
- MemoryLayer.L1_RAW,
- MemoryLayer.L2_FACT,
- MemoryLayer.L4_IDENTITY,
- MemoryLayer.L7_INTENTION,
- }
-)
-_UPDATE_FIELDS = frozenset(
- {
- "content",
- "embedding",
- "tags",
- "tag_ners",
- "layer",
- "memory_at",
- "gmt_modified",
- "is_latest",
- "status",
- "supersedes",
- "superseded_by",
- "source_raw_memory_id",
- # SDK 2.0.2 sends both of these on an in-place reconcile UPDATE.
- # They are metadata only and must never carry identity, permission or
- # tenant state: `owner` is a provenance label whose only values are
- # "user" / "agent" (who said this line), and `custom` is the SDK's own
- # free-form notes field. Neither may hold a user_id, scope, grant or
- # tenancy marker, so allowing the field names does not widen the
- # cross-user boundary that the store still enforces per node.
- "owner",
- "custom",
- }
-)
-_DEDUP_AUDIT_FIELDS = frozenset({"shadow_reason", "dedup_kept_id"})
-_DIGEST_COUNTER_FIELD = "s2_evidence_count"
-
-
-def _write_view(node: MemoryNode) -> dict[str, Any]:
- """Only native Chroma's empty-string and float32 storage normalization."""
- payload: dict[str, Any] = node.to_dict()
- payload["source_session_id"] = payload["source_session_id"] or ""
- # The pinned public SDK serializes tag_ners as JSON, but its Chroma read path omits this
- # field from the JSON decode list. Decode only the exact list-of-strings
- # shape that the write path produced; malformed values still fail the
- # complete-node comparison below.
- tag_ners = payload.get("tag_ners")
- if (
- isinstance(tag_ners, list)
- and tag_ners
- and all(isinstance(value, str) and len(value) == 1 for value in tag_ners)
- and tag_ners[0] == "["
- and tag_ners[-1] == "]"
- ):
- tag_ners = "".join(tag_ners)
- if isinstance(tag_ners, str):
- try:
- decoded = json.loads(tag_ners)
- except (json.JSONDecodeError, TypeError):
- decoded = None
- if isinstance(decoded, list) and all(isinstance(value, str) for value in decoded):
- payload["tag_ners"] = decoded
- if payload.get("tag_ners") in ([], ["[", "]"]):
- payload["tag_ners"] = []
- # HY Chroma encodes None as ""; these JSON fields keep "" when json.loads
- # fails (vector_store_chroma.py:741-788). Only an absent link is equivalent.
- for field in ("supersedes", "superseded_by"):
- if payload[field] == "":
- payload[field] = None
- if payload["embedding"] is not None:
- payload["embedding"] = [
- struct.unpack("f", struct.pack("f", n))[0] for n in payload["embedding"]
- ]
- if not all(math.isfinite(n) for n in payload["embedding"]):
- raise ValueError("nonfinite_embedding")
- return payload
-
-
-def _same_written_vector(actual: list[float] | None, expected: list[float] | None) -> bool:
- if actual == expected:
- return True
- if actual is None or expected is None or len(actual) != len(expected):
- return False
- # Chroma's cosine index can renormalize float32 vectors on readback. Bound
- # only this write roundtrip; the saved full-node proof uses exact readback.
- return all(
- math.isclose(left, right, rel_tol=4 * 2**-23, abs_tol=0.0)
- for left, right in zip(actual, expected, strict=True)
- )
-
-
-_NATIVE_WRITES = frozenset(
- {"upsert", "upsert_batch", "update_payload", "update_embedding", "delete"}
-)
-
-
-class HyPermissionGraphStore:
- """Lease-gated graph store for HY System 2's digest transaction.
-
- The native graph is deliberately absent from ordinary ingest leases. Digest
- needs it to write L6 schemas, but every operation must remain inside the
- same frozen permission lease rather than exposing the raw store.
- """
-
- def __init__(self, native: Any, recheck: Any) -> None:
- self._native = native
- self._recheck = recheck
- self._closed = False
-
- def recheck(self) -> None:
- if self._closed:
- raise permission_error("hy_graph_store_closed")
- self._recheck()
-
- async def close(self) -> None:
- self._closed = True
-
- def __getattr__(self, name: str) -> Any:
- if name.startswith("_") and name != "_run":
- raise AttributeError(name)
- attribute = getattr(self._native, name)
- if not callable(attribute):
- return attribute
-
- async def guarded(*args: Any, **kwargs: Any) -> Any:
- self.recheck()
- result = await attribute(*args, **kwargs)
- self.recheck()
- return result
-
- return guarded
-
-
-class HyPermissionVectorStore:
- """Closed, task-exclusive adapter; initialize/close never own the native store.
-
- node_permissions may attest additional native raw/profile nodes not represented
- in known_memories. Every attestation must cover all its contributors, and all
- Evidence IDs must already be in this lease. Known entries also constrain exact
- content/owner. Without a full attestation even a known entry is denied.
-
- written_nodes records successful writes with the entire lease footprint, a
- conservative provenance bound, not a claim of semantic citation accuracy.
- These transient fingerprints must be persisted by the integrating factory if
- future operations are to reuse raw/profile nodes. No content is stored here.
- """
-
- __slots__ = (
- "__native",
- "__lease",
- "__agent_id",
- "__session_id",
- "__cross_evidence_scope",
- "__user_id",
- "__known",
- "__permissions",
- "__written",
- "__evidence_ids",
- "__deleted",
- "__shadowed",
- "__superseded",
- "__heads",
- "__lock",
- "__closed",
- "__failure",
- "__step_timer",
- "__refresh_permission",
- )
-
- def __init__(
- self,
- native: Any,
- lease: ModelProcessingPermissionLease,
- *,
- agent_id: str,
- session_id: str,
- cross_evidence_scope: bool = False,
- node_permissions: Mapping[str, HyNodePermission] | None = None,
- refresh_permission: Callable[[str], Awaitable[HyNodePermission | None]] | None = None,
- step_timer: HyStepTimer | None = None,
- ) -> None:
- self.__step_timer = step_timer
- self.__native = native
- self.__lease = lease
- self.__agent_id = agent_id
- self.__session_id = session_id
- self.__cross_evidence_scope = cross_evidence_scope
- self.__user_id = lease.user_id
- self.__failure: str | None = None
- self.__closed = False
- self.__lock = asyncio.Lock()
- self.__deleted: set[str] = set()
- self.__written: dict[str, HyNodePermission] = {}
- self.__shadowed: set[str] = set()
- self.__superseded: dict[str, tuple[str, ...]] = {}
- self.__heads: dict[str, tuple[str, ...]] = {}
- self.__refresh_permission = refresh_permission
- self.recheck()
- self.__evidence_ids = tuple(record.evidence_id for record in lease.records)
- current = lease.source_context.current if lease.source_context else lease.records
- conversation_mismatch = any(r.conversation_id != session_id for r in current)
- if (
- not lease.task_id
- or not agent_id
- or agent_id == "*"
- or not session_id
- or not current
- or (conversation_mismatch and not cross_evidence_scope)
- or not self.__evidence_ids
- or len(set(self.__evidence_ids)) != len(lease.records)
- or any(r.user_id != self.__user_id for r in lease.records)
- ):
- self.__deny("scope")
- known = lease.source_context.known_memories if lease.source_context else ()
- self.__known = {item.hy_memory_id: item for item in known}
- if len(self.__known) != len(known):
- self.__deny("provenance")
- self.__permissions = dict(node_permissions or {})
- for key, proof in self.__permissions.items():
- if (
- not key
- or not isinstance(proof, HyNodePermission)
- or len(proof.fingerprint) != 64
- or any(c not in "0123456789abcdef" for c in proof.fingerprint)
- or not isinstance(proof.evidence_ids, tuple)
- or not proof.evidence_ids
- or len(set(proof.evidence_ids)) != len(proof.evidence_ids)
- or not set(proof.evidence_ids) <= set(self.__evidence_ids)
- ):
- self.__deny("provenance")
- for key, item in self.__known.items():
- if not item.evidence_ids or not set(item.evidence_ids) <= set(self.__evidence_ids):
- self.__deny("provenance")
- known_proof = self.__permissions.get(key)
- if known_proof and not set(item.evidence_ids) <= set(known_proof.evidence_ids):
- self.__deny("provenance")
-
- @property
- def written_nodes(self) -> Mapping[str, HyNodePermission]:
- """Latest verified write proofs; each access returns a new immutable snapshot.
-
- Export after SDK writes have drained and before close/governance projection.
- Merge these rows into the lineage ledger, then remove deleted_node_ids;
- this write delta does not include untouched pre-existing permissions.
- """
- self.recheck()
- return MappingProxyType(dict(self.__written))
-
- @property
- def deleted_node_ids(self) -> frozenset[str]:
- """Verified native deletions in this operation, for lineage-ledger cleanup."""
- self.recheck()
- return frozenset(self.__deleted)
-
- @property
- def retired_node_ids(self) -> frozenset[str]:
- """Nodes HY deleted or shadowed (its logical delete) in this operation."""
- self.recheck()
- return frozenset(self.__deleted | self.__shadowed)
-
- @property
- def superseded_nodes(self) -> Mapping[str, tuple[str, ...]]:
- """Nodes HY marked superseded in this operation, with what replaced them.
-
- HY writes the two directions separately and may fail either one, so a
- replacement counts whether it was recorded on the old node or only on
- the new head.
- """
- self.recheck()
- result = {}
- for key, by in self.__superseded.items():
- heads = set(by) | {head for head, olds in self.__heads.items() if key in olds}
- heads -= self.__deleted | {key}
- if key not in self.__deleted and heads:
- result[key] = tuple(sorted(heads))
- return MappingProxyType(result)
-
- @property
- def supports_fulltext(self) -> bool:
- return False
-
- def recheck(self) -> None:
- """Must also gate transport/publication after HY swallows a store failure."""
- self.__assert_open()
- try:
- self.__lease.recheck()
- except Exception as error:
- code = str(error)
- self.__failure = (
- code
- if code.startswith("model_content_") and len(code) <= 100
- else "model_content_hy_store_lease_failed"
- )
- raise permission_error(self.__failure) from None
-
- def __assert_open(self) -> None:
- if self.__closed:
- self.__deny("closed")
- if self.__failure:
- raise permission_error(self.__failure)
-
- async def initialize(self) -> None:
- self.recheck()
-
- async def close(self) -> None:
- """Revoke this wrapper locally without rechecking context or closing HY.
-
- Cleanup must remain possible after governance projection changes the
- lease's known-memory revisions, or after permission has been revoked.
- """
- async with self.__lock:
- self.__closed = True
- self.__permissions.clear()
- self.__written.clear()
-
- async def has_tag_embedding(self, user_id: str, tag: str) -> bool:
- self.recheck()
- # Native chat calls this best-effort helper. NotImplemented prevents it
- # from embedding tags or falling through to an unguarded entity store.
- raise NotImplementedError("hy_permission_store_tag_index_unsupported")
-
- def __deny(self, suffix: str) -> NoReturn:
- self.__failure = self.__failure or f"model_content_hy_store_{suffix}"
- raise permission_error(self.__failure)
-
- async def __invoke(self, method: str, *args: Any, **kwargs: Any) -> Any:
- writing = method in _NATIVE_WRITES
- self.recheck() if writing else self.__assert_open()
- step = "vector_store_write" if writing else "vector_store_read"
- first = args[0] if args else None
- try:
- with (
- self.__step_timer.step(step, items=len(first) if isinstance(first, list) else 1)
- if self.__step_timer is not None
- else nullcontext()
- ):
- result = await getattr(self.__native, method)(*args, **kwargs)
- except Exception:
- self.__deny("backend_failed")
- self.recheck() if writing else self.__assert_open()
- return result
-
- async def __read_native(self, key: str) -> MemoryNode | None:
- value = await self.__invoke("get_by_id", key)
- if value is not None and (type(value) is not MemoryNode or value.node_id != key):
- self.__deny("node_shape")
- return deepcopy(value)
-
- def __fingerprint(self, node: MemoryNode) -> str:
- try:
- return hy_node_fingerprint(node)
- except (TypeError, ValueError, AttributeError, OverflowError):
- self.__deny("node_shape")
-
- def __write_view(self, node: MemoryNode) -> dict[str, Any]:
- self.__fingerprint(node)
- try:
- return _write_view(node)
- except (TypeError, ValueError, OverflowError, struct.error):
- self.__deny("node_shape")
-
- def __in_scope(self, node: MemoryNode) -> bool:
- if node.user_id != self.__user_id or node.layer not in _CHAT_LAYERS:
- return False
- if node.layer is MemoryLayer.L0_BASIC_INFO:
- # Public SDK 2.0.2 migrates verified legacy agent-level profiles into one
- # user-level chain. Existing nodes still require a full proof in
- # __load; accepting their historical agent id does not authorize
- # unproven cross-agent memories.
- return True
- if self.__cross_evidence_scope:
- return bool(node.agent_id == self.__agent_id)
- return bool(node.agent_id == self.__agent_id and node.session_id == self.__session_id)
-
- def __new_node_in_scope(self, node: MemoryNode) -> bool:
- if not self.__in_scope(node):
- return False
- if node.layer is MemoryLayer.L0_BASIC_INFO:
- return node.agent_id in ("", self.__agent_id) and node.session_id in (
- "",
- self.__session_id,
- )
- return True
-
- def __references(self, node: MemoryNode) -> list[str]:
- refs = []
- for field in ("supersedes", "superseded_by", "evidence_chain"):
- values = getattr(node, field)
- if field != "evidence_chain" and values == "":
- continue
- if values is not None:
- if not isinstance(values, list) or any(
- not isinstance(v, str) or not v for v in values
- ):
- self.__deny("node_shape")
- refs.extend(values)
- if node.source_raw_memory_id is not None:
- if not isinstance(node.source_raw_memory_id, str) or not node.source_raw_memory_id:
- self.__deny("node_shape")
- refs.append(node.source_raw_memory_id)
- return refs
-
- async def __load(
- self,
- key: str,
- first: MemoryNode | None = None,
- *,
- allow_deleted_links: bool = False,
- ) -> MemoryNode | None:
- pending = [key]
- checked: dict[str, MemoryNode] = {}
- while pending:
- current = pending.pop()
- if current in checked:
- continue
- # HY dedup deletes head then ancestors. Only this operation's
- # verified tombstones may terminate a closure during deletion.
- if allow_deleted_links and current != key and current in self.__deleted:
- continue
- proof = self.__permissions.get(current)
- if proof is None or current in self.__deleted:
- return None
- value = (
- first if current == key and first is not None else await self.__read_native(current)
- )
- if value is None or not self.__in_scope(value):
- return None
- if self.__fingerprint(value) != proof.fingerprint:
- refreshed = (
- await self.__refresh_permission(current)
- if self.__refresh_permission is not None
- else None
- )
- if refreshed is None or self.__fingerprint(value) != refreshed.fingerprint:
- self.__deny("node_changed")
- self.__permissions[current] = refreshed
- proof = refreshed
- known = self.__known.get(current) if current not in self.__written else None
- if known and (value.content != known.content or value.owner != known.owner):
- self.__deny("node_changed")
- checked[current] = value
- pending.extend(self.__references(value))
- return deepcopy(checked[key])
-
- async def get_by_id(self, node_id: str) -> MemoryNode | None:
- async with self.__lock:
- self.recheck()
- result = await self.__load(node_id)
- self.recheck()
- return result
-
- async def get_by_ids(self, node_ids: list[str]) -> list[MemoryNode]:
- async with self.__lock:
- self.recheck()
- result = [
- n for key in dict.fromkeys(node_ids) if (n := await self.__load(key)) is not None
- ]
- self.recheck()
- return result
-
- async def get_embeddings(self, node_ids: list[str]) -> dict[str, list[float]]:
- return {
- n.node_id: list(n.embedding)
- for n in await self.get_by_ids(node_ids)
- if n.embedding is not None
- }
-
- def __scope(self, user_id: str | None, agent_id: str | None) -> None:
- if user_id not in (None, self.__user_id) or agent_id not in (None, self.__agent_id):
- self.__deny("scope")
-
- async def list_by_user(
- self,
- user_id: str | None = None,
- agent_id: str | None = None,
- limit: int = 10000,
- status_filter: list[Any] | None = None,
- layers: list[Any] | None = None,
- user_ids: list[str] | None = None,
- agent_ids: list[str] | None = None,
- only_latest: bool = False,
- with_vectors: bool = True,
- allow_raw_layer: bool = False,
- ) -> list[MemoryNode]:
- async with self.__lock:
- self.recheck()
- effective_users = user_ids or ([user_id] if user_id else [])
- effective_agents = agent_ids or ([agent_id] if agent_id else [])
- if effective_users != [self.__user_id] or any(
- value != self.__agent_id for value in effective_agents
- ):
- self.__deny("scope")
- rows = await self.__invoke(
- "list_by_user",
- user_id=self.__user_id,
- agent_id=agent_id,
- limit=limit,
- status_filter=status_filter,
- layers=layers,
- user_ids=[self.__user_id],
- agent_ids=effective_agents or None,
- only_latest=only_latest,
- with_vectors=with_vectors,
- allow_raw_layer=allow_raw_layer,
- )
- result = []
- for row in rows:
- if type(row) is not MemoryNode:
- self.__deny("node_shape")
- value = await self.__load(row.node_id)
- if (
- value is not None
- and (not layers or value.layer in layers or value.layer.value in layers)
- and (
- not status_filter
- or value.status in status_filter
- or value.status.value in status_filter
- )
- ):
- result.append(value)
- self.recheck()
- return result[:limit]
-
- async def search(
- self,
- query_embedding: list[float],
- isolation_key: str = "",
- isolation_keys: list[str] | None = None,
- user_id: str | None = None,
- user_ids: list[str] | None = None,
- agent_ids: list[str] | None = None,
- layers: list[Any] | None = None,
- limit: int = 10,
- score_threshold: float = 0.0,
- status_filter: list[Any] | None = None,
- only_latest: bool = True,
- tags_match_any: list[str] | None = None,
- created_after: float | None = None,
- ) -> list[dict[str, Any]]:
- async with self.__lock:
- self.recheck()
- self.__scope(user_id, None)
- key = MemoryNode.build_isolation_key(self.__user_id, self.__agent_id, self.__session_id)
- if (
- any(uid != self.__user_id for uid in user_ids or [])
- or any(aid != self.__agent_id for aid in agent_ids or [])
- or isolation_key not in ("", key)
- or any(ik != key for ik in isolation_keys or [])
- ):
- self.__deny("scope")
- hits = await self.__invoke(
- "search",
- query_embedding=deepcopy(query_embedding),
- isolation_key=isolation_key,
- isolation_keys=isolation_keys,
- user_id=self.__user_id,
- user_ids=[self.__user_id],
- agent_ids=[self.__agent_id],
- layers=layers,
- limit=limit,
- score_threshold=score_threshold,
- status_filter=status_filter,
- only_latest=only_latest,
- tags_match_any=tags_match_any,
- created_after=created_after,
- )
- result = []
- for hit in hits:
- value = await self.__load(hit["node_id"])
- if value is None:
- continue
- score = float(hit["score"])
- if not math.isfinite(score):
- self.__deny("node_shape")
- # Do not forward arbitrary backend hit metadata. Rehydrate the
- # full fingerprinted node; native search often omits embedding.
- result.append({"node_id": value.node_id, "node": value, "score": score})
- self.recheck()
- return result[:limit]
-
- async def __target(
- self,
- key: str,
- first: MemoryNode | None = None,
- *,
- allow_deleted_links: bool = False,
- ) -> MemoryNode:
- value = await self.__load(key, first, allow_deleted_links=allow_deleted_links)
- if value is None:
- self.__deny("target_denied")
- return value
-
- async def __check_links(self, value: MemoryNode) -> None:
- for ref in self.__references(value):
- if await self.__load(ref) is None:
- self.__deny("link_denied")
-
- async def __plan_upsert(self, value: MemoryNode) -> MemoryNode:
- value = deepcopy(value)
- if type(value) is not MemoryNode or not value.node_id or not self.__in_scope(value):
- self.__deny("scope")
- existing = await self.__read_native(value.node_id)
- if existing is not None:
- await self.__target(value.node_id, existing)
- elif (
- not self.__new_node_in_scope(value)
- or value.node_id in self.__permissions
- or value.node_id in self.__known
- or value.node_id in self.__deleted
- ):
- self.__deny("scope")
- await self.__check_links(value)
- self.__write_view(value)
- return value
-
- async def __capture(
- self,
- expected: MemoryNode,
- *,
- allow_native_modified_time: bool = False,
- ) -> None:
- actual = await self.__read_native(expected.node_id)
- if actual is None:
- self.__deny("write_mismatch")
- actual_view, expected_view = self.__write_view(actual), self.__write_view(expected)
- if allow_native_modified_time:
- if actual.gmt_modified < expected.gmt_modified:
- self.__deny("write_mismatch")
- actual_view["gmt_modified"] = expected_view["gmt_modified"]
- actual_vector = actual_view.pop("embedding")
- expected_vector = expected_view.pop("embedding")
- if actual_view != expected_view or not _same_written_vector(actual_vector, expected_vector):
- self.__deny("write_mismatch")
- proof = HyNodePermission(self.__fingerprint(actual), self.__evidence_ids)
- self.__permissions[actual.node_id] = proof
- self.__written[actual.node_id] = proof
- status = getattr(actual.status, "value", actual.status)
- if status == MemoryStatus.SHADOW.value:
- self.__shadowed.add(actual.node_id)
- else:
- self.__shadowed.discard(actual.node_id)
- if status == MemoryStatus.SUPERSEDED.value:
- by = actual.superseded_by
- self.__superseded[actual.node_id] = tuple(by) if isinstance(by, list) else ()
- else:
- self.__superseded.pop(actual.node_id, None)
- olds = actual.supersedes
- if isinstance(olds, list) and olds:
- self.__heads[actual.node_id] = tuple(olds)
- else:
- self.__heads.pop(actual.node_id, None)
-
- async def upsert(self, node: MemoryNode) -> str:
- async with _native_write_lock(), self.__lock:
- self.recheck()
- expected = await self.__plan_upsert(node)
- result = await self.__invoke("upsert", deepcopy(expected))
- if result != expected.node_id:
- self.__deny("write_mismatch")
- await self.__capture(expected)
- return str(result)
-
- async def upsert_batch(self, nodes: list[MemoryNode]) -> list[str]:
- async with _native_write_lock(), self.__lock:
- self.recheck()
- if len({n.node_id for n in nodes}) != len(nodes):
- self.__deny("node_shape")
- # No forward links to uncommitted batch members. Preflight every
- # existing target before the backend can make a partial write.
- planned = [await self.__plan_upsert(n) for n in nodes]
- ids = [n.node_id for n in planned]
- result = await self.__invoke("upsert_batch", deepcopy(planned))
- if result != ids:
- self.__deny("write_mismatch")
- for expected in planned:
- await self.__capture(expected)
- return ids
-
- async def update_payload(self, node_id: str, updates: dict[str, Any]) -> bool:
- async with _native_write_lock(), self.__lock:
- self.recheck()
- current = await self.__target(node_id)
- updates = deepcopy(updates)
- dedup_audit = False
- if set(updates) & _DEDUP_AUDIT_FIELDS:
- if (
- not set(updates) <= (_UPDATE_FIELDS | _DEDUP_AUDIT_FIELDS)
- or updates.get("status") != MemoryStatus.SHADOW.value
- or updates.get("is_latest") is not False
- or not isinstance(updates.get("shadow_reason"), str)
- or not updates["shadow_reason"].startswith("dedup:")
- or not isinstance(updates.get("dedup_kept_id"), str)
- or not updates["dedup_kept_id"]
- ):
- self.__deny("update_fields")
- await self.__target(updates["dedup_kept_id"])
- custom = deepcopy(current.custom)
- custom["hy_dedup"] = {
- "reason": updates.pop("shadow_reason"),
- "kept_id": updates.pop("dedup_kept_id"),
- }
- updates["custom"] = custom
- dedup_audit = True
- allowed = _UPDATE_FIELDS | ({"custom"} if dedup_audit else set())
- if self.__cross_evidence_scope and set(updates) == {"custom"}:
- custom = updates.get("custom")
- if not isinstance(custom, dict):
- self.__deny("update_fields")
- current_custom = current.custom if isinstance(current.custom, dict) else {}
- previous = current_custom.get(_DIGEST_COUNTER_FIELD, 0)
- proposed = custom.get(_DIGEST_COUNTER_FIELD)
- if (
- not isinstance(previous, int)
- or isinstance(previous, bool)
- or not isinstance(proposed, int)
- or isinstance(proposed, bool)
- or proposed != previous + 1
- or set(custom) != set(current_custom) | {_DIGEST_COUNTER_FIELD}
- or any(custom[key] != value for key, value in current_custom.items())
- ):
- self.__deny("update_fields")
- allowed |= {"custom"}
- # `custom` is an allowed field name (SDK 2.0.2 sends it on a
- # reconcile UPDATE), but the System 2 evidence counter it can hold
- # stays digest-only: only the digest scope may advance it, and only
- # by one per write. Letting the name through must not let an
- # ordinary write move that counter.
- if not self.__cross_evidence_scope and _DIGEST_COUNTER_FIELD in (
- updates.get("custom") or {}
- ):
- self.__deny("update_fields")
- if not set(updates) <= allowed:
- self.__deny("update_fields")
- try:
- if "layer" in updates:
- MemoryLayer(updates["layer"])
- if "status" in updates:
- MemoryStatus(updates["status"])
- if "tag_ners" in updates and (
- not isinstance(updates["tag_ners"], list)
- or any(not isinstance(value, str) for value in updates["tag_ners"])
- ):
- self.__deny("node_shape")
- expected = MemoryNode.from_dict({**current.to_dict(), **updates})
- self.__fingerprint(expected)
- except (TypeError, ValueError):
- self.__deny("node_shape")
- if not self.__in_scope(expected):
- self.__deny("scope")
- await self.__check_links(expected)
- self.__write_view(expected)
- if await self.__invoke("update_payload", node_id, updates) is not True:
- self.__deny("write_failed")
- await self.__capture(expected, allow_native_modified_time=True)
- return True
-
- async def update_embedding(self, node_id: str, embedding: list[float]) -> bool:
- async with _native_write_lock(), self.__lock:
- self.recheck()
- expected = await self.__target(node_id)
- expected.embedding = deepcopy(embedding)
- self.__write_view(expected)
- if await self.__invoke("update_embedding", node_id, deepcopy(embedding)) is not True:
- self.__deny("write_failed")
- await self.__capture(expected, allow_native_modified_time=True)
- return True
-
- async def delete(self, node_id: str) -> bool:
- async with _native_write_lock(), self.__lock:
- self.recheck()
- await self.__target(node_id, allow_deleted_links=True)
- if await self.__invoke("delete", node_id) is not True:
- self.__deny("write_failed")
- if await self.__read_native(node_id) is not None:
- self.__deny("write_mismatch")
- self.__deleted.add(node_id)
- self.__permissions.pop(node_id, None)
- self.__written.pop(node_id, None)
- self.__superseded.pop(node_id, None)
- self.__heads.pop(node_id, None)
- return True
diff --git a/memhub/apps/core/src/one_memory/hy_permission_transport.py b/memhub/apps/core/src/one_memory/hy_permission_transport.py
deleted file mode 100644
index bbd145d..0000000
--- a/memhub/apps/core/src/one_memory/hy_permission_transport.py
+++ /dev/null
@@ -1,1117 +0,0 @@
-"""Lease-bound leaf transport for the pinned HY Memory SDK, not context isolation.
-
-The factory must hold an exclusive client lock on HY's own loop throughout this
-async context, bind EVERY captured native provider/service before use, and keep
-unsupported lazy-provider paths disabled. Store provenance, prompts, extraction,
-reconcile and publication remain the caller's responsibility. No global SDK or
-logging state is changed here. Saved pre-installation native methods are outside
-this boundary; never expose an unbound client to permission-scoped work.
-
-An embedding queue must be empty/idle at installation. Its cache and queue are
-lease-local while bound. Exit cancels and awaits outstanding bound calls/timers,
-clears pending futures, closes HTTP resources, then restores instance attributes.
-Local encoder threads cannot be cancelled; exit waits for their completion.
-The caller must stop creating child work before exit. Old bound callables remain
-denied after exit; restored native methods require a NEW context before reuse.
-
-Recheck happens at the physical transport boundary with no intervening await
-before delegation. It cannot recall an in-flight send or make revocation atomic
-with remote receipt. Responses and normal context exit are also rechecked.
-After the last SDK/profile read, main may ``await transport.seal()`` before its
-own governance projection. Seal requires idle children/queues, fully rechecks
-the lease, and permanently closes request admission. Sealed exit does NOT
-recheck context versions that main may just have updated; main must independently
-recheck its frozen permission receipt before committing/publishing governance.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import inspect
-import json
-import math
-import re
-from collections import OrderedDict
-from collections.abc import AsyncIterator, Awaitable, Callable
-from contextlib import asynccontextmanager, nullcontext
-from contextvars import ContextVar
-from dataclasses import dataclass, field
-from functools import wraps
-from http.cookiejar import CookieJar, DefaultCookiePolicy
-from importlib import import_module, metadata
-from time import monotonic
-from types import MethodType, TracebackType
-from typing import Any, Literal, Never, Protocol, Self, cast
-from urllib.parse import urlsplit, urlunsplit
-
-import httpx
-from openai import APIConnectionError, APITimeoutError, AsyncOpenAI, Omit
-
-from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms
-from one_memory.hy_runtime_config import HY_MEMORY_DISTRIBUTION, HY_MEMORY_VERSION
-from one_memory.hy_step_timing import HyStepTimer
-from one_memory.model_profiles import (
- ModelProfileSnapshot,
- ProviderKind,
- attach_disable_thinking,
- validate_model_base_url,
-)
-from one_memory.model_request_budget import ModelRequestBudgetError
-
-_MISSING = object()
-_OWNER = "_one_memory_permission_transport"
-_Role = Literal["generation", "embedding"]
-_FailureKind = Literal["timeout", "connection", "transport", "invalid_response", "unknown"]
-_HEADERS = frozenset(
- {
- "accept",
- "accept-encoding",
- "connection",
- "content-length",
- "content-type",
- "user-agent",
- "host",
- "authorization",
- "x-stainless-async",
- "x-stainless-arch",
- "x-stainless-lang",
- "x-stainless-os",
- "x-stainless-package-version",
- "x-stainless-runtime",
- "x-stainless-runtime-version",
- "x-stainless-retry-count",
- "x-stainless-read-timeout",
- "x-stainless-raw-response",
- }
-)
-
-
-@dataclass(frozen=True)
-class _Recipient:
- provider_kind: ProviderKind
- endpoint: str | None
- model: str
- dimensions: int | None = None
-
-
-def _canonical_endpoint(provider: ProviderKind, value: str | None) -> str:
- if not value or any(character.isspace() or ord(character) < 32 for character in value):
- raise ValueError("missing_endpoint")
- validate_model_base_url(value)
- parsed = urlsplit(value)
- if parsed.query or parsed.fragment or "?" in value or "#" in value:
- raise ValueError("ambiguous_endpoint")
- hostname = (parsed.hostname or "").encode("idna").decode().lower()
- if not hostname or "%" in hostname or "\\" in value:
- raise ValueError("ambiguous_endpoint")
- host = f"[{hostname}]" if ":" in hostname else hostname
- port = parsed.port
- if port is not None and port != (443 if parsed.scheme == "https" else 80):
- host = f"{host}:{port}"
- path = parsed.path.rstrip("/")
- if provider is ProviderKind.OLLAMA and not path.endswith("/v1"):
- path += "/v1"
- return urlunsplit((parsed.scheme.lower(), host, path, "", ""))
-
-
-def _recipients(profile: ModelProfileSnapshot) -> tuple[_Recipient, _Recipient]:
- if profile.provider_kind not in {ProviderKind.OPENAI_COMPATIBLE, ProviderKind.OLLAMA}:
- raise HyPermissionTransportError("unsupported_backend")
- embedding_provider = profile.embedding_provider_kind or profile.provider_kind
- if not profile.embedding_model or embedding_provider not in {
- ProviderKind.OPENAI_COMPATIBLE,
- ProviderKind.OLLAMA,
- ProviderKind.LOCAL_ONNX,
- }:
- raise HyPermissionTransportError("unsupported_backend")
- try:
- generation = _Recipient(
- profile.provider_kind,
- _canonical_endpoint(profile.provider_kind, profile.base_url),
- profile.model,
- )
- embedding = _Recipient(
- embedding_provider,
- (
- None
- if embedding_provider is ProviderKind.LOCAL_ONNX
- else _canonical_endpoint(
- embedding_provider,
- profile.embedding_base_url or profile.base_url,
- )
- ),
- profile.embedding_model,
- profile.embedding_dimensions,
- )
- except (UnicodeError, ValueError):
- raise HyPermissionTransportError("endpoint_invalid") from None
- return generation, embedding
-
-
-class PermissionLease(Protocol):
- @property
- def user_id(self) -> str: ...
-
- @property
- def profile(self) -> ModelProfileSnapshot: ...
-
- def recheck(self) -> None: ...
-
-
-class HyPermissionTransportError(RuntimeError):
- """Stable code only: never retains a request, credential or upstream error."""
-
- def __init__(self, reason: str) -> None:
- self.code = f"model_content_transport_{reason}"
- super().__init__(self.code)
-
-
-class _RequestFailure(HyPermissionTransportError):
- def __init__(
- self,
- *,
- retryable: bool = False,
- status_code: int | None = None,
- failure_kind: _FailureKind = "unknown",
- ) -> None:
- super().__init__("request_failed")
- self.retryable = retryable
- self.status_code = status_code
- self.failure_kind = failure_kind
-
-
-def _failure_kind(error: Exception) -> _FailureKind:
- if isinstance(error, (APITimeoutError, httpx.TimeoutException, TimeoutError)):
- return "timeout"
- if isinstance(error, (APIConnectionError, httpx.ConnectError, ConnectionError)):
- return "connection"
- if isinstance(error, httpx.TransportError):
- return "transport"
- return "unknown"
-
-
-@dataclass(frozen=True)
-class _Spec:
- role: _Role
- endpoint: str
- model: str
- dimensions: int | None
- api_key: str = field(repr=False)
- retries: int
- delay: float
- timeout: float
- extra_body: str
-
-
-@dataclass
-class _Remote:
- instance: Any = field(repr=False)
- spec: _Spec
- client: AsyncOpenAI = field(repr=False)
-
-
-@dataclass(frozen=True)
-class _Envelope:
- remote: _Remote = field(repr=False)
- body: str = field(repr=False)
-
-
-class _PhysicalTransport(httpx.AsyncBaseTransport):
- def __init__(self, owner: HyPermissionTransport, inner: httpx.AsyncBaseTransport) -> None:
- self.owner = owner
- self.inner = inner
-
- async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
- # After all httpx request hooks, immediately before the physical send.
- self.owner.assert_allowed()
- if not isinstance(request.stream, httpx.AsyncByteStream):
- self.owner._deny("unsupported_stream")
- content = b"".join([part async for part in request.stream])
- request.stream = httpx.ByteStream(content)
- self.owner._check_request(request, content=content)
- self.owner._physical_requests += 1
- try:
- try:
- response = await self.inner.handle_async_request(request)
- except ModelRequestBudgetError:
- self.owner._deny("budget_exceeded")
- if 300 <= response.status_code < 400:
- await response.aclose()
- self.owner._deny("redirect")
- return response
- finally:
- self.owner._physical_requests -= 1
-
- async def aclose(self) -> None:
- await self.inner.aclose()
-
-
-class HyPermissionTransport:
- """Single-use context; bind methods return the same native instance.
-
- ``http_transport`` is an optional trusted test transport, owned and closed by
- this context. Production uses a direct httpx transport with no environment
- proxy/auth configuration, HTTP redirects or connection retries.
- """
-
- def __init__(
- self,
- lease: PermissionLease,
- *,
- http_transport: httpx.AsyncBaseTransport | None = None,
- step_timer: HyStepTimer | None = None,
- ) -> None:
- self.lease = lease
- self._step_timer = step_timer
- self._profile = lease.profile.model_copy(deep=True)
- self._transport = http_transport
- self._active = False
- self._ended = False
- self._requests_sealed = False
- self._physical_requests = 0
- self._denial: str | None = None
- self._patches: list[tuple[Any, str, Any, Any]] = []
- self._tasks: dict[asyncio.Task[Any], int] = {}
- self._local_work: set[asyncio.Task[Any]] = set()
- self._services: list[Any] = []
- self._clients: list[AsyncOpenAI] = []
- self._http_client: httpx.AsyncClient
- self._envelope: ContextVar[_Envelope | None] = ContextVar("hy_leaf_envelope", default=None)
-
- @property
- def denied_code(self) -> str | None:
- return f"model_content_transport_{self._denial}" if self._denial else None
-
- @property
- def requests_sealed(self) -> bool:
- return self._requests_sealed
-
- async def seal(self) -> None:
- """Fully check an idle lease, then permanently refuse SDK/request admission.
-
- Call on HY's loop after ALL SDK work, including profile readback. Active
- children/queues cause ``active_requests`` without sealing or latching a
- permission denial: await those children and try again. No work is dropped
- or implicitly approved. There is no await between the idle check, full
- lease check and state change. A successful seal is idempotent.
-
- Main owns the subsequent frozen-permission check and governance commit.
- This method deliberately does not call an optional recheck_permission.
- """
- if self._ended or not self._active:
- raise HyPermissionTransportError("lease_ended")
- if self._denial is not None:
- raise HyPermissionTransportError(self._denial)
- if self._requests_sealed:
- return
- if (
- self._tasks
- or self._physical_requests
- or self._local_work
- or any(_embedding_queue_active(service) for service in self._services)
- ):
- raise HyPermissionTransportError("active_requests")
- self.assert_allowed()
- self._requests_sealed = True
-
- async def __aenter__(self) -> Self:
- if self._active or self._ended:
- raise HyPermissionTransportError("already_used")
- self._active = True
- try:
- self._verify_sdk()
- self.assert_allowed()
- inner = self._transport or httpx.AsyncHTTPTransport(retries=0, trust_env=False)
- self._http_client = httpx.AsyncClient(
- transport=_PhysicalTransport(self, inner),
- trust_env=False,
- follow_redirects=False,
- # A gateway may set cookies that alter the next call in the
- # same task. Model requests never rely on cookies, so never
- # store any.
- # Pass the jar itself: httpx copies a Cookies object into a
- # fresh jar with the default policy, which would store again.
- cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
- event_hooks={"request": [self._request_hook]},
- )
- except BaseException:
- self._active = False
- self._ended = True
- if self._transport is not None:
- await self._transport.aclose()
- raise
- return self
-
- async def __aexit__(
- self,
- exc_type: type[BaseException] | None,
- exc: BaseException | None,
- traceback: TracebackType | None,
- ) -> None:
- failure: HyPermissionTransportError | None = None
- if exc_type is None and not self._requests_sealed:
- try:
- self.assert_allowed()
- except HyPermissionTransportError as error:
- failure = error
- self._active = False
- self._ended = True
- cleanup = asyncio.create_task(self._cleanup(asyncio.current_task()))
- try:
- await asyncio.shield(cleanup)
- except asyncio.CancelledError:
- await cleanup
- raise
- if exc_type is None:
- if failure is not None:
- raise failure
- if self._denial is not None:
- raise HyPermissionTransportError(self._denial)
-
- def assert_allowed(self) -> None:
- """Recheck publication/child boundaries; a denial is terminal for this context."""
- self._assert_active()
- try:
- self.lease.recheck()
- except Exception as error:
- reason = str(error).removeprefix("model_content_")
- self._deny(
- f"lease_denied_{reason}"
- if re.fullmatch(r"[a-z_]{1,60}", reason)
- else "lease_denied"
- )
- if self.lease.profile != self._profile:
- self._deny("lease_changed")
-
- def _assert_active(self) -> None:
- if self._ended or not self._active:
- raise HyPermissionTransportError("lease_ended")
- if self._denial is not None:
- raise HyPermissionTransportError(self._denial)
- if self._requests_sealed:
- raise HyPermissionTransportError("requests_sealed")
-
- def _deny(self, reason: str) -> Never:
- if self._denial is None:
- self._denial = reason
- raise HyPermissionTransportError(self._denial) from None
-
- @staticmethod
- def _verify_sdk() -> None:
- if metadata.version(HY_MEMORY_DISTRIBUTION) != HY_MEMORY_VERSION:
- raise HyPermissionTransportError("sdk_version")
- llm = import_module("hy_memory.agent.llm_provider").LLMProvider
- embed = import_module("hy_memory.core.embed_service").EmbedService
- prompt = (
- "self",
- "prompt",
- "max_tokens",
- "temperature",
- "stop",
- "tools",
- "tool_choice",
- "kwargs",
- )
- messages = (
- "self",
- "messages",
- "max_tokens",
- "temperature",
- "tools",
- "tool_choice",
- )
- chat = (
- "self",
- "messages",
- "max_tokens",
- "temperature",
- "stop",
- "tools",
- "tool_choice",
- )
- for cls, name, expected in (
- (llm, "_call_llm", prompt),
- (llm, "complete", prompt),
- (llm, "complete_messages", messages),
- (llm, "_chat_completions", chat),
- (embed, "_embed_openai", ("self", "texts")),
- ):
- method = getattr(cls, name, None)
- if (
- not inspect.iscoroutinefunction(method)
- or tuple(inspect.signature(method).parameters) != expected
- ):
- raise HyPermissionTransportError("sdk_shape")
-
- @asynccontextmanager
- async def _operation(self) -> AsyncIterator[None]:
- self._assert_active()
- task = asyncio.current_task()
- assert task is not None
- self._tasks[task] = self._tasks.get(task, 0) + 1
- try:
- yield
- self.assert_allowed()
- finally:
- count = self._tasks[task] - 1
- if count:
- self._tasks[task] = count
- else:
- del self._tasks[task]
-
- def _patch(self, instance: Any, name: str, value: Any) -> None:
- previous = vars(instance).get(name, _MISSING)
- self._patches.append((instance, name, previous, value))
- setattr(instance, name, value)
-
- def _bind_method(
- self,
- instance: Any,
- name: str,
- call: Callable[..., Awaitable[Any]],
- check: Callable[[], None],
- *,
- drain_local: bool = False,
- step: str | None = None,
- ) -> None:
- original = getattr(instance, name, call)
- timer = self._step_timer
- signature = inspect.signature(original)
-
- @wraps(getattr(original, "__func__", original))
- async def bound(_instance: Any, *args: Any, **kwargs: Any) -> Any:
- async with self._operation():
- check()
- try:
- signature.bind(*args, **kwargs)
- with (
- timer.step(step, items=_step_items(args))
- if timer is not None and step is not None
- else nullcontext()
- ):
- if drain_local:
- return await self._drain_local(call(*args, **kwargs))
- return await call(*args, **kwargs)
- except HyPermissionTransportError:
- raise
- except TypeError:
- self._deny("unsupported_operation")
- except Exception as error:
- # Only these two replacement methods create this native
- # error, with sanitized attempts. Other native/local errors
- # must not propagate provider details to HY's queue logger.
- retry_type = import_module("hy_memory.agent.llm_provider").LLMRetryError
- if name in {"complete", "complete_messages"} and type(error) is retry_type:
- raise
- raise _RequestFailure() from None
-
- self._patch(instance, name, MethodType(bound, instance))
-
- async def _drain_local(self, call: Awaitable[Any]) -> Any:
- async def run() -> Any:
- return await call
-
- task = asyncio.create_task(run())
- self._local_work.add(task)
- try:
- return await asyncio.shield(task)
- except asyncio.CancelledError:
- await asyncio.gather(task, return_exceptions=True)
- raise
- finally:
- self._local_work.discard(task)
-
- def _claim(self, instance: Any, expected_type: type[Any], names: tuple[str, ...]) -> bool:
- self._assert_active()
- if type(instance) is not expected_type:
- self._deny("unsupported_instance")
- owner = vars(instance).get(_OWNER)
- if owner is self:
- return False
- if owner is not None:
- self._deny("instance_already_bound")
- for name in names:
- if getattr(getattr(instance, name, None), "__func__", None) is not getattr(
- expected_type, name, None
- ):
- self._deny("unsupported_instance_method")
- return True
-
- def _spec(self, instance: Any, role: _Role) -> _Spec:
- generation, embedding = _recipients(self._profile)
- recipient = generation if role == "generation" else embedding
- cfg = instance._llm_config if role == "generation" else instance._embed_config
- if recipient.provider_kind not in {ProviderKind.OPENAI_COMPATIBLE, ProviderKind.OLLAMA}:
- self._deny("unsupported_backend")
- if role == "generation":
- if (
- getattr(cfg.backend, "value", None) != "openai"
- or instance.config.llm.provider != "openai"
- ):
- self._deny("unsupported_backend")
- elif cfg.provider != "openai":
- self._deny("unsupported_backend")
- try:
- endpoint = _canonical_endpoint(ProviderKind.OPENAI_COMPATIBLE, cfg.base_url)
- path = httpx.URL(endpoint).path
- if (
- "%" in endpoint
- or any(part in {".", ".."} for part in cfg.base_url.split("/"))
- or "//" in path
- ):
- self._deny("endpoint_invalid")
- except (ValueError, RuntimeError):
- self._deny("endpoint_invalid")
- if endpoint != recipient.endpoint:
- self._deny("endpoint_mismatch")
- if cfg.model != recipient.model:
- self._deny("model_mismatch")
- dimensions = (cfg.embedding_dims or None) if role == "embedding" else None
- if dimensions != recipient.dimensions or (
- dimensions is not None and type(dimensions) is not int
- ):
- self._deny("dimensions_mismatch")
- extras = attach_disable_thinking(self._profile, {}) if role == "generation" else {}
- if cfg.extra_headers or (cfg.extra_body or {}) != extras:
- self._deny("unexpected_overrides")
- if (
- not isinstance(cfg.api_key, str)
- or not cfg.api_key
- or any(c.isspace() for c in cfg.api_key)
- ):
- self._deny("credentials_invalid")
- if type(cfg.max_retries) is not int or cfg.max_retries < (1 if role == "generation" else 0):
- self._deny("retry_config_invalid")
- if cfg.retry_delay is None or cfg.retry_delay < 0:
- self._deny("retry_config_invalid")
- return _Spec(
- role,
- endpoint,
- cfg.model,
- dimensions,
- cfg.api_key,
- cfg.max_retries,
- float(cfg.retry_delay),
- float(cfg.timeout or 3),
- _json(extras),
- )
-
- def _remote(self, instance: Any, role: _Role) -> _Remote:
- spec = self._spec(instance, role)
- client = AsyncOpenAI(
- api_key=spec.api_key,
- base_url=spec.endpoint,
- max_retries=0,
- timeout=spec.timeout,
- organization="",
- project="",
- http_client=self._http_client,
- default_headers=cast(Any, {"OpenAI-Organization": Omit(), "OpenAI-Project": Omit()}),
- )
- self._clients.append(client)
- if getattr(client, "_provider_runtime", None) is not None:
- self._deny("unsupported_backend")
- return _Remote(instance, spec, client)
-
- def _check_remote(self, remote: _Remote) -> None:
- self._assert_active()
- if self._spec(remote.instance, remote.spec.role) != remote.spec:
- self._deny("configuration_changed")
-
- async def _request_hook(self, request: httpx.Request) -> None:
- self._check_request(request)
-
- def _check_request(self, request: httpx.Request, *, content: bytes | None = None) -> None:
- self._assert_active()
- envelope = self._envelope.get()
- if envelope is None:
- self._deny("unsupported_request")
- remote = envelope.remote
- self._check_remote(remote)
- suffix = "/chat/completions" if remote.spec.role == "generation" else "/embeddings"
- url = httpx.URL(remote.spec.endpoint + suffix)
- if request.method != "POST":
- self._deny("method_mismatch")
- if request.url != url or request.url.raw_path != url.raw_path:
- self._deny("endpoint_mismatch")
- if (
- set(request.headers) - _HEADERS
- or request.headers.get_list("authorization") != [f"Bearer {remote.spec.api_key}"]
- or request.headers.get_list("host") != [url.netloc.decode("ascii")]
- or request.headers.get("content-type") != "application/json"
- ):
- self._deny("headers_mismatch")
- try:
- content = request.content if content is None else content
- if request.headers.get_list("content-length") != [str(len(content))]:
- self._deny("headers_mismatch")
- body = json.loads(content)
- matches = _json(body) == envelope.body
- except (ValueError, TypeError, httpx.RequestNotRead):
- self._deny("body_invalid")
- if not matches or body.get("model") != remote.spec.model:
- self._deny("body_mismatch")
- if remote.spec.role == "embedding" and body.get("dimensions") != remote.spec.dimensions:
- self._deny("dimensions_mismatch")
-
- async def _exchange(self, remote: _Remote, payload: dict[str, Any]) -> tuple[Any, int]:
- started = monotonic()
- phase = "embedding" if remote.spec.role == "embedding" else "generation"
- self._check_remote(remote)
- diagnostic_event(
- level="info",
- component="hy.transport",
- event="hy.transport_call.started",
- outcome="started",
- details={"phase": phase, "call_count": 1},
- )
- # Freeze the complete native payload, not just model/destination fields.
- body = _json(payload)
- token = self._envelope.set(_Envelope(remote, body))
- try:
- if remote.spec.role == "generation":
- extras = json.loads(remote.spec.extra_body)
- kwargs = {key: value for key, value in payload.items() if key not in extras}
- result = await remote.client.chat.completions.create(
- **kwargs, extra_body=extras or None
- )
- status = 200
- else:
- raw = await remote.client.embeddings.with_raw_response.create(**payload)
- result, status = raw.parse(), raw.status_code
- self.assert_allowed()
- diagnostic_event(
- level="info",
- component="hy.transport",
- event="hy.transport_call.completed",
- outcome="succeeded",
- duration_ms=elapsed_ms(started),
- details={"phase": phase, "call_count": 1, "status": str(status)},
- )
- return result, status
- except Exception as error:
- if self._denial is not None:
- raise HyPermissionTransportError(self._denial) from None
- status_code = getattr(error, "status_code", None)
- failure_kind = _failure_kind(error)
- diagnostic_event(
- level="error",
- component="hy.transport",
- event="hy.transport_call.failed",
- outcome="failed",
- duration_ms=elapsed_ms(started),
- error_code=f"hy_transport_{failure_kind}",
- **diagnostic_exception(error),
- details={
- "phase": phase,
- "call_count": 1,
- "status": str(status_code) if type(status_code) is int else "unknown",
- },
- )
- raise _RequestFailure(
- retryable=isinstance(
- error, (APIConnectionError, httpx.TransportError, TimeoutError, ConnectionError)
- ),
- status_code=status_code if type(status_code) is int else None,
- failure_kind=failure_kind,
- ) from None
- finally:
- self._envelope.reset(token)
-
- def bind_provider(self, provider: Any) -> Any:
- """Bind a native LLMProvider, including _call_llm and both complete paths."""
- module = import_module("hy_memory.agent.llm_provider")
- names = ("complete", "complete_messages", "_call_llm", "_chat_completions")
- if not self._claim(provider, module.LLMProvider, names):
- return provider
- remote = self._remote(provider, "generation")
- self._patch(provider, _OWNER, self)
-
- def check() -> None:
- self._check_remote(remote)
-
- async def leaf_messages(
- messages: list[dict[str, Any]],
- max_tokens: int,
- temperature: float | None,
- tools: Any = None,
- tool_choice: Any = None,
- *,
- stop: Any = None,
- ) -> Any:
- if not any(message.get("role") == "system" for message in messages):
- messages = [{"role": "system", "content": ""}, *messages]
- payload: dict[str, Any] = {
- "model": remote.spec.model,
- "messages": messages,
- "max_tokens": max_tokens,
- }
- if temperature is not None:
- payload["temperature"] = temperature
- if stop:
- payload["stop"] = stop
- if tools:
- payload["tools"] = tools
- if tool_choice is not None:
- payload["tool_choice"] = tool_choice
- payload.update(json.loads(remote.spec.extra_body))
- response, _ = await self._exchange(remote, payload)
- try:
- choice = response.choices[0]
- message, usage = choice.message, response.usage
- calls = [
- {
- "id": tc.id or "",
- "type": tc.type or "function",
- "function": {
- "name": getattr(tc.function, "name", ""),
- "arguments": getattr(tc.function, "arguments", ""),
- },
- }
- for tc in (message.tool_calls or [])
- ]
- return module.LLMResponse(
- content=message.content or "",
- model=remote.spec.model,
- tokens_used=_token_count(usage.total_tokens) if usage else 0,
- prompt_tokens=_token_count(usage.prompt_tokens) if usage else 0,
- completion_tokens=_token_count(usage.completion_tokens) if usage else 0,
- finish_reason=choice.finish_reason,
- tool_calls=calls or None,
- )
- except Exception:
- raise _RequestFailure(failure_kind="invalid_response") from None
-
- async def leaf(
- prompt: str,
- max_tokens: int,
- temperature: float | None,
- stop: Any = None,
- tools: Any = None,
- tool_choice: Any = None,
- ) -> Any:
- return await leaf_messages(
- [{"role": "user", "content": prompt}],
- max_tokens,
- temperature,
- tools,
- tool_choice,
- stop=stop,
- )
-
- async def dispatch(
- prompt: str,
- max_tokens: int,
- temperature: float | None,
- stop: Any = None,
- tools: Any = None,
- tool_choice: Any = None,
- **kwargs: Any,
- ) -> Any:
- if kwargs:
- self._deny("unexpected_overrides")
- return await leaf(prompt, max_tokens, temperature, stop, tools, tool_choice)
-
- async def retry(call: Callable[[], Awaitable[Any]]) -> Any:
- # HY's total-attempt budget/counters/backoff, without native prompt
- # logging or raw upstream exceptions in its attempt receipts.
- attempts = []
- for attempt in range(remote.spec.retries):
- check()
- try:
- result = await call()
- self.assert_allowed()
- provider._total_calls += 1
- provider._total_tokens += result.tokens_used
- return result
- except HyPermissionTransportError as error:
- provider._errors += 1
- if self._denial is not None or not isinstance(error, _RequestFailure):
- raise
- record: dict[str, Any] = {
- "attempt": attempt + 1,
- "error_type": "TransportError",
- "error": error.code,
- "failure_kind": error.failure_kind,
- }
- if error.status_code is not None:
- record["status_code"] = error.status_code
- attempts.append(record)
- if attempt + 1 < remote.spec.retries:
- await asyncio.sleep(remote.spec.delay * (attempt + 1))
- raise module.LLMRetryError("hy_memory_model_request_failed", attempts) from None
-
- async def complete(
- prompt: str,
- max_tokens: int = 500,
- temperature: float | None = None,
- stop: Any = None,
- tools: Any = None,
- tool_choice: Any = None,
- **kwargs: Any,
- ) -> Any:
- if kwargs:
- self._deny("unexpected_overrides")
- return await retry(
- lambda: provider._call_llm(
- prompt, max_tokens, temperature, stop, tools, tool_choice
- )
- )
-
- async def complete_messages(
- messages: list[dict[str, Any]],
- max_tokens: int = 500,
- temperature: float | None = None,
- tools: Any = None,
- tool_choice: Any = None,
- ) -> Any:
- return await retry(
- lambda: leaf_messages(messages, max_tokens, temperature, tools, tool_choice)
- )
-
- for name, call in (
- ("complete", complete),
- ("complete_messages", complete_messages),
- ("_call_llm", dispatch),
- ("_chat_completions", leaf_messages),
- ):
- self._bind_method(provider, name, call, check, step="model")
- return provider
-
- async def _unsupported_backend(self, *args: Any, **kwargs: Any) -> Any:
- self._deny("unsupported_backend")
-
- def bind_embed_service(self, service: Any) -> Any:
- """Bind native remote EmbedService; local ONNX uses the separate method."""
- cls = import_module("hy_memory.core.embed_service").EmbedService
- names = (
- "embed",
- "embed_batch",
- "embed_queued",
- "embed_queued_with_meta",
- "embed_with_meta",
- "embed_with_cache_info",
- "_embed_texts",
- "_embed_texts_with_meta",
- "_embed_openai",
- "_delayed_flush",
- "_send_batch",
- )
- if not self._claim(service, cls, names):
- return service
- if _embedding_queue_active(service):
- self._deny("foreign_embedding_queue")
- remote = self._remote(service, "embedding")
- self._patch(service, _OWNER, self)
- isolated_state: tuple[tuple[str, Any], ...] = (
- ("_cache", OrderedDict()),
- ("_last_embed_meta", {}),
- ("_queue_locks", {}),
- ("_queue_pending", {}),
- ("_queue_flush_tasks", {}),
- ("_client_cache", {}),
- )
- for name, value in isolated_state:
- self._patch(service, name, value)
- self._services.append(service)
-
- def check() -> None:
- self._check_remote(remote)
-
- async def leaf(texts: list[str]) -> tuple[list[list[float]], dict[str, Any]]:
- payload: dict[str, Any] = {
- "model": remote.spec.model,
- "input": texts,
- "encoding_format": "float",
- }
- if remote.spec.dimensions is not None:
- payload["dimensions"] = remote.spec.dimensions
- for attempt in range(remote.spec.retries + 1):
- check()
- try:
- result, status = await self._exchange(remote, payload)
- vectors = [item.embedding for item in result.data]
- if len(vectors) != len(texts) or any(
- not isinstance(vector, list)
- or (
- remote.spec.dimensions is not None
- and len(vector) != remote.spec.dimensions
- )
- or any(
- type(value) not in {int, float} or not math.isfinite(value)
- for value in vector
- )
- for vector in vectors
- ):
- raise _RequestFailure()
- service._last_embed_meta = {
- "http_status": status,
- "model": remote.spec.model,
- "attempts": attempt + 1,
- "usage": {
- "prompt_tokens": _token_count(result.usage.prompt_tokens),
- "total_tokens": _token_count(result.usage.total_tokens),
- }
- if result.usage
- else None,
- }
- return vectors, dict(service._last_embed_meta)
- except _RequestFailure as error:
- service._last_embed_meta = {
- "attempts": attempt + 1,
- "retryable": error.retryable,
- }
- if not error.retryable or attempt == remote.spec.retries:
- raise
- await asyncio.sleep(remote.spec.delay)
- raise _RequestFailure() # Unreachable with validated nonnegative retry count.
-
- for name in names:
- call = leaf if name == "_embed_openai" else getattr(service, name)
- self._bind_method(service, name, call, check, step="embedding")
- return service
-
- def bind_local_embed_service(self, service: Any) -> Any:
- """Keep the product's LocalHyEmbedService local; never substitute a cloud leaf."""
- from one_memory.embedding_assets import (
- BUILTIN_EMBEDDING_DIMENSIONS,
- BUILTIN_EMBEDDING_MODEL,
- )
- from one_memory.hy_memory_bridge import LocalHyEmbedService
-
- names = (
- "embed",
- "embed_batch",
- "embed_batch_with_meta",
- "embed_queued",
- "embed_queued_with_meta",
- "embed_with_meta",
- )
- if not self._claim(service, LocalHyEmbedService, names):
- return service
- _, recipient = _recipients(self._profile)
- if (
- recipient.provider_kind != ProviderKind.LOCAL_ONNX
- or recipient.endpoint is not None
- or recipient.model != BUILTIN_EMBEDDING_MODEL
- or recipient.dimensions != BUILTIN_EMBEDDING_DIMENSIONS
- ):
- self._deny("local_embedding_mismatch")
- encoder = service._embedder
-
- def check() -> None:
- self.assert_allowed()
- if (
- service._embedder is not encoder
- or service.get_embedding_dims() != recipient.dimensions
- ):
- self._deny("local_embedding_changed")
-
- self._patch(service, _OWNER, self)
- for name in names:
- self._bind_method(
- service, name, getattr(service, name), check, drain_local=True, step="embedding"
- )
- self._bind_method(service, "_embed_openai", self._unsupported_backend, check)
- return service
-
- async def _cleanup(self, exiting: asyncio.Task[Any] | None) -> None:
- tasks = {
- task
- for task in self._tasks
- if task is not exiting and task not in self._local_work and not task.done()
- }
- # Native HY schedules the timer before its bound coroutine can register
- # itself. Include done timers too, to retrieve an admission-time failure.
- for service in self._services:
- tasks.update(task for task in _embedding_flush_tasks(service) if task is not exiting)
- for task in tasks:
- if not task.done():
- task.cancel()
- if tasks:
- await asyncio.gather(*tasks, return_exceptions=True)
- if self._local_work:
- await asyncio.gather(*self._local_work, return_exceptions=True)
- for service in self._services:
- for future in _embedding_pending_futures(service):
- if not future.done():
- future.cancel()
- service._queue_pending.clear()
- service._cache.clear()
- try:
- clients: list[AsyncOpenAI | httpx.AsyncClient] = [*self._clients, self._http_client]
- for client in clients:
- try:
- if isinstance(client, httpx.AsyncClient):
- await client.aclose()
- else:
- await client.close()
- except Exception:
- self._denial = self._denial or "cleanup_failed"
- finally:
- for instance, name, previous, installed in reversed(self._patches):
- # Queue/meta attributes are intentionally mutated by native HY.
- dynamic = name in {
- "_last_embed_meta",
- "_queue_locks",
- "_queue_pending",
- "_queue_flush_tasks",
- "_client_cache",
- }
- if not dynamic and vars(instance).get(name, _MISSING) is not installed:
- self._denial = self._denial or "restoration_conflict"
- continue
- if previous is _MISSING:
- delattr(instance, name)
- else:
- setattr(instance, name, previous)
- self._patches.clear()
- self._clients.clear()
- self._services.clear()
-
-
-def _json(value: Any) -> str:
- return json.dumps(
- value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")
- )
-
-
-def _embedding_flush_tasks(service: Any) -> tuple[asyncio.Task[Any], ...]:
- tasks = getattr(service, "_queue_flush_tasks", {})
- if not isinstance(tasks, dict):
- return ()
- return tuple(task for task in tasks.values() if isinstance(task, asyncio.Task))
-
-
-def _embedding_pending_futures(service: Any) -> tuple[asyncio.Future[Any], ...]:
- pending = getattr(service, "_queue_pending", {})
- batches = pending.values() if isinstance(pending, dict) else (pending,)
- futures: list[asyncio.Future[Any]] = []
- for batch in batches:
- if not isinstance(batch, list):
- continue
- for item in batch:
- if isinstance(item, tuple) and len(item) > 1 and isinstance(item[1], asyncio.Future):
- futures.append(item[1])
- return tuple(futures)
-
-
-def _embedding_queue_active(service: Any) -> bool:
- pending = getattr(service, "_queue_pending", {})
- has_pending = (
- any(bool(batch) for batch in pending.values())
- if isinstance(pending, dict)
- else bool(pending)
- )
- return has_pending or any(not task.done() for task in _embedding_flush_tasks(service))
-
-
-def _step_items(args: tuple[Any, ...]) -> int:
- first = args[0] if args else None
- return len(first) if isinstance(first, list) else 1
-
-
-def _token_count(value: Any) -> int:
- if type(value) is not int or value < 0:
- raise _RequestFailure()
- return value
diff --git a/memhub/apps/core/src/one_memory/hy_response_guard.py b/memhub/apps/core/src/one_memory/hy_response_guard.py
index cdb8287..184c5e8 100644
--- a/memhub/apps/core/src/one_memory/hy_response_guard.py
+++ b/memhub/apps/core/src/one_memory/hy_response_guard.py
@@ -144,6 +144,10 @@ class HyReconcileGuard:
self.failures: list[str] = []
self.bindings: HyWriteBindings | None = None
self.imported_contents: frozenset[str] | None = None
+ self.mutation_target_ids: set[str] = set()
+
+ def begin_write(self) -> None:
+ self.mutation_target_ids.clear()
def __getattr__(self, name: str) -> Any:
return getattr(self.reconciler, name)
@@ -210,6 +214,15 @@ class HyReconcileGuard:
for operation in result.ops:
raw_name = getattr(operation, "op", "")
name = str(getattr(raw_name, "value", raw_name)).upper()
+ if name in {"UPDATE", "SUPERSEDE", "DELETE", "SHADOW"}:
+ target = getattr(operation, "memory_id", None)
+ if isinstance(target, str) and target:
+ self.mutation_target_ids.add(target)
+ self.mutation_target_ids.update(
+ key
+ for key in getattr(operation, "memory_ids", ())
+ if isinstance(key, str) and key
+ )
if name == "ADD":
operation_counts["ADD"] += 1
elif name in {"UPDATE", "MERGE", "NOOP"}:
@@ -232,20 +245,6 @@ class HyReconcileGuard:
"result_count": len(result.ops),
},
)
- timer = self.step_timer
- try:
- with (
- timer.step("write_verification", items=len(result.ops))
- if timer is not None
- else nullcontext()
- ):
- await self._check_targets(result, kwargs)
- except Exception as error:
- code = str(error)
- if not code.startswith("hy_memory_write_verification_") or len(code) > 100:
- code = "hy_memory_write_verification_failed"
- self.failures.append(code)
- raise ValueError(code) from error
return result
async def _check_targets(self, result: Any, kwargs: dict[str, Any]) -> None:
diff --git a/memhub/apps/core/src/one_memory/hy_store_concurrency.py b/memhub/apps/core/src/one_memory/hy_store_concurrency.py
new file mode 100644
index 0000000..d996926
--- /dev/null
+++ b/memhub/apps/core/src/one_memory/hy_store_concurrency.py
@@ -0,0 +1,41 @@
+"""Process-local coordination for HY's native stores."""
+
+from __future__ import annotations
+
+import asyncio
+import weakref
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
+ weakref.WeakKeyDictionary()
+)
+
+
+def native_store_write_lock() -> asyncio.Lock:
+ """Return the write lock for the current event loop."""
+ loop = asyncio.get_running_loop()
+ lock = _WRITE_LOCKS.get(loop)
+ if lock is None:
+ lock = asyncio.Lock()
+ _WRITE_LOCKS[loop] = lock
+ return lock
+
+
+@asynccontextmanager
+async def native_store_read_slot() -> AsyncIterator[bool]:
+ """Admit a native HY read only while no write is active.
+
+ Chroma's native reader may retain the GIL after an asyncio timeout. Reads
+ therefore share the physical-store lock, but never wait behind a writer:
+ host recall can immediately fall back to the governance ledger.
+ """
+ lock = native_store_write_lock()
+ if lock.locked():
+ yield False
+ return
+ await lock.acquire()
+ try:
+ yield True
+ finally:
+ lock.release()
diff --git a/memhub/apps/core/src/one_memory/memory_notes.py b/memhub/apps/core/src/one_memory/memory_notes.py
index ad17f17..fa0fec5 100644
--- a/memhub/apps/core/src/one_memory/memory_notes.py
+++ b/memhub/apps/core/src/one_memory/memory_notes.py
@@ -10,7 +10,6 @@ from fastapi.params import Depends
from pydantic import BaseModel, ConfigDict, Field, field_validator
from one_memory.database_access import main_database
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
from one_memory.imports.models import (
EvidenceInput,
EvidenceOrigin,
@@ -186,6 +185,10 @@ class MemoryNoteStore:
]
if task is None or task.status == MemoryTaskStatus.WAITING_FOR_MODEL:
state = "waiting_for_model"
+ elif task.status == MemoryTaskStatus.WAITING_FOR_PERMISSION:
+ # Upgrade compatibility: old rows are claimable and will leave
+ # this state as soon as the direct SDK runner claims them.
+ state = "waiting_for_permission"
elif task.status == MemoryTaskStatus.COMPLETED:
state = "ready" if memory_ids else "no_memories"
elif task.status == MemoryTaskStatus.RUNNING:
@@ -195,22 +198,6 @@ class MemoryNoteStore:
else:
state = "failed"
error = task.last_error_code if task else None
- if self.processor.model_content_config is not None and state in {
- "waiting_for_model",
- "failed",
- }:
- try:
- active = self.processor.profiles.active(self.processor.secrets)
- if active is not None:
- self.processor._guard_records((record,), active)
- except HyMemoryBridgeError as failure:
- if str(failure).startswith("model_content_"):
- state = "waiting_for_permission"
- error = str(failure)
- else:
- raise
- except SecretStoreUnavailableError:
- error = "secure_storage_unavailable"
return MemoryNoteReceipt(
evidence_id=record.evidence_id,
text=sanitize_model_input(record.verbatim),
@@ -274,11 +261,8 @@ def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter:
# unchanged; replaying the same request returns the original
# `entry:` job instead of a second HY call.
if readmit:
- # `/{evidence_id}/process` is the explicit "the condition
- # that parked it may have changed" signal: the person just
- # granted consent or fixed the model. `submit_evidence`
- # alone would return an existing parked row unchanged, so
- # without this readmit the note stays parked forever.
+ # `/{evidence_id}/process` is the explicit retry signal.
+ # It also revives pre-upgrade permission waiters.
await asyncio.to_thread(notes.intake.queue.readmit_parked)
submitted = await asyncio.to_thread(
notes.intake.submit_evidence,
@@ -296,10 +280,6 @@ def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter:
last_round=1,
priority="live",
)
- except HyMemoryBridgeError as error:
- if not str(error).startswith("model_content_"):
- raise
- return await asyncio.to_thread(notes.receipt, record.evidence_id)
except SecretStoreUnavailableError:
saved = await asyncio.to_thread(notes.receipt, record.evidence_id)
return saved.model_copy(
diff --git a/memhub/apps/core/src/one_memory/memory_processing.py b/memhub/apps/core/src/one_memory/memory_processing.py
index d6f96ca..98c3320 100644
--- a/memhub/apps/core/src/one_memory/memory_processing.py
+++ b/memhub/apps/core/src/one_memory/memory_processing.py
@@ -13,7 +13,7 @@ from collections.abc import (
Generator,
Mapping,
)
-from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
+from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from hashlib import sha256
@@ -82,7 +82,6 @@ from one_memory.memory_queue import (
classify,
extraction_window_for,
)
-from one_memory.memory_server.config_state import ConfigStore
from one_memory.memory_tasks import (
TASK_MAX_ATTEMPTS,
ActiveMemoryTask,
@@ -99,17 +98,10 @@ from one_memory.memory_tasks import (
materialize_evidence_ref,
segment_evidence_record,
)
-from one_memory.model_processing_permission_guard import (
- ModelProcessingPermissionGuard,
- ModelProcessingPermissionLease,
- error_is_policy_stop,
- permission_error,
-)
from one_memory.model_profiles import (
ModelProfile,
ModelProfileSnapshot,
ModelProfileStore,
- ProviderKind,
)
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
from one_memory.semantic_deadline import SemanticMemoryHit, normalize_semantic_hits
@@ -258,12 +250,6 @@ class MemoryBridgeProtocol(Protocol):
async def close(self) -> None: ...
-PermissionBridgeFactory = Callable[
- [ModelProcessingPermissionLease, str | None, Path],
- AbstractAsyncContextManager[MemoryBridgeProtocol],
-]
-
-
class MemoryTaskRunner:
def __init__(
self,
@@ -278,8 +264,6 @@ class MemoryTaskRunner:
Callable[[ModelProfileSnapshot, str | None, Path], MemoryBridgeProtocol] | None
) = None,
close_bridge: bool = True,
- model_content_config: ConfigStore | None = None,
- permission_bridge_factory: PermissionBridgeFactory | None = None,
) -> None:
self.tasks = tasks
self.profiles = profiles
@@ -289,17 +273,6 @@ class MemoryTaskRunner:
self.data_dir = data_dir
self.bridge_factory = bridge_factory or self._create_bridge
self.close_bridge = close_bridge
- self.model_content_config = model_content_config
- self.permission_bridge_factory = permission_bridge_factory
- self._permission_guard = (
- ModelProcessingPermissionGuard(
- model_content_config,
- governance,
- profiles,
- )
- if model_content_config is not None
- else None
- )
async def run(
self,
@@ -317,24 +290,13 @@ class MemoryTaskRunner:
task_id=task_id,
)
try:
- if self._permission_guard is not None:
- body = (
- self._run_with_content_permission(task_id)
- if before_hy_invocation is None
- else self._run_with_content_permission(
- task_id, before_hy_invocation=before_hy_invocation
- )
- )
- else:
- # Explicit compatibility for isolated callers without a guard. Production
- # Compatibility for isolated callers. Production always supplies ConfigStore.
- body = (
- self._run_without_content_permission(task_id)
- if before_hy_invocation is None
- else self._run_without_content_permission(
- task_id, before_hy_invocation=before_hy_invocation
- )
+ body = (
+ self._run_without_content_permission(task_id)
+ if before_hy_invocation is None
+ else self._run_without_content_permission(
+ task_id, before_hy_invocation=before_hy_invocation
)
+ )
# The ceiling bounds how long this task holds its harness's slot:
# a harness is serial, so one hung task starves every turn behind
# it. It is insurance against a single bad call, not a way to run
@@ -396,213 +358,6 @@ class MemoryTaskRunner:
details={"phase": str(step), "action": action},
)
- def _prepare_permission_lease(
- self,
- task: MemoryTask,
- *,
- persist_context: bool = True,
- ) -> tuple[ModelProcessingPermissionLease, HyMemoryIngestRequest, tuple[EvidenceRecord, ...]]:
- guard = self._permission_guard
- if guard is None:
- raise permission_error("permission_required")
- try:
- chunk = self._frozen_chunk(task)
- if not chunk:
- raise permission_error("task_input_scope_required")
- permission_records = self._original_records(chunk)
- # Check current inputs before expanding history; the complete context
- # is checked again before the adapter can see any of it.
- guard.check(task.profile_snapshot, permission_records)
- request = self._request(task, chunk)
- if request.source_context is not None:
- context = (
- self._source_context(task, request.source_context)
- if persist_context
- else self._source_context(task, request.source_context, persist_inputs=False)
- )
- request = request.model_copy(
- update={
- "source_context": context,
- }
- )
- lease = guard.lease(task, permission_records, request.source_context)
- if request.source_context is not None:
- self._check_source_context(request.source_context)
- return lease, request, chunk
- except HyMemoryBridgeError as error:
- if str(error).startswith("model_content_"):
- raise
- raise permission_error("evidence_changed") from error
-
- async def _run_with_content_permission(
- self,
- task_id: str,
- *,
- before_hy_invocation: Callable[[], Awaitable[None]] | None = None,
- ) -> MemoryTask:
- step_started = monotonic()
- task, token = await in_the_background(self.tasks.claim, task_id)
- self._log_step(task_id, 1, "claim", step_started)
- hy_invoked = False
- try:
- if task.kind is not MemoryTaskKind.EXTRACT:
- raise permission_error("task_kind_unsupported")
- derivation = await in_the_background(self.governance.hy_derivation)
- step_started = monotonic()
- lease, request, chunk = await in_the_background(self._prepare_permission_lease, task)
- self._log_step(task_id, 2, "permission_context", step_started)
- factory = self.permission_bridge_factory
- if factory is None:
- # Explicit refs cannot prove what an existing HY cache holds.
- # The adapter must isolate that context and guard SDK children.
- raise permission_error("context_unverified")
- await in_the_background(lease.recheck)
- step_started = monotonic()
- secret = await in_the_background(
- self.profiles.resolve_snapshot_secret,
- task.profile_id,
- task.profile_revision,
- self.secrets,
- )
- if task.profile_snapshot.has_api_key and secret is None:
- raise HyMemoryBridgeError("model_secret_unavailable")
- self._log_step(task_id, 3, "credential", step_started)
- await in_the_background(lease.recheck)
- step_started = monotonic()
- async with factory(lease, secret, self.data_dir) as bridge:
- self._log_step(task_id, 4, "bridge_context", step_started)
- await in_the_background(lease.recheck)
- contents = tuple(
- sanitize_model_input(record.verbatim)
- for record in chunk
- if _imported_as_said(record)
- )
- only_contents = bool(contents) and all(_imported_as_said(r) for r in chunk)
- step_started = monotonic()
- if before_hy_invocation is not None:
- await before_hy_invocation()
- await in_the_background(
- self.tasks.record_hy_invocation,
- task_id,
- runner_token=token,
- )
- hy_invoked = True
- result = (
- await bridge.ingest_extracted(request, contents)
- if only_contents
- else await bridge.ingest(request)
- )
- self._log_step(task_id, 5, "hy_ingest", step_started)
- await in_the_background(lease.recheck)
- if not result.success:
- raise HyMemoryBridgeError("hy_memory_runtime_failed")
- if only_contents and not result.memories:
- raise HyMemoryBridgeError("hy_memory_import_no_readable_output")
- outputs = (
- *result.memories,
- *await bridge.profile_memories(
- user_id=request.user_id,
- agent_id=request.agent_id,
- ),
- )
- # HY's request-scoped wiring and native-store lease are closed now.
- # Governance writes only touch One Memory's SQLite store, so keeping
- # the shared HY bridge locked through them serializes unrelated
- # sources without protecting any HY state.
- await in_the_background(lease.recheck)
- step_started = monotonic()
- await in_the_background(
- self._record_outputs,
- task=task,
- chunk=self._original_records(chunk),
- outputs=outputs,
- request=request,
- derivation=derivation,
- permission_lease=lease,
- retired=result.retired_memory_ids,
- superseded=result.superseded_memory_ids,
- )
- self._log_step(task_id, 6, "governance", step_started)
- await in_the_background(lease.recheck_permission)
- except SecretStoreUnavailableError:
- return await in_the_background(
- self.tasks.wait_for_model,
- task_id,
- runner_token=token,
- error_code="secure_storage_unavailable",
- )
- except HyMemoryBridgeError as error:
- code = str(error)
- model_wait_errors = {
- "hy_memory_unavailable",
- "hy_memory_rate_limit",
- "hy_memory_runtime_failed",
- "model_secret_unavailable",
- "hy_memory_model_authentication_failed",
- "hy_memory_model_payment_required",
- "hy_memory_model_access_denied",
- "hy_memory_model_rate_limited",
- "hy_memory_model_service_unavailable",
- "hy_memory_model_timeout",
- }
- safe_terminal_after_invocation = {"hy_memory_import_no_readable_output"}
- if hy_invoked and (
- code == "hy_memory_evidence_changed"
- or (
- not error_is_policy_stop(code)
- and code not in model_wait_errors | safe_terminal_after_invocation
- )
- ):
- return await in_the_background(
- self.tasks.mark_unknown,
- task_id,
- runner_token=token,
- error_code=code,
- )
- if error_is_policy_stop(code):
- # The user turned this off while the task was running, or the
- # policy behind it was revoked. Evidence replacement after an
- # HY call is the exception: its outcome is unknown above.
- return await in_the_background(
- self.tasks.stop,
- task_id,
- runner_token=token,
- error_code=code,
- )
- if code == "model_content_permission_required":
- return await in_the_background(
- self.tasks.wait_for_permission,
- task_id,
- runner_token=token,
- error_code=code,
- )
- if code == "model_content_transport_request_failed":
- return await in_the_background(
- self.tasks.mark_unknown,
- task_id,
- runner_token=token,
- error_code=code,
- )
- finish = self.tasks.wait_for_model if code in model_wait_errors else self.tasks.fail
- return await in_the_background(finish, task_id, runner_token=token, error_code=code)
- except Exception:
- # Adapter/setup errors must not strand RUNNING tasks or expose
- # content/credentials through provider exception messages.
- if hy_invoked:
- return await in_the_background(
- self.tasks.mark_unknown,
- task_id,
- runner_token=token,
- error_code="model_content_outcome_unknown",
- )
- return await in_the_background(
- self.tasks.fail,
- task_id,
- runner_token=token,
- error_code="model_content_execution_failed",
- )
- return await in_the_background(self.tasks.complete, task_id, runner_token=token)
-
async def _run_without_content_permission(
self,
task_id: str,
@@ -777,7 +532,7 @@ class MemoryTaskRunner:
if hy_invoked and (
code == "hy_memory_evidence_changed"
or (
- not error_is_policy_stop(code)
+ code not in {"hy_memory_source_changed", "hy_memory_source_revoked"}
and code not in model_wait_errors | safe_terminal_after_invocation
)
):
@@ -787,10 +542,8 @@ class MemoryTaskRunner:
runner_token=runner_token,
error_code=code,
)
- if error_is_policy_stop(code):
- # The user stopped the policy after this task was claimed.
- # Evidence replacement after an HY call is the exception: its
- # outcome is unknown and is handled above.
+ if code in {"hy_memory_source_changed", "hy_memory_source_revoked"}:
+ # The source grant changed after this task was claimed.
return await in_the_background(
self.tasks.stop,
task_id,
@@ -1022,24 +775,10 @@ class MemoryTaskRunner:
outputs: tuple[HyMemoryOutput, ...],
request: HyMemoryIngestRequest,
derivation: HyDerivation,
- permission_lease: ModelProcessingPermissionLease | None = None,
retired: tuple[str, ...] = (),
superseded: Mapping[str, tuple[str, ...]] | None = None,
) -> None:
try:
- if permission_lease is not None:
- with self.governance.guard_writes(permission_lease.recheck_permission):
- self._record_outputs_checked(
- task=task,
- chunk=chunk,
- outputs=outputs,
- request=request,
- derivation=derivation,
- permission_lease=permission_lease,
- retired=retired,
- superseded=superseded or {},
- )
- return
self._record_outputs_checked(
task=task,
chunk=chunk,
@@ -1062,14 +801,11 @@ class MemoryTaskRunner:
outputs: tuple[HyMemoryOutput, ...],
request: HyMemoryIngestRequest,
derivation: HyDerivation,
- permission_lease: ModelProcessingPermissionLease | None = None,
retired: tuple[str, ...] = (),
superseded: Mapping[str, tuple[str, ...]] | None = None,
) -> None:
"""Writes what HY produced into governance. Synchronous on purpose:
the caller runs it in a thread, so the loop stays free to answer."""
- if permission_lease is not None:
- permission_lease.recheck()
source_records = (
(
*request.source_context.current,
@@ -1084,8 +820,6 @@ class MemoryTaskRunner:
# rests on everything the turn showed it.
whole_turn = derivation.citing(by_id)
for output in outputs:
- if permission_lease is not None:
- permission_lease.recheck_permission()
layer = output.layer
ids = output.evidence_ids or request.evidence_ids
if not set(ids) <= set(by_id):
@@ -1115,8 +849,6 @@ class MemoryTaskRunner:
if known.owner != output.owner:
raise HyMemoryBridgeError("hy_memory_write_verification_authority_invalid")
try:
- if permission_lease is not None:
- permission_lease.recheck_permission()
updated = self.governance.write_from_hy(
HyRevise(
memory_id=known.governed_memory_id,
@@ -1130,8 +862,6 @@ class MemoryTaskRunner:
)
except MemoryMutationConflictError as error:
raise HyMemoryBridgeError("hy_memory_evidence_changed") from error
- if permission_lease is not None:
- permission_lease.recheck_permission()
self._record_provenance(task, updated.memory_id, output, support)
continue
revised = (
@@ -1146,8 +876,6 @@ class MemoryTaskRunner:
) <= set(by_id):
raise HyMemoryBridgeError("hy_memory_write_verification_authority_invalid")
try:
- if permission_lease is not None:
- permission_lease.recheck_permission()
updated = self.governance.write_from_hy(
HyRevise(
memory_id=revised.memory_id,
@@ -1161,27 +889,19 @@ class MemoryTaskRunner:
)
except MemoryMutationConflictError as error:
raise HyMemoryBridgeError("hy_memory_evidence_changed") from error
- if permission_lease is not None:
- permission_lease.recheck_permission()
self._record_provenance(task, updated.memory_id, output, support)
continue
existing = self.governance.get(output.memory_id, user_id=task.user_id)
if existing is not None and existing.content == content:
linked = existing
for evidence_id in ids:
- if permission_lease is not None:
- permission_lease.recheck_permission()
linked = self.governance.write_from_hy(
HyLink(memory_id=existing.memory_id, evidence_id=evidence_id),
user_id=task.user_id,
derivation=derivation.citing(ids),
)
- if permission_lease is not None:
- permission_lease.recheck_permission()
self._record_provenance(task, linked.memory_id, output, support)
continue
- if permission_lease is not None:
- permission_lease.recheck_permission()
memory = self.governance.write_from_hy(
HyCreate(
draft=MemoryDraft(
@@ -1201,15 +921,11 @@ class MemoryTaskRunner:
)
# Usable on arrival whoever said it; `origin` tells recall and
# matters that an assistant's sentence is not the person's own.
- if permission_lease is not None:
- permission_lease.recheck_permission()
self._record_provenance(task, memory.memory_id, output, support)
for memory_id in retired:
mirrored = self.governance.get(memory_id, user_id=task.user_id)
if mirrored is None or mirrored.status is MemoryStatus.DELETED:
continue
- if permission_lease is not None:
- permission_lease.recheck_permission()
self.governance.write_from_hy(
HyRetire(
memory_id=memory_id,
@@ -1236,8 +952,6 @@ class MemoryTaskRunner:
)
if not heads:
continue
- if permission_lease is not None:
- permission_lease.recheck_permission()
self.governance.write_from_hy(
HySupersede(
memory_id=memory_id,
@@ -1248,8 +962,6 @@ class MemoryTaskRunner:
user_id=task.user_id,
derivation=whole_turn,
)
- if permission_lease is not None:
- permission_lease.recheck_permission()
self._save_extraction_for_training(task, outputs)
def _save_extraction_for_training(
@@ -1486,8 +1198,6 @@ class MemoryProcessingSupervisor:
Callable[[ModelProfileSnapshot, str | None, Path], MemoryBridgeProtocol] | None
) = None,
max_concurrent_tasks: int | None = None,
- model_content_config: ConfigStore | None = None,
- permission_bridge_factory: PermissionBridgeFactory | None = None,
source_writes_permitted: Callable[[str], bool] | None = None,
service_levels: ServiceLevelScheduler | None = None,
) -> None:
@@ -1497,23 +1207,12 @@ class MemoryProcessingSupervisor:
self.evidence = evidence
self.governance = governance
self.data_dir = data_dir
- self.model_content_config = model_content_config
- self.permission_bridge_factory = permission_bridge_factory
#: Per-source "may this become a memory" switch. Optional so tests and
#: a Core without a grant store keep their current behaviour; when
#: absent every source is treated as writable, which is what the
#: product did before the switch existed.
self.source_writes_permitted = source_writes_permitted
self.service_levels = service_levels or ServiceLevelScheduler()
- self._permission_guard = (
- ModelProcessingPermissionGuard(
- model_content_config,
- governance,
- profiles,
- )
- if model_content_config is not None
- else None
- )
self.extraction_settings = extraction_settings or ExtractionSettingsStore(
data_dir / "one-memory.sqlite3"
)
@@ -1597,8 +1296,6 @@ class MemoryProcessingSupervisor:
active = await in_the_background(self.profiles.active, self.secrets)
if active is None or (profile_id is not None and profile_id != active.profile_id):
raise HyMemoryBridgeError("model_profile_not_active")
- if self._permission_guard is not None:
- await in_the_background(self._guard_records, records, active)
# The durable drain and explicit processing entry reach HY through
# this method, not through the backfill scheduler. New work from a
# frozen Harness must be refused before any task or bridge call.
@@ -1607,7 +1304,7 @@ class MemoryProcessingSupervisor:
and self.source_writes_permitted is not None
and not await in_the_background(self._source_may_write, list(records))
):
- raise permission_error("source_disabled")
+ raise HyMemoryBridgeError("hy_memory_source_disabled")
conversations: dict[tuple[str, str, str | None], list[EvidenceRecord]] = {}
for record in records:
stored = await in_the_background(self.evidence.get, record.evidence_id)
@@ -1633,9 +1330,6 @@ class MemoryProcessingSupervisor:
for conversation in conversations.values()
for chunk in _conversation_chunks(conversation, max_turns=12, completed_only=True)
]
- if self._permission_guard is not None:
- for chunk in chunks:
- await in_the_background(self._preview_chunk_permission, chunk, active)
prepared = []
for chunk in chunks:
task = await in_the_background(
@@ -1701,8 +1395,6 @@ class MemoryProcessingSupervisor:
}
if expected != actual:
raise HyMemoryBridgeError("hy_memory_input_coverage_incomplete")
- if self._permission_guard is not None:
- await in_the_background(self._guard_records, records, active)
if completed:
await self._refresh_read_bridge(completed[-1].profile_snapshot)
return tuple(completed)
@@ -1719,15 +1411,7 @@ class MemoryProcessingSupervisor:
bridge: MemoryBridgeProtocol | None = None
try:
chunk = await in_the_background(runner._frozen_chunk, task)
- permission_lease: ModelProcessingPermissionLease | None = None
- if runner._permission_guard is not None:
- permission_lease, request, chunk = await in_the_background(
- runner._prepare_permission_lease, task
- )
- assert permission_lease is not None
- await in_the_background(permission_lease.recheck)
- else:
- request = runner._request(task, chunk)
+ request = runner._request(task, chunk)
secret = await in_the_background(
self.profiles.resolve_snapshot_secret,
task.profile_id,
@@ -1756,7 +1440,6 @@ class MemoryProcessingSupervisor:
outputs=outputs,
request=request,
derivation=derivation,
- permission_lease=permission_lease,
)
resolved = await in_the_background(
self.tasks.resolve_unknown,
@@ -1877,26 +1560,6 @@ class MemoryProcessingSupervisor:
task_ids: tuple[str, ...] | None = None,
) -> list[MemoryTask]:
"""Promote at most one small batch of deferred work into the queue."""
- if self._permission_guard is not None:
- candidates = (
- task_ids
- or tuple(self._manual_batch_remaining)
- or tuple(
- task.task_id
- for task in self.tasks.list(
- statuses=(MemoryTaskStatus.DEFERRED_OUTSIDE_WINDOW,)
- )
- )
- )
- permitted = tuple(
- key for key in candidates if self._guard_task(self.tasks.require(key))
- )
- if not permitted:
- return []
- if task_ids is not None:
- task_ids = permitted
- else:
- self._manual_batch_remaining = list(permitted)
if task_ids is not None:
self._plan_manual_batches(list(task_ids))
elif not self._manual_batch_remaining and self._manual_batches_planned == 0:
@@ -2032,8 +1695,6 @@ class MemoryProcessingSupervisor:
if active is None:
self._record_idle("no_active_profile")
return
- if self._permission_guard is not None:
- self._permission_guard.check(self.profiles.snapshot(active.profile_id, active.revision))
tasks: list[MemoryTask] = []
first = True
visited = 0
@@ -2057,63 +1718,20 @@ class MemoryProcessingSupervisor:
# but preparation must not create additional processing tasks.
if not self._source_may_write(conversation):
continue
- scoped = conversation
- allowed = True
- if self._permission_guard is not None:
- try:
- self._guard_records(
- tuple(r for r in scoped if _is_hy_turn_record(r)),
- active,
- )
- except HyMemoryBridgeError:
- allowed = False
- try:
- chunks = (
- self._prepare_conversation_chunks(
- scoped,
- active,
- merge_history=self._permission_guard is not None,
- )
- if allowed
- else []
- )
- except HyMemoryBridgeError as error:
- # A stale conversation is that conversation's problem. Skipping
- # it lets the rest of the scan prepare instead of discarding an
- # entire round; the next scan re-evaluates this conversation.
- if str(error) != "model_content_evidence_changed":
- raise
- conversation_hash = sha256(
- (conversation[0].conversation_id if conversation else "").encode()
- ).hexdigest()[:16]
- diagnostic_event(
- level="warn",
- component="memory.scheduler",
- event="memory_scheduler.conversation_skipped",
- outcome="skipped",
- details={
- "reason": "evidence_changed",
- "conversation_hash": conversation_hash,
- },
- )
- continue
+ chunks = self._prepare_conversation_chunks(conversation, active)
for chunk in chunks:
if should_stop() or self._shutting_down:
return
- try:
- # Historical turns may be coalesced across their original
- # boundaries. Freeze that exact union so the runner cannot
- # reconstruct only the final turn from `input_ref` later.
- tasks.append(
- self._enqueue_chunk(
- chunk,
- active,
- freeze_inputs=sum(_opens_turn(record) for record in chunk) > 1,
- )
+ # Historical turns may be coalesced across their original
+ # boundaries. Freeze that exact union so the runner cannot
+ # reconstruct only the final turn from `input_ref` later.
+ tasks.append(
+ self._enqueue_chunk(
+ chunk,
+ active,
+ freeze_inputs=sum(_opens_turn(record) for record in chunk) > 1,
)
- except HyMemoryBridgeError as error:
- if not str(error).startswith("model_content_"):
- raise
+ )
if len(tasks) >= (1 if first else _BACKFILL_BATCH_SIZE):
self._check_backfill_context(active)
classified = self._classify(tasks)
@@ -2136,9 +1754,7 @@ class MemoryProcessingSupervisor:
active.profile_id,
active.revision,
):
- raise permission_error("profile_changed")
- if self._permission_guard is not None:
- self._permission_guard.check(self.profiles.snapshot(active.profile_id, active.revision))
+ raise HyMemoryBridgeError("model_profile_changed")
def backfill(self) -> None:
self._schedule_tasks(self.prepare_backfill())
@@ -2272,12 +1888,10 @@ class MemoryProcessingSupervisor:
async def accept_backfill_off_thread(self) -> None:
"""Accept processing after the first scheduled batch; the rest continues."""
if self._shutting_down:
- raise permission_error("processing_unavailable")
- if self._permission_guard is not None:
- await in_the_background(self._permission_guard.require_allowed)
+ raise HyMemoryBridgeError("hy_memory_processing_unavailable")
async with self._accept_backfill_lock:
if self._shutting_down:
- raise permission_error("processing_unavailable")
+ raise HyMemoryBridgeError("hy_memory_processing_unavailable")
state = self._backfill_acceptance
running = state.task if state is not None else None
if running is not None and not running.done() and state is not None:
@@ -2291,11 +1905,9 @@ class MemoryProcessingSupervisor:
self._live_backfill = state.task
await state.ready.wait()
if self._shutting_down or state.task is None or state.task.cancelled():
- raise permission_error("processing_unavailable")
+ raise HyMemoryBridgeError("hy_memory_processing_unavailable")
if state.error is not None:
raise HyMemoryBridgeError(state.error)
- if self._permission_guard is not None:
- await in_the_background(self._permission_guard.require_allowed)
async def _prewarm_write_bridges(self, source_ids: tuple[str, ...]) -> None:
credentials = await self._active_credentials()
@@ -2442,10 +2054,7 @@ class MemoryProcessingSupervisor:
await self._schedule_tasks_off_thread(work)
async def resume_allowed(self) -> None:
- """Recover tasks and backfill while the live global permission is enabled."""
- if self._permission_guard is None:
- raise permission_error("permission_required")
- await in_the_background(self._permission_guard.require_allowed)
+ """Compatibility entry: recover waiting tasks and start backfill."""
await self.resume_off_thread()
await self.backfill_off_thread(cohort_size=8)
@@ -2468,12 +2077,6 @@ class MemoryProcessingSupervisor:
now = datetime.now(UTC)
window = self._extraction_window() if within_window else None
exempt_all_apps_from_window = True
- if self._permission_guard is not None:
- try:
- self._permission_guard.require_allowed()
- window = None
- except HyMemoryBridgeError:
- pass
work: list[QueuedWork] = []
deferred_count = 0
for task in tasks:
@@ -2536,21 +2139,6 @@ class MemoryProcessingSupervisor:
def _schedule_tasks(self, work: list[QueuedWork]) -> None:
if self._shutting_down:
return
- tasks_by_id: dict[str, MemoryTask] = {}
- for item in work:
- task = self.tasks.get(item.task_id)
- if task is not None:
- tasks_by_id[task.task_id] = task
- if self._permission_guard is not None:
- work = [
- item
- for item in work
- if (task := tasks_by_id.get(item.task_id)) is not None and self._guard_task(task)
- ]
- # `_classify` already validated every task while it was off the event
- # loop. Re-running the full guard here doubles permission work before a
- # task starts and does not improve revocation semantics: the runner takes a
- # fresh lease before model content is released.
self._queue_validated_work(work)
async def _schedule_tasks_off_thread(self, work: list[QueuedWork]) -> None:
@@ -2742,20 +2330,9 @@ class MemoryProcessingSupervisor:
task.status is MemoryTaskStatus.FAILED and task.attempts >= TASK_MAX_ATTEMPTS
):
continue
- if self._permission_guard is not None and not await in_the_background(
- self._guard_task, task
- ):
+ if not await in_the_background(self._guard_task, task):
continue
runner = self.runner_factory()
- if self._permission_guard is not None and (
- runner.model_content_config is not self.model_content_config
- ):
- await in_the_background(
- self._permission_guard.reject_unstarted,
- work.task_id,
- "runner_permission_required",
- )
- continue
# A wall-clock ceiling, because a harness is serial: a task
# that hangs holds the only slot its harness has, and every
# later turn of that harness waits behind it. The runner owns
@@ -2929,7 +2506,7 @@ class MemoryProcessingSupervisor:
self.secrets,
)
if self.profiles.active_identity() != identity:
- raise permission_error("profile_changed")
+ raise HyMemoryBridgeError("model_profile_changed")
if snapshot.has_api_key and secret is None:
raise HyMemoryBridgeError("model_secret_unavailable")
return snapshot, secret
@@ -2944,8 +2521,6 @@ class MemoryProcessingSupervisor:
return await self._digest_locked()
async def _digest_locked(self) -> dict[str, object]:
- if self._permission_guard is not None:
- raise permission_error("context_unverified")
credentials = await self._active_credentials()
if credentials is None:
raise HyMemoryBridgeError("model_profile_not_active")
@@ -3163,30 +2738,6 @@ class MemoryProcessingSupervisor:
)
async def update_hy_memory(self, memory_id: str, content: str) -> dict[str, object]:
- if self._permission_guard is not None:
- with self.profiles._connect() as db:
- row = db.execute(
- "SELECT profile_id,revision FROM model_profiles WHERE is_active=1"
- ).fetchone()
- if row is None:
- raise HyMemoryBridgeError("model_profile_not_active")
- profile = self.profiles.snapshot(row["profile_id"], row["revision"])
- if profile.embedding_provider_kind is not ProviderKind.LOCAL_ONNX:
- raise permission_error("correction_embedding_consent_required")
- current = self.governance.get(memory_id, user_id="local")
- if (
- current is None
- or current.content != content
- or current.status is MemoryStatus.DELETED
- ):
- raise permission_error("context_changed")
- # Explicit correction updates the existing on-device index only. This
- # branch cannot authorize extraction, digest or a remote embedder.
- bridge = self._bridge(builtin_retrieval_snapshot(), None, self.data_dir)
- result = await bridge.update(memory_id, content)
- if self.governance.get(memory_id, user_id="local") != current:
- raise permission_error("context_changed")
- return result
credentials = await self._active_credentials()
if credentials is None:
raise HyMemoryBridgeError("model_profile_not_active")
@@ -3315,28 +2866,10 @@ class MemoryProcessingSupervisor:
async def semantic_recall(
self, query: str, *, limit: int = 12
) -> tuple[SemanticMemoryHit, ...]:
- if self._permission_guard is not None:
- # A consent for Evidence cannot authorize an arbitrary remote query.
- # Preserve existing local-ONNX paths, without a new fallback/store.
- def ensure_local_embedding() -> None:
- with self.profiles._connect() as db:
- row = db.execute(
- "SELECT profile_id, revision FROM model_profiles WHERE is_active=1"
- ).fetchone()
- if row is not None:
- profile = self.profiles.snapshot(row["profile_id"], row["revision"])
- if profile.embedding_provider_kind is not ProviderKind.LOCAL_ONNX:
- raise permission_error("semantic_scope_unsupported")
-
- await in_the_background(ensure_local_embedding)
credentials = await self._active_credentials()
if credentials is None:
return await self._search_with_builtin_embedding(query, limit=limit)
snapshot, secret = credentials
- if self._permission_guard is not None and (
- snapshot.embedding_provider_kind is not ProviderKind.LOCAL_ONNX
- ):
- raise permission_error("semantic_scope_unsupported")
key = (snapshot.profile_id, snapshot.revision)
bridge = self._read_bridges.get(key)
if bridge is None or key not in self._read_bridges_ready:
@@ -3410,8 +2943,6 @@ class MemoryProcessingSupervisor:
data_dir=self.data_dir,
bridge_factory=self._bridge,
close_bridge=False,
- model_content_config=self.model_content_config,
- permission_bridge_factory=self.permission_bridge_factory,
)
def _bridge(
@@ -3589,7 +3120,6 @@ class MemoryProcessingSupervisor:
active = self.profiles.active(self.secrets)
if active is None:
return []
- self._guard_records((seed,), active)
conversation = self.evidence.list_conversation(seed.evidence_id)
if not self._source_may_write(conversation):
return []
@@ -3601,10 +3131,6 @@ class MemoryProcessingSupervisor:
for item in conversation
):
return []
- self._guard_records(
- tuple(r for r in conversation if _is_hy_turn_record(r)),
- active,
- )
# A realtime turn that is still open must not become a task, whichever
# path gets here first. The debounce is the record of "this arrival is
# still waiting for its reply"; without this check a periodic scan
@@ -3646,16 +3172,6 @@ class MemoryProcessingSupervisor:
admitted: list[tuple[tuple[EvidenceRecord, ...], MemoryTask | None]] = []
for chunk in chunks:
done = self._finished_chunk_task(chunk)
- if done is None and self._permission_guard is not None:
- try:
- self._preview_chunk_permission(chunk, profile)
- except HyMemoryBridgeError:
- if not self.governance.blocked_source_ids(
- user_id=chunk[0].user_id,
- evidence_ids=tuple(record.evidence_id for record in chunk),
- ):
- raise
- continue
admitted.append((chunk, done))
return admitted
@@ -3710,7 +3226,6 @@ class MemoryProcessingSupervisor:
records = tuple(record for record in conversation if _is_hy_turn_record(record))
if self._shutting_down or not records:
return []
- self._guard_records(records, profile)
self._check_backfill_context(profile)
# Keep a full, immutable failure snapshot. Appended originals remain
# unprocessed while the same blocker persists; they are never marked done.
@@ -3747,28 +3262,8 @@ class MemoryProcessingSupervisor:
preparation_error="hy_memory_input_exceeds_window",
preparation_group=group,
)
- if self._permission_guard is not None:
- self._guard_records(records, profile)
return []
- def _guard_records(
- self,
- records: tuple[EvidenceRecord, ...],
- profile: ModelProfile | None = None,
- ) -> None:
- guard = self._permission_guard
- if guard is None:
- return
- records = self._original_records(records)
- if profile is None:
- active = self.profiles.active_identity()
- if active is None:
- raise permission_error("profile_changed")
- snapshot = self.profiles.snapshot(*active)
- else:
- snapshot = self.profiles.snapshot(profile.profile_id, profile.revision)
- guard.check(snapshot, records)
-
def _original_records(self, records: tuple[EvidenceRecord, ...]) -> tuple[EvidenceRecord, ...]:
originals: dict[str, EvidenceRecord] = {}
for record in records:
@@ -3778,46 +3273,6 @@ class MemoryProcessingSupervisor:
originals.setdefault(current.evidence_id, current)
return tuple(originals.values())
- def _preview_chunk_permission(
- self,
- chunk: tuple[EvidenceRecord, ...],
- profile: ModelProfile,
- ) -> None:
- guard = self._permission_guard
- if guard is None:
- return
- snapshot = self.profiles.snapshot(profile.profile_id, profile.revision)
- now = datetime.now(UTC)
- # An in-memory task-shaped query selects exactly the history the runner
- # would load. It is not persisted and cannot claim or execute work.
- preview = MemoryTask(
- task_id="permission-preview",
- request_id="permission-preview",
- user_id=chunk[0].user_id,
- kind=MemoryTaskKind.EXTRACT,
- input_ref=chunk[0].evidence_id,
- status=MemoryTaskStatus.PENDING,
- profile_id=snapshot.profile_id,
- profile_revision=snapshot.revision,
- profile_snapshot=snapshot,
- attempts=0,
- last_error_code=None,
- created_at=now,
- updated_at=now,
- )
- runner = self._create_runner()
- request = runner._request(preview, chunk)
- context = request.source_context
- originals = self._original_records(chunk)
- if context is not None:
- try:
- context = runner._source_context(preview, context, persist_inputs=False)
- except HyMemoryBridgeError as error:
- raise permission_error("evidence_changed") from error
- records = guard.context_records(originals, context)
- guard.check(snapshot, records)
- guard.check_context(records, context)
-
def _guard_task(self, task: MemoryTask) -> bool:
if task.last_error_code in {
"hy_memory_input_exceeds_window",
@@ -3832,19 +3287,6 @@ class MemoryProcessingSupervisor:
# Freeze: the durable row stays pending. Reopening the Harness
# causes the ordinary resume pass to classify it again.
return False
- guard = self._permission_guard
- if guard is None:
- return True
- try:
- guard.check(task.profile_snapshot)
- self._create_runner()._prepare_permission_lease(task, persist_context=False)
- except HyMemoryBridgeError as error:
- code = str(error)
- if code == "model_content_permission_required":
- self.tasks.park_for_permission(task.task_id, error_code=code)
- else:
- guard.reject_unstarted(task.task_id, code)
- return False
return True
def _source_may_write(self, conversation: list[EvidenceRecord]) -> bool:
@@ -3868,7 +3310,6 @@ class MemoryProcessingSupervisor:
*,
freeze_inputs: bool = False,
) -> MemoryTask:
- self._guard_records(chunk, profile)
if any("segment_hash" in evidence_ref(record) for record in chunk):
freeze_inputs = True
source = chunk[0]
@@ -3876,9 +3317,6 @@ class MemoryProcessingSupervisor:
finished = self._finished_chunk_task(chunk)
if finished is not None:
return finished
- if self._permission_guard is not None:
- self._preview_chunk_permission(chunk, profile)
- freeze_inputs = True
digest = sha256(
"|".join(
(
@@ -3912,15 +3350,6 @@ class MemoryProcessingSupervisor:
profile=profile,
input_records=chunk if freeze_inputs else (),
)
- if self._permission_guard is not None:
- try:
- self._permission_guard.check(
- task.profile_snapshot,
- self._original_records(chunk),
- )
- except HyMemoryBridgeError as error:
- self._permission_guard.reject_unstarted(task.task_id, str(error))
- raise
if task.status is MemoryTaskStatus.PENDING:
self.tasks.supersede_unstarted(
task,
diff --git a/memhub/apps/core/src/one_memory/memory_server/config_state.py b/memhub/apps/core/src/one_memory/memory_server/config_state.py
index db5e401..1916a07 100644
--- a/memhub/apps/core/src/one_memory/memory_server/config_state.py
+++ b/memhub/apps/core/src/one_memory/memory_server/config_state.py
@@ -29,7 +29,6 @@ class ConfigDocument(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
schema_version: int = Field(default=1, ge=1, le=1)
- model_content_allowed: StrictBool = False
sources: dict[str, SourceConfig] = Field(default_factory=dict)
@field_validator("sources")
@@ -64,10 +63,6 @@ class ConfigStore:
source = self._document.sources.get(source_id)
return bool(source and source.sync_enabled)
- def model_content_allowed(self) -> bool:
- with self._lock:
- return self._document.model_content_allowed
-
def set_sync(self, source_id: str, enabled: bool) -> ConfigDocument:
return self.set_sync_many({source_id: enabled})
@@ -86,20 +81,17 @@ class ConfigStore:
self._document = updated
return updated
- def set_model_content_allowed(self, allowed: bool) -> ConfigDocument:
- if type(allowed) is not bool:
- raise ValueError("invalid_model_content_config")
- with self._lock:
- updated = self._document.model_copy(update={"model_content_allowed": allowed})
- self._write(updated)
- self._document = updated
- return updated
-
def _read(self) -> ConfigDocument:
if not self.path.exists():
return ConfigDocument()
try:
- return ConfigDocument.model_validate_json(self.path.read_text(encoding="utf-8"))
+ value = json.loads(self.path.read_text(encoding="utf-8"))
+ if isinstance(value, dict):
+ # Builds through 1.0.2 persisted this global consent bit.
+ # The embedded SDK is now trusted to use all local memory, so
+ # accept existing files while discarding the retired setting.
+ value.pop("model_content_allowed", None)
+ return ConfigDocument.model_validate(value)
except (OSError, ValueError) as error:
raise ConfigFileError("config_invalid") from error
diff --git a/memhub/apps/core/src/one_memory/memory_server/migrations.py b/memhub/apps/core/src/one_memory/memory_server/migrations.py
index 32629c1..a736b08 100644
--- a/memhub/apps/core/src/one_memory/memory_server/migrations.py
+++ b/memhub/apps/core/src/one_memory/memory_server/migrations.py
@@ -1404,7 +1404,14 @@ async def _024_matter_model_cache(cursor: Any) -> None:
async def _026_hy_node_permissions(cursor: Any) -> None:
- await cursor.execute(hy_node_permissions_ddl(_dialect(cursor)))
+ # Historical migration retained as a ledger slot. Fresh databases no
+ # longer create this retired proof table; migration 51 removes it from
+ # databases that ran the original v26 body.
+ return None
+
+
+async def _051_drop_hy_node_permissions(cursor: Any) -> None:
+ await cursor.execute("DROP TABLE IF EXISTS hy_node_permissions")
def model_profiles_ddl(dialect: str = "mysql") -> str:
@@ -2756,7 +2763,7 @@ MIGRATIONS: tuple[Migration, ...] = (
),
Migration(23, "source_candidates", _023_source_candidates, ("source_candidates",)),
Migration(24, "matter_model_cache", _024_matter_model_cache, ("matter_model_cache",)),
- Migration(26, "hy_node_permissions", _026_hy_node_permissions, ("hy_node_permissions",)),
+ Migration(26, "hy_node_permissions", _026_hy_node_permissions),
Migration(
27,
"model_profiles",
@@ -2841,4 +2848,5 @@ MIGRATIONS: tuple[Migration, ...] = (
"model_probe_operations_reserved_at",
_050_model_probe_operations_reserved_at,
),
+ Migration(51, "drop_hy_node_permissions", _051_drop_hy_node_permissions),
)
diff --git a/memhub/apps/core/src/one_memory/memory_tasks.py b/memhub/apps/core/src/one_memory/memory_tasks.py
index 1f04923..94c6ab7 100644
--- a/memhub/apps/core/src/one_memory/memory_tasks.py
+++ b/memhub/apps/core/src/one_memory/memory_tasks.py
@@ -267,15 +267,13 @@ class MemoryTaskOutput(BaseModel):
MEMORY_TASK_SCHEMA = memory_tasks_ddl("sqlite")
-#: Transport refusals raised before the physical send, so provably nothing
-#: reached HY: nothing can have been written and retrying cannot duplicate.
+#: Legacy transport refusals raised before the physical send, so provably
+#: nothing reached HY: nothing can have been written and retrying cannot
+#: duplicate. Kept to recover rows written by builds through 1.0.2.
#:
-#: Every name here is denied by `HyPermissionTransport._check_request` (the
-#: httpx request hook) or by `_PhysicalTransport.handle_async_request` before
-#: it counts the request — both run inside httpx, before the socket. Explicit
-#: on purpose (D88): the transport also denies `budget_exceeded`, `redirect`,
-#: `lease_*` and `request_failed`, all of which can arrive after HY persisted
-#: a node, so a prefix rule would requeue work that may already exist.
+#: The retired request hook denied every name below before opening a socket.
+#: This stays explicit: other old transport codes may have happened after HY
+#: persisted a node and must not be blindly requeued.
_PRE_SEND_REFUSALS = (
"model_content_transport_headers_mismatch",
"model_content_transport_body_mismatch",
diff --git a/memhub/apps/core/src/one_memory/model_content_routes.py b/memhub/apps/core/src/one_memory/model_content_routes.py
deleted file mode 100644
index a4adeae..0000000
--- a/memhub/apps/core/src/one_memory/model_content_routes.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""One global final-state permission for remote model content egress."""
-
-import asyncio
-from collections.abc import Sequence
-from typing import Annotated, cast
-
-from fastapi import APIRouter, Request
-from fastapi.params import Depends
-from pydantic import BaseModel, ConfigDict, StrictBool
-
-from one_memory.memory_server.config_state import ConfigStore
-
-
-class ModelContentPermissionState(BaseModel):
- model_config = ConfigDict(frozen=True, extra="forbid")
-
- allowed: bool
-
-
-class SetModelContentPermission(BaseModel):
- model_config = ConfigDict(extra="forbid")
-
- allowed: StrictBool
-
-
-def model_content_router(dependencies: Sequence[Depends]) -> APIRouter:
- router = APIRouter(prefix="/api/v1/model-content-permission", dependencies=dependencies)
-
- def config(request: Request) -> ConfigStore:
- return cast(ConfigStore, request.app.state.config_store)
-
- Config = Annotated[ConfigStore, Depends(config)]
-
- @router.get("", response_model=ModelContentPermissionState)
- async def current(store: Config) -> ModelContentPermissionState:
- return ModelContentPermissionState(allowed=store.model_content_allowed())
-
- @router.post("", response_model=ModelContentPermissionState)
- async def set_permission(
- request: Request,
- value: SetModelContentPermission,
- store: Config,
- ) -> ModelContentPermissionState:
- await asyncio.to_thread(store.set_model_content_allowed, value.allowed)
- if value.allowed:
- request.app.state.start_memory_backfill()
- return ModelContentPermissionState(allowed=value.allowed)
-
- return router
diff --git a/memhub/apps/core/src/one_memory/model_processing_permission_guard.py b/memhub/apps/core/src/one_memory/model_processing_permission_guard.py
deleted file mode 100644
index 5b06bf1..0000000
--- a/memhub/apps/core/src/one_memory/model_processing_permission_guard.py
+++ /dev/null
@@ -1,180 +0,0 @@
-"""Live global permission checks for remote model-content egress.
-
-``config.json`` is the only durable permission state. Tasks do not carry a
-permission receipt, revision, source scope, or model pin. Every remote send
-and every publication boundary comes back through the lease and reads the
-current final-state boolean.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Iterator
-from contextlib import contextmanager
-from dataclasses import dataclass, field
-from datetime import UTC, datetime
-
-from one_memory.hy_source_context import HySourceContext
-from one_memory.imports.models import EvidenceRecord
-from one_memory.memory_governance import MemoryGovernanceStore, MemoryStatus
-from one_memory.memory_server.config_state import ConfigStore
-from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus
-from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
-
-
-def permission_error(code: str) -> RuntimeError:
- from one_memory.hy_memory_bridge import HyMemoryBridgeError
-
- return HyMemoryBridgeError(
- code if code.startswith("model_content_") else f"model_content_{code}"
- )
-
-
-def error_is_policy_stop(code: str) -> bool:
- """A disabled final-state permission is an intentional stop."""
-
- return code in {
- "model_content_source_disabled",
- "hy_memory_evidence_changed",
- }
-
-
-@dataclass(frozen=True)
-class ModelProcessingPermissionLease:
- task_id: str
- user_id: str
- profile: ModelProfileSnapshot
- records: tuple[EvidenceRecord, ...]
- source_context: HySourceContext | None
- _guard: ModelProcessingPermissionGuard = field(repr=False, compare=False)
-
- def recheck(self) -> None:
- """Read current config/profile immediately before an egress boundary."""
-
- self.recheck_permission()
- self._guard.check_context(self.records, self.source_context)
-
- def recheck_permission(self) -> None:
- self._guard.check(self.profile, self.records)
-
-
-class ModelProcessingPermissionGuard:
- def __init__(
- self,
- config: ConfigStore,
- governance: MemoryGovernanceStore,
- profiles: ModelProfileStore,
- ) -> None:
- self.config = config
- self.governance = governance
- self.profiles = profiles
-
- def require_allowed(self) -> None:
- if not self.config.model_content_allowed():
- raise permission_error("permission_required")
-
- def check(
- self,
- profile: ModelProfileSnapshot,
- records: tuple[EvidenceRecord, ...] = (),
- ) -> None:
- """Validate only live global consent and the currently active model."""
-
- self.require_allowed()
- if self.profiles.active_identity() != (profile.profile_id, profile.revision):
- raise permission_error("profile_changed")
- if records and any(record.user_id != records[0].user_id for record in records):
- raise permission_error("context_scope_invalid")
-
- def lease(
- self,
- task: MemoryTask,
- records: tuple[EvidenceRecord, ...],
- context: HySourceContext | None,
- ) -> ModelProcessingPermissionLease:
- combined = self.context_records(records, context)
- lease = ModelProcessingPermissionLease(
- task_id=task.task_id,
- user_id=task.user_id,
- profile=task.profile_snapshot,
- records=combined,
- source_context=context,
- _guard=self,
- )
- lease.recheck()
- return lease
-
- @staticmethod
- def context_records(
- records: tuple[EvidenceRecord, ...],
- context: HySourceContext | None,
- ) -> tuple[EvidenceRecord, ...]:
- by_id = {record.evidence_id: record for record in records}
- if len(by_id) != len(records):
- raise permission_error("context_scope_invalid")
- if context is not None:
- for record in (*context.current, *context.history, *context.supporting):
- previous = by_id.get(record.evidence_id)
- if previous is not None and previous != record:
- raise permission_error("evidence_changed")
- by_id[record.evidence_id] = record
- return tuple(by_id.values())
-
- def check_context(
- self,
- records: tuple[EvidenceRecord, ...],
- context: HySourceContext | None,
- ) -> None:
- if context is None:
- return
- ids = {record.evidence_id for record in records}
- if not context.current:
- raise permission_error("context_scope_invalid")
- context_records = (*context.current, *context.history, *context.supporting)
- if len({record.evidence_id for record in context_records}) != len(context_records):
- raise permission_error("context_scope_invalid")
- for known in context.known_memories:
- if not known.evidence_ids or not set(known.evidence_ids) <= ids:
- raise permission_error("context_support_missing")
- memory = self.governance.get(
- known.governed_memory_id,
- user_id=context.current[0].user_id,
- )
- if (
- memory is None
- or memory.revision != known.revision
- or memory.content != known.content
- or set(memory.evidence_ids) != set(known.evidence_ids)
- or memory.status is not MemoryStatus.CONFIRMED
- ):
- raise permission_error("context_changed")
-
- def reject_unstarted(self, task_id: str, code: str) -> None:
- stopped = error_is_policy_stop(code)
- with self._task_write() as tasks:
- tasks.execute(
- "UPDATE memory_tasks SET status=?, last_error_code=?, updated_at=? "
- "WHERE task_id=? AND status IN (?,?,?,?)",
- (
- MemoryTaskStatus.CANCELLED if stopped else MemoryTaskStatus.FAILED,
- str(permission_error(code)),
- datetime.now(UTC).isoformat(),
- task_id,
- MemoryTaskStatus.PENDING,
- MemoryTaskStatus.WAITING_FOR_PERMISSION,
- MemoryTaskStatus.WAITING_FOR_MODEL,
- MemoryTaskStatus.DEFERRED_OUTSIDE_WINDOW,
- ),
- )
-
- @contextmanager
- def _task_write(self) -> Iterator[object]:
- from one_memory.database import take_the_write_lock_first
- from one_memory.database_access import main_database
-
- with main_database(self.governance.database_path).write(
- autocommit=True,
- foreign_keys=True,
- ) as connection:
- take_the_write_lock_first(connection)
- with connection:
- yield connection
diff --git a/memhub/apps/core/src/one_memory/processing_coordinator.py b/memhub/apps/core/src/one_memory/processing_coordinator.py
deleted file mode 100644
index 72ff170..0000000
--- a/memhub/apps/core/src/one_memory/processing_coordinator.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""Continuous recovery monitor for model processing.
-
-The monitor is intentionally retained. It observes the global config final
-state plus ``memory_tasks`` and asks the supervisor to recover work; it owns no
-second request/receipt state machine.
-"""
-
-import asyncio
-import time
-from collections.abc import Hashable
-
-from one_memory.memory_processing import MemoryProcessingSupervisor
-from one_memory.memory_server.config_state import ConfigStore
-
-
-class ProcessingCoordinator:
- RESCAN_SECONDS = 60.0
- POLL_SECONDS = 2.0
-
- def __init__(self, config: ConfigStore, supervisor: MemoryProcessingSupervisor):
- self.config = config
- self.supervisor = supervisor
- self._stopping = False
- self._monitor: asyncio.Task[None] | None = None
- self._error: str | None = None
- self._observed: tuple[Hashable, ...] | None = None
- self._last_scan = 0.0
- self._clock = time.monotonic
-
- async def recover(self) -> None:
- """Start the one process-owned monitor, whether permission is on or off."""
-
- if self._stopping:
- return
- if self._monitor is None or self._monitor.done():
- self._monitor = asyncio.create_task(
- self._monitor_loop(),
- name="one-memory-processing-monitor",
- )
-
- def _identity(self) -> tuple[Hashable, ...]:
- counts = self.supervisor.tasks.counts()
- return (
- self.config.model_content_allowed(),
- tuple(sorted((str(status), count) for status, count in counts.items())),
- )
-
- async def _tick(self) -> None:
- identity = await asyncio.to_thread(self._identity)
- now = self._clock()
- if identity[0] and (
- identity != self._observed or now - self._last_scan >= self.RESCAN_SECONDS
- ):
- await self.supervisor.resume_allowed()
- self._last_scan = now
- identity = await asyncio.to_thread(self._identity)
- self._observed = identity
- self._error = None
-
- async def _monitor_loop(self) -> None:
- while not self._stopping:
- try:
- await self._tick()
- except asyncio.CancelledError:
- raise
- except Exception:
- self._error = "model_content_recovery_blocked"
- await asyncio.sleep(self.POLL_SECONDS)
-
- def status(self) -> dict[str, object]:
- return {
- "monitoring": self._monitor is not None and not self._monitor.done(),
- "allowed": self._observed[0] if self._observed else None,
- "error": self._error,
- }
-
- async def shutdown(self) -> None:
- self._stopping = True
- if self._monitor is not None:
- self._monitor.cancel()
- await asyncio.gather(self._monitor, return_exceptions=True)
diff --git a/memhub/apps/core/tests/TEST_INDEX.md b/memhub/apps/core/tests/TEST_INDEX.md
index 9f03083..f157574 100644
--- a/memhub/apps/core/tests/TEST_INDEX.md
+++ b/memhub/apps/core/tests/TEST_INDEX.md
@@ -13,10 +13,7 @@
| `test_archive_extract.py` | `contract` | Unpacking an archive a stranger sent, onto this user's disk. |
| `test_ask_workspace_path.py` | `contract` | Ask must honour the same workspace_path expansion recall uses. |
| `test_async_bridge.py` | `contract` | 覆盖 reader rejects parallel workers that could close during next、cancelling a read stops the worker before the batch finishes、starting a read does not stop the app answering 等 10 个场景。 |
-| `test_async_dispatch_checks.py` | `contract` | 覆盖 cancelling quiet timer does not start any permission reader、dispatch check does not queue work after shutdown、dispatch permission check denies before queue after classification 等 6 个场景。 |
| `test_auth.py` | `integration` | 覆盖 local api requires bearer token、browser origin is checked in addition to token 场景。 |
-| `test_backfill_acceptance_lifecycle.py` | `contract` | A processing acknowledgement belongs to one consent revision and live worker. |
-| `test_backfill_conversation_isolation.py` | `contract` | One stale conversation cannot invalidate an entire backfill scan. |
| `test_background_work.py` | `contract` | 覆盖 a request does not wait behind a catch up、bulk work is kept to a few threads at a time、the pool outlives one hand off 等 4 个场景。 |
| `test_backup_destroy_restore_reconciliation.py` | `contract` | Backup -> destroy -> restore, reconciled row for row. |
| `test_backup_endpoint.py` | `integration` | The 'POST /api/v1/backup' endpoint the CLI's 'backup create' calls. |
@@ -64,7 +61,6 @@
| `test_cli_lifecycle.py` | `integration` | 覆盖 cli starts and stops without residual runtime 场景。 |
| `test_cli_restore.py` | `contract` | 覆盖 restore requires a source、restore refuses while the runtime is running、restore previews without confirmation 等 4 个场景。 |
| `test_codex_header_provenance.py` | `contract` | 覆盖 header workspace versions and native ids reach file origin、new adapter rereads header after a real checkpoint、machine and unknown session sources do not establish human or agent sender 等 14 个场景。 |
-| `test_compact_model_content.py` | `contract` | Server-held previews preserve exact grants without round-tripping all refs. |
| `test_connection_lifetime.py` | `integration` | Every SQLite connection a request opens must be closed when the request ends. |
| `test_context_access.py` | `contract` | 覆盖 memory only never reads raw authority、raw permission does not grant memory and removes revoked sources、raw requires all apps and source 等 13 个场景。 |
| `test_context_api.py` | `integration` | 覆盖 no model local query returns real raw and governed items、existing single lane authorization never runs denied reader、authentication and origin remain required 等 14 个场景。 |
@@ -76,12 +72,6 @@
| `test_context_search.py` | `contract` | 覆盖 authorization and requested paths never invoke other lane、empty partial zero unavailable and safe failures、parallel common deadline and finalizers wait for both reads 等 24 个场景。 |
| `test_context_search_both_paths.py` | `integration` | The objective names 'recall' and requires BOTH paths: raw and memory. |
| `test_context_v2_contract_parity.py` | `unit` | Mechanical parity checks for the shared v2 coverage receipt contract. |
-| `test_continuous_completed_input_review.py` | `contract` | Independent review of completed-input reuse; real stores and controlled HY only. |
-| `test_continuous_coordinator.py` | `contract` | 覆盖 activation hook and startup recovery start single monitor then stop、failed preparation does not advance observed watermark、new journal event and source regrant wake replay without legacy request 等 6 个场景。 |
-| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 a timed out continuous task becomes unknown not pending、a timed out non continuous task also becomes unknown、an unsettled continuous send becomes unknown not failed 等 8 个场景。 |
-| `test_continuous_policy.py` | `contract` | 覆盖 backlog snapshot over one hundred thousand is compact、caller rollback includes policy and backlog、enable idempotence rejects changed payload and superseded operation 等 19 个场景。 |
-| `test_continuous_processing_bridge.py` | `contract` | 覆盖 bound source regrant refuses old task、continuous inactive never falls back to legacy、continuous prepare lease ingest has no legacy receipt or old context 等 17 个场景。 |
-| `test_continuous_processing_integration.py` | `contract` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. |
| `test_contract_parity.py` | `contract` | Python 的枚举和 TypeScript 里那份手写的联合类型必须一致。 |
| `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. |
| `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 |
@@ -97,7 +87,6 @@
| `test_database_contention.py` | `contract` | Writers meet in this one file, and the loser used to lose data. |
| `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 |
| `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. |
-| `test_delete_no_conversation_freeze.py` | `contract` | 删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。 |
| `test_delivery_board.py` | `contract` | 覆盖 parses only the marked structured record、verified requires evidence with a positive control、rejects unknown dependency 等 5 个场景。 |
| `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. |
| `test_detailed_logging.py` | `contract` | 覆盖 detailed pipeline logging defaults off and survives restart、live log level applies to full hy and provider call chain、info collects hy info without enabling debug pipeline content 等 7 个场景。 |
@@ -111,7 +100,6 @@
| `test_dual_engine_helper.py` | `contract` | Tests for the dual-engine harness itself. |
| `test_event_loop_blocking.py` | `contract` | Proof, as a test, that blocking work on the loop freezes it — and the fix works. |
| `test_evidence_reconcile_primitive.py` | `integration` | A bounded, read-only discovery primitive for the H23 Evidence reconcile. |
-| `test_evidence_revision_read_review.py` | `contract` | Read invalidation review: real guard/provenance, controlled HY, no model/network. |
| `test_evidence_store.py` | `contract` | 覆盖 same source message merges origins without duplication、identical text from different sources is not deduplicated、rescanning an unchanged file does not write the message again 等 26 个场景。 |
| `test_export_probe.py` | `contract` | What a probe may say about a stranger's chat history, and what it may not. |
| `test_external_endpoint_scopes.py` | `integration` | Core boundary tests using the broker's envelope, never a host's claimed identity. |
@@ -125,10 +113,9 @@
| `test_file_import_operations.py` | `integration` | Bounded synthetic uploads; operation identity is not a content dedup key. |
| `test_file_import_operations_review.py` | `contract` | Upload refusal/legacy compatibility review with bounded synthetic files only. |
| `test_file_publish_job_boundary.py` | `contract` | Staged uploads cannot widen an earlier directory import's uncaptured input. |
-| `test_four_app_live_ingest.py` | `integration` | Actual source readers + OS watch + Core routes; no model or native host calls. |
+| `test_four_app_live_ingest.py` | `contract` | Actual source readers + OS watch + Core routes; no model or native host calls. |
| `test_frozen_core_layout.py` | `contract` | 覆盖 single file layout remains available without private preset、folder layout keeps same core and all native dependencies、unknown layout does not silently build another package 等 5 个场景。 |
| `test_golden_corpus.py` | `contract` | A7-01: the golden corpus comparison. |
-| `test_governance_authority_review.py` | `contract` | D04 authority and workspace scope through the real continuous permission guard. |
| `test_governance_permission_commit.py` | `contract` | 覆盖 permission guard is rechecked inside write transaction and rolls back、guard context does not leak into subsequent manual actions、guard check and commit hold database write authority 场景。 |
| `test_governance_read_path_does_not_take_the_writer.py` | `contract` | The recall read path must not acquire the single global writer. |
| `test_governance_store_root.py` | `contract` | 生产代码里治理 store 只在 Core 启动的组合根建一次。 |
@@ -144,32 +131,19 @@
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
| `test_hy_baseline_source.py` | `contract` | 覆盖 first import replay copies raw prefix without selecting message ids、frozen transcript keeps middle user message and excludes later records 场景。 |
-| `test_hy_coding_lease.py` | `contract` | HY's own coding/working route under the permission lease, on the pinned SDK. |
| `test_hy_coding_reply.py` | `contract` | R-07: HY's coding-shaped replies read as "no memories at all". |
-| `test_hy_lease_responsiveness.py` | `contract` | Blocked lease checks, local failing doubles only; no network or credentials. |
-| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. Pinned SDK, scripted HTTP. |
| `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. |
| `test_hy_memory_bridge.py` | `contract` | 覆盖 hy client initialization is serial across bridges、native hy clients share one runtime per data directory、ollama profile ignores ambient remote storage and models 等 41 个场景。 |
| `test_hy_metrics_lifecycle.py` | `contract` | What the pinned public SDK's MetricsCollector does, and what that costs us. |
-| `test_hy_native_write_chain.py` | `contract` | HY writes with its own prompts and judgment; the product adds no audit of its own. |
| `test_hy_network_egress.py` | `contract` | Where a real HY client is allowed to send bytes, and where it is not. |
-| `test_hy_permission_context.py` | `contract` | Whole native HY client with synthetic content and HTTP MockTransport, never cloud. |
-| `test_hy_permission_context_races.py` | `contract` | Bounded factory races with a temporary native HY client and no model calls. |
-| `test_hy_permission_raw_context.py` | `contract` | Real pinned HY SDK/Chroma and guards; scripted HTTP is not model acceptance. |
-| `test_hy_permission_store.py` | `contract` | Native HY node/algorithm tests with synthetic data and an in-memory backend only. |
-| `test_hy_permission_transport.py` | `contract` | Native HY entry points, synthetic inputs and MockTransport only. |
| `test_hy_response_guard.py` | `contract` | 覆盖 invalid response retries same hy provider without changing prompt、repeated empty response fails instead of becoming no op、failure receipt survives a later successful model call 等 23 个场景。 |
| `test_hy_response_shapes.py` | `contract` | HY response shape selection only; no provider, write routing or model calls. |
| `test_hy_runtime_config.py` | `contract` | MEMORY.md is the audited record of what we send HY; keep it answerable. |
| `test_hy_sdk_idempotency_probe.py` | `contract` | A0-03: probe whether the HY SDK can make a write idempotent. |
| `test_hy_source_context.py` | `contract` | 覆盖 bound context rejects a same named conversation in another workspace、bound context rejects a duplicated evidence id、bound context rejects a same conversation from another source 等 9 个场景。 |
| `test_hy_stage_trace.py` | `contract` | 覆盖 observer returns native results without editing or requesting models、observer redacts content without recording prompts or exception details 场景。 |
-| `test_hy_state_source.py` | `contract` | RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。 |
-| `test_hy_step_timing.py` | `contract` | Where an HY write spends the time that is not a model call. |
| `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. |
-| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. Pinned SDK, scripted HTTP. |
| `test_hy_trial_budget.py` | `contract` | Offline proofs for the real-model acceptance budget (no network, no Keychain). |
-| `test_hy_turn_input.py` | `contract` | HY gets one turn at a time, exactly as it happened, and picks its own route. |
| `test_hy_writeback.py` | `integration` | HY 的结果只有一扇门进治理表,而且每次进门都带着调用开始时的证据快照。 |
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
@@ -181,7 +155,6 @@
| `test_import_three_scenarios.py` | `contract` | The three import scenarios a user actually meets. |
| `test_import_three_scenarios_real.py` | `contract` | The three import scenarios, on a real Core at a real volume. |
| `test_imported_index_background.py` | `contract` | The ordering index is built off the startup path. |
-| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、backfill budget yields between rounds and resumes same cursor、backfill holds an unanswered raw turn until its reply arrives 等 18 个场景。 |
| `test_ingest_journal.py` | `contract` | 覆盖 baseline and empty initialize are explicit transactional and idempotent、old date new import and revise noop and rollbacks、import metadata changes invalidate without authorizing content 等 12 个场景。 |
| `test_input_sanitization.py` | `unit` | 覆盖 redacts cli and assignment secrets without removing context、redacts bearer known tokens urls and private keys、redacts quoted json assignments and basic authorization 等 8 个场景。 |
| `test_installer.py` | `contract` | 覆盖 installer create verify and rollback is transactional、installer updates owned file and restores backup、installer refuses unowned or changed target 场景。 |
@@ -209,7 +182,6 @@
| `test_main_database_fairness.py` | `contract` | Waiting for the one database has to be a queue, not a scramble. |
| `test_main_database_isolation.py` | `contract` | What one checkout of the main database can do, and what it leaves for the next. |
| `test_managed_restore.py` | `contract` | 覆盖 restore rejects a source outside the managed backup directory、restore rejects a symlinked source、restore rejects a non one memory database 等 9 个场景。 |
-| `test_manual_memory_via_hy.py` | `contract` | T04: what the person explicitly asks to remember is written through HY's import path. |
| `test_manual_transcript_import.py` | `integration` | 覆盖 manual json keeps authors unknown time and exact text、marked text keeps code fences and never invents timestamps、ambiguous partial or invalid manual data never leaves partial import 等 4 个场景。 |
| `test_matter_collection.py` | `integration` | 覆盖 hy windows cover every turn including leading and boundary assistants、oversize turn is split without silently dropping content、partial timestamps preserve source sequence 等 8 个场景。 |
| `test_matter_discovery.py` | `contract` | 覆盖 synthesis materializes only known verbatim citations、duplicate claims keep all governed refs and distinct states、synthesis cannot echo the original sixty claim list 等 27 个场景。 |
@@ -267,30 +239,18 @@
| `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. |
| `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. |
| `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. |
-| `test_model_content_bulk.py` | `contract` | Bulk consent reads preserve exact identities and SQLite snapshot boundaries. |
-| `test_model_content_parse_cost.py` | `contract` | Memoize parsing only; every permission decision still reads current SQLite state. |
-| `test_model_content_permissions.py` | `contract` | Content permission tests use only a temporary database and synthetic sources. |
-| `test_model_content_preview_races.py` | `contract` | Confirmation must tolerate append-only import without broadening consent. |
-| `test_model_content_routes.py` | `integration` | 覆盖 permission route reads and writes only config final state、core composition starts with config permission and durable drain 场景。 |
-| `test_model_content_scope_seals.py` | `contract` | Indexed scope invalidation retains complete-snapshot semantics without rereading it. |
-| `test_model_content_snapshot_cost.py` | `contract` | Reuse derived fingerprints only after reading every raw row in this transaction. |
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
-| `test_model_policy_activation.py` | `contract` | 覆盖 activation helper is transactional and restores row factory、backlog scan does not hold the single writer、cloud credential is checked without keychain write 等 21 个场景。 |
-| `test_model_policy_routes.py` | `contract` | 覆盖 all routes require auth、backlog is required not defaulted、enable and transition safe shape and callback once after commit 等 10 个场景。 |
| `test_model_probe.py` | `contract` | 覆盖 probe still measures embedding when structured output fails、probe records the real embedding width on the profile、openai compatible probe checks model and structured output 等 23 个场景。 |
| `test_model_probe_input_revision.py` | `contract` | 覆盖 input revision precedes measured width update、concurrent update still rejects width or capability cas、early returns record the actual starting revision 等 6 个场景。 |
| `test_model_probe_operations.py` | `integration` | Persisted probe identity, real SQLite/API and synthetic transport only. |
-| `test_model_processing_permissions.py` | `contract` | 覆盖 lease reads current allowed before each boundary、lease refuses a task profile that is no longer active 场景。 |
| `test_model_profile_api.py` | `integration` | 覆盖 model profile api never returns key and enforces revision、model profile probe route returns only machine results、model profile validation never echoes api key 等 4 个场景。 |
| `test_model_profile_context_reset.py` | `integration` | 覆盖 create context default and inclusive bounds、explicit null clears override and preserves history and secret、omitted or same value is noop with unchanged revision and metadata 等 11 个场景。 |
| `test_model_profile_identity.py` | `contract` | Stable model editing and configured-vs-available credentials; no Keychain/network. |
| `test_model_profile_queue_handover.py` | `contract` | Queued work when the model changes or its configuration is deleted (D75). |
| `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 |
| `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 |
-| `test_model_request_budget_integration.py` | `contract` | Real HY leaf retries plus durable budget accounting; no network or provider. |
-| `test_model_scope_lifecycle.py` | `contract` | Bound derived/temporary data without changing consent or original history. |
| `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. |
| `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. |
| `test_obs03_health_probe.py` | `contract` | OBS-03: '/health' answered with the web app instead of a health payload. |
@@ -299,14 +259,11 @@
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
-| `test_policy_operation_lookup.py` | `contract` | 覆盖 corrupt current row cannot make history or missing operation look safe、current operation uses safe receipt without callback or writes、lookup connection rejects writes and preserves database 等 12 个场景。 |
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
| `test_primary_source_adapters.py` | `contract` | 覆盖 pipeline json is host generated even when truncated、workbuddy record parsing never blocks the event loop、cursor store user info envelope is not attributed to the person 等 34 个场景。 |
| `test_probe_code_contract_parity.py` | `contract` | 探测码在三处必须同时存在:python 枚举、TS 契约、界面文案表。 |
| `test_probe_payment_required.py` | `contract` | 覆盖 http 402 without a useful body is still understood as unpaid、a wrong address is still a missing model not an unpaid one 场景。 |
-| `test_processing_coordinator.py` | `contract` | 覆盖 monitor is retained and observes config plus tasks 场景。 |
-| `test_processing_merged_context.py` | `contract` | 覆盖 new chunk omits known memory with changed support but keeps original history 场景。 |
| `test_processing_requests.py` | `contract` | 覆盖 concurrent claim only one snapshot wins、concurrent create same grant has one id and profile、concurrent different profile never reuses another scope 等 14 个场景。 |
| `test_quality_acceptance.py` | `contract` | 覆盖 acceptance pack is deterministic and scores personal boundary、acceptance score fails missing personal memory 场景。 |
| `test_raw_search_without_model.py` | `integration` | Search imported conversations with no generative model configured at all. |
@@ -375,8 +332,6 @@
| `test_session_inbox.py` | `contract` | A session inbox is not a shared mailbox and a pull is not adoption. |
| `test_session_lease.py` | `contract` | A session file on disk is not proof that the session is online. |
| `test_session_registry.py` | `contract` | Trusted session identity is not a workspace path. |
-| `test_settings_policy_lifecycle.py` | `contract` | Settings data-plane lifecycle: real temporary Core, fake probe/HY, no UI or package. |
-| `test_simplified_model_permission_migrations.py` | `contract` | 覆盖 fresh schema omits removed permission state machines 场景。 |
| `test_snapshot_adapter.py` | `contract` | Selection-only binding reopens checksum-verified frozen copies. |
| `test_snapshot_pool.py` | `contract` | A whole-directory snapshot must not hold a thread everything else is waiting for. |
| `test_soak_probe_effectiveness.py` | `integration` | The soak probe must observe the load it reports on. |
@@ -392,7 +347,7 @@
| `test_source_parsing.py` | `contract` | 覆盖 reminders are removed only at message edges、original supported outer context and query envelopes、attributed reminder is opaque before finding the outer query 等 11 个场景。 |
| `test_source_permission_races.py` | `integration` | Permission execution regressions using temporary stores and Core ASGI instances. |
| `test_source_query_preview.py` | `contract` | 覆盖 preserves exact user text and span、supported outer wrapper is stripped without paraphrasing、cursor host prefixes are not shown as the user query 等 8 个场景。 |
-| `test_source_read_revocation.py` | `integration` | 覆盖 source revocation stops every outward read but keeps other sources、revocation during model wait prevents late answer 场景。 |
+| `test_source_read_revocation.py` | `contract` | 覆盖 revocation during model wait prevents late answer、source revocation stops every outward read but keeps other sources 场景。 |
| `test_source_readers.py` | `contract` | 覆盖 jsonl is resumable and does not advance over an incomplete tail、sqlite reader uses monotonic row checkpoint、zstd jsonl reader resumes by completed record 等 7 个场景。 |
| `test_source_registry_and_grants.py` | `contract` | 覆盖 catalog resolves defaults and marks environment overrides、recall hosts that can append join the live sync poller、declarations with no reader are named rather than mistaken for behaviour 等 15 个场景。 |
| `test_source_revocation_parking.py` | `integration` | Revoking a source parks its unfinished extraction instead of failing it (D73). |
@@ -402,7 +357,7 @@
| `test_source_sync_idempotent.py` | `integration` | Final-state Harness sync is idempotent and resumes missed source work. |
| `test_source_sync_permissions.py` | `integration` | S1.3 source permission tests use temporary files only, never real host history. |
| `test_source_totals.py` | `contract` | 覆盖 the first ask is answered from the rows、same named sessions in two workspaces count as two conversations、insert during count is not marked as already counted 等 7 个场景。 |
-| `test_source_upload_intents.py` | `integration` | Upload identity binds retries without replacing file receipts or directory consent. |
+| `test_source_upload_intents.py` | `contract` | Upload identity binds retries without replacing file receipts or directory consent. |
| `test_source_write_switch.py` | `contract` | A frozen Harness produces no extraction tasks. |
| `test_sql_compat.py` | `contract` | The shared compatibility layer, especially 'rowcount'. |
| `test_sqlite_semantics_gate.py` | `contract` | D56: prevent SQLite implementation details spreading outside the data layer. |
@@ -411,13 +366,10 @@
| `test_store_binding.py` | `contract` | A2: which store is in force, and honest reporting of it. |
| `test_store_binding_ratchet.py` | `contract` | D56: ratchet the retired dual-engine 'store_for' seam out of the Core. |
| `test_store_port.py` | `contract` | A1-04/A2: the store port, and that real implementations satisfy it. |
-| `test_subagent_sessions.py` | `contract` | D98: a sub agent's transcript is its own conversation, not part of the main one. |
-| `test_subagent_turns.py` | `contract` | D98: a sub agent's turn is ordinary user-assistant data. |
| `test_subscription_visibility.py` | `unit` | A shared directory is not a read grant. |
| `test_sync_facade.py` | `contract` | A2: the sync facade must keep the sync shape call sites already use. |
| `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. |
| `test_terminal_payload_reaping.py` | `contract` | Rule 14: dead-end payloads are actually reaped, not just reapable. |
-| `test_tool_evidence_hy_turns.py` | `contract` | Every adapter's tool records reach HY inside the turn they happened in. |
| `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. |
| `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 |
| `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 |
@@ -429,8 +381,8 @@
| `test_windows_process_control.py` | `contract` | R-29:Windows 上 'os.kill' 不是发信号,是无条件杀进程。 |
| `test_workbuddy_boundary.py` | `contract` | WorkBuddy only injects on SessionStart and UserPromptSubmit. |
| `test_workbuddy_parsing.py` | `contract` | 覆盖 automation preserves query without claiming fresh human authorship、automation context attributes do not depend on order or quote style、quoted full automation envelope is not scheduled provenance 等 17 个场景。 |
-| `test_workbuddy_prompt_backfill.py` | `integration` | When the prompt hook's capture fails, the transcript file is what brings the words in. |
+| `test_workbuddy_prompt_backfill.py` | `contract` | When the prompt hook's capture fails, the transcript file is what brings the words in. |
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
-共 428 个测试文件。
+共 380 个测试文件。
diff --git a/memhub/apps/core/tests/core_test_classification.py b/memhub/apps/core/tests/core_test_classification.py
index 53a6a11..e2272ad 100644
--- a/memhub/apps/core/tests/core_test_classification.py
+++ b/memhub/apps/core/tests/core_test_classification.py
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
-EXPECTED_TEST_FILE_COUNT = 428
-EXPECTED_TEST_FILES_SHA256 = "bd3bf4fc9cda2a8e4427d106283c40d22dad026fd452db954046b6f28d21b896"
+EXPECTED_TEST_FILE_COUNT = 380
+EXPECTED_TEST_FILES_SHA256 = "9f075d18403328882c25325d29ebe77d0e3365d675ba19d56134a6a0fb9b64b4"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -68,7 +68,6 @@ INTEGRATION_FILES = frozenset(
"test_external_installation_permissions.py",
"test_fault_matrix.py",
"test_file_import_operations.py",
- "test_four_app_live_ingest.py",
"test_graph_width_endpoint.py",
"test_home_summary.py",
"test_hy_writeback.py",
@@ -100,7 +99,6 @@ INTEGRATION_FILES = frozenset(
"test_memory_supersede.py",
"test_memory_visibility.py",
"test_memory_write_routes.py",
- "test_model_content_routes.py",
"test_model_creation_api.py",
"test_model_creation_validation_boundary.py",
"test_model_probe_operations.py",
@@ -124,17 +122,14 @@ INTEGRATION_FILES = frozenset(
"test_source_connection_intents.py",
"test_source_import_receipts.py",
"test_source_permission_races.py",
- "test_source_read_revocation.py",
"test_source_revocation_parking.py",
"test_source_session_count_api.py",
"test_source_status_latency.py",
"test_source_sync_idempotent.py",
"test_source_sync_permissions.py",
- "test_source_upload_intents.py",
"test_ui_session_api.py",
"test_unified_write_entry_wiring.py",
"test_visibility_background.py",
- "test_workbuddy_prompt_backfill.py",
}
)
diff --git a/memhub/apps/core/tests/source_consent.py b/memhub/apps/core/tests/source_consent.py
index 1b647f0..1e6b138 100644
--- a/memhub/apps/core/tests/source_consent.py
+++ b/memhub/apps/core/tests/source_consent.py
@@ -26,67 +26,6 @@ async def thaw_source(client, source_id, receipt):
return response.json()
-async def confirm_model_content(
- client,
- *,
- source_ids,
- evidence_ids=(),
- all_imported=None,
- future_additions=True,
- process=True,
-):
- """Grant model processing the same way the product does: preview, then confirm.
-
- Live suites need this because importing Evidence is not consent to send it
- to a model; without it the processing guard refuses, which is the correct
- product behaviour and not something a test may bypass by writing the store.
- """
- profiles = await client.get("/api/v1/model-profiles")
- profiles.raise_for_status()
- active = next(item for item in profiles.json() if item["is_active"])
- preview = await client.post(
- "/api/v1/model-content-permission/preview",
- json={
- "profile_id": active["profile_id"],
- "profile_revision": active["revision"],
- "source_ids": list(source_ids),
- "evidence_ids": list(evidence_ids),
- # The manual source only accepts named originals, so "everything
- # from these sources" is not a default the caller can ignore.
- "all_imported_from_selected_sources": (
- not evidence_ids if all_imported is None else all_imported
- ),
- "include_future_additions": future_additions,
- },
- )
- # Report the refusal code, not just the status: every content refusal is a
- # named product decision and a live run that hides it cannot be diagnosed.
- assert preview.status_code == 200, preview.text
- current = await client.get("/api/v1/model-content-permission")
- current.raise_for_status()
- existing = current.json()
- confirmed = await client.post(
- "/api/v1/model-content-permission",
- json={
- "snapshot": preview.json(),
- "expected_revision": existing["revision"] if existing else 0,
- "user_confirmed": True,
- },
- )
- confirmed.raise_for_status()
- receipt = confirmed.json()
- if process:
- # What the product does after the user confirms: take the already
- # imported Evidence through the pipeline instead of waiting for the
- # next import to trigger a debounce.
- accepted = await client.post(
- "/api/v1/model-content-permission/process",
- json={"expected_revision": receipt["revision"], "user_confirmed": True},
- )
- accepted.raise_for_status()
- return receipt
-
-
async def connection_request(client, source_ids, **options):
roots = {}
for source_id in source_ids:
diff --git a/memhub/apps/core/tests/test_architecture_layer_gate.py b/memhub/apps/core/tests/test_architecture_layer_gate.py
index 001da44..95e5745 100644
--- a/memhub/apps/core/tests/test_architecture_layer_gate.py
+++ b/memhub/apps/core/tests/test_architecture_layer_gate.py
@@ -65,12 +65,7 @@ HY_BRIDGE = f"{PACKAGE_LABEL}/hy_memory_bridge.py"
SDK_CALLER = HY_BRIDGE
#: Type-only importers of HY datatypes; they hold no client and write nothing.
-HY_TYPE_ONLY = frozenset(
- {
- f"{PACKAGE_LABEL}/hy_permission_store.py",
- f"{PACKAGE_LABEL}/hy_permission_coding_store.py",
- }
-)
+HY_TYPE_ONLY: frozenset[str] = frozenset()
def _relative(path: Path) -> str:
diff --git a/memhub/apps/core/tests/test_async_dispatch_checks.py b/memhub/apps/core/tests/test_async_dispatch_checks.py
deleted file mode 100644
index 998ce9d..0000000
--- a/memhub/apps/core/tests/test_async_dispatch_checks.py
+++ /dev/null
@@ -1,248 +0,0 @@
-import asyncio
-import threading
-
-import pytest
-
-from one_memory.model_profiles import ModelProfilePatch
-from tests.test_model_processing_permissions import Harness
-
-
-def test_quiet_arrival_guard_is_off_loop_and_5000_events_coalesce(tmp_path, monkeypatch):
- h = Harness(tmp_path)
- supervisor = h.supervisor()
- entered, release = threading.Event(), threading.Event()
- checked = []
- original = supervisor._guard_records
-
- async def scenario():
- loop_thread = threading.get_ident()
-
- def guard(records, *args, **kwargs):
- checked.append(threading.get_ident())
- assert threading.get_ident() != loop_thread, "arrival parsed permission on the loop"
- entered.set()
- assert release.wait(5)
- return original(records, *args, **kwargs)
-
- def credentials(*_args, **_kwargs):
- pytest.fail("denied preparation reached credential/profile resolution")
-
- monkeypatch.setattr(supervisor, "_guard_records", guard)
- monkeypatch.setattr(h.profiles, "active", credentials)
- monkeypatch.setattr(h.profiles, "resolve_snapshot_secret", credentials)
- try:
- for _ in range(5000):
- supervisor.enqueue_evidence(h.record)
- assert checked == []
- assert len(supervisor._debounces) == 1
- assert await asyncio.to_thread(entered.wait, 3)
- # This callback must run while the actual permission reader is blocked.
- responsive = asyncio.Event()
- asyncio.get_running_loop().call_soon(responsive.set)
- await asyncio.wait_for(responsive.wait(), 0.5)
- assert checked == [checked[0]]
- release.set()
- await asyncio.gather(*tuple(supervisor._debounces.values()))
- assert supervisor._preparations == {}
- assert h.tasks.list() == []
- assert len(supervisor._queue) == 0 and supervisor._workers == {}
- assert h.factory_calls == [] and h.bridge.calls == []
- finally:
- release.set()
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("stop_mode", ["cancel", "shutdown"])
-def test_live_preparation_cancel_joins_thread_without_late_dispatch(
- tmp_path, monkeypatch, stop_mode
-):
- h = Harness(tmp_path)
- h.grant()
- supervisor = h.supervisor()
- entered, release = threading.Event(), threading.Event()
- original = supervisor._prepare_conversation_work
- completed = []
-
- def blocked(seed, **kwargs):
- entered.set()
- assert release.wait(5)
- result = original(seed, **kwargs)
- completed.append(True)
- return result
-
- monkeypatch.setattr(supervisor, "_prepare_conversation_work", blocked)
-
- async def scenario():
- stopping = None
- preparation = None
- try:
- supervisor.enqueue_evidence(h.record)
- assert await asyncio.to_thread(entered.wait, 3)
- preparation = next(iter(supervisor._preparations.values()))
- if stop_mode == "cancel":
- preparation.cancel()
- await asyncio.sleep(0.02)
- preparation.cancel()
- else:
- stopping = asyncio.create_task(supervisor.shutdown())
- await asyncio.sleep(0.02)
- assert not preparation.done(), "cancel detached the actual preparation thread"
- if stopping is not None:
- assert not stopping.done()
- finally:
- release.set()
- if preparation is not None:
- await asyncio.gather(preparation, return_exceptions=True)
- if stopping is not None:
- await stopping
- await supervisor.shutdown()
- assert completed == [True]
- assert supervisor._preparations == {} and supervisor._debounces == {}
- assert len(supervisor._queue) == 0 and supervisor._workers == {}
- assert h.factory_calls == [] and h.bridge.calls == []
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize(
- "change", ["model-revoked", "source-revoked", "profile-changed", "regranted-other-input"]
-)
-def test_quiet_preparation_rechecks_permission_before_credentials_or_durable_work(
- tmp_path, monkeypatch, change
-):
- h = Harness(tmp_path)
- receipt = h.grant()
- supervisor = h.supervisor()
- entered, release = threading.Event(), threading.Event()
- original = supervisor._prepare_conversation_work
- guarded = []
- original_guard = supervisor._guard_records
-
- def blocked(seed, **kwargs):
- entered.set()
- assert release.wait(5)
- return original(seed, **kwargs)
-
- def guard(*args, **kwargs):
- guarded.append(True)
- return original_guard(*args, **kwargs)
-
- def credentials(*_args, **_kwargs):
- pytest.fail("denied preparation reached credential/profile resolution")
-
- monkeypatch.setattr(supervisor, "_prepare_conversation_work", blocked)
- monkeypatch.setattr(supervisor, "_guard_records", guard)
-
- async def scenario():
- try:
- supervisor.enqueue_evidence(h.record)
- assert await asyncio.to_thread(entered.wait, 3)
- if change in {"model-revoked", "regranted-other-input"}:
- h.permissions.revoke(
- user_id="local", expected_revision=receipt.revision, user_confirmed=True
- )
- if change == "regranted-other-input":
- h.grant((h.put("other", conversation="other"),))
- elif change == "source-revoked":
- h.sources.revoke(user_id="local", source_id="cursor")
- else:
- h.profiles.update(
- h.profile.profile_id,
- ModelProfilePatch(expected_revision=h.profile.revision, model="changed"),
- h.secrets,
- )
- monkeypatch.setattr(h.profiles, "active", credentials)
- monkeypatch.setattr(h.profiles, "resolve_snapshot_secret", credentials)
- release.set()
- await asyncio.gather(*tuple(supervisor._debounces.values()))
- assert guarded == [True]
- assert h.tasks.list() == []
- assert h.factory_calls == [] and h.bridge.calls == []
- assert len(supervisor._queue) == 0 and supervisor._workers == {}
- finally:
- release.set()
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_cancelling_quiet_timer_does_not_start_any_permission_reader(tmp_path, monkeypatch):
- h = Harness(tmp_path)
- h.grant()
- supervisor = h.supervisor()
- checks = []
- original = supervisor._guard_records
-
- def guard(*args, **kwargs):
- checks.append(True)
- return original(*args, **kwargs)
-
- monkeypatch.setattr(supervisor, "_guard_records", guard)
-
- async def scenario():
- try:
- supervisor.enqueue_evidence(h.record)
- timer = next(iter(supervisor._debounces.values()))
- await asyncio.sleep(0)
- timer.cancel()
- await asyncio.gather(timer, return_exceptions=True)
- assert checks == []
- assert supervisor._debounces == {} and supervisor._preparations == {}
- assert h.tasks.list() == [] and h.factory_calls == [] and h.bridge.calls == []
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_dispatch_permission_check_denies_before_queue_after_classification(tmp_path, monkeypatch):
- h = Harness(tmp_path)
- h.grant()
- supervisor = h.supervisor()
- work = supervisor._classify(supervisor.prepare_conversation(h.record))
- checked = []
-
- def reject(task):
- checked.append(task.task_id)
- return False
-
- async def scenario():
- monkeypatch.setattr(supervisor, "_guard_task", reject)
- try:
- supervisor._schedule_tasks(work)
- assert len(checked) == 1
- assert len(supervisor._queue) == 0 and supervisor._workers == {}
- assert h.factory_calls == []
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_dispatch_check_does_not_queue_work_after_shutdown(tmp_path, monkeypatch):
- h = Harness(tmp_path)
- h.grant()
- supervisor = h.supervisor()
- work = supervisor._classify(supervisor.prepare_conversation(h.record))
- checked = []
-
- def allowed(task):
- checked.append(task.task_id)
- return True
-
- monkeypatch.setattr(supervisor, "_guard_task", allowed)
-
- async def scenario():
- stopping = asyncio.create_task(supervisor.shutdown())
- try:
- await stopping
- supervisor._schedule_tasks(work)
- finally:
- await supervisor.shutdown()
- assert len(supervisor._queue) == 0 and supervisor._workers == {}
- assert checked == []
- assert h.factory_calls == []
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_backfill_acceptance_lifecycle.py b/memhub/apps/core/tests/test_backfill_acceptance_lifecycle.py
deleted file mode 100644
index 970dfdd..0000000
--- a/memhub/apps/core/tests/test_backfill_acceptance_lifecycle.py
+++ /dev/null
@@ -1,244 +0,0 @@
-"""A processing acknowledgement belongs to one consent revision and live worker."""
-
-import asyncio
-
-import pytest
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from tests.test_model_processing_permissions import Harness
-
-
-def test_changed_consent_does_not_acknowledge_an_older_running_request(tmp_path, monkeypatch):
- async def scenario():
- h = Harness(tmp_path)
- first = h.grant()
- supervisor = h.supervisor()
- started = asyncio.Event()
- release = asyncio.Event()
- calls = []
-
- async def backfill(*, expected_permission_revision=None, on_first_scheduled=None):
- calls.append(expected_permission_revision)
- if on_first_scheduled is not None:
- on_first_scheduled()
- started.set()
- await release.wait()
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", backfill)
- try:
- await asyncio.wait_for(
- supervisor.accept_backfill_off_thread(expected_permission_revision=first.revision),
- timeout=5,
- )
- await asyncio.wait_for(started.wait(), timeout=5)
- second = h.grant()
- await asyncio.wait_for(
- supervisor.accept_backfill_off_thread(expected_permission_revision=second.revision),
- timeout=5,
- )
- started.clear()
- await asyncio.wait_for(started.wait(), timeout=5)
- assert calls == [first.revision, second.revision]
- finally:
- release.set()
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_late_failure_is_observed_in_processing_status(tmp_path, monkeypatch):
- async def scenario():
- h = Harness(tmp_path)
- receipt = h.grant()
- supervisor = h.supervisor()
- release = asyncio.Event()
-
- async def backfill(*, expected_permission_revision=None, on_first_scheduled=None):
- if on_first_scheduled is not None:
- on_first_scheduled()
- await release.wait()
- raise HyMemoryBridgeError("model_content_source_permission_changed")
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", backfill)
- try:
- await supervisor.accept_backfill_off_thread(
- expected_permission_revision=receipt.revision
- )
- assert supervisor.accepted_backfill_status() == ("running", None)
- release.set()
- await supervisor._live_backfill
- assert supervisor.accepted_backfill_status() == (
- "failed",
- "model_content_source_permission_changed",
- )
- finally:
- release.set()
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_same_consent_concurrent_requests_share_one_worker(tmp_path, monkeypatch):
- async def scenario():
- h = Harness(tmp_path)
- receipt = h.grant()
- supervisor = h.supervisor()
- entered, acknowledge, finish = asyncio.Event(), asyncio.Event(), asyncio.Event()
- calls = []
-
- async def backfill(*, expected_permission_revision=None, on_first_scheduled=None):
- calls.append(expected_permission_revision)
- entered.set()
- await acknowledge.wait()
- if on_first_scheduled is not None:
- on_first_scheduled()
- await finish.wait()
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", backfill)
- first = asyncio.create_task(
- supervisor.accept_backfill_off_thread(expected_permission_revision=receipt.revision)
- )
- try:
- await entered.wait()
- second = asyncio.create_task(
- supervisor.accept_backfill_off_thread(expected_permission_revision=receipt.revision)
- )
- await asyncio.sleep(0)
- acknowledge.set()
- await asyncio.wait_for(asyncio.gather(first, second), 2)
- assert calls == [receipt.revision]
- finally:
- acknowledge.set()
- finish.set()
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_background_failure_is_reported_after_acceptance_and_can_retry(tmp_path, monkeypatch):
- async def scenario():
- h = Harness(tmp_path)
- receipt = h.grant()
- supervisor = h.supervisor()
-
- async def broken(**_kwargs):
- raise RuntimeError("secret or provider body must not be returned")
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", broken)
- try:
- await supervisor.accept_backfill_off_thread(
- expected_permission_revision=receipt.revision
- )
- await supervisor._live_backfill
- assert supervisor.accepted_backfill_status() == (
- "failed",
- "model_content_processing_unavailable",
- )
-
- async def complete(**kwargs):
- callback = kwargs.get("on_first_scheduled")
- if callback is not None:
- callback()
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", complete)
- await supervisor.accept_backfill_off_thread(
- expected_permission_revision=receipt.revision
- )
- await supervisor._live_backfill
- assert supervisor.accepted_backfill_status() == ("done", None)
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_shutdown_rejects_new_processing_acceptance(tmp_path):
- async def scenario():
- h = Harness(tmp_path)
- receipt = h.grant()
- supervisor = h.supervisor()
- await supervisor.shutdown()
- with pytest.raises(HyMemoryBridgeError, match="model_content_processing_unavailable"):
- await supervisor.accept_backfill_off_thread(
- expected_permission_revision=receipt.revision
- )
- assert h.tasks.list() == []
- assert h.factory_calls == []
-
- asyncio.run(scenario())
-
-
-def test_health_reports_late_explicit_processing_failure(tmp_path, monkeypatch):
- from httpx import ASGITransport, AsyncClient
-
- from one_memory.api import create_app
- from one_memory.config import Settings
-
- monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
-
- async def scenario():
- app = create_app(Settings(_env_file=None, data_dir=tmp_path, imports_enabled=False))
- release = asyncio.Event()
- async with app.router.lifespan_context(app):
- await app.state.memory_backfill
- supervisor = app.state.memory_processing_supervisor
- supervisor._permission_guard = None
-
- async def failed_later(**kwargs):
- callback = kwargs.get("on_first_scheduled")
- if callback is not None:
- callback()
- await release.wait()
- raise HyMemoryBridgeError("model_content_source_permission_changed")
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", failed_later)
- try:
- await supervisor.accept_backfill_off_thread()
- async with AsyncClient(
- transport=ASGITransport(app=app), base_url="http://test"
- ) as client:
- running = (await client.get("/api/v1/health")).json()
- assert running["memory_backfill"] == "running"
- release.set()
- await supervisor._live_backfill
- failed = (await client.get("/api/v1/health")).json()
- assert failed["memory_backfill"] == "failed"
- assert (
- failed["memory_backfill_error"] == "model_content_source_permission_changed"
- )
- finally:
- release.set()
-
- asyncio.run(scenario())
-
-
-def test_completed_caller_does_not_cancel_accepted_background_work(tmp_path, monkeypatch):
- async def scenario():
- h = Harness(tmp_path)
- receipt = h.grant()
- supervisor = h.supervisor()
- entered, release = asyncio.Event(), asyncio.Event()
-
- async def backfill(**kwargs):
- entered.set()
- await release.wait()
- callback = kwargs.get("on_first_scheduled")
- if callback is not None:
- callback()
-
- monkeypatch.setattr(supervisor, "backfill_off_thread", backfill)
- caller = asyncio.create_task(
- supervisor.accept_backfill_off_thread(expected_permission_revision=receipt.revision)
- )
- try:
- await asyncio.wait_for(caller, timeout=2)
- assert not supervisor._live_backfill.done()
- await asyncio.wait_for(entered.wait(), timeout=5)
- release.set()
- await supervisor._live_backfill
- assert supervisor.accepted_backfill_status() == ("done", None)
- finally:
- release.set()
- await supervisor.shutdown()
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_backfill_conversation_isolation.py b/memhub/apps/core/tests/test_backfill_conversation_isolation.py
deleted file mode 100644
index d5cf9ef..0000000
--- a/memhub/apps/core/tests/test_backfill_conversation_isolation.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""One stale conversation cannot invalidate an entire backfill scan."""
-
-import asyncio
-from contextlib import suppress
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.imports.models import EvidenceInput
-from tests.test_continuous_processing_integration import IntegrationCore
-
-
-def test_one_changed_conversation_does_not_block_other_conversations(tmp_path, monkeypatch):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running(automatic_memory_processing=False):
- monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
- core.grant_source()
- core.put_complete("conversation-good", conversation="good")
- changed = core.put_complete("conversation-changed", conversation="changed")
- await core.save_and_probe()
- core.kernel_enable("include")
-
- evidence_store = core.app.state.evidence_store
- changed_input = EvidenceInput.model_validate(
- {
- **changed.model_dump(include=set(EvidenceInput.model_fields)),
- "verbatim": changed.verbatim + " edited",
- }
- )
- evidence_store.put(changed_input)
- assert evidence_store.get(changed.evidence_id).revision > changed.revision
-
- # Production reaches completed-input reuse with the live chunk when
- # an earlier task already covered it; that reuse rechecks the
- # stored input against the row now in Evidence. Model the observed
- # stale-row refusal at the exact boundary that raises in production.
- guard = core.app.state.memory_processing_supervisor._permission_guard
- original_completed_lookup = guard.completed_input_task
-
- def refuse_changed_conversation(receipt, records, **kwargs):
- if any(record.conversation_id == "changed" for record in records):
- raise HyMemoryBridgeError("model_content_evidence_changed")
- return original_completed_lookup(receipt, records, **kwargs)
-
- monkeypatch.setattr(guard, "completed_input_task", refuse_changed_conversation)
-
- supervisor = core.app.state.memory_processing_supervisor
- with suppress(Exception):
- await supervisor.backfill_off_thread()
-
- await core.wait_completed(timeout=10, minimum_tasks=1)
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py b/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py
index e5fc42b..e9121b2 100644
--- a/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py
+++ b/memhub/apps/core/tests/test_c1_preview_scope_diagnostic.py
@@ -1,9 +1,8 @@
"""Diagnostic: why does an all-imported preview see zero Evidence?
-The C0 integration red asserts a preview snapshot has two Evidence rows after a
-real WorkBuddy import. Reproduces the exact query path from
-`ModelContentPermissionStore.snapshot_from_ids` and reports what the import
-actually wrote, so the owner can be named from evidence instead of inference.
+The C0 integration red asserted a preview snapshot had two Evidence rows after
+a real WorkBuddy import. This keeps the useful import-path diagnostic after
+the retired model-content preview layer was removed.
"""
import asyncio
diff --git a/memhub/apps/core/tests/test_compact_model_content.py b/memhub/apps/core/tests/test_compact_model_content.py
deleted file mode 100644
index 277442c..0000000
--- a/memhub/apps/core/tests/test_compact_model_content.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""Server-held previews preserve exact grants without round-tripping all refs."""
-
-import json
-import sqlite3
-
-import pytest
-
-from one_memory.model_content_permissions import (
- ModelContentPermissionError,
- ModelContentPermissionStore,
-)
-from tests.test_model_content_bulk import many_records
-from tests.test_model_content_permissions import USER, evidence_value
-from tests.test_model_content_permissions import env as env
-
-
-def test_compact_preview_grants_exact_scope_and_survives_restart(env):
- records = many_records(env, 1000)
- snapshot = env.snapshot(records=tuple(records), future=True)
- result = env.permissions.retain_preview(snapshot)
- assert result["evidence"] == [] and result["evidence_count"] == 1000
- assert len(json.dumps(result)) < 5000
- assert env.permissions.receipt(user_id=USER) is None
- restarted = ModelContentPermissionStore(env.database)
- receipt = restarted.grant_preview(
- result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- assert receipt.snapshot == snapshot
- assert env.check(receipt) == receipt
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- restarted.grant_preview(
- result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
-
-
-@pytest.mark.parametrize(
- "change", ["edit", "revoke-source", "grant-race", "expiry", "wrong-owner", "confirmation"]
-)
-def test_compact_preview_does_not_bypass_live_checks(env, change):
- result = env.permissions.retain_preview(env.snapshot(future=True))
- if change == "edit":
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence SET verbatim='changed raw original'")
- elif change == "revoke-source":
- env.sources.revoke(user_id=USER, source_id=env.record.source_id)
- elif change == "grant-race":
- env.grant()
- elif change == "expiry":
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_content_previews SET expires_at='2000-01-01T00:00:00+00:00'")
- with pytest.raises(ModelContentPermissionError):
- env.permissions.grant_preview(
- result["preview_id"],
- user_id="other" if change == "wrong-owner" else USER,
- expected_revision=0,
- user_confirmed=change != "confirmation",
- )
-
-
-def test_new_originals_keep_explicit_future_scope_but_not_exact_scope(env):
- result = env.permissions.retain_preview(env.snapshot(future=True))
- later = env.evidence.put(evidence_value("new-arrival")).record
- receipt = env.permissions.grant_preview(
- result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- assert len(receipt.snapshot.evidence) == 1
- assert env.check(receipt, records=(later,)) == receipt
-
-
-def test_compact_receipt_is_metadata_only_and_version_bound(env):
- result = env.permissions.retain_preview(env.snapshot())
- receipt = env.permissions.grant_preview(
- result["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- compact = env.permissions.compact_receipt(receipt)
- assert compact["revision"] == receipt.revision
- assert compact["snapshot"]["snapshot_hash"] == result["snapshot_hash"]
- assert compact["snapshot"]["source_counts"] == {env.record.source_id: 1}
- assert env.record.verbatim not in json.dumps(compact)
- assert compact["snapshot"]["evidence"] == []
-
-
-@pytest.mark.parametrize("future", [False, True])
-def test_preview_cannot_rebase_over_a_grant_or_revoke_between_reads(env, future):
- first = env.grant(future=future)
- preview = env.snapshot(future=future)
- env.permissions.revoke(user_id=USER, expected_revision=first.revision, user_confirmed=True)
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- env.permissions.retain_preview(preview)
- assert env.permissions.receipt(user_id=USER).revoked_at is not None
-
-
-def test_shared_migration_declares_the_retained_preview_table() -> None:
- """Retained previews are product state; migration 28 must recreate them.
-
- This is a red-state guard for the A1-04 schema extraction. The table exists
- in the historical f6d624a schema and is still read/written by this module,
- so migration 28's generator must declare it for both SQLite and MySQL.
- """
- from one_memory.memory_server import migrations
-
- sqlite_schema = migrations.model_content_permissions_ddl("sqlite")
- mysql_schema = migrations.model_content_permissions_ddl("mysql")
- assert "model_content_previews" in sqlite_schema
- assert "model_content_previews" in mysql_schema
diff --git a/memhub/apps/core/tests/test_connection_lifetime.py b/memhub/apps/core/tests/test_connection_lifetime.py
index c993052..1356920 100644
--- a/memhub/apps/core/tests/test_connection_lifetime.py
+++ b/memhub/apps/core/tests/test_connection_lifetime.py
@@ -12,15 +12,12 @@ import asyncio
import gc
import sqlite3
from contextlib import contextmanager
-from types import SimpleNamespace
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
-from one_memory.database_access import close_main_databases, main_database_stats
-from one_memory.hy_permission_context import _NativeProofs
from one_memory.imports.sources.registry import build_source_catalog
@@ -81,24 +78,3 @@ def test_polled_endpoints_close_every_connection_they_open(tmp_path, monkeypatch
assert left_open == 0, f"{left_open} connections left for the collector"
asyncio.run(scenario())
-
-
-def test_hy_permission_proofs_close_their_connections(tmp_path):
- database = tmp_path / "one-memory.sqlite3"
- with sqlite3.connect(database) as db:
- db.execute("CREATE TABLE governed_memories (memory_id TEXT, revision INT, status TEXT)")
- db.close()
- proofs = _NativeProofs(database)
- lease = SimpleNamespace(records=())
- # The first read opens the pooled reader; it is meant to stay open.
- proofs.guard(proofs.load(lease))()
-
- with _without_cyclic_collection():
- before = _open_connections()
- for _ in range(5):
- check = proofs.guard(proofs.load(lease))
- check()
- left_open = _open_connections() - before
- assert left_open == 0, f"{left_open} connections left for the collector"
- close_main_databases()
- assert main_database_stats()["open_connections"] == 0
diff --git a/memhub/apps/core/tests/test_continuous_completed_input_review.py b/memhub/apps/core/tests/test_continuous_completed_input_review.py
deleted file mode 100644
index 61c5777..0000000
--- a/memhub/apps/core/tests/test_continuous_completed_input_review.py
+++ /dev/null
@@ -1,341 +0,0 @@
-"""Independent review of completed-input reuse; real stores and controlled HY only."""
-
-import asyncio
-import sqlite3
-from contextlib import closing
-from uuid import uuid4
-
-import pytest
-
-from one_memory import continuous_policy
-from one_memory.continuous_permission_bridge import ContinuousPermissionReceipt
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.imports.models import EvidenceInput
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.memory_processing import MEMORY_INGEST_POLICY_VERSION
-from one_memory.memory_tasks import MemoryTaskStatus
-from tests.test_continuous_processing_integration import IntegrationCore
-
-
-def transition(core, action):
- with closing(sqlite3.connect(core.database)) as db, db:
- db.execute("BEGIN IMMEDIATE")
- policy = continuous_policy.current(db)
- return continuous_policy.transition(
- db,
- action=action,
- expected_revision=policy.revision,
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
-
-
-def bindings(core):
- with closing(sqlite3.connect(core.database)) as db:
- return db.execute(
- "SELECT task_id,policy_revision,policy_epoch,receipt_json "
- "FROM memory_task_continuous_permissions ORDER BY task_id"
- ).fetchall()
-
-
-async def completed(core):
- core.grant_source()
- await core.save_and_probe()
- core.kernel_enable("none")
- record = core.put_complete("review-completed", conversation="review")
- tasks = await core.run_prepared(record)
- assert len(tasks) == 1 and tasks[0].status == MemoryTaskStatus.COMPLETED
- assert len(core.bridge.requests) == 1
- return record, tasks[0]
-
-
-def frozen(core, records):
- guard = core.app.state.memory_processing_supervisor._permission_guard
- receipt = guard.freeze(guard.current("local"), records)
- assert isinstance(receipt, ContinuousPermissionReceipt)
- return guard, receipt
-
-
-def lookup(guard, receipt, records, seed):
- return guard.completed_input_task(
- receipt,
- records,
- input_ref=seed.evidence_id,
- request_prefix=f"conversation:{MEMORY_INGEST_POLICY_VERSION}:{seed.source_id}:",
- )
-
-
-def test_resume_reuses_completed_without_rebinding_historical_receipt(tmp_path, monkeypatch):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- record, task = await completed(core)
- before = bindings(core)
- transition(core, "pause")
- resumed = transition(core, "resume")
- supervisor = core.app.state.memory_processing_supervisor
- profile = core.app.state.model_profile_store.active(core.secrets)
-
- def cannot_rebind(*args, **kwargs):
- pytest.fail("completed history was rebound to a new execution receipt")
-
- with monkeypatch.context() as patch:
- patch.setattr(supervisor._permission_guard, "bind", cannot_rebind)
- reused = supervisor._enqueue_chunk(core.turn_records(record), profile)
- assert reused.task_id == task.task_id
- assert reused.status == MemoryTaskStatus.COMPLETED
- assert resumed.revision == 3 and before[0][1] == 1
- assert bindings(core) == before
- assert supervisor._classify([reused]) == []
- assert len(core.bridge.requests) == 1
- assert len(core.app.state.memory_task_store.list()) == 1
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("change", ["chunk", "event-revision", "profile"])
-def test_changed_input_identity_is_not_reused_as_completed(tmp_path, monkeypatch, change):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- record, task = await completed(core)
- before = bindings(core)
- records = core.turn_records(record)
- if change == "chunk":
- other = core.put("review-added", conversation="review")
- # Same input_ref deliberately: input_ref alone cannot prove chunk identity.
- records = (other, *records)
- elif change == "event-revision":
- changed = EvidenceInput.model_validate(
- {
- **record.model_dump(include=set(EvidenceInput.model_fields)),
- "verbatim": record.verbatim + " changed",
- }
- )
- record = core.app.state.evidence_store.put(changed).record
- assert record.revision > 1
- records = (record, *records[1:])
- elif change == "profile":
- transition(core, "revoke")
- # Persist a new profile through the real API; probe remains controlled.
- saved = await core.client.post(
- "/api/v1/model-profiles",
- json={
- "name": "review-second-profile",
- "provider_kind": "openai_compatible",
- "base_url": "https://integration.invalid/v1",
- "model": "integration-model",
- "api_key": "synthetic-key-not-a-credential",
- "embedding_model": "integration-vector",
- "embedding_provider_kind": "openai_compatible",
- "embedding_base_url": "https://integration.invalid/v1",
- },
- )
- assert saved.status_code == 200
- result = await core.client.post(
- f"/api/v1/model-profiles/{saved.json()['profile_id']}/probe"
- )
- assert result.status_code == 200
- core.profile = next(
- p
- for p in (await core.client.get("/api/v1/model-profiles")).json()
- if p["profile_id"] == saved.json()["profile_id"]
- )
- current = core.policy_current()
- core.app.state.model_policy_activation.enable(
- operation_id=str(uuid4()),
- profile_id=core.profile["profile_id"],
- profile_revision=core.profile["revision"],
- expected_policy_revision=current.revision,
- expected_legacy_revision=current.legacy_revision,
- backlog_mode="include",
- user_confirmed=True,
- )
- guard, receipt = frozen(core, records)
- assert lookup(guard, receipt, records, record) is None
- assert bindings(core) == before
- assert (
- core.app.state.memory_task_store.require(task.task_id).status
- == MemoryTaskStatus.COMPLETED
- )
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
-
-
-def test_changed_source_root_is_not_reused_as_completed(tmp_path, monkeypatch):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- record, task = await completed(core)
- before = bindings(core)
- grants = core.app.state.source_grant_store
- grants.revoke(user_id="local", source_id="cursor")
- replacement = core.home / "cursor-replacement"
- replacement.mkdir()
- grants.grant(
- user_id="local",
- source_id="cursor",
- root=replacement,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- records = core.turn_records(record)
- guard, receipt = frozen(core, records)
-
- assert lookup(guard, receipt, records, record) is None
- assert bindings(core) == before
- assert (
- core.app.state.memory_task_store.require(task.task_id).status
- == MemoryTaskStatus.COMPLETED
- )
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
-
-
-def test_reenabled_policy_reuses_exact_completed_input_without_duplicate_hy_call(
- tmp_path, monkeypatch
-):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- record, task = await completed(core)
- before = bindings(core)
- transition(core, "revoke")
- prior = core.policy_current()
- core.app.state.model_policy_activation.enable(
- operation_id=str(uuid4()),
- profile_id=core.profile["profile_id"],
- profile_revision=core.profile["revision"],
- expected_policy_revision=prior.revision,
- expected_legacy_revision=prior.legacy_revision,
- backlog_mode="include",
- user_confirmed=True,
- )
- assert core.policy_current().epoch != before[0][2]
-
- supervisor = core.app.state.memory_processing_supervisor
- profile = core.app.state.model_profile_store.active(core.secrets)
- reused = supervisor._enqueue_chunk(core.turn_records(record), profile)
-
- assert reused.task_id == task.task_id
- assert reused.status == MemoryTaskStatus.COMPLETED
- assert bindings(core) == before
- assert len(core.bridge.requests) == 1
- assert len(core.app.state.memory_task_store.list()) == 1
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("when", ["before", "after-first-check"])
-@pytest.mark.parametrize("authority", ["source", "policy"])
-def test_current_source_permission_is_rechecked_around_completed_lookup(
- tmp_path, monkeypatch, when, authority
-):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- record, _ = await completed(core)
- before = bindings(core)
- records = core.turn_records(record)
- guard, receipt = frozen(core, records)
-
- def revoke():
- if authority == "source":
- core.app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
- else:
- transition(core, "pause")
-
- calls = []
- original = guard.check
-
- def checked(*args, **kwargs):
- value = original(*args, **kwargs)
- calls.append(True)
- if len(calls) == 1:
- revoke()
- return value
-
- if when == "before":
- revoke()
- else:
- monkeypatch.setattr(guard, "check", checked)
- with pytest.raises(HyMemoryBridgeError):
- lookup(guard, receipt, records, record)
- assert bindings(core) == before
- assert len(core.bridge.requests) == 1
- if when == "after-first-check":
- assert calls == [True]
-
- asyncio.run(scenario())
-
-
-def test_corrupt_historical_binding_is_not_accepted_as_completion(tmp_path, monkeypatch):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- record, task = await completed(core)
- with sqlite3.connect(core.database) as db:
- db.execute(
- "UPDATE memory_task_continuous_permissions "
- "SET policy_revision=99 WHERE task_id=?",
- (task.task_id,),
- )
- records = core.turn_records(record)
- guard, receipt = frozen(core, records)
- with pytest.raises(HyMemoryBridgeError, match="continuous_binding_invalid"):
- lookup(guard, receipt, records, record)
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
-
-
-def test_sdk_admitted_unknown_is_not_reused_or_resent_on_resume(tmp_path, monkeypatch):
- core = IntegrationCore(tmp_path, monkeypatch)
-
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- core.kernel_enable()
- record = core.put_complete("review-unknown")
- supervisor = core.app.state.memory_processing_supervisor
- tasks = supervisor.prepare_conversation(record)
- entered = asyncio.Event()
-
- async def interrupted(request):
- core.bridge.requests.append(request)
- entered.set()
- await asyncio.Event().wait()
-
- monkeypatch.setattr(core.bridge, "ingest", interrupted)
- running = asyncio.create_task(supervisor._create_runner().run(tasks[0].task_id))
- try:
- await asyncio.wait_for(entered.wait(), 3)
- finally:
- running.cancel()
- with pytest.raises(asyncio.CancelledError):
- await running
- # Actual restart recovery marks a previously admitted RUNNING task unknown.
- core.app.state.memory_task_store.recover_running()
- before = bindings(core)
- transition(core, "pause")
- transition(core, "resume")
- with pytest.raises(HyMemoryBridgeError, match="outcome_unknown"):
- supervisor._enqueue_chunk(
- core.turn_records(record),
- core.app.state.model_profile_store.active(core.secrets),
- )
- assert bindings(core) == before
- assert len(core.bridge.requests) == 1
- task = core.app.state.memory_task_store.require(tasks[0].task_id)
- assert task.last_error_code == "model_content_outcome_unknown" and task.attempts == 1
- assert len(core.app.state.memory_task_store.list()) == 1
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_continuous_coordinator.py b/memhub/apps/core/tests/test_continuous_coordinator.py
deleted file mode 100644
index a7eff42..0000000
--- a/memhub/apps/core/tests/test_continuous_coordinator.py
+++ /dev/null
@@ -1,190 +0,0 @@
-import asyncio
-
-import pytest
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.processing_coordinator import ProcessingCoordinator
-from one_memory.processing_requests import ProcessingRequestStore
-from tests.test_continuous_processing_bridge import enable, put_after_policy, transition
-from tests.test_model_processing_permissions import h as h # noqa: F401
-
-
-def coordinator(h, monkeypatch):
- supervisor = h.supervisor()
- scheduled = []
-
- async def schedule(work):
- scheduled.extend(work)
-
- monkeypatch.setattr(supervisor, "_schedule_tasks_off_thread", schedule)
- return ProcessingCoordinator(ProcessingRequestStore(h.database), supervisor), scheduled
-
-
-def test_restart_replays_lost_callback_into_same_durable_task_queue(h, monkeypatch):
- policy = enable(h)
- fresh = put_after_policy(h, "committed-before-crash")
- owner, scheduled = coordinator(h, monkeypatch)
-
- async def run():
- try:
- await owner._continuous_tick()
- assert scheduled
- task_ids = {task.task_id for task in h.tasks.list()}
- assert len(task_ids) == 1
- assert {
- ref["evidence_id"]
- for task in h.tasks.list()
- for ref in h.tasks.inputs(task.task_id)
- } == {fresh.evidence_id}
- assert owner.continuous_status()["observed_seq"] > policy.after_seq
- await owner._continuous_tick()
- assert {task.task_id for task in h.tasks.list()} == task_ids
- restarted, _ = coordinator(h, monkeypatch)
- try:
- await restarted._continuous_tick()
- assert {task.task_id for task in h.tasks.list()} == task_ids
- finally:
- await restarted.shutdown()
- await restarted.supervisor.shutdown()
- finally:
- await owner.shutdown()
- await owner.supervisor.shutdown()
-
- asyncio.run(run())
-
-
-def test_new_journal_event_and_source_regrant_wake_replay_without_legacy_request(h, monkeypatch):
- from one_memory.imports.sources.registry import RootCandidateKind
-
- enable(h)
- owner, scheduled = coordinator(h, monkeypatch)
-
- async def run():
- try:
- await owner._continuous_tick()
- assert not scheduled
- fresh = put_after_policy(h, "new")
- h.sources.revoke(user_id="local", source_id="cursor")
- await owner._continuous_tick()
- assert not scheduled
- h.sources.grant(
- user_id="local",
- source_id="cursor",
- root=h.root / "synthetic-source",
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- await owner._continuous_tick()
- assert scheduled
- assert h.tasks.inputs(scheduled[-1].task_id)[0]["evidence_id"] == fresh.evidence_id
- with h.evidence._connect() as db:
- assert db.execute("SELECT count(*) FROM processing_requests").fetchone()[0] == 0
- finally:
- await owner.shutdown()
- await owner.supervisor.shutdown()
-
- asyncio.run(run())
-
-
-def test_activation_hook_and_startup_recovery_start_single_monitor_then_stop(h, monkeypatch):
- policy = enable(h)
- put_after_policy(h, "new")
- owner, scheduled = coordinator(h, monkeypatch)
-
- async def run():
- try:
- await owner.recover()
- worker = owner._continuous_worker
- assert worker is not None
- await owner.resume_continuous(expected_policy_revision=policy.revision)
- assert owner._continuous_worker is worker
- for _ in range(100):
- if scheduled:
- break
- await asyncio.sleep(0.01)
- assert scheduled
- finally:
- await owner.shutdown()
- await owner.supervisor.shutdown()
- assert worker.done()
- with pytest.raises(HyMemoryBridgeError):
- await owner.resume_continuous(expected_policy_revision=policy.revision)
-
- asyncio.run(run())
-
-
-def test_pause_refuses_recovery_without_fallback_and_resume_replays(h, monkeypatch):
- h.grant()
- enable(h)
- fresh = put_after_policy(h, "new")
- owner, scheduled = coordinator(h, monkeypatch)
- transition(h, "pause")
-
- async def run():
- try:
- await owner.recover()
- assert not scheduled and owner._continuous_worker is None
- with pytest.raises(HyMemoryBridgeError):
- await owner.resume_continuous()
- transition(h, "resume")
- await owner._continuous_tick()
- assert scheduled
- assert h.tasks.inputs(scheduled[-1].task_id)[0]["evidence_id"] == fresh.evidence_id
- finally:
- await owner.shutdown()
- await owner.supervisor.shutdown()
-
- asyncio.run(run())
-
-
-def test_failed_preparation_does_not_advance_observed_watermark(h, monkeypatch):
- enable(h)
- put_after_policy(h, "new")
- owner, _ = coordinator(h, monkeypatch)
- original = owner.supervisor.resume_continuous
-
- async def fail(**kwargs):
- raise RuntimeError("synthetic failed preparation")
-
- monkeypatch.setattr(owner.supervisor, "resume_continuous", fail)
-
- async def run():
- try:
- with pytest.raises(RuntimeError):
- await owner._continuous_tick()
- assert owner.continuous_status()["observed_seq"] is None
- monkeypatch.setattr(owner.supervisor, "resume_continuous", original)
- await owner._continuous_tick()
- assert owner.continuous_status()["observed_seq"] is not None
- finally:
- await owner.shutdown()
- await owner.supervisor.shutdown()
-
- asyncio.run(run())
-
-
-def test_unchanged_continuous_identity_is_rescanned_every_sixty_seconds(h, monkeypatch):
- enable(h)
- owner, _scheduled = coordinator(h, monkeypatch)
- calls = []
- now = 100.0
-
- async def resume(*, expected_policy_revision=None):
- calls.append(expected_policy_revision)
-
- monkeypatch.setattr(owner.supervisor, "resume_continuous", resume)
- owner._clock = lambda: now
-
- async def run():
- nonlocal now
- try:
- await owner._continuous_tick()
- now = 159.9
- await owner._continuous_tick()
- now = 160.0
- await owner._continuous_tick()
- assert calls == [1, 1]
- finally:
- await owner.shutdown()
- await owner.supervisor.shutdown()
-
- asyncio.run(run())
diff --git a/memhub/apps/core/tests/test_continuous_outcome_unknown.py b/memhub/apps/core/tests/test_continuous_outcome_unknown.py
deleted file mode 100644
index 594b8a3..0000000
--- a/memhub/apps/core/tests/test_continuous_outcome_unknown.py
+++ /dev/null
@@ -1,176 +0,0 @@
-import asyncio
-
-import pytest
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.memory_tasks import MemoryTaskOwnershipError, MemoryTaskStatus
-from tests.test_continuous_processing_bridge import enable, put_after_policy
-from tests.test_model_processing_permissions import h as h # noqa: F401
-
-
-def test_crashed_continuous_send_is_not_replayed_but_new_event_progresses(h):
- enable(h)
- fresh = put_after_policy(h, "sent-before-crash")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- claimed, _ = h.tasks.claim(task.task_id)
- runner = supervisor._create_runner()
- lease, request, _ = runner._prepare_permission_lease(claimed)
- h.bridge.fail = False
- asyncio.run(h.bridge.ingest(request)) # Controlled send returned, process lost its receipt.
- assert len(h.bridge.calls) == 1
- assert h.tasks.recover_running() == 1
- recovered = h.tasks.require(task.task_id)
- assert recovered.status == MemoryTaskStatus.UNKNOWN
- assert recovered.last_error_code == "model_content_outcome_unknown"
- with pytest.raises(MemoryTaskOwnershipError):
- h.tasks.claim(task.task_id)
- assert supervisor.prepare_resume() == []
- assert supervisor.prepare_backfill() == []
- # The continuous-policy filter now excludes an already possibly-sent
- # Evidence before task preparation. Either explicit unknown or outside
- # policy proves it cannot be blindly replayed.
- with pytest.raises(HyMemoryBridgeError, match="outcome_unknown|evidence_outside_policy"):
- asyncio.run(supervisor.process_records((fresh,)))
- # A new message in the same conversation must not resend the unknown original as context.
- newest = put_after_policy(h, "new-after-restart")
- tasks = supervisor.prepare_conversation(newest)
- assert len(tasks) == 1 and tasks[0].task_id != task.task_id
-
- async def run():
- try:
- result = await supervisor._create_runner().run(tasks[0].task_id)
- assert result.status == MemoryTaskStatus.COMPLETED
- sent = [
- request
- for kind, request in h.bridge.calls
- if kind in {"ingest", "ingest_extracted"}
- ]
- assert len(sent) == 2
- assert sent[-1].evidence_ids == (newest.evidence_id,)
- assert all(r.evidence_id != fresh.evidence_id for r in h.leases[-1].records)
- assert supervisor.prepare_backfill() == []
- finally:
- await supervisor.shutdown()
-
- asyncio.run(run())
- assert h.tasks.require(task.task_id).last_error_code == "model_content_outcome_unknown"
-
-
-def test_legacy_running_recovery_remains_pending_and_no_continuous_table(h):
- legacy = h.legacy_task()
- h.tasks.claim(legacy.task_id)
- assert h.tasks.recover_running() == 1
- restored = h.tasks.require(legacy.task_id)
- assert restored.status == MemoryTaskStatus.PENDING
- assert restored.last_error_code == "worker_restarted"
-
-
-def test_a_timed_out_non_continuous_task_also_becomes_unknown(h):
- """The timeout reason alone is enough to make a task possibly-sent.
-
- The continuous-permission record is not the only way HY can have been
- reached: `MemoryTaskRunner.run` wraps the HY call in `asyncio.wait_for`, so
- a wall-clock timeout can fire after the request was in flight. A
- non-continuous task must therefore also become sticky `unknown`, or the
- scheduler re-selects it and sends a second copy (HY has no idempotency key).
- """
- # A legacy (non-continuous) task: no continuous permission record exists.
- task = h.legacy_task()
- _claimed, runner_token = h.tasks.claim(task.task_id)
- h.tasks.record_hy_invocation(task.task_id, runner_token=runner_token)
- assert h.tasks.recover_one_running(task.task_id, reason="hy_memory_timeout") is True
- recovered = h.tasks.require(task.task_id)
- assert recovered.status == MemoryTaskStatus.UNKNOWN
- assert recovered.last_error_code == "model_content_outcome_unknown"
- with pytest.raises(MemoryTaskOwnershipError):
- h.tasks.claim(task.task_id)
-
-
-def test_claim_without_proven_send_is_still_conservatively_unknown(h):
- enable(h)
- fresh = put_after_policy(h, "claim-only")
- task = h.supervisor().prepare_conversation(fresh)[0]
- h.tasks.claim(task.task_id)
- h.tasks.recover_running()
- recovered = h.tasks.require(task.task_id)
- assert recovered.status == MemoryTaskStatus.UNKNOWN
- assert recovered.last_error_code == "model_content_outcome_unknown"
- assert h.bridge.calls == []
-
-
-def test_a_timed_out_continuous_task_becomes_unknown_not_pending(h):
- """The one-shot recovery must be continuous-aware, like process-start recovery.
-
- `recover_one_running` is the path a timeout takes. A continuous task may
- already have reached HY, so returning it to `pending` would let it be
- re-sent and duplicate the memory; it must become sticky `unknown` exactly
- as `recover_running` marks it at process start.
- """
- enable(h)
- fresh = put_after_policy(h, "timed-out-continuous-send")
- task = h.supervisor().prepare_conversation(fresh)[0]
- _claimed, runner_token = h.tasks.claim(task.task_id)
- h.tasks.record_hy_invocation(task.task_id, runner_token=runner_token)
-
- assert h.tasks.recover_one_running(task.task_id, reason="hy_memory_timeout") is True
-
- recovered = h.tasks.require(task.task_id)
- assert recovered.status == MemoryTaskStatus.UNKNOWN
- assert recovered.last_error_code == "model_content_outcome_unknown"
- with pytest.raises(MemoryTaskOwnershipError):
- h.tasks.claim(task.task_id)
-
-
-def test_restart_preserves_the_original_unknown_ordering_time(h):
- """Recovery must not collapse old in-flight work into the restart instant.
-
- `unknown_jobs()` pages in `(updated_at, job_id)` order. If a process
- restart rewrote every recovered task's `updated_at`, a crash with many
- in-flight tasks would erase their original request order and make the
- inquiry drain start from an arbitrary same-timestamp batch. The recovery
- transition changes status/error only; the timestamp remains the durable
- moment the row last changed before the process died.
- """
- enable(h)
- fresh = put_after_policy(h, "restart-order")
- task = h.supervisor().prepare_conversation(fresh)[0]
- h.tasks.claim(task.task_id)
- original = h.tasks.require(task.task_id).updated_at
-
- assert h.tasks.recover_running() == 1
-
- recovered = h.tasks.require(task.task_id)
- assert recovered.status is MemoryTaskStatus.UNKNOWN
- assert recovered.updated_at == original
-
-
-def test_an_unsettled_continuous_send_becomes_unknown_not_failed(h):
- """`failed` is retryable, so an abandoned continuous send must be `unknown`.
-
- `fail_unsettled` runs when a runner raised without settling. That exception
- may have arrived after HY was already called; `failed` would be re-claimed
- and re-sent, duplicating the memory. Only a task that never reached
- `running` (still pending) is provably unsent and may be `failed`.
- """
- enable(h)
- fresh = put_after_policy(h, "unsettled-continuous-send")
- task = h.supervisor().prepare_conversation(fresh)[0]
- h.tasks.claim(task.task_id)
-
- assert h.tasks.fail_unsettled(task.task_id, error_code="memory_task_dispatch_failed") is True
-
- settled = h.tasks.require(task.task_id)
- assert settled.status == MemoryTaskStatus.UNKNOWN
- with pytest.raises(MemoryTaskOwnershipError):
- h.tasks.claim(task.task_id)
-
-
-def test_an_unsettled_non_continuous_task_is_still_failed(h):
- """The continuous rule must not change the ordinary path."""
- legacy = h.legacy_task()
- h.tasks.claim(legacy.task_id)
-
- assert h.tasks.fail_unsettled(legacy.task_id, error_code="memory_task_dispatch_failed") is True
-
- assert h.tasks.require(legacy.task_id).status == MemoryTaskStatus.FAILED
diff --git a/memhub/apps/core/tests/test_continuous_policy.py b/memhub/apps/core/tests/test_continuous_policy.py
deleted file mode 100644
index c100059..0000000
--- a/memhub/apps/core/tests/test_continuous_policy.py
+++ /dev/null
@@ -1,336 +0,0 @@
-import sqlite3
-from contextlib import contextmanager
-from uuid import uuid4
-
-import pytest
-
-from one_memory import continuous_policy as policy
-from one_memory import ingest_journal as journal
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.model_profiles import ModelProfilePatch
-from tests.test_model_content_permissions import SOURCE, evidence_value
-from tests.test_model_content_permissions import env as env # noqa: F401
-
-
-@contextmanager
-def transaction(environment):
- db = sqlite3.connect(environment.database)
- db.row_factory = sqlite3.Row
- db.execute("BEGIN IMMEDIATE")
- try:
- journal.initialize(db)
- policy.initialize(db)
- yield db
- db.commit()
- except BaseException:
- db.rollback()
- raise
- finally:
- db.close()
-
-
-def enable(db, environment, **overrides):
- values = dict(
- profile=environment.profile,
- expected_revision=0,
- expected_legacy_revision=0,
- operation_id=str(uuid4()),
- backlog_mode="none",
- user_confirmed=True,
- )
- values.update(overrides)
- return policy.enable(db, **values)
-
-
-def test_explicit_policy_never_upgrades_legacy_or_sends_or_creates_tasks(env):
- with transaction(env) as db:
- assert policy.current(db) is None
- receipt = enable(db, env)
- assert policy.current(db) == receipt
- assert db.execute("SELECT COUNT(*) FROM model_content_permissions").fetchone()[0] == 0
- assert receipt.backlog_events == ()
- with pytest.raises(policy.ContinuousPolicyError, match="evidence_outside_policy"):
- policy.check(db, receipt, evidence_ids=(env.record.evidence_id,))
-
-
-def test_backlog_snapshot_over_one_hundred_thousand_is_compact(monkeypatch, env) -> None:
- count = 100_001
-
- class Database:
- in_transaction = True
-
- def execute(self, _sql, _values):
- return iter(((SOURCE, count, 1),))
-
- monkeypatch.setattr(policy.ingest_journal, "watermark", lambda _db: count)
- monkeypatch.setattr(
- policy.ModelContentPermissionStore,
- "_source",
- lambda _db, _user, _source: object(),
- )
-
- snapshot = policy.snapshot_backlog(Database()) # type: ignore[arg-type]
-
- assert snapshot.after_seq == count
- assert snapshot.source_ids == (SOURCE,)
- assert snapshot.evidence_count == count
- assert snapshot.conversation_count == 1
- with transaction(env) as db:
- receipt = enable(db, env, backlog_mode="include", backlog_snapshot=snapshot)
- assert receipt.backlog_through_seq == count
- assert receipt.backlog_source_ids == (SOURCE,)
- assert receipt.backlog_count == count
- assert receipt.backlog_conversation_count == 1
- assert receipt.backlog_events == ()
- assert len(receipt.model_dump_json()) < 5_000
-
-
-def test_legacy_backlog_receipt_remains_readable(env) -> None:
- with transaction(env) as db:
- receipt = enable(db, env)
- legacy = receipt.model_dump(
- exclude={"backlog_through_seq", "backlog_source_ids", "backlog_count"}
- )
- legacy["backlog_mode"] = "include"
- legacy["backlog_events"] = (1,)
- restored = policy.ContinuousPolicy.model_validate(legacy)
- db.execute(
- "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=?",
- (restored.model_dump_json(), restored.user_id),
- )
- assert policy.check(db, restored, evidence_ids=(env.record.evidence_id,)) == restored
-
-
-@pytest.mark.parametrize("confirmation", [False, 1, "true", None])
-def test_explicit_confirmation_is_required(env, confirmation):
- with transaction(env) as db:
- with pytest.raises(policy.ContinuousPolicyError):
- enable(db, env, user_confirmed=confirmation)
- assert policy.current(db) is None
-
-
-def test_future_old_dated_import_and_new_allowed_source_are_eligible(env, tmp_path):
- with transaction(env) as db:
- receipt = enable(db, env)
- other_root = tmp_path / "other-source"
- other_root.mkdir()
- env.sources.grant(
- user_id="local",
- source_id="cursor",
- root=other_root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- with transaction(env) as db:
- value = evidence_value("after", source_id="cursor")
- result = env.evidence.put(value, connection=db)
- event = journal.record(db, result.record.evidence_id)
- assert event.seq > receipt.after_seq
- assert policy.check(db, receipt, evidence_ids=(result.record.evidence_id,)) == receipt
-
-
-def test_include_uses_current_source_authority_not_the_enable_time_snapshot(env, tmp_path):
- old_ungranted = env.evidence.put(evidence_value("old-cursor", source_id="cursor")).record
- with transaction(env) as db:
- receipt = enable(db, env, backlog_mode="include")
- assert receipt.backlog_source_ids == (SOURCE,)
-
- other_root = tmp_path / "later-authorized-source"
- other_root.mkdir()
- env.sources.grant(
- user_id="local",
- source_id="cursor",
- root=other_root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- with transaction(env) as db:
- assert policy.check(db, receipt, evidence_ids=(old_ungranted.evidence_id,)) == receipt
-
-
-def test_include_keeps_legacy_backlog_fields_readable_without_using_them_as_a_boundary(env):
- with transaction(env) as db:
- receipt = enable(db, env, backlog_mode="include")
- assert receipt.backlog_events == ()
- assert receipt.backlog_through_seq == 1
- assert receipt.backlog_source_ids == (SOURCE,)
- policy.check(db, receipt, evidence_ids=(env.record.evidence_id,))
- db.execute(
- "UPDATE evidence SET workspace='new identity' WHERE evidence_id=?",
- (env.record.evidence_id,),
- )
- journal.record(db, env.record.evidence_id, cause="maintenance")
- assert policy.check(db, receipt, evidence_ids=(env.record.evidence_id,)) == receipt
-
-
-def test_only_new_mode_keeps_the_enable_time_boundary(env):
- with transaction(env) as db:
- receipt = enable(db, env, backlog_mode="none")
- with pytest.raises(policy.ContinuousPolicyError, match="evidence_outside_policy"):
- policy.check(db, receipt, evidence_ids=(env.record.evidence_id,))
-
-
-def test_pause_resume_and_revoke_are_cas_and_old_receipts_never_reactivate(env):
- with transaction(env) as db:
- receipt = enable(db, env)
- paused = policy.transition(
- db, action="pause", operation_id=str(uuid4()), user_confirmed=True, expected_revision=1
- )
- with pytest.raises(policy.ContinuousPolicyError):
- policy.check(db, receipt)
- with pytest.raises(policy.ContinuousPolicyError):
- policy.check(db, paused)
- resumed = policy.transition(
- db, action="resume", operation_id=str(uuid4()), user_confirmed=True, expected_revision=2
- )
- assert resumed.epoch == receipt.epoch and resumed.after_seq == receipt.after_seq
- revoked = policy.transition(
- db, action="revoke", operation_id=str(uuid4()), user_confirmed=True, expected_revision=3
- )
- with pytest.raises(policy.ContinuousPolicyError):
- policy.check(db, resumed)
- with pytest.raises(policy.ContinuousPolicyError, match="requires_new_enable"):
- policy.transition(
- db,
- action="resume",
- operation_id=str(uuid4()),
- user_confirmed=True,
- expected_revision=4,
- )
- new = enable(db, env, expected_revision=revoked.revision)
- assert new.epoch != receipt.epoch
-
-
-def test_enable_idempotence_rejects_changed_payload_and_superseded_operation(env):
- with transaction(env) as db:
- request_id = str(uuid4())
- receipt = enable(db, env, operation_id=request_id)
- assert enable(db, env, operation_id=request_id) == receipt
- assert db.execute("SELECT COUNT(*) FROM continuous_policy_history").fetchone()[0] == 1
- with pytest.raises(policy.ContinuousPolicyError, match="payload_changed"):
- enable(db, env, operation_id=request_id, backlog_mode="include")
- policy.transition(
- db, action="revoke", operation_id=str(uuid4()), user_confirmed=True, expected_revision=1
- )
- with pytest.raises(policy.ContinuousPolicyError, match="superseded"):
- enable(db, env, operation_id=request_id)
-
-
-def test_source_revoke_profile_change_and_legacy_changes_block_existing_policy(env):
- with transaction(env) as db:
- receipt = enable(db, env, backlog_mode="include")
- env.sources.revoke(user_id="local", source_id=SOURCE)
- with transaction(env) as db, pytest.raises(RuntimeError):
- policy.check(db, receipt, evidence_ids=(env.record.evidence_id,))
- env.profiles.update(
- env.profile.profile_id,
- ModelProfilePatch(expected_revision=1, model="different"),
- env.secrets,
- )
- with transaction(env) as db, pytest.raises(RuntimeError):
- policy.check(db, receipt)
-
-
-def test_caller_rollback_includes_policy_and_backlog(env):
- with pytest.raises(RuntimeError, match="rollback"), transaction(env) as db:
- enable(db, env)
- raise RuntimeError("rollback")
- with transaction(env) as db:
- assert policy.current(db) is None
-
-
-@pytest.mark.parametrize("value", [True, -1, "0", 0.5])
-def test_revision_values_are_not_coerced(env, value):
- with (
- transaction(env) as db,
- pytest.raises(policy.ContinuousPolicyError, match="invalid_revision"),
- ):
- enable(db, env, expected_revision=value)
-
-
-def test_legacy_permission_changes_do_not_invalidate_continuous_policy(env):
- legacy = env.grant()
- with transaction(env) as db:
- receipt = enable(db, env, expected_legacy_revision=legacy.revision)
- env.permissions.revoke(user_id="local", expected_revision=legacy.revision, user_confirmed=True)
- with transaction(env) as db:
- assert policy.check(db, receipt) == receipt
- paused = policy.transition(
- db,
- expected_revision=receipt.revision,
- action="pause",
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
- resumed = policy.transition(
- db,
- expected_revision=paused.revision,
- action="resume",
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
- assert resumed.state == "enabled"
-
-
-def test_paused_policy_cannot_resume_after_model_identity_changes(env):
- with transaction(env) as db:
- enable(db, env)
- policy.transition(
- db, expected_revision=1, action="pause", operation_id=str(uuid4()), user_confirmed=True
- )
- env.profiles.update(
- env.profile.profile_id,
- ModelProfilePatch(expected_revision=1, model="changed-model"),
- env.secrets,
- )
- with transaction(env) as db:
- with pytest.raises(RuntimeError):
- policy.transition(
- db,
- expected_revision=2,
- action="resume",
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
- assert policy.current(db).state == "paused"
-
-
-def test_read_check_restores_callers_row_factory(env):
- with transaction(env) as db:
- receipt = enable(db, env)
- db.row_factory = None
- assert policy.check(db, receipt) == receipt
- assert db.row_factory is None
-
-
-def test_prepared_input_cannot_be_replaced_by_a_newer_allowed_revision(env):
- with transaction(env) as db:
- receipt = enable(db, env)
- first = env.evidence.put(evidence_value("new-input"), connection=db).record
- lease = policy.bind_inputs(db, receipt, (first.evidence_id,))
- policy.recheck_inputs(db, lease)
- env.evidence.put(evidence_value("new-input", text="changed material"), connection=db)
- policy.check(db, receipt, evidence_ids=(first.evidence_id,))
- with pytest.raises(policy.ContinuousPolicyError, match="bound_input_changed"):
- policy.recheck_inputs(db, lease)
-
-
-def test_prepared_source_cannot_survive_regrant_without_revalidation(env):
- with transaction(env) as db:
- receipt = enable(db, env, backlog_mode="include")
- lease = policy.bind_inputs(db, receipt, (env.record.evidence_id,))
- env.sources.revoke(user_id="local", source_id=SOURCE)
- env.sources.grant(
- user_id="local", source_id=SOURCE, root=env.root, candidate_kind=RootCandidateKind.DEFAULT
- )
- with (
- transaction(env) as db,
- pytest.raises(policy.ContinuousPolicyError, match="bound_source_authority_changed"),
- ):
- policy.recheck_inputs(db, lease)
-
-
-@pytest.mark.parametrize("inputs", [(), ("duplicate", "duplicate")])
-def test_lease_requires_explicit_unique_inputs(env, inputs):
- with transaction(env) as db:
- receipt = enable(db, env)
- with pytest.raises(policy.ContinuousPolicyError, match="unique_inputs"):
- policy.bind_inputs(db, receipt, inputs)
diff --git a/memhub/apps/core/tests/test_continuous_processing_bridge.py b/memhub/apps/core/tests/test_continuous_processing_bridge.py
deleted file mode 100644
index 79c3d6c..0000000
--- a/memhub/apps/core/tests/test_continuous_processing_bridge.py
+++ /dev/null
@@ -1,472 +0,0 @@
-import asyncio
-import json
-from uuid import uuid4
-
-import pytest
-
-from one_memory import continuous_policy as cp
-from one_memory import ingest_journal
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.memory_tasks import MemoryTaskStatus
-from one_memory.model_processing_permission_guard import ModelProcessingPermissionGuard
-from tests.test_model_processing_permissions import h as h # noqa: F401
-
-
-def enable(h, backlog="none"):
- with h.evidence._connect(writing=True) as db:
- ingest_journal.initialize(db)
- cp.initialize(db)
- legacy = h.permissions.receipt(user_id="local")
- return cp.enable(
- db,
- profile=h.profiles.snapshot(h.profile.profile_id, h.profile.revision),
- expected_revision=0,
- expected_legacy_revision=legacy.revision if legacy else 0,
- operation_id=str(uuid4()),
- backlog_mode=backlog,
- user_confirmed=True,
- )
-
-
-def transition(h, action):
- with h.evidence._connect(writing=True) as db:
- policy = cp.current(db)
- return cp.transition(
- db,
- expected_revision=policy.revision,
- action=action,
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
-
-
-def put_after_policy(h, key, *, source="cursor"):
- """Create one complete continuous input and record its ingest identity.
-
- These policy tests used a raw user-only record, which the complete-turn
- contract correctly holds back. MEMORY_CONTENT is a complete input by
- itself; recording it after policy enable proves the `after_seq` boundary
- without inventing an assistant reply that the assertions do not inspect.
- """
- record = h.put(key, source=source, memory_content=True)
- with h.evidence._connect(writing=True) as db:
- ingest_journal.record(db, record.evidence_id, cause="import")
- return h.evidence.get(record.evidence_id)
-
-
-def test_continuous_prepare_lease_ingest_has_no_legacy_receipt_or_old_context(h):
- policy = enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
-
- async def scenario():
- try:
- tasks = supervisor.prepare_conversation(fresh)
- assert len(tasks) == 1
- result = await supervisor._create_runner().run(tasks[0].task_id)
- assert result.status == MemoryTaskStatus.WAITING_FOR_MODEL # Controlled HY failure.
- assert result.last_error_code == "hy_memory_unavailable"
- lease = h.leases[0]
- assert lease.receipt.schema_version == 2
- assert lease.receipt.authority_kind == "continuous"
- assert lease.receipt.policy == policy
- assert {r.evidence_id for r in lease.records} == {fresh.evidence_id}
- assert h.record.evidence_id not in {r.evidence_id for r in lease.records}
- assert h.permissions.receipt(user_id="local") is None
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("action", ["pause", "revoke"])
-def test_continuous_inactive_never_falls_back_to_legacy(h, action):
- h.grant()
- enable(h, "include")
- transition(h, action)
- supervisor = h.supervisor()
- with pytest.raises(HyMemoryBridgeError, match="model_content_"):
- supervisor.prepare_conversation(h.record)
- assert not h.bridge.calls
-
-
-@pytest.mark.parametrize("phase", ["before_run", "ingest", "profile"])
-def test_revocation_rechecked_at_runner_and_publication_boundaries(h, phase):
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- h.bridge.fail = False
- if phase == "before_run":
- transition(h, "revoke")
- elif phase == "ingest":
- h.bridge.on_ingest = lambda: transition(h, "revoke")
- else:
- h.bridge.on_profile = lambda: transition(h, "revoke")
-
- async def scenario():
- try:
- result = await supervisor._create_runner().run(task.task_id)
- assert result.status == MemoryTaskStatus.CANCELLED
- assert result.last_error_code.startswith("model_content_")
- assert h.governance.list(user_id="local") == []
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_durable_binding_restarts_and_cannot_adopt_new_policy_or_new_revision(h):
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- guard = ModelProcessingPermissionGuard(h.permissions, h.governance)
- saved = guard.bound(task)
- assert saved.authority_kind == "continuous" and saved.inputs
- guard.lease(task, (fresh,), None).recheck()
- with h.evidence._connect(writing=True) as db:
- db.execute(
- "UPDATE evidence SET revision=revision+1,verbatim='changed' WHERE evidence_id=?",
- (fresh.evidence_id,),
- )
- ingest_journal.record(db, fresh.evidence_id, cause="import")
- with pytest.raises(HyMemoryBridgeError):
- guard.lease(task, (fresh,), None).recheck()
-
-
-def test_legacy_task_not_promoted_by_continuous_policy(h):
- legacy = h.grant()
- task = h.legacy_task()
- guard = ModelProcessingPermissionGuard(h.permissions, h.governance)
- guard.bind(task, legacy)
- enable(h, "include")
- with pytest.raises(HyMemoryBridgeError):
- guard.bound(task)
-
-
-def test_future_new_source_old_date_uses_continuous_ingest_boundary(h, tmp_path):
- enable(h)
- folder = tmp_path / "second"
- folder.mkdir()
- h.sources.grant(
- user_id="local",
- source_id="new-source",
- root=folder,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- fresh = put_after_policy(h, "new", source="new-source")
- with h.evidence._connect(writing=True) as db:
- db.execute(
- "UPDATE evidence SET created_at='1990-01-01T00:00:00+00:00' WHERE evidence_id=?",
- (fresh.evidence_id,),
- )
- # Actual import material revision, not authorizing an attribution repair.
- db.execute(
- "UPDATE evidence SET verbatim='Newly imported old-dated content' WHERE evidence_id=?",
- (fresh.evidence_id,),
- )
- ingest_journal.record(db, fresh.evidence_id, cause="import")
- fresh = h.evidence.get(fresh.evidence_id)
- supervisor = h.supervisor()
- tasks = supervisor.prepare_conversation(fresh)
- assert len(tasks) == 1
- assert [work.task_id for work in supervisor._classify(tasks)] == [tasks[0].task_id]
- assert supervisor.tasks.require(tasks[0].task_id).status is MemoryTaskStatus.PENDING
-
-
-def test_schema2_roundtrip_rejects_unknown_and_preserves_bound_events(h):
- from one_memory.continuous_permission_bridge import decode_authority
-
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- receipt = supervisor._permission_guard.bound(task)
- assert decode_authority(receipt.model_dump_json()) == receipt
- payload = receipt.model_dump(mode="json")
- payload["schema_version"] = 99
- with pytest.raises(ValueError):
- decode_authority(json.dumps(payload))
-
-
-def test_transport_rechecks_continuous_lease_without_forged_snapshot(h):
- from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
-
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- lease = supervisor._create_runner()._prepare_permission_lease(task)[0]
- transport = HyPermissionTransport(lease)
- transport._active = True
- transport.assert_allowed()
- transition(h, "pause")
- with pytest.raises(HyPermissionTransportError, match="lease_denied"):
- transport.assert_allowed()
-
-
-def test_transport_denial_names_why_the_lease_failed(h):
- from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
-
- enable(h)
- task = h.supervisor().prepare_conversation(put_after_policy(h, "new"))[0]
- lease = h.supervisor()._create_runner()._prepare_permission_lease(task)[0]
- transport = HyPermissionTransport(lease)
- transport._active = True
- transition(h, "pause")
- with pytest.raises(Exception) as direct:
- lease.recheck()
- reason = str(direct.value).removeprefix("model_content_")
- assert reason and reason != "lease_denied"
-
- with pytest.raises(HyPermissionTransportError) as denied:
- transport.assert_allowed()
-
- assert denied.value.code == f"model_content_transport_lease_denied_{reason}"
-
-
-def test_successful_continuous_runner_completes_without_old_backlog(h):
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- h.bridge.fail = False
-
- async def run():
- try:
- task = supervisor.prepare_conversation(fresh)[0]
- result = await supervisor._create_runner().run(task.task_id)
- assert result.status == MemoryTaskStatus.COMPLETED
- assert [kind for kind, _ in h.bridge.calls] == [
- "ingest_extracted",
- "profile",
- ]
- request = h.bridge.calls[0][1]
- assert request.evidence_ids == (fresh.evidence_id,)
- assert request.source_context is None
- finally:
- await supervisor.shutdown()
-
- asyncio.run(run())
-
-
-def test_prepare_backfill_filters_old_inputs_and_paused_policy_refuses(h):
- policy = enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- tasks = supervisor.prepare_backfill(expected_permission_revision=policy.revision)
- assert tasks
- for work in tasks:
- task = h.tasks.require(work.task_id)
- assert {ref["evidence_id"] for ref in h.tasks.inputs(task.task_id)} == {fresh.evidence_id}
- transition(h, "pause")
- with pytest.raises(HyMemoryBridgeError):
- supervisor.prepare_backfill()
-
-
-def test_bound_source_regrant_refuses_old_task(h):
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- h.sources.revoke(user_id="local", source_id="cursor")
- h.sources.grant(
- user_id="local",
- source_id="cursor",
- root=h.root / "synthetic-source",
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- with pytest.raises(HyMemoryBridgeError):
- supervisor._create_runner()._prepare_permission_lease(task)
-
-
-def test_schema2_native_sdk_child_send_denies_after_policy_revoke(h):
- import httpx
-
- from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
- from tests.test_hy_permission_transport import native, response
-
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- task = supervisor.prepare_conversation(fresh)[0]
- lease = supervisor._create_runner()._prepare_permission_lease(task)[0]
- provider, _ = native(lease)
- requests = []
-
- def transport(request):
- requests.append(request.url.path)
- return response(request)
-
- async def run():
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(
- lease, http_transport=httpx.MockTransport(transport)
- ) as boundary:
- boundary.bind_provider(provider)
- await provider.complete("Synthetic permitted input")
- assert len(requests) == 1
- transition(h, "revoke")
- await provider.complete("This request must not leave")
- assert len(requests) == 1
-
- asyncio.run(run())
-
-
-def test_recreated_event_identity_produces_new_task_even_same_revision(h):
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- first = supervisor.prepare_conversation(fresh)[0]
- with h.evidence._connect(writing=True) as db:
- db.execute(
- "UPDATE evidence SET verbatim='changed without revision' WHERE evidence_id=?",
- (fresh.evidence_id,),
- )
- ingest_journal.record(db, fresh.evidence_id, cause="import")
- new = h.evidence.get(fresh.evidence_id)
- second = supervisor.prepare_conversation(new)[0]
- assert second.task_id != first.task_id
-
-
-def test_default_guard_does_not_create_continuous_schema(h):
- ModelProcessingPermissionGuard(h.permissions, h.governance)
- with h.evidence._connect() as db:
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='memory_task_continuous_permissions'"
- ).fetchone()
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'"
- ).fetchone()
-
-
-def test_publish_guard_rechecks_continuous_after_last_bridge_read(h, monkeypatch):
- enable(h)
- fresh = put_after_policy(h, "new")
- supervisor = h.supervisor()
- h.bridge.fail = False
- task = supervisor.prepare_conversation(fresh)[0]
- runner = supervisor._create_runner()
- original = runner._record_outputs
-
- def revoke_at_commit(**kwargs):
- transition(h, "revoke")
- return original(**kwargs)
-
- monkeypatch.setattr(runner, "_record_outputs", revoke_at_commit)
- result = asyncio.run(runner.run(task.task_id))
- assert result.status == MemoryTaskStatus.CANCELLED
- assert result.last_error_code.startswith("model_content_")
- assert h.tasks.extraction(task.task_id) is None
- assert h.governance.list(user_id="local") == []
-
-
-def test_native_hy_continuous_source_to_governance_with_mock_network(tmp_path):
- import httpx
-
- from one_memory.hy_permission_context import permission_bridge_factory
- from one_memory.imports.models import (
- EvidenceContentKind,
- EvidenceInput,
- EvidenceOrigin,
- OriginMetadata,
- WrittenBy,
- )
- from tests.test_hy_permission_context import whole_client
-
- async def run():
- with whole_client(tmp_path) as (supervisor, old, permissions, legacy):
- with supervisor.evidence._connect(writing=True) as db:
- ingest_journal.initialize(db)
- cp.initialize(db)
- cp.enable(
- db,
- profile=legacy.snapshot.profile,
- expected_revision=0,
- expected_legacy_revision=legacy.revision,
- operation_id=str(uuid4()),
- backlog_mode="none",
- user_confirmed=True,
- )
- fresh = supervisor.evidence.put(
- EvidenceInput(
- source_id=old.source_id,
- conversation_id=old.conversation_id,
- message_id="new",
- role="user",
- written_by=WrittenBy.PERSON,
- content_kind=EvidenceContentKind.MEMORY_CONTENT,
- verbatim="Synthetic fresh continuous memory, not old backlog.",
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- ).record
- sends = []
-
- def handler(request):
- body = json.loads(request.content)
- sends.append(body)
- assert old.verbatim not in request.content.decode()
- if request.url.path.endswith("embeddings"):
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": "test-embedding",
- "data": [
- {
- "index": i,
- "object": "embedding",
- "embedding": [1.0] + [0.0] * 383,
- }
- for i, _ in enumerate(body["input"])
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
- return httpx.Response(
- 200,
- json={
- "id": "fixture",
- "object": "chat.completion",
- "created": 1,
- "model": "test-generation",
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {
- "role": "assistant",
- "content": json.dumps(
- [
- {
- "op": "ADD",
- "content": fresh.verbatim,
- "layer": "L2_FACT",
- "owner": "user",
- }
- ]
- ),
- },
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
-
- supervisor.permission_bridge_factory = permission_bridge_factory(
- supervisor._bridge, http_transport=httpx.MockTransport(handler)
- )
- try:
- task = supervisor.prepare_conversation(fresh)[0]
- result = await supervisor._create_runner().run(task.task_id)
- assert result.status == "completed", result.last_error_code
- memories = supervisor.governance.list(user_id="local")
- assert sends and len(memories) == 1
- assert memories[0].content == fresh.verbatim
- assert memories[0].evidence_ids == (fresh.evidence_id,)
- assert permissions.receipt(user_id="local") == legacy
- finally:
- await supervisor.shutdown()
-
- asyncio.run(run())
diff --git a/memhub/apps/core/tests/test_continuous_processing_integration.py b/memhub/apps/core/tests/test_continuous_processing_integration.py
deleted file mode 100644
index 5fc7d27..0000000
--- a/memhub/apps/core/tests/test_continuous_processing_integration.py
+++ /dev/null
@@ -1,1327 +0,0 @@
-"""Real temporary Core/stores with controlled probe and HY boundaries, never real data.
-
-Kernel-helper tests isolate the scheduling handoff while the public activation
-route lands. They do not claim atomic activation; API tests require the real route.
-"""
-
-import asyncio
-import sqlite3
-from contextlib import asynccontextmanager, closing
-from datetime import UTC, datetime, timedelta
-from uuid import uuid4
-
-import httpx
-import pytest
-
-from one_memory import api, continuous_policy, ingest_journal
-from one_memory.config import Settings
-from one_memory.hy_memory_bridge import HyMemoryBridgeError, HyMemoryIngestResult, HyMemoryOutput
-from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.memory_processing import MemoryTaskRunner
-from one_memory.memory_tasks import MemoryTaskStatus
-from one_memory.model_probe import ModelProber
-from one_memory.secrets import InMemorySecretStore
-
-API_ROOT = "/api/v1/continuous-processing-policy"
-TEXT = "用户偏好本地优先的记忆处理,并保留可追溯的原始依据。"
-OLD = datetime(2019, 1, 1, tzinfo=UTC)
-
-
-class FakeSecretStore(InMemorySecretStore):
- """Only the existing in-memory SecretStore protocol; no keychain calls."""
-
-
-class ControlledHyBridge:
- def __init__(self):
- self.requests = []
- self.leases = []
- self.before_return = lambda: None
- self.closed = 0
-
- async def ingest(self, request):
- if request.source_id == "one-memory-manual":
- pytest.fail("A note the person asked to keep must be imported, not re-extracted")
- return await self.answer(request)
-
- async def ingest_extracted(self, request, contents):
- if request.source_id != "one-memory-manual":
- pytest.fail("Raw conversation must use HY ingest, not direct content import")
- assert list(contents) == [turn.content for turn in request.turns]
- return await self.answer(request)
-
- async def answer(self, request):
- self.requests.append(request)
- self.before_return()
- return HyMemoryIngestResult(
- success=True,
- memories=(
- HyMemoryOutput(
- memory_id="integration-hy-" + request.evidence_ids[0],
- content=TEXT,
- layer="L2_FACT",
- owner="user",
- evidence_ids=request.evidence_ids,
- ),
- ),
- )
-
- async def profile_memories(self, **kwargs):
- return ()
-
- async def close(self):
- self.closed += 1
-
-
-class IntegrationCore:
- def __init__(self, root, monkeypatch):
- self.root = root
- self.data = root / "data"
- self.data.mkdir()
- self.home = root / "synthetic-home"
- self.home.mkdir()
- self.database = self.data / "one-memory.sqlite3"
- self.secrets = FakeSecretStore()
- self.bridge = ControlledHyBridge()
- self.probe_requests = []
- self.profile = None
- self.app = None
- self.client = None
-
- async def no_network(*args, **kwargs):
- pytest.fail("Real network transport forbidden in continuous integration")
-
- def no_sync_network(*args, **kwargs):
- pytest.fail("Real synchronous network forbidden")
-
- def no_unguarded(*args, **kwargs):
- pytest.fail("Production runner bypassed its permission-isolated HY boundary")
-
- @asynccontextmanager
- async def controlled_context(app, lease, secret, directory):
- assert directory.resolve() == self.data.resolve()
- assert app.state.evidence_store.database_path.resolve() == self.database.resolve()
- lease.recheck()
- self.bridge.leases.append(lease)
- try:
- yield self.bridge
- finally:
- await self.bridge.close()
-
- monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", no_network)
- monkeypatch.setattr(httpx.HTTPTransport, "handle_request", no_sync_network)
- monkeypatch.setattr(MemoryTaskRunner, "_create_bridge", no_unguarded)
- monkeypatch.setattr(api, "_permission_context", controlled_context)
-
- def probe_transport(self, request):
- body = request.content.decode() if request.content else ""
- assert TEXT not in body and "old-neighbor" not in body
- self.probe_requests.append((request.url.path, body))
- if request.url.path.endswith("/models"):
- return httpx.Response(200, json={"data": [{"id": "integration-model"}]})
- if request.url.path.endswith("/chat/completions"):
- return httpx.Response(
- 200,
- json={
- "choices": [{"message": {"content": '{"memory_probe":true}'}}],
- },
- )
- if request.url.path.endswith("/embeddings"):
- return httpx.Response(200, json={"data": [{"embedding": [0.1, 0.2, 0.3]}]})
- pytest.fail("Unexpected synthetic probe path: " + request.url.path)
-
- @asynccontextmanager
- async def running(self, *, enable_continuous=True, automatic_memory_processing=True):
- capability = {"continuous_processing_enabled": True} if enable_continuous else {}
- self.app = api.create_app(
- Settings(
- data_dir=self.data,
- source_home=self.home,
- api_token="integration-only-token",
- instance_id="continuous-integration",
- imports_enabled=False,
- live_sync_enabled=False,
- automatic_memory_processing=automatic_memory_processing,
- ),
- source_catalog={},
- secret_store=self.secrets,
- **capability,
- )
- async with self.app.router.lifespan_context(self.app):
-
- async def skip_read_bridge_refresh(_snapshot):
- # These tests replace only the permission-isolated write
- # boundary. A post-write local read-client refresh is covered
- # separately and must not invoke the forbidden write factory.
- return None
-
- self.app.state.memory_processing_supervisor._refresh_read_bridge = (
- skip_read_bridge_refresh
- )
- self.app.state.model_prober = ModelProber(
- store=self.app.state.model_profile_store,
- secrets=self.secrets,
- transport=httpx.MockTransport(self.probe_transport),
- )
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=self.app),
- base_url="http://test",
- headers={"Authorization": "Bearer integration-only-token"},
- ) as client:
- self.client = client
- assert (await client.get("/api/v1/health")).status_code == 200
- for name in (
- "evidence_store",
- "memory_governance_store",
- "memory_task_store",
- "model_profile_store",
- "model_content_permissions",
- ):
- assert (
- getattr(self.app.state, name).database_path.resolve()
- == self.database.resolve()
- )
- yield self
- self.client = None
-
- def grant_source(self, source="cursor", *, enabled=True):
- root = self.home / source
- root.mkdir(exist_ok=True)
- receipt = self.app.state.source_grant_store.grant(
- user_id="local",
- source_id=source,
- root=root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- if enabled:
- self.app.state.config_store.set_sync(source, True)
- return receipt
-
- def put(
- self,
- key,
- *,
- source="cursor",
- conversation="same-conversation",
- created_at=OLD,
- role="user",
- written_by=WrittenBy.PERSON,
- verbatim=None,
- ):
- return self.app.state.evidence_store.put(
- EvidenceInput(
- user_id="local",
- source_id=source,
- conversation_id=conversation,
- message_id=key,
- role=role,
- written_by=written_by,
- created_at=created_at,
- verbatim=verbatim if verbatim is not None else TEXT + " " + key,
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- ).record
-
- def put_complete(
- self,
- key,
- *,
- source="cursor",
- conversation="same-conversation",
- created_at=OLD,
- verbatim=None,
- ):
- """Store a complete user/assistant turn and return its user record."""
- user = self.put(
- key,
- source=source,
- conversation=conversation,
- created_at=created_at,
- verbatim=verbatim,
- )
- self.put(
- key + "-reply",
- source=source,
- conversation=conversation,
- created_at=created_at + timedelta(microseconds=1),
- role="assistant",
- written_by=WrittenBy.AGENT,
- )
- return user
-
- def turn_records(self, seed):
- """Return the complete turn beginning at ``seed`` from durable Evidence."""
- conversation = self.app.state.evidence_store.list_conversation(seed.evidence_id)
- start = next(index for index, record in enumerate(conversation) if record == seed)
- turn = []
- for record in conversation[start:]:
- if turn and record.role == "user":
- break
- turn.append(record)
- return tuple(turn)
-
- async def save_and_probe(self):
- response = await self.client.post(
- "/api/v1/model-profiles",
- json={
- "name": "Controlled integration",
- "provider_kind": "openai_compatible",
- "base_url": "https://integration.invalid/v1",
- "model": "integration-model",
- "api_key": "synthetic-key-not-a-credential",
- "disable_thinking": True,
- "embedding_provider_kind": "openai_compatible",
- "embedding_model": "integration-vector",
- "embedding_base_url": "https://integration.invalid/v1",
- "max_concurrency": 1,
- },
- )
- assert response.status_code == 200, response.text
- self.profile = response.json()
- probe = await self.client.post(f"/api/v1/model-profiles/{self.profile['profile_id']}/probe")
- assert probe.status_code == 200, probe.text
- caps = probe.json()["capabilities"]
- assert (
- caps["endpoint_reachable"] and caps["model_available"] and caps["embedding_available"]
- )
- profiles = await self.client.get("/api/v1/model-profiles")
- assert profiles.status_code == 200
- self.profile = next(
- p for p in profiles.json() if p["profile_id"] == self.profile["profile_id"]
- )
- assert self.profile["revision"] == probe.json()["profile_revision"]
- assert self.probe_requests and not self.bridge.requests
- assert self.app.state.model_profile_store.active_identity() is None
- assert self.app.state.memory_task_store.list() == []
-
- def policy_current(self):
- with closing(sqlite3.connect(self.database)) as db:
- exists = db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'"
- ).fetchone()
- return continuous_policy.current(db) if exists else None
-
- def kernel_enable(self, mode="none"):
- """Known helper path only; explicitly not the atomic public enable transaction."""
- store = self.app.state.model_profile_store
- store.activate(self.profile["profile_id"], self.secrets)
- profile = store.snapshot(self.profile["profile_id"], self.profile["revision"])
- with closing(sqlite3.connect(self.database)) as db, db:
- db.execute("BEGIN IMMEDIATE")
- ingest_journal.initialize(db)
- continuous_policy.initialize(db)
- return continuous_policy.enable(
- db,
- profile=profile,
- expected_revision=0,
- expected_legacy_revision=0,
- operation_id=str(uuid4()),
- backlog_mode=mode,
- user_confirmed=True,
- )
-
- async def enable_api(self, mode="none"):
- body = dict(
- operation_id=str(uuid4()),
- profile_id=self.profile["profile_id"],
- profile_revision=self.profile["revision"],
- expected_policy_revision=0,
- expected_legacy_revision=0,
- backlog_mode=mode,
- user_confirmed=True,
- )
- response = await self.client.post(API_ROOT + "/enable", json=body)
- assert response.status_code in (200, 202), (
- f"Real enable route not integrated: {response.status_code} {response.text}"
- )
- assert (
- "secret_ref" not in response.text
- and "synthetic-key-not-a-credential" not in response.text
- )
- receipt = self.policy_current()
- assert receipt is not None and receipt.state == "enabled"
- assert self.app.state.model_profile_store.active_identity() == (
- self.profile["profile_id"],
- self.profile["revision"],
- )
- return receipt, body
-
- async def run_prepared(self, record):
- supervisor = self.app.state.memory_processing_supervisor
- tasks = await asyncio.to_thread(supervisor.prepare_conversation, record)
- assert tasks, "Policy accepted input but production prepare created no tasks"
- return [await supervisor._create_runner().run(task.task_id) for task in tasks]
-
- async def wait_completed(self, timeout=3, *, minimum_tasks=1):
- end = asyncio.get_running_loop().time() + timeout
- while asyncio.get_running_loop().time() < end:
- tasks = self.app.state.memory_task_store.list()
- if len(tasks) >= minimum_tasks and all(
- t.status
- in {
- MemoryTaskStatus.COMPLETED,
- MemoryTaskStatus.FAILED,
- MemoryTaskStatus.WAITING_FOR_MODEL,
- }
- for t in tasks
- ):
- assert all(t.status == MemoryTaskStatus.COMPLETED for t in tasks), [
- (t.status, t.last_error_code) for t in tasks
- ]
- return tasks
- await asyncio.sleep(0.025)
- tasks = self.app.state.memory_task_store.list()
- pytest.fail(
- "Continuous ingest/restart did not reach completed task within bounded wait: "
- + repr([(task.status, task.last_error_code, task.input_ref) for task in tasks])
- )
-
- async def assert_governed(self, record):
- assert self.bridge.requests, (
- "No controlled HY invocation; governance alone is not completion"
- )
- response = await self.client.get("/api/v1/memory-library", params={"status": "confirmed"})
- assert response.status_code == 200, response.text
- items = response.json()["items"]
- assert any(record.evidence_id in item["evidence_ids"] for item in items), items
-
-
-@pytest.fixture
-def core(tmp_path, monkeypatch):
- return IntegrationCore(tmp_path, monkeypatch)
-
-
-def test_save_probe_and_kernel_boundaries_are_real_without_auto_legacy_upgrade(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- old = core.put_complete("old-neighbor")
- await core.save_and_probe()
- receipt = core.kernel_enable()
- assert receipt.backlog_events == ()
- core.grant_source("workbuddy")
- new = core.put("new-old-date", source="workbuddy")
- with closing(sqlite3.connect(core.database)) as db, db:
- db.execute("BEGIN")
- assert (
- continuous_policy.check(db, receipt, evidence_ids=(new.evidence_id,)) == receipt
- )
- with pytest.raises(continuous_policy.ContinuousPolicyError, match="outside_policy"):
- continuous_policy.check(db, receipt, evidence_ids=(old.evidence_id,))
- assert core.app.state.model_content_permissions.receipt(user_id="local") is None
- assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("backlog", ["none", "include"])
-def test_kernel_policy_reaches_real_guard_runner_hy_and_governance(core, backlog):
- async def scenario():
- async with core.running():
- core.grant_source()
- old = core.put_complete("old-neighbor")
- await core.save_and_probe()
- core.kernel_enable(backlog)
- record = old if backlog == "include" else core.put_complete("new-permitted")
- results = await core.run_prepared(record)
- assert all(t.status == MemoryTaskStatus.COMPLETED for t in results), results
- await core.assert_governed(record)
- if backlog == "none":
- for request in core.bridge.requests:
- assert old.evidence_id not in request.evidence_ids
- context = request.source_context
- if context:
- assert old.evidence_id not in {
- e.evidence_id
- for e in (*context.current, *context.history, *context.supporting)
- }
-
- asyncio.run(scenario())
-
-
-def test_live_batch_drops_pre_policy_neighbor_without_cancelling_new_turn(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- old = core.put("old-neighbor")
- await core.save_and_probe()
- core.kernel_enable("none")
- user = core.put(
- "new-user",
- conversation=old.conversation_id,
- created_at=datetime.now(UTC),
- )
- assistant = core.put(
- "new-assistant",
- conversation=old.conversation_id,
- created_at=datetime.now(UTC),
- role="assistant",
- written_by=WrittenBy.AGENT,
- )
-
- completed = await core.app.state.memory_processing_supervisor.process_records(
- (old, user, assistant)
- )
-
- assert completed and all(task.status == "completed" for task in completed)
- sent = {
- evidence_id
- for request in core.bridge.requests
- for evidence_id in request.evidence_ids
- }
- assert old.evidence_id not in sent
- assert {user.evidence_id, assistant.evidence_id} <= sent
-
- asyncio.run(scenario())
-
-
-def test_real_enable_api_atomic_active_policy_and_idempotence(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- policy, body = await core.enable_api()
- repeated = await core.client.post(API_ROOT + "/enable", json=body)
- assert repeated.status_code in (200, 202), repeated.text
- assert core.policy_current() == policy
- current = await core.client.get(API_ROOT)
- assert current.status_code == 200 and "secret_ref" not in current.text
- assert len(core.app.state.model_profile_store.list(core.secrets)) == 1
-
- asyncio.run(scenario())
-
-
-def test_enable_future_new_source_old_dated_ingest_normal_notification(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- await core.enable_api()
- core.grant_source("workbuddy")
- record = core.put_complete("later-source-old-date", source="workbuddy")
- core.app.state.memory_processing_supervisor.enqueue_evidence(record)
- await core.wait_completed()
- await core.assert_governed(record)
-
- asyncio.run(scenario())
-
-
-def test_explicit_backlog_api_positive_control(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- old = core.put_complete("old-explicit-backlog")
- await core.save_and_probe()
- policy, _ = await core.enable_api("include")
- assert policy.backlog_events == ()
- assert policy.backlog_through_seq is not None
- assert policy.backlog_source_ids == ("cursor",)
- await core.wait_completed()
- await core.assert_governed(old)
-
- asyncio.run(scenario())
-
-
-def test_import_before_model_enable_and_later_append_both_reach_memory_within_ten_seconds(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- historical = core.put_complete("before-model")
- assert core.app.state.memory_task_store.list() == []
- assert core.app.state.memory_task_store.processing_metrics()["failed"] == 0
-
- await core.save_and_probe()
- policy, _body = await core.enable_api("include")
- assert policy.backlog_mode == "include"
- historical_tasks = await core.wait_completed(timeout=10)
- assert any(
- historical.evidence_id
- in {
- ref["evidence_id"]
- for ref in core.app.state.memory_task_store.inputs(task.task_id)
- }
- for task in historical_tasks
- )
- await core.assert_governed(historical)
-
- later = core.put("after-model", created_at=datetime.now(UTC))
- reply = core.put(
- "after-model-reply",
- created_at=datetime.now(UTC),
- role="assistant",
- written_by=WrittenBy.AGENT,
- )
- core.app.state.memory_processing_supervisor.enqueue_evidence(reply)
- deadline = asyncio.get_running_loop().time() + 10
- later_tasks = []
- while asyncio.get_running_loop().time() < deadline:
- later_tasks = core.app.state.memory_task_store.list()
- if any(
- later.evidence_id
- in {
- ref["evidence_id"]
- for ref in core.app.state.memory_task_store.inputs(task.task_id)
- }
- and task.status is MemoryTaskStatus.COMPLETED
- for task in later_tasks
- ):
- break
- await asyncio.sleep(0.025)
- assert any(
- later.evidence_id
- in {
- ref["evidence_id"]
- for ref in core.app.state.memory_task_store.inputs(task.task_id)
- }
- for task in later_tasks
- )
- await core.assert_governed(later)
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("revocation", ["source", "policy"])
-def test_revocation_after_hy_blocks_governance_publication(core, revocation):
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- core.kernel_enable()
- record = core.put_complete("revoked-in-flight")
-
- def revoke():
- if revocation == "source":
- core.app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
- else:
- with closing(sqlite3.connect(core.database)) as db, db:
- db.execute("BEGIN IMMEDIATE")
- current = continuous_policy.current(db)
- continuous_policy.transition(
- db,
- expected_revision=current.revision,
- action="revoke",
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
-
- core.bridge.before_return = revoke
- results = await core.run_prepared(record)
- assert core.bridge.requests, "Revocation test requires positive send admission"
- assert all(t.status != MemoryTaskStatus.COMPLETED for t in results)
- assert core.app.state.memory_governance_store.list(user_id="local") == []
-
- asyncio.run(scenario())
-
-
-def test_kernel_future_source_normal_enqueue_uses_existing_production_debounce(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- core.kernel_enable()
- core.grant_source("workbuddy")
- record = core.put("future-source", source="workbuddy")
- core.put(
- "future-source-reply",
- source="workbuddy",
- role="assistant",
- written_by=WrittenBy.AGENT,
- )
- core.app.state.memory_processing_supervisor.enqueue_evidence(record)
- await core.wait_completed()
- await core.assert_governed(record)
-
- asyncio.run(scenario())
-
-
-def test_kernel_restart_recovers_unnotified_ingest_without_legacy_grant(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- core.kernel_enable()
- record = core.put_complete("committed-before-restart", conversation="restart")
- assert not core.bridge.requests
- async with core.running():
- await core.wait_completed()
- await core.assert_governed(record)
- assert core.app.state.model_content_permissions.receipt(user_id="local") is None
-
- asyncio.run(scenario())
-
-
-def test_no_backlog_old_conversation_is_rejected_before_bridge_with_live_positive_control(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- old = core.put_complete("old-only", conversation="old-only")
- await core.save_and_probe()
- core.kernel_enable()
- supervisor = core.app.state.memory_processing_supervisor
- with pytest.raises(HyMemoryBridgeError, match="model_content_evidence_outside_policy"):
- await asyncio.to_thread(supervisor.prepare_conversation, old)
- assert not core.bridge.requests
- new = core.put_complete("new-positive", conversation="new-positive")
- results = await core.run_prepared(new)
- assert all(t.status == MemoryTaskStatus.COMPLETED for t in results)
- await core.assert_governed(new)
- assert all(
- old.evidence_id not in request.evidence_ids for request in core.bridge.requests
- )
-
- asyncio.run(scenario())
-
-
-def test_real_current_and_disabled_capability_preserve_no_side_effects(core):
- async def scenario():
- async with core.running(enable_continuous=False):
- await core.save_and_probe()
- current = await core.client.get(API_ROOT)
- assert current.status_code == 200, current.text
- value = current.json()
- assert value["available"] is True and value["policy"] is None
- assert "secret_ref" not in current.text
- assert value["capability"]["enable"] is False
- body = dict(
- operation_id=str(uuid4()),
- profile_id=core.profile["profile_id"],
- profile_revision=core.profile["revision"],
- expected_policy_revision=0,
- expected_legacy_revision=0,
- backlog_mode="none",
- user_confirmed=True,
- )
- denied = await core.client.post(API_ROOT + "/enable", json=body)
- assert denied.status_code == 409
- assert denied.json()["detail"] == "continuous_processing_unavailable"
- resume = await core.client.post(
- API_ROOT + "/resume",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": 1,
- "user_confirmed": True,
- },
- )
- assert resume.status_code == 409
- assert resume.json()["detail"] == "continuous_processing_unavailable"
- assert core.policy_current() is None
- assert core.app.state.model_profile_store.active_identity() is None
- assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-def test_actual_activation_service_rejects_preprobe_revision_then_commits_exact_profile(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- service = core.app.state.model_policy_activation
- assert core.profile["revision"] > 1 # Probe measured the embedding width.
- body = dict(
- operation_id=str(uuid4()),
- profile_id=core.profile["profile_id"],
- profile_revision=1,
- expected_policy_revision=0,
- expected_legacy_revision=0,
- backlog_mode="none",
- user_confirmed=True,
- )
- with pytest.raises(
- continuous_policy.ContinuousPolicyError, match="model_profile_changed"
- ):
- await asyncio.to_thread(service.enable, **body)
- assert core.policy_current() is None
- assert core.app.state.model_profile_store.active_identity() is None
- body["profile_revision"] = core.profile["revision"]
- result = await asyncio.to_thread(service.enable, **body)
- assert result.created and result.policy.profile.revision == core.profile["revision"]
- assert core.policy_current() == result.policy
- record = core.put_complete("after-atomic-service")
- tasks = await core.run_prepared(record)
- assert all(t.status == MemoryTaskStatus.COMPLETED for t in tasks)
- await core.assert_governed(record)
-
- asyncio.run(scenario())
-
-
-def test_actual_activation_conflict_does_not_partially_activate_or_create_policy(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- service = core.app.state.model_policy_activation
- with pytest.raises(
- continuous_policy.ContinuousPolicyError, match="policy_revision_changed"
- ):
- await asyncio.to_thread(
- service.enable,
- operation_id=str(uuid4()),
- profile_id=core.profile["profile_id"],
- profile_revision=core.profile["revision"],
- expected_policy_revision=9,
- expected_legacy_revision=0,
- backlog_mode="none",
- user_confirmed=True,
- )
- assert core.app.state.model_profile_store.active_identity() is None
- assert core.policy_current() is None
- assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-def test_actual_current_and_enable_require_authentication(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=core.app),
- base_url="http://test",
- ) as anonymous:
- assert (await anonymous.get(API_ROOT)).status_code == 401
- response = await anonymous.post(
- API_ROOT + "/enable",
- json={
- "operation_id": str(uuid4()),
- "profile_id": core.profile["profile_id"],
- "profile_revision": core.profile["revision"],
- "expected_policy_revision": 0,
- "expected_legacy_revision": 0,
- "backlog_mode": "none",
- "user_confirmed": True,
- },
- )
- assert response.status_code == 401
- assert core.policy_current() is None and not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-def test_legacy_revoked_policy_not_upgraded_on_restart(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- record = core.put("legacy")
- await core.save_and_probe()
- store = core.app.state.model_profile_store
- store.activate(core.profile["profile_id"], core.secrets)
- permissions = core.app.state.model_content_permissions
- receipt = permissions.grant(
- permissions.snapshot(
- user_id="local",
- source_ids=("cursor",),
- profile=store.snapshot(core.profile["profile_id"], core.profile["revision"]),
- evidence=(record,),
- include_future_additions=False,
- ),
- user_confirmed=True,
- expected_revision=0,
- )
- revoked = permissions.revoke(
- user_id="local", expected_revision=receipt.revision, user_confirmed=True
- )
- async with core.running():
- await core.app.state.memory_backfill
- assert core.app.state.model_content_permissions.receipt(user_id="local") == revoked
- assert core.policy_current() is None
- assert core.app.state.memory_task_store.list() == [] and not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-def test_restart_recovers_future_ingest_without_lost_notification(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- await core.enable_api()
- # Offline arrival uses the same normal store; no synthetic queue/task is injected.
- from one_memory.imports.storage import EvidenceStore
-
- record = (
- EvidenceStore(core.database)
- .put(
- EvidenceInput(
- source_id="cursor",
- conversation_id="offline",
- message_id="offline-new",
- role="user",
- written_by=WrittenBy.PERSON,
- created_at=OLD,
- verbatim=TEXT,
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- )
- .record
- )
- EvidenceStore(core.database).put(
- EvidenceInput(
- source_id="cursor",
- conversation_id="offline",
- message_id="offline-new-reply",
- role="assistant",
- written_by=WrittenBy.AGENT,
- created_at=OLD + timedelta(microseconds=1),
- verbatim=TEXT + " reply",
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- )
- async with core.running():
- await core.wait_completed()
- await core.assert_governed(record)
-
- asyncio.run(scenario())
-
-
-def test_api_pause_resume_revoke_keeps_original_boundary_and_refuses_revoked_resume(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- original, _ = await core.enable_api()
- for action, state in (
- ("pause", "paused"),
- ("resume", "enabled"),
- ("revoke", "revoked"),
- ):
- current = core.policy_current()
- response = await core.client.post(
- API_ROOT + "/" + action,
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": current.revision,
- "user_confirmed": True,
- },
- )
- assert response.status_code in (200, 202), response.text
- changed = core.policy_current()
- assert changed.state == state and changed.after_seq == original.after_seq
- denied = await core.client.post(
- API_ROOT + "/resume",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": changed.revision,
- "user_confirmed": True,
- },
- )
- assert denied.status_code == 409
-
- asyncio.run(scenario())
-
-
-def test_reauthorize_with_backlog_includes_conversations_received_while_revoked(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- original, _ = await core.enable_api("none")
- revoked_response = await core.client.post(
- API_ROOT + "/revoke",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": original.revision,
- "user_confirmed": True,
- },
- )
- assert revoked_response.status_code == 200, revoked_response.text
- revoked = core.policy_current()
- record = core.put_complete("received-while-revoked", conversation="revoked-backlog")
- core.app.state.memory_processing_supervisor.enqueue_evidence(record)
- await asyncio.sleep(1)
- assert core.bridge.requests == []
-
- response = await core.client.post(
- API_ROOT + "/enable",
- json={
- "operation_id": str(uuid4()),
- "profile_id": core.profile["profile_id"],
- "profile_revision": core.profile["revision"],
- "expected_policy_revision": revoked.revision,
- "expected_legacy_revision": revoked.legacy_revision,
- "backlog_mode": "include",
- "user_confirmed": True,
- },
- )
- assert response.status_code in (200, 202), response.text
- await core.wait_completed(timeout=4)
- await core.assert_governed(record)
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize(
- ("authority", "new_conversation"),
- [
- ("continuous", "new-event-after-unknown"),
- ("continuous", "unknown-outcome"),
- ("legacy", "new-event-after-unknown"),
- ],
-)
-def test_sdk_admitted_running_restart_preserves_unknown_without_blocking_new_event(
- core,
- monkeypatch,
- authority,
- new_conversation,
-):
- """Interrupt AFTER controlled SDK admission, not an unsent pending task.
-
- CancelledError emulates loss of the caller before the result is known; the
- real runner has already claimed and bound the task. No status is forged.
- Legacy is a positive control for the existing RUNNING -> PENDING behavior.
- """
-
- async def scenario():
- admitted = asyncio.Event()
- never_return = asyncio.Event()
- normal_ingest = core.bridge.ingest
-
- async def accepted_then_interrupted(request):
- core.bridge.requests.append(request)
- admitted.set()
- await never_return.wait()
- pytest.fail("Interrupted SDK invocation must not produce a result")
-
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- if authority == "continuous":
- core.kernel_enable()
- original = core.put_complete("sdk-admitted", conversation="unknown-outcome")
- if authority == "legacy":
- profiles = core.app.state.model_profile_store
- profiles.activate(core.profile["profile_id"], core.secrets)
- permissions = core.app.state.model_content_permissions
- permissions.grant(
- permissions.snapshot(
- user_id="local",
- source_ids=("cursor",),
- profile=profiles.snapshot(
- core.profile["profile_id"], core.profile["revision"]
- ),
- evidence=core.turn_records(original),
- include_future_additions=True,
- ),
- user_confirmed=True,
- expected_revision=0,
- )
- supervisor = core.app.state.memory_processing_supervisor
- prepared = await asyncio.to_thread(supervisor.prepare_conversation, original)
- assert len(prepared) == 1
- task_id = prepared[0].task_id
- monkeypatch.setattr(core.bridge, "ingest", accepted_then_interrupted)
- running = asyncio.create_task(supervisor._create_runner().run(task_id))
- try:
- await asyncio.wait_for(admitted.wait(), timeout=3)
- task = core.app.state.memory_task_store.require(task_id)
- assert task.status == MemoryTaskStatus.RUNNING and task.attempts == 1
- assert len(core.bridge.requests) == 1
- assert original.evidence_id in core.bridge.requests[0].evidence_ids
- assert core.app.state.memory_governance_store.list(user_id="local") == []
- finally:
- running.cancel()
- with pytest.raises(asyncio.CancelledError):
- await running
- stranded = core.app.state.memory_task_store.require(task_id)
- assert stranded.status == MemoryTaskStatus.RUNNING and stranded.attempts == 1
- monkeypatch.setattr(core.bridge, "ingest", normal_ingest)
-
- with closing(sqlite3.connect(core.database)) as db:
- row = db.execute(
- "SELECT status,attempts FROM memory_tasks WHERE task_id=?", (task_id,)
- ).fetchone()
- assert row == ("running", 1), "Fixture must preserve an actually admitted RUNNING task"
-
- async with core.running():
- await asyncio.wait_for(core.app.state.memory_backfill, timeout=4)
- if authority == "legacy":
- await core.wait_completed(timeout=4)
- recovered = core.app.state.memory_task_store.require(task_id)
- assert recovered.status == MemoryTaskStatus.COMPLETED
- assert recovered.attempts == 2 and len(core.bridge.requests) == 2
- await core.assert_governed(original)
- return
-
- recovered = core.app.state.memory_task_store.require(task_id)
- assert recovered.last_error_code == "model_content_outcome_unknown", recovered
- assert recovered.status not in {
- MemoryTaskStatus.PENDING,
- MemoryTaskStatus.RUNNING,
- MemoryTaskStatus.COMPLETED,
- }, recovered
- assert recovered.attempts == 1
- assert len(core.bridge.requests) == 1, "Same admitted event was sent again on startup"
-
- # Repeated import observation and notification must not mint a new
- # request identity for the already admitted event.
- repeated = core.put("sdk-admitted", conversation="unknown-outcome")
- assert repeated.evidence_id == original.evidence_id
- core.app.state.memory_processing_supervisor.enqueue_evidence(repeated)
- fresh = core.put_complete("new-event-positive", conversation=new_conversation)
- core.app.state.memory_processing_supervisor.enqueue_evidence(fresh)
- end = asyncio.get_running_loop().time() + 4
- completed = []
- while asyncio.get_running_loop().time() < end:
- completed = [
- task
- for task in core.app.state.memory_task_store.list()
- if task.input_ref == fresh.evidence_id
- and task.status == MemoryTaskStatus.COMPLETED
- ]
- if completed:
- break
- await asyncio.sleep(0.025)
- assert completed, "Unknown task must not starve a newly permitted event"
- # Both debounce callbacks have had a chance to run; new work is the
- # positive control that the restarted queue is actually live.
- await asyncio.sleep(1)
- old_calls = [r for r in core.bridge.requests if original.evidence_id in r.evidence_ids]
- new_calls = [r for r in core.bridge.requests if fresh.evidence_id in r.evidence_ids]
- assert len(old_calls) == len(new_calls) == 1
- new_context = new_calls[0].source_context
- if new_context:
- assert original.evidence_id not in {
- item.evidence_id
- for item in (
- *new_context.current,
- *new_context.history,
- *new_context.supporting,
- )
- }, "Unknown old event must not be resent as context for the new event"
- unchanged = core.app.state.memory_task_store.require(task_id)
- assert unchanged.attempts == 1
- assert unchanged.last_error_code == "model_content_outcome_unknown"
- await core.assert_governed(fresh)
- assert all(
- original.evidence_id not in memory.evidence_ids
- for memory in core.app.state.memory_governance_store.list(user_id="local")
- )
-
- asyncio.run(scenario())
-
-
-def test_public_none_policy_excludes_old_history_and_does_not_replay_legacy_startup(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- old = core.put_complete("old-neighbor", conversation="mixed-public")
- await core.save_and_probe()
- await core.enable_api("none")
- fresh = core.put_complete("public-future", conversation="mixed-public")
- core.app.state.memory_processing_supervisor.enqueue_evidence(fresh)
- await core.wait_completed(timeout=4)
- await core.assert_governed(fresh)
- assert all(old.evidence_id not in r.evidence_ids for r in core.bridge.requests)
- for request in core.bridge.requests:
- if request.source_context:
- context = request.source_context
- assert old.evidence_id not in {
- e.evidence_id
- for e in (*context.current, *context.history, *context.supporting)
- }
- calls = len(core.bridge.requests)
- async with core.running():
- await core.app.state.memory_backfill
- await asyncio.sleep(2.2) # Covers the coordinator's normal monitor interval.
- assert len(core.bridge.requests) == calls
- assert core.app.state.model_content_permissions.receipt(user_id="local") is None
- assert all(
- t.status == MemoryTaskStatus.COMPLETED
- for t in core.app.state.memory_task_store.list()
- )
-
- asyncio.run(scenario())
-
-
-def test_public_revoke_after_sdk_admission_blocks_publish_and_future_work(core, monkeypatch):
- async def scenario():
- admitted, release = asyncio.Event(), asyncio.Event()
- original_ingest = core.bridge.ingest
-
- async def hold_response(request):
- admitted.set()
- await release.wait()
- return await original_ingest(request)
-
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- policy, _ = await core.enable_api()
- monkeypatch.setattr(core.bridge, "ingest", hold_response)
- record = core.put_complete("public-in-flight", conversation="revoke-public")
- core.app.state.memory_processing_supervisor.enqueue_evidence(record)
- try:
- await asyncio.wait_for(admitted.wait(), 4)
- current = core.app.state.memory_task_store.list()
- assert any(t.status == MemoryTaskStatus.RUNNING for t in current)
- revoked = await core.client.post(
- API_ROOT + "/revoke",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": policy.revision,
- "user_confirmed": True,
- },
- )
- assert revoked.status_code == 200, revoked.text
- assert revoked.json()["state"] == "revoked"
- finally:
- release.set()
- deadline = asyncio.get_running_loop().time() + 4
- while asyncio.get_running_loop().time() < deadline:
- tasks = core.app.state.memory_task_store.list()
- if tasks and all(t.status != MemoryTaskStatus.RUNNING for t in tasks):
- break
- await asyncio.sleep(0.025)
- assert tasks and all(t.status == MemoryTaskStatus.CANCELLED for t in tasks)
- assert len(core.bridge.requests) == 1
- assert core.app.state.memory_governance_store.list(user_id="local") == []
- next_record = core.put("after-revoke", conversation="after-revoke")
- core.app.state.memory_processing_supervisor.enqueue_evidence(next_record)
- await asyncio.sleep(2.2)
- assert len(core.bridge.requests) == 1
- assert core.app.state.memory_governance_store.list(user_id="local") == []
-
- asyncio.run(scenario())
-
-
-def test_public_pause_accumulation_resumes_through_actual_callback(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- policy, _ = await core.enable_api()
- paused = await core.client.post(
- API_ROOT + "/pause",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": policy.revision,
- "user_confirmed": True,
- },
- )
- assert paused.status_code == 200, paused.text
- record = core.put_complete("while-paused", conversation="paused")
- core.app.state.memory_processing_supervisor.enqueue_evidence(record)
- await asyncio.sleep(1)
- assert core.bridge.requests == []
- resumed = await core.client.post(
- API_ROOT + "/resume",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": paused.json()["revision"],
- "user_confirmed": True,
- },
- )
- assert resumed.status_code == 200, resumed.text
- assert resumed.json()["after_seq"] == policy.after_seq
- await core.wait_completed(timeout=4)
- await core.assert_governed(record)
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
-
-
-def test_public_memory_note_auto_processes_under_continuous_policy_without_legacy_grant(core):
- async def scenario():
- async with core.running():
- await core.save_and_probe()
- policy, _ = await core.enable_api("none")
- with closing(sqlite3.connect(core.database)) as db:
- assert (
- db.execute(
- "SELECT 1 FROM source_grants WHERE user_id='local' "
- "AND source_id='one-memory-manual'"
- ).fetchone()
- is None
- )
- note = {"request_id": str(uuid4()), "text": TEXT, "scope": "all-apps"}
- response = await core.client.post("/api/v1/memory-notes", json=note)
- assert response.status_code == 201, response.text
- saved = response.json()
- assert saved["state"] in {
- "pending",
- "running",
- "waiting_for_model",
- "ready",
- }, saved
- assert saved["error"] is None
- record = core.app.state.evidence_store.get(saved["evidence_id"])
- assert record is not None and record.source_id == "one-memory-manual"
- assert record.verbatim == TEXT and record.written_by == WrittenBy.PERSON
- with closing(sqlite3.connect(core.database)) as db:
- db.execute("BEGIN")
- grant = db.execute(
- "SELECT root_path,revoked_at FROM source_grants WHERE user_id='local' "
- "AND source_id='one-memory-manual'"
- ).fetchone()
- assert grant == (str(core.data), None)
- event = ingest_journal.current(db, "local", record.evidence_id)
- assert event is not None and event.seq > policy.after_seq
- assert event.generation_eligible and ingest_journal.matches_current(db, event)
- continuous_policy.check(db, policy, evidence_ids=(record.evidence_id,))
- assert core.app.state.model_content_permissions.receipt(user_id="local") is None
- # No /process request or second content consent: the normal POST owns scheduling.
- await core.wait_completed(timeout=4)
- await core.assert_governed(record)
- final = await core.client.get(f"/api/v1/memory-notes/{record.evidence_id}")
- assert final.status_code == 200, final.text
- assert final.json()["state"] == "ready" and final.json()["memory_ids"]
- assert final.json()["error"] is None
- listing = await core.client.get("/api/v1/memory-notes")
- assert listing.status_code == 200
- assert any(
- item["evidence_id"] == record.evidence_id and item["state"] == "ready"
- for item in listing.json()
- )
- calls = [r for r in core.bridge.requests if record.evidence_id in r.evidence_ids]
- assert len(calls) == 1 and calls[0].source_id == "one-memory-manual"
- repeated = await core.client.post("/api/v1/memory-notes", json=note)
- assert repeated.status_code == 201, repeated.text
- assert repeated.json()["evidence_id"] == record.evidence_id
- assert repeated.json()["state"] == "ready"
- assert core.app.state.model_content_permissions.receipt(user_id="local") is None
- assert core.policy_current() == policy
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
-
-
-def test_the_summary_tells_running_rows_from_work_actually_being_done(core):
- """`running` rows are not the current concurrency.
-
- A task is marked running before anything slow happens, so a worker that
- gives up — a timeout, a crash between steps — leaves a row that still says
- running. Reporting that as "what is running" is how a stalled queue looks
- busy, so the count stays as `running` and rows nobody owns are broken out
- as `stranded_running`.
- """
-
- async def scenario():
- async with core.running():
- core.grant_source()
- await core.save_and_probe()
- core.kernel_enable()
- # Make the difference real: a row the store marks running that no
- # worker owns. Without this the two numbers are both zero and a
- # hardcoded zero would pass just as well.
- stranded = core.app.state.memory_task_store
- record = core.put("stranded-task")
- task = stranded.create(
- request_id=f"native:{record.evidence_id}:{record.revision}:strand",
- user_id=record.user_id,
- kind="extract",
- input_ref=record.evidence_id,
- profile=core.app.state.model_profile_store.list(core.secrets)[0],
- )
- stranded.claim(task.task_id)
- assert stranded.require(task.task_id).status == "running"
-
- response = await core.client.get("/api/v1/memory-processing")
- assert response.status_code == 200, response.text
- summary = response.json()
- for field in ("running", "in_flight_count", "stranded_running"):
- assert field in summary, (field, summary)
- assert summary["stranded_running"] >= 0
- assert summary["stranded_running"] == max(
- 0, summary["running"] - summary["in_flight_count"]
- )
- # The strand is real here, so it must be visible: this is the
- # assertion a hardcoded zero fails.
- assert summary["stranded_running"] >= 1, summary
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_delete_no_conversation_freeze.py b/memhub/apps/core/tests/test_delete_no_conversation_freeze.py
deleted file mode 100644
index 2617b18..0000000
--- a/memhub/apps/core/tests/test_delete_no_conversation_freeze.py
+++ /dev/null
@@ -1,223 +0,0 @@
-"""删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。
-
-沙箱真 HY 实验里实测过:人删掉一条记忆后,同一对话之后的新消息也不再成为记忆。
-处理一段对话时,每个 chunk 都要过一遍上下文预检;已处理过的旧 turn 的证据
-正托着被删的那条记忆,于是预检在旧 chunk 上抛 evidence_changed,整段对话中止,
-新 turn 连同它一起被丢下。live 路径还把这个错误吞成一行日志,实时同步就此静默停住。
-
-删除删的是那条记忆实体,不是"永远不许再说这件事":
-新 turn 是新的陈述,应当照常沉淀;同一段旧原话(按证据 id)不得把已删记忆抽回。
-
-三条入口各守一条:即时 prepare、live 到达、backfill 回放。
-全部走真实的临时 Core、真实的连续处理授权与守卫,只把 HY 换成可控的替身。
-"""
-
-import asyncio
-
-import pytest
-
-from one_memory.imports.models import WrittenBy
-from one_memory.imports.runner import live_sync_arrival
-from one_memory.memory_governance import MemoryStatus
-from one_memory.memory_tasks import MemoryTaskStatus
-from tests.test_continuous_processing_integration import IntegrationCore
-
-
-@pytest.fixture
-def core(tmp_path, monkeypatch):
- return IntegrationCore(tmp_path, monkeypatch)
-
-
-def _memory_for(core, record):
- return core.app.state.memory_governance_store.get(
- "integration-hy-" + record.evidence_id, user_id="local"
- )
-
-
-async def _first_turn_then_delete(core):
- """第一个 turn 沉淀成记忆,然后人把它删掉。返回那一轮的原话与连续授权。"""
- core.grant_source()
- await core.save_and_probe()
- receipt = core.kernel_enable()
- first = core.put_complete("first-turn")
- results = await core.run_prepared(first)
- assert [t.status for t in results] == [MemoryTaskStatus.COMPLETED]
- memory = _memory_for(core, first)
- # 正向前提:第一轮确实沉淀出了一条可删的记忆。
- assert memory is not None and memory.status is MemoryStatus.CONFIRMED
- forgotten = []
-
- async def forget_in_hy(memory_id):
- # HY 那头的遗忘另有守护;这里只要治理侧的删除照常落地。
- forgotten.append(memory_id)
- return {"deleted": True}
-
- core.app.state.memory_processing_supervisor.delete_hy_memory = forget_in_hy
- deleted = await core.client.post(
- f"/api/v1/memories/{memory.memory_id}/actions/delete",
- json={"request_id": "delete-first", "expected_revision": memory.revision},
- )
- assert deleted.status_code == 200, deleted.text
- assert _memory_for(core, first).status is MemoryStatus.DELETED
- assert forgotten == [memory.memory_id]
- return first, receipt
-
-
-def test_prepare_after_a_deletion_still_settles_the_new_turn(core):
- async def scenario():
- async with core.running():
- first, _receipt = await _first_turn_then_delete(core)
- second = core.put_complete("second-turn")
- supervisor = core.app.state.memory_processing_supervisor
-
- prepared = await asyncio.to_thread(supervisor.prepare_conversation, second)
- # 已完成的旧 turn 在合并前剔除、不再预检重跑;只有新 turn 是待做的。
- by_input = {t.input_ref: t for t in prepared}
- assert set(by_input) == {second.evidence_id}
- pending = [t for t in prepared if t.status is not MemoryTaskStatus.COMPLETED]
- assert [t.input_ref for t in pending] == [second.evidence_id]
- for task in pending:
- await supervisor._create_runner().run(task.task_id)
-
- memory = _memory_for(core, second)
- assert memory is not None and memory.status is MemoryStatus.CONFIRMED
- assert _memory_for(core, first).status is MemoryStatus.DELETED
- # 新 turn 照常处理,但被删的记忆和它托着的原话都不进这一轮的上下文。
- sent = next(r for r in core.bridge.requests if second.evidence_id in r.evidence_ids)
- context = sent.source_context
- assert context is not None
- assert first.evidence_id not in {
- r.evidence_id for r in (*context.history, *context.supporting)
- }
- deleted_id = "integration-hy-" + first.evidence_id
- assert deleted_id not in {m.governed_memory_id for m in context.known_memories}
-
- asyncio.run(scenario())
-
-
-async def _live_settled(supervisor, tasks):
- """等防抖、准备与派发都走完,而不是等固定秒数:慢机器上也不会早判。"""
- loop = asyncio.get_running_loop()
- end = loop.time() + 30
- while loop.time() < end:
- idle = not supervisor._debounces and not supervisor._preparations
- busy = any(
- t.status in {MemoryTaskStatus.PENDING, MemoryTaskStatus.RUNNING} for t in tasks.list()
- )
- if idle and not busy:
- return
- await asyncio.sleep(0.05)
- pytest.fail("live 到达的准备与派发没有在界限内走完")
-
-
-def test_live_arrival_after_a_deletion_still_settles_the_new_turn(core):
- async def scenario():
- async with core.running():
- first, _receipt = await _first_turn_then_delete(core)
- second = core.put("second-turn")
- reply = core.put(
- "second-turn-reply",
- conversation="same-conversation",
- role="assistant",
- written_by="agent",
- )
- supervisor = core.app.state.memory_processing_supervisor
- assert _memory_for(core, second) is None
-
- token = live_sync_arrival.set(True)
- try:
- supervisor.enqueue_evidence(second)
- supervisor.enqueue_evidence(reply)
- finally:
- live_sync_arrival.reset(token)
- await _live_settled(supervisor, core.app.state.memory_task_store)
-
- memory = _memory_for(core, second)
- assert memory is not None and memory.status is MemoryStatus.CONFIRMED
- assert _memory_for(core, first).status is MemoryStatus.DELETED
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("deleted", [True, False], ids=["deleted", "control-kept"])
-def test_reprocessing_the_same_old_evidence_does_not_bring_a_deleted_memory_back(core, deleted):
- """按证据 id 挡住:同一段旧原话再交给 HY 一次,也不得重新长出被删的那条记忆。
-
- 对照组不删:同一条重跑路径确实会把同一段原话再抽成一条新记忆——
- 证明挡住它的是删除,而不是这条路径本来就到不了写入。
- """
-
- async def scenario():
- async with core.running():
- if deleted:
- first, _receipt = await _first_turn_then_delete(core)
- else:
- core.grant_source()
- await core.save_and_probe()
- core.kernel_enable()
- first = core.put_complete("first-turn")
- await core.run_prepared(first)
- original = core.bridge.ingest
-
- async def reextract(request):
- # 真 HY 重抽同一段原话时给的是新 id,内容相同。
- result = await original(request)
- return result.model_copy(
- update={
- "memories": tuple(
- m.model_copy(update={"memory_id": "reextracted-" + m.memory_id})
- for m in result.memories
- )
- }
- )
-
- core.bridge.ingest = reextract
- tasks = core.app.state.memory_task_store
- done = next(t for t in tasks.list() if t.input_ref == first.evidence_id)
- tasks.requeue_completed(done.task_id, reason="reprocess_same_evidence")
-
- finished = await core.app.state.memory_processing_supervisor._create_runner().run(
- done.task_id
- )
-
- back = core.app.state.memory_governance_store.get(
- "reextracted-integration-hy-" + first.evidence_id, user_id="local"
- )
- if deleted:
- assert back is None or back.status is not MemoryStatus.CONFIRMED
- assert finished.status is not MemoryTaskStatus.COMPLETED
- else:
- assert finished.status is MemoryTaskStatus.COMPLETED, finished.last_error_code
- assert back is not None and back.status is MemoryStatus.CONFIRMED
-
- asyncio.run(scenario())
-
-
-def test_backfill_after_a_deletion_still_settles_the_new_turn(core):
- """backfill 修复前就不冻结:已完成的旧 chunk 在入队时跳过,出错的 chunk 也只丢它自己。
-
- 只拿掉其中一道它仍是绿的;两道都拿掉时才红(实测)。
- 它守的是 backfill 这一路的结果,不单独守"已完成跳过"——那一道由 prepare 的守卫看住。
- """
-
- async def scenario():
- async with core.running():
- first, receipt = await _first_turn_then_delete(core)
- second = core.put_complete("second-turn")
- core.put(
- "second-turn-answer",
- conversation=second.conversation_id,
- role="assistant",
- written_by=WrittenBy.AGENT,
- )
- supervisor = core.app.state.memory_processing_supervisor
- assert _memory_for(core, second) is None
-
- await supervisor.backfill_off_thread(expected_permission_revision=receipt.revision)
- await core.wait_completed(minimum_tasks=2)
-
- memory = _memory_for(core, second)
- assert memory is not None and memory.status is MemoryStatus.CONFIRMED
- assert _memory_for(core, first).status is MemoryStatus.DELETED
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_detailed_logging.py b/memhub/apps/core/tests/test_detailed_logging.py
index 26a6cd2..c095b51 100644
--- a/memhub/apps/core/tests/test_detailed_logging.py
+++ b/memhub/apps/core/tests/test_detailed_logging.py
@@ -1,10 +1,8 @@
import json
import logging
from importlib import import_module
-from inspect import getsource
from pathlib import Path
-from one_memory.hy_permission_transport import HyPermissionTransport
from one_memory.hy_runtime_config import (
configure_hy_runtime_environment,
detailed_logging_enabled,
@@ -66,13 +64,6 @@ def test_info_collects_hy_info_without_enabling_debug_pipeline_content(
assert __import__("os").environ["MEMORY_LOG_LLM_CONTENT"] == "false"
-def test_transport_does_not_reject_debug_wire_logging() -> None:
- source = getsource(HyPermissionTransport)
-
- assert "_check_wire_logging" not in source
- assert "unsafe_wire_logging" not in source
-
-
def test_live_debug_switch_makes_sdk_pipeline_writer_keep_original_content(
tmp_path: Path,
) -> None:
diff --git a/memhub/apps/core/tests/test_evidence_revision_read_review.py b/memhub/apps/core/tests/test_evidence_revision_read_review.py
deleted file mode 100644
index 6db6676..0000000
--- a/memhub/apps/core/tests/test_evidence_revision_read_review.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""Read invalidation review: real guard/provenance, controlled HY, no model/network."""
-
-import asyncio
-import sqlite3
-from contextlib import closing
-
-import pytest
-
-from one_memory import context_routes
-from one_memory.hy_memory_bridge import HyMemoryOutput
-from one_memory.imports.models import WrittenBy
-from one_memory.memory_governance import MemoryStatus
-from one_memory.memory_tasks import evidence_ref
-from tests.test_continuous_processing_integration import IntegrationCore
-from tests.test_governance_authority_review import WORKSPACE, prepare, process, put, search
-
-OLD = "revisionneedle the project deploys only on Windows"
-NEW = "revisionneedle the project deploys only on macOS"
-
-
-async def derived(core):
- await prepare(core)
- original = put(core, "edited-turn", OLD, WrittenBy.PERSON)
- put(core, "edited-turn-reply", "Acknowledged.", WrittenBy.AGENT)
- outputs = await process(
- core,
- original,
- [
- HyMemoryOutput(
- memory_id="review-old-claim",
- content=OLD,
- layer="L2_FACT",
- owner="user",
- evidence_ids=(original.evidence_id,),
- )
- ],
- )
- output = outputs[0]
- assert output.support_refs == (evidence_ref(original),)
- assert output.memory_revision == 1
- before = await search(core, "revisionneedle", [WORKSPACE])
- assert before.status_code == 200 and len(before.json()["items"]) == 1
- assert before.json()["items"][0]["content"] == OLD
- return original, output
-
-
-@pytest.mark.parametrize("during_read", [False, True], ids=["before-query", "before-publish"])
-def test_revised_evidence_does_not_supply_old_derived_claim(tmp_path, monkeypatch, during_read):
- async def scenario():
- core = IntegrationCore(tmp_path, monkeypatch)
- async with core.running():
- original, output = await derived(core)
- revised = None
-
- def revise():
- nonlocal revised
- revised = put(core, "edited-turn", NEW, WrittenBy.PERSON)
- assert revised.evidence_id == original.evidence_id
- assert revised.revision == original.revision + 1
- assert revised.content_hash != original.content_hash
-
- if during_read:
- read = context_routes.read_memory_context
-
- def revision_after_selection(*args, **kwargs):
- result = read(*args, **kwargs)
- assert len(result.items) == 1
- revise()
- return result
-
- monkeypatch.setattr(context_routes, "read_memory_context", revision_after_selection)
- else:
- revise()
- response = await search(core, "revisionneedle", [WORKSPACE])
- assert response.status_code == 200
- assert revised is not None
- stored = core.app.state.memory_task_store.outputs(output.task_id)[0]
- assert stored.support_refs == output.support_refs
- memory = core.app.state.memory_governance_store.get(output.memory_id, user_id="local")
- assert memory.status is MemoryStatus.CONFIRMED and memory.content == OLD
- assert len(core.bridge.requests) == 1
- with closing(sqlite3.connect(core.database)) as db:
- versions = db.execute(
- "SELECT revision FROM evidence_versions WHERE evidence_id=? ORDER BY revision",
- (original.evidence_id,),
- ).fetchall()
- events = db.execute(
- "SELECT evidence_revision FROM evidence_ingest_events "
- "WHERE evidence_id=? ORDER BY seq",
- (original.evidence_id,),
- ).fetchall()
- assert versions == [(1,), (2,)] and events == [(1,), (2,)]
- print("revision review:", during_read, output.support_refs, response.json())
- assert response.json()["items"] == []
- library = await core.client.get("/api/v1/memory-library")
- # D22: the total skips the per-row freshness scan (product accepted a
- # small drift), but the stale claim itself must never be listed.
- assert library.status_code == 200 and library.json()["items"] == []
- home = await core.client.get("/api/v1/home-summary")
- # Same D22 drift for the homepage count; the latest card must not be it.
- assert home.status_code == 200 and home.json()["latest"] is None
- assert (await core.client.get("/api/v1/memories")).json() == []
- detail = await core.client.get("/api/v1/memories/" + output.memory_id)
- assert detail.status_code == 403
-
- asyncio.run(scenario())
-
-
-def test_explicit_correction_is_distinguishable_from_unchanged_sdk_projection(
- tmp_path, monkeypatch
-):
- async def scenario():
- core = IntegrationCore(tmp_path, monkeypatch)
- async with core.running():
- original, output = await derived(core)
- corrected = core.app.state.person_writes.correct(
- output.memory_id,
- user_id="local",
- request_id="explicit-user-correction",
- expected_revision=output.memory_revision,
- content=NEW,
- )
- assert corrected.revision > output.memory_revision
- assert corrected.origin.value == "extracted"
- assert corrected.evidence_ids == (original.evidence_id,)
- with closing(sqlite3.connect(core.database)) as db:
- operation = db.execute(
- "SELECT operation FROM governed_memory_mutations WHERE request_id=?",
- ("explicit-user-correction",),
- ).fetchone()
- version = db.execute(
- "SELECT action,content FROM governed_memory_versions "
- "WHERE memory_id=? AND revision=?",
- (corrected.memory_id, corrected.revision),
- ).fetchone()
- assert operation == ("correct",) and version == ("correct", NEW)
- stored = core.app.state.memory_task_store.outputs(output.task_id)[0]
- assert stored.memory_revision == 1 and stored.support_refs == output.support_refs
- # Inspect metadata only: no new policy that old refs authorize a manual correction.
- print("correction distinction:", corrected.revision, stored.memory_revision, operation)
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_frozen_core_layout.py b/memhub/apps/core/tests/test_frozen_core_layout.py
index c514240..06e3f44 100644
--- a/memhub/apps/core/tests/test_frozen_core_layout.py
+++ b/memhub/apps/core/tests/test_frozen_core_layout.py
@@ -79,9 +79,3 @@ def test_default_distribution_avoids_per_launch_self_extraction(monkeypatch):
calls, _, _ = evaluate_spec(monkeypatch, None)
assert calls["exe"][1]["exclude_binaries"] is True
assert calls["collect"][1]["name"] == "one-memory-runtime"
-
-
-@pytest.mark.parametrize("layout", ["folder", "single-file"])
-def test_runtime_permission_factory_is_explicitly_bundled(monkeypatch, layout):
- calls, _, _ = evaluate_spec(monkeypatch, layout)
- assert "one_memory.hy_permission_context" in calls["analysis"][1]["hiddenimports"]
diff --git a/memhub/apps/core/tests/test_governance_authority_review.py b/memhub/apps/core/tests/test_governance_authority_review.py
deleted file mode 100644
index f8af235..0000000
--- a/memhub/apps/core/tests/test_governance_authority_review.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""D04 authority and workspace scope through the real continuous permission guard.
-
-Only HY/model transport is controlled. These assert the documented boundary,
-not that a confirmed machine projection constitutes human factual acceptance.
-"""
-
-import asyncio
-import json
-from datetime import UTC, datetime
-
-import pytest
-
-from one_memory.hy_memory_bridge import HyMemoryIngestResult, HyMemoryOutput
-from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
-from one_memory.memory_governance import MemoryDraft, MemoryKind, MemoryOrigin, MemoryStatus
-from one_memory.memory_tasks import MemoryTaskStatus
-from tests.test_continuous_processing_integration import IntegrationCore
-
-WORKSPACE = "workspace:v1:" + "a" * 64
-OTHER_WORKSPACE = "workspace:v1:" + "b" * 64
-AGENT_TEXT = "authorityneedle assistant proposes project delivery without user acceptance"
-PROJECT_TEXT = "projectneedle this project requires a staging approval before deployment"
-
-
-def envelope(scopes, tools=("om_recall",)):
- # Simulated authenticated Broker envelope, not a real host installation/grant.
- return {
- "x-one-memory-read-context": "external",
- "x-one-memory-installation-id": "00000000-0000-0000-0000-000000000001",
- "x-one-memory-installation-revision": "1",
- "x-one-memory-allowed-scopes": json.dumps(scopes),
- "x-one-memory-allowed-tools": json.dumps(tools),
- }
-
-
-async def search(core, query, scopes, tools=("om_recall",)):
- return await core.client.post(
- "/api/v1/context/search",
- json={"query": query, "kinds": ["memory"]},
- headers=envelope(scopes, tools),
- )
-
-
-def put(core, key, text, author):
- return core.app.state.evidence_store.put(
- EvidenceInput(
- user_id="local",
- source_id="cursor",
- conversation_id="authority-review",
- message_id=key,
- workspace=WORKSPACE,
- role="assistant" if author is WrittenBy.AGENT else "user",
- written_by=author,
- created_at=datetime.now(UTC),
- verbatim=text,
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- ).record
-
-
-async def prepare(core):
- core.grant_source()
- core.app.state.config_store.set_sync("cursor", True)
- await core.save_and_probe()
- await core.enable_api()
- runner = core.app.state.memory_processing_supervisor._create_runner()
- assert runner._permission_guard is not None
-
-
-async def process(core, record, outputs):
- async def ingest(request):
- core.bridge.requests.append(request)
- assert record.evidence_id in request.evidence_ids
- return HyMemoryIngestResult(success=True, memories=tuple(outputs))
-
- core.bridge.ingest = ingest
- results = await core.run_prepared(record)
- assert results and all(task.status == MemoryTaskStatus.COMPLETED for task in results)
- assert core.bridge.requests and core.bridge.leases
- provenance = [
- output
- for task in results
- for output in core.app.state.memory_task_store.outputs(task.task_id)
- ]
- assert len(provenance) == len(outputs)
- return provenance
-
-
-@pytest.mark.parametrize("existing", [False, True])
-def test_agent_output_lands_usable_without_demoting_an_existing_memory(
- tmp_path, monkeypatch, existing
-):
- async def scenario():
- core = IntegrationCore(tmp_path, monkeypatch)
- async with core.running():
- await prepare(core)
- person = put(core, "person", "Please review the project proposal", WrittenBy.PERSON)
- agent = put(core, "agent", AGENT_TEXT, WrittenBy.AGENT)
- governance = core.app.state.memory_governance_store
- if existing:
- previous = core.app.state.person_writes.create(
- MemoryDraft(
- user_id="local",
- kind=MemoryKind.CHAT,
- content=AGENT_TEXT,
- scopes=(WORKSPACE,),
- evidence_ids=(agent.evidence_id,),
- origin=MemoryOrigin.AGENT,
- ),
- request_id="fixture-prior",
- memory_id="prior-agent-memory",
- )
- await process(
- core,
- person,
- [
- HyMemoryOutput(
- memory_id="hy-agent-proposal",
- content=AGENT_TEXT,
- layer="L2_FACT",
- owner="agent",
- evidence_ids=(agent.evidence_id,),
- )
- ],
- )
- # HY's id is mirrored as its own row; the ledger does not fold it
- # into an older row that happens to share the words.
- memory = governance.get("hy-agent-proposal", user_id="local")
- assert memory is not None and memory.origin is MemoryOrigin.AGENT
- assert memory.evidence_ids == (agent.evidence_id,)
- assert memory.status is MemoryStatus.CONFIRMED
- prior_visible = 0
- if existing:
- prior = governance.get("prior-agent-memory", user_id="local")
- assert prior.status is MemoryStatus.CONFIRMED
- assert prior.revision == previous.revision
- prior_visible = 1
- matching = await search(core, "authorityneedle", [WORKSPACE])
- assert matching.status_code == 200, matching.text
- assert len(matching.json()["items"]) == 1 + prior_visible
- assert memory.scopes == (WORKSPACE,) or WORKSPACE in memory.scopes
- denied = await search(core, "authorityneedle", [WORKSPACE], ("om_conversations",))
- assert denied.status_code == 403
-
- asyncio.run(scenario())
-
-
-def test_project_fact_is_shared_across_apps_and_leaves_with_its_grant(tmp_path, monkeypatch):
- async def scenario():
- core = IntegrationCore(tmp_path, monkeypatch)
- async with core.running():
- await prepare(core)
- record = put(core, "project", PROJECT_TEXT, WrittenBy.PERSON)
- put(core, "project-reply", "Acknowledged.", WrittenBy.AGENT)
- await process(
- core,
- record,
- [
- HyMemoryOutput(
- memory_id="hy-project-rule",
- content=PROJECT_TEXT,
- layer="L2_FACT",
- owner="user",
- evidence_ids=(record.evidence_id,),
- ),
- *[
- HyMemoryOutput(
- memory_id="hy-profile-" + layer,
- content="profileneedle " + layer + " user prefers concise explanations",
- layer=layer,
- owner="user",
- evidence_ids=(record.evidence_id,),
- )
- for layer in ("L0_BASIC_INFO", "L6_SCHEMA")
- ],
- ],
- )
- memory = core.app.state.memory_governance_store.get("hy-project-rule", user_id="local")
- assert memory is not None and memory.status is MemoryStatus.CONFIRMED
- assert memory.origin is MemoryOrigin.EXTRACTED
- workspace = await search(core, "projectneedle", [WORKSPACE])
- assert workspace.status_code == 200 and len(workspace.json()["items"]) == 1
- unrelated = await search(core, "projectneedle", [OTHER_WORKSPACE])
- assert unrelated.status_code == 200 and unrelated.json()["items"] == []
- global_read = await search(core, "projectneedle", ["all-apps", OTHER_WORKSPACE])
- assert global_read.status_code == 200 and len(global_read.json()["items"]) == 1
- assert memory.scopes == ("all-apps", WORKSPACE)
- profiles = await search(core, "profileneedle", ["all-apps"])
- assert profiles.status_code == 200 and len(profiles.json()["items"]) == 2
- denied = await search(core, "projectneedle", [WORKSPACE], ("om_conversations",))
- assert denied.status_code == 403
- core.app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
- revoked = await search(core, "projectneedle", [WORKSPACE])
- assert revoked.status_code == 200 and revoked.json()["items"] == []
- revoked_global = await search(core, "projectneedle", ["all-apps"])
- assert revoked_global.status_code == 200 and revoked_global.json()["items"] == []
- revoked_profile = await search(core, "profileneedle", ["all-apps"])
- assert revoked_profile.status_code == 200 and revoked_profile.json()["items"] == []
- assert len(core.bridge.requests) == 1
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_health.py b/memhub/apps/core/tests/test_health.py
index 46d28ca..b59bc1d 100644
--- a/memhub/apps/core/tests/test_health.py
+++ b/memhub/apps/core/tests/test_health.py
@@ -3,13 +3,10 @@ import logging
import sqlite3
import threading
import traceback
-from collections.abc import Callable
from contextlib import suppress
import pytest
from httpx import ASGITransport, AsyncClient, Response
-from one_memory.model_content_permissions import ModelContentPermissionStore
-from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
@@ -18,10 +15,10 @@ from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetad
from one_memory.imports.sources.registry import RootCandidateKind
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_governance import MemoryGovernanceStore
-from one_memory.memory_processing import MemoryProcessingSupervisor
+from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ModelProfileStore
-from one_memory.secrets import InMemorySecretStore, SecretStoreUnavailableError
+from one_memory.secrets import InMemorySecretStore
async def request(
@@ -86,45 +83,6 @@ def test_core_refuses_to_start_a_database_written_by_a_newer_build(tmp_path, mon
asyncio.run(scenario())
-def test_core_stays_ready_when_legacy_policy_upgrade_cannot_read_keychain(
- tmp_path, monkeypatch
-) -> None:
- monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
- monkeypatch.setattr(
- "one_memory.model_policy_activation.ModelPolicyActivation.upgrade_legacy_none_policy",
- lambda _self: (_ for _ in ()).throw(
- SecretStoreUnavailableError("Operating system secure storage read backoff")
- ),
- )
- settings = Settings(
- _env_file=None,
- data_dir=tmp_path / "data",
- source_home=tmp_path / "source",
- api_token=SecretStr("test"),
- imports_enabled=False,
- automatic_memory_processing=False,
- live_sync_enabled=False,
- )
- app = create_app(settings, secret_store=InMemorySecretStore())
-
- async def scenario() -> None:
- async with (
- app.router.lifespan_context(app),
- AsyncClient(
- transport=ASGITransport(app=app),
- base_url="http://test",
- headers={"Authorization": "Bearer test"},
- ) as client,
- ):
- health = await client.get("/api/v1/health")
- recall = await client.post("/api/v1/recall", json={"query": "still ready"})
- assert health.status_code == 200
- assert health.json()["status"] == "ok"
- assert recall.status_code == 200
-
- asyncio.run(scenario())
-
-
def test_lifespan_starts_the_content_candidate_backfill_and_exposes_running(
tmp_path, monkeypatch
) -> None:
@@ -211,7 +169,7 @@ def test_lifespan_drains_committed_pending_dispatch_through_enqueue(tmp_path, mo
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
- evidence.put(
+ _ = evidence.put(
EvidenceInput(
source_id="fixture",
conversation_id="conversation",
@@ -360,7 +318,7 @@ def test_diagnostic_log_level_allows_desktop_put_preflight() -> None:
assert "PUT" in response.headers["access-control-allow-methods"]
-def core(tmp_path, *, permission=True):
+def core(tmp_path):
database = tmp_path / "data/one-memory.sqlite3"
root = tmp_path / "source"
root.mkdir()
@@ -369,7 +327,7 @@ def core(tmp_path, *, permission=True):
user_id="local", source_id="fixture", root=root, candidate_kind=RootCandidateKind.DEFAULT
)
evidence = EvidenceStore(database)
- record = evidence.put(
+ _record = evidence.put(
EvidenceInput(
source_id="fixture",
conversation_id="health",
@@ -399,15 +357,6 @@ def core(tmp_path, *, permission=True):
secrets=secrets,
)
profiles.activate(profile.profile_id, secrets)
- store = ModelContentPermissionStore(database)
- if permission:
- snapshot = store.snapshot(
- user_id="local",
- source_ids=("fixture",),
- profile=profiles.snapshot(profile.profile_id, profile.revision),
- evidence=(record,),
- )
- store.grant(snapshot, user_confirmed=True, expected_revision=0)
return create_app(
Settings(
data_dir=tmp_path / "data",
@@ -420,25 +369,6 @@ def core(tmp_path, *, permission=True):
)
-def test_configured_model_without_content_consent_does_not_start_backfill(tmp_path, monkeypatch):
- started = []
-
- def prepare(*args, **kwargs):
- started.append(True)
- yield []
-
- monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", prepare)
-
- async def scenario():
- app = core(tmp_path, permission=False)
- async with app.router.lifespan_context(app):
- await app.state.memory_backfill
- assert started == []
- assert app.state.memory_task_store.list() == []
-
- asyncio.run(scenario())
-
-
def test_startup_finishes_before_the_memory_backfill_does(tmp_path, monkeypatch) -> None:
"""Startup must not wait on work that grows with the corpus.
@@ -451,16 +381,16 @@ def test_startup_finishes_before_the_memory_backfill_does(tmp_path, monkeypatch)
release = threading.Event()
def blocking(
- _: MemoryProcessingSupervisor,
- should_stop: Callable[[], bool] = lambda: False,
+ _: DurableWriteQueue,
*,
- expected_permission_revision: int | None = None,
+ limit: int = 512,
+ after_cursor: str | None = None,
):
walking.set()
assert release.wait(10)
- yield []
+ return ()
- monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", blocking)
+ monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", blocking)
async def scenario() -> None:
app = core(tmp_path)
@@ -489,15 +419,14 @@ def test_startup_finishes_before_the_memory_backfill_does(tmp_path, monkeypatch)
def test_a_backfill_that_fails_says_so_rather_than_disappearing(tmp_path, monkeypatch) -> None:
def broken(
- _: MemoryProcessingSupervisor,
- should_stop: Callable[[], bool] = lambda: False,
+ _: DurableWriteQueue,
*,
- expected_permission_revision: int | None = None,
+ limit: int = 512,
+ after_cursor: str | None = None,
):
raise RuntimeError("the catch-up broke")
- yield []
- monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", broken)
+ monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", broken)
async def scenario() -> None:
app = core(tmp_path)
@@ -521,16 +450,16 @@ def test_shutting_down_mid_backfill_leaves_nothing_running(tmp_path, monkeypatch
started = threading.Event()
def slow(
- _: MemoryProcessingSupervisor,
- should_stop: Callable[[], bool] = lambda: False,
+ _: DurableWriteQueue,
*,
- expected_permission_revision: int | None = None,
+ limit: int = 512,
+ after_cursor: str | None = None,
):
started.set()
threading.Event().wait(0.2)
- yield []
+ return ()
- monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", slow)
+ monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", slow)
async def scenario() -> None:
app = core(tmp_path)
@@ -553,15 +482,14 @@ def test_a_failed_catch_up_leaves_a_traceback_behind(tmp_path, monkeypatch, capl
"""
def failing(
- _: MemoryProcessingSupervisor,
- should_stop: Callable[[], bool] = lambda: False,
+ _: DurableWriteQueue,
*,
- expected_permission_revision: int | None = None,
+ limit: int = 512,
+ after_cursor: str | None = None,
):
raise sqlite3.OperationalError("database is locked")
- yield []
- monkeypatch.setattr(MemoryProcessingSupervisor, "_prepare_backfill_batches", failing)
+ monkeypatch.setattr(DurableWriteQueue, "unsubmitted_evidence_ids", failing)
async def scenario() -> dict:
app = core(tmp_path)
@@ -591,7 +519,7 @@ def test_a_failed_catch_up_leaves_a_traceback_behind(tmp_path, monkeypatch, capl
if record.exc_info is not None
for frame in traceback.extract_tb(record.exc_info[2])
]
- assert any(frame.name == "backfill_off_thread" for frame in frames)
+ assert any(frame.name == "_run_backfill" for frame in frames)
assert any(frame.name == "failing" and frame.filename == __file__ for frame in frames), (
"栈里没有出事的那个调用"
)
diff --git a/memhub/apps/core/tests/test_hy_coding_lease.py b/memhub/apps/core/tests/test_hy_coding_lease.py
deleted file mode 100644
index 2d70c37..0000000
--- a/memhub/apps/core/tests/test_hy_coding_lease.py
+++ /dev/null
@@ -1,384 +0,0 @@
-"""HY's own coding/working route under the permission lease, on the pinned SDK.
-
-HY decides the route; scripted HTTP stands in for the model, not for HY.
-"""
-
-import asyncio
-import json
-import sqlite3
-import traceback
-from collections.abc import Mapping
-
-import httpx
-from hy_memory.coding.types import CodingMemory
-
-from one_memory.memory_governance import MemoryKind, MemoryStatus
-from tests.test_hy_permission_raw_context import (
- bind_oracle,
- grant_records,
- native_client,
- on_native_loop,
- put_turn,
- raw_client,
-)
-from tests.test_hy_permission_store import Lease
-
-TOOL_TEXT = "[Bash] pytest -q\n12 passed"
-TASK = "Run the synthetic test suite"
-SEARCH_KEY = "pytest quiet run"
-SOLUTION = "Run `pytest -q` from the repository root."
-STATEMENT = "The agent ran pytest -q in the synthetic repository and 12 tests passed."
-DENIED_EXPERIENCE = "denied-coding-experience"
-DENIED_TASK = "DENIED_CODING_TASK_5d2e run the suite"
-DENIED_SOLUTION = "DENIED_CODING_SOLUTION_7f10: skip the tests"
-
-JUDGE = "You are a single-label scene classifier."
-EXTRACT = "You extract durable WORKING memories"
-RECONCILE = "You decide how to merge new coding memory drafts"
-FACT_RECONCILE = "You maintain session-level WORKING FACTS"
-
-
-def coding_vector():
- # One shared direction: every stored experience and fact is a candidate.
- return [float(index == 5) for index in range(384)]
-
-
-class FixedEmbeddings:
- async def embed_batch(self, texts):
- return [coding_vector() for _ in texts]
-
- async def embed(self, text):
- return coding_vector()
-
-
-class CodingOracle:
- def __init__(
- self,
- records,
- *,
- solution=SOLUTION,
- statement=STATEMENT,
- update_target=None,
- forbidden=(),
- after_response=None,
- ):
- self.records = records
- self.solution, self.statement = solution, statement
- self.update_target = update_target
- self.forbidden = forbidden
- self.after_response = after_response
- self.requests = []
- self.stages = []
- self.reconcile_prompts = []
- self.errors = []
- self.revoked_at = None
-
- def __call__(self, request):
- try:
- return self.respond(request)
- except Exception:
- self.errors.append(traceback.format_exc())
- raise
-
- def respond(self, request):
- body = json.loads(request.content)
- self.requests.append(body)
- assert self.revoked_at is None, "native child request escaped after revocation"
- serialized = json.dumps(body, ensure_ascii=False)
- for forbidden in self.forbidden:
- assert forbidden not in serialized, f"unapproved coding row reached HTTP: {forbidden}"
- if request.url.path == "/v1/embeddings":
- texts = body["input"] if isinstance(body["input"], list) else [body["input"]]
- self.stages.append("embedding")
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": body["model"],
- "data": [
- {"index": i, "object": "embedding", "embedding": coding_vector()}
- for i in range(len(texts))
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
- prompt = body["messages"][-1]["content"]
- if prompt.startswith(JUDGE):
- result = {"is_coding": True, "reason": "synthetic tool run"}
- stage = "judge"
- elif prompt.startswith(EXTRACT):
- for record in self.records:
- assert record.verbatim in prompt, record.role
- result = {
- "experiences": [
- {
- "task": TASK,
- "search_keys": [SEARCH_KEY],
- "solution": self.solution,
- "boundary_envs": "",
- "confidence": 0.9,
- }
- ],
- "fact": {
- "subject": "synthetic repository",
- "statement": self.statement,
- "files": [],
- "details": "",
- },
- }
- stage = "extract"
- elif prompt.startswith(RECONCILE):
- self.reconcile_prompts.append(prompt)
- op = (
- {"action": "UPDATE", "draft_idx": 0, "target_experience_id": self.update_target}
- if self.update_target
- else {"action": "ADD", "draft_idx": 0}
- )
- result = {"ops": [op]}
- stage = "reconcile"
- elif prompt.startswith(FACT_RECONCILE):
- result = {"ops": []}
- stage = "fact_reconcile"
- else:
- raise AssertionError(f"unexpected native prompt: {prompt[:80]!r}")
- self.stages.append(stage)
- response = httpx.Response(
- 200,
- json={
- "id": "synthetic-coding-response",
- "object": "chat.completion",
- "created": 1,
- "model": body["model"],
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {"role": "assistant", "content": json.dumps(result)},
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
- if self.after_response is not None and self.after_response(stage):
- self.revoked_at = len(self.requests)
- return response
-
-
-def proof_ids(supervisor):
- with sqlite3.connect(supervisor.data_dir / "one-memory.sqlite3") as db:
- return {row[0] for row in db.execute("SELECT node_id FROM hy_node_permissions")}
-
-
-async def run_coding_turn(supervisor, seed, oracle):
- # Renewed consent also re-queues earlier turns; run only the seed's turn.
- (task,) = (
- task
- for task in supervisor.prepare_conversation(seed)
- if seed.evidence_id in {ref["evidence_id"] for ref in supervisor.tasks.inputs(task.task_id)}
- )
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(task.task_id)
- assert not oracle.errors, oracle.errors
- return task, result
-
-
-def by_kind(supervisor):
- memories = supervisor.governance.list(user_id="local")
- grouped = {}
- for memory in memories:
- grouped.setdefault(memory.kind, []).append(memory)
- return grouped
-
-
-def test_hy_routes_a_tool_turn_to_coding_and_both_layers_are_kept(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, _):
- oracle = CodingOracle(records)
- task, result = await run_coding_turn(supervisor, records[0], oracle)
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- assert oracle.stages[:2] == ["judge", "extract"]
- assert "reconcile" in oracle.stages
- kinds = by_kind(supervisor)
- assert set(kinds) == {MemoryKind.CODING, MemoryKind.WORKING}
- (coding,), (working,) = kinds[MemoryKind.CODING], kinds[MemoryKind.WORKING]
- assert coding.content == f"{TASK}\n{SOLUTION}"
- assert working.content == STATEMENT
- every_record = {record.evidence_id for record in records}
- for memory in (coding, working):
- assert set(memory.evidence_ids) == every_record
- assert memory.status is MemoryStatus.CONFIRMED
- outputs = supervisor.tasks.outputs(task.task_id)
- assert {output.layer for output in outputs} == {"coding_experience", "working_fact"}
- assert {output.hy_memory_id for output in outputs} <= proof_ids(supervisor)
- # HY's pipeline log copies every coding prompt it is handed a cache for.
- (log_dir,) = tmp_path.rglob("hy-memory/*/logs")
- for path in log_dir.rglob("*"):
- if path.is_file():
- for text in (records[0].verbatim, TASK):
- assert text.encode() not in path.read_bytes(), (path.name, text)
-
- asyncio.run(scenario())
-
-
-def test_an_unapproved_experience_never_reaches_the_model_or_changes(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, _):
- task = supervisor.prepare_conversation(records[0])[0]
- _, request, _ = supervisor._create_runner()._prepare_permission_lease(task)
- client = await native_client(supervisor, task)
-
- async def seed():
- await client._get_coding_writer()
- store = client._coding_store
- native_embeddings = store.embed_service
- store.embed_service = FixedEmbeddings()
- try:
- await store.insert(
- CodingMemory(
- experience_id=DENIED_EXPERIENCE,
- user_id=request.user_id,
- agent_id=request.agent_id,
- session_id=request.session_id,
- task=DENIED_TASK,
- solution=DENIED_SOLUTION,
- )
- )
- finally:
- store.embed_service = native_embeddings
- found = await store.search_by_query_embedding(
- coding_vector(), user_id=request.user_id
- )
- assert [hit["experience_id"] for hit in found] == [DENIED_EXPERIENCE]
- return (await store.get_by_id(DENIED_EXPERIENCE)).to_dict()
-
- before = await on_native_loop(client, seed())
- oracle = CodingOracle(
- records, forbidden=(DENIED_EXPERIENCE, DENIED_TASK, DENIED_SOLUTION)
- )
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(task.task_id)
- assert not oracle.errors, oracle.errors
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- assert oracle.reconcile_prompts
- after = await on_native_loop(client, client._coding_store.get_by_id(DENIED_EXPERIENCE))
- assert after.to_dict() == before
- assert DENIED_EXPERIENCE not in proof_ids(supervisor)
- contents = {memory.content for memory in supervisor.governance.list(user_id="local")}
- assert contents == {f"{TASK}\n{SOLUTION}", STATEMENT}
-
- asyncio.run(scenario())
-
-
-def test_a_later_turn_updating_an_experience_updates_the_same_memory(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission):
- _, first = await run_coding_turn(supervisor, records[0], CodingOracle(records))
- assert first.status == "completed", first.last_error_code
- (original,) = by_kind(supervisor)[MemoryKind.CODING]
- question = put_turn(
- supervisor.evidence, "user-again", "user", "Run it again verbosely.", 4
- )
- tool = put_turn(
- supervisor.evidence, "tool-again", "tool", "[Bash] pytest -v\n12 passed", 5
- )
- everything = (*records, question, tool)
- grant_records(supervisor, everything, permission.revision)
- verbose = "Run `pytest -v` from the repository root for per-test output."
- oracle = CodingOracle(
- (question, tool),
- solution=verbose,
- statement="The agent re-ran the suite verbosely; 12 passed.",
- update_target=original.memory_id,
- )
- _, second = await run_coding_turn(supervisor, question, oracle)
- assert second.status == "completed", (second.last_error_code, oracle.stages)
- assert original.memory_id in oracle.reconcile_prompts[0]
- kinds = by_kind(supervisor)
- (updated,) = kinds[MemoryKind.CODING]
- assert updated.memory_id == original.memory_id
- assert updated.content == f"{TASK}\n{verbose}"
- assert updated.revision > original.revision
- assert {question.evidence_id, tool.evidence_id} <= set(updated.evidence_ids)
- assert len(kinds[MemoryKind.WORKING]) == 2
-
- asyncio.run(scenario())
-
-
-def test_revoking_during_coding_extraction_stops_the_route(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission):
-
- def revoke(stage):
- if stage != "extract":
- return False
- supervisor.model_content_permissions.revoke(
- user_id="local", expected_revision=permission.revision, user_confirmed=True
- )
- return True
-
- oracle = CodingOracle(records, after_response=revoke)
- task, result = await run_coding_turn(supervisor, records[0], oracle)
- assert result.status == "cancelled"
- assert result.last_error_code == "model_content_permission_revoked", result
- assert oracle.revoked_at is not None and len(oracle.requests) == oracle.revoked_at
- assert not supervisor.governance.list(user_id="local")
- assert not supervisor.tasks.outputs(task.task_id)
-
- asyncio.run(scenario())
-
-
-def test_exposed_coding_config_is_a_redacted_projection_not_the_native_handle(tmp_path):
- """Public SDK 2.0.2 reads `config` off the coding store; it must not hand out secrets.
-
- The wrapper answers `config` so the SDK's routing works, but the native
- MemoryConfig is a live backend handle carrying api keys and passwords. This
- asserts callers get a plain projection: not the native object, and no
- credential value survives anywhere inside it.
- """
-
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, _):
- task = supervisor.prepare_conversation(records[0])[0]
- client = await native_client(supervisor, task)
- await on_native_loop(client, client._get_coding_writer())
- return client._coding_store
-
- native = asyncio.run(scenario())
- from one_memory.hy_permission_coding_store import HyPermissionCodingStore
-
- native.config.llm.api_key = "DENIED_LLM_KEY_2f31"
- native.config.embedder.api_key = "DENIED_EMBED_KEY_9c04"
- native.config.graph_store.password = "DENIED_GRAPH_PW_77aa"
- store = HyPermissionCodingStore(
- native,
- Lease(),
- agent_id="agent",
- session_id="session",
- )
-
- exposed = store.config
- assert exposed is not native.config
- assert not isinstance(exposed, type(native.config))
- # Attribute access still works: that is how the public SDK reads it.
- assert isinstance(exposed["llm"], Mapping)
- assert exposed["llm"]["model"] == native.config.llm.model
- assert exposed.llm.model == native.config.llm.model
-
- def contains_secret(value, depth=0):
- if depth > 5:
- return False
- if isinstance(value, str):
- return "DENIED_" in value
- if isinstance(value, Mapping):
- return any(contains_secret(item, depth + 1) for item in value.values())
- if isinstance(value, (list, tuple)):
- return any(contains_secret(item, depth + 1) for item in value)
- return False
-
- assert not contains_secret(exposed)
- assert exposed["llm"]["api_key"] is None
- assert exposed["embedder"]["api_key"] is None
- assert exposed["graph_store"]["password"] is None
- # No native handle, connection or callable escapes the projection.
- assert not hasattr(exposed, "_native")
- assert "native" not in exposed
diff --git a/memhub/apps/core/tests/test_hy_lease_responsiveness.py b/memhub/apps/core/tests/test_hy_lease_responsiveness.py
deleted file mode 100644
index 12bd739..0000000
--- a/memhub/apps/core/tests/test_hy_lease_responsiveness.py
+++ /dev/null
@@ -1,250 +0,0 @@
-"""Blocked lease checks, local failing doubles only; no network or credentials."""
-
-import asyncio
-import threading
-from contextlib import asynccontextmanager
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-import pytest
-
-from one_memory.hy_memory_bridge import HyMemoryBridge, HyMemoryBridgeError
-from one_memory.hy_permission_context import _LeasedBridge, permission_bridge_factory
-from one_memory.hy_permission_transport import HyPermissionTransportError
-
-
-class ControlledLease:
- def __init__(self, *, blocked_call=1, error=None):
- self.receipt = SimpleNamespace(user_id="local")
- self.profile = object()
- self.task_id = "synthetic-task"
- self.records = (SimpleNamespace(evidence_id="synthetic-evidence"),)
- self.blocked_call = blocked_call
- self.error = error
- self.calls = []
- self.entered = threading.Event()
- self.release = threading.Event()
- self.exited = threading.Event()
- self.revoked = False
-
- def recheck(self):
- self.calls.append(threading.get_ident())
- if len(self.calls) == self.blocked_call:
- self.entered.set()
- try:
- if not self.release.wait(2):
- raise HyMemoryBridgeError("test_check_was_not_released")
- if self.revoked:
- raise HyMemoryBridgeError("model_content_permission_revoked")
- if self.error is not None:
- raise self.error
- finally:
- self.exited.set()
- return self.receipt
-
-
-class ControlledLoop:
- def __init__(self):
- self.calls = []
-
- def run(self, coroutine):
- self.calls.append(threading.get_ident())
- return asyncio.run(coroutine)
-
-
-class ControlledWiring:
- def __init__(self, *, error=None):
- self.client = SimpleNamespace(_loop_thread=ControlledLoop())
- self.error = error
- self.finished = 0
- self.closed = 0
-
- async def finish(self):
- self.finished += 1
- if self.error is not None:
- raise self.error
- return {}
-
- async def exit(self):
- self.closed += 1
-
-
-REQUEST = SimpleNamespace(
- user_id="local",
- evidence_ids=("synthetic-evidence",),
- agent_id="agent",
- session_id="session",
- request_id="synthetic-request",
-)
-
-
-@asynccontextmanager
-async def blocked_operation(lease, operation):
- main_thread = threading.get_ident()
- pending = asyncio.create_task(operation())
- try:
- # A timer on this loop must run while the synchronous check is held.
- async with asyncio.timeout(0.5):
- while not lease.entered.is_set():
- await asyncio.sleep(0.005)
- ticked = asyncio.Event()
- asyncio.get_running_loop().call_later(0.02, ticked.set)
- await ticked.wait()
- assert not lease.exited.is_set(), "lease check blocked the main event loop"
- assert main_thread not in lease.calls, "lease check executed on the main thread"
- yield pending
- finally:
- lease.release.set()
- if not pending.done():
- pending.cancel()
- await asyncio.gather(pending, return_exceptions=True)
- assert lease.exited.is_set()
-
-
-@pytest.mark.parametrize("operation", ["ingest", "ingest_extracted"])
-def test_cancelled_start_joins_check_without_later_sdk_entry(operation):
- async def scenario():
- lease = ControlledLease()
- bridge = SimpleNamespace(
- _get_client=AsyncMock(), ingest=AsyncMock(), ingest_extracted=AsyncMock()
- )
- bound = _LeasedBridge(bridge, lease, object(), None)
-
- async def start():
- try:
- if operation == "ingest":
- await bound.ingest(REQUEST)
- else:
- await bound.ingest_extracted(REQUEST, ("Synthetic original",))
- finally:
- await bound.close()
-
- async with blocked_operation(lease, start) as pending:
- pending.cancel()
- await asyncio.sleep(0.02)
- pending.cancel()
- await asyncio.sleep(0.02)
- assert not pending.done(), "cancel abandoned the active lease check"
- assert not bridge._get_client.called
- lease.release.set()
- with pytest.raises(asyncio.CancelledError):
- await pending
- assert not bridge._get_client.called
- assert not bridge.ingest.called and not bridge.ingest_extracted.called
- assert bound.wiring is None
-
- asyncio.run(scenario())
-
-
-def test_revoked_start_preserves_error_and_never_opens_sdk():
- async def scenario():
- lease = ControlledLease()
- bridge = SimpleNamespace(_get_client=AsyncMock())
- bound = _LeasedBridge(bridge, lease, object(), None)
- async with blocked_operation(lease, lambda: bound._start(REQUEST)) as pending:
- lease.revoked = True
- lease.release.set()
- with pytest.raises(HyMemoryBridgeError, match="^model_content_permission_revoked$"):
- await pending
- await bound.close()
- assert not bridge._get_client.called and bound.wiring is None
-
- asyncio.run(scenario())
-
-
-def test_released_start_reaches_client_once_and_preserves_client_error():
- async def scenario():
- lease = ControlledLease()
- expected = HyMemoryBridgeError("hy_memory_init_failed")
- bridge = SimpleNamespace(_get_client=AsyncMock(side_effect=expected))
- bound = _LeasedBridge(bridge, lease, object(), None)
- async with blocked_operation(lease, lambda: bound._start(REQUEST)) as pending:
- assert not bridge._get_client.called
- lease.release.set()
- with pytest.raises(HyMemoryBridgeError) as raised:
- await pending
- assert raised.value is expected
- bridge._get_client.assert_awaited_once_with()
- await bound.close()
- assert len(lease.calls) == 1 and bound.wiring is None
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("transport_failure", [False, True])
-@pytest.mark.parametrize("cancel", [False, True])
-def test_postcheck_and_transport_error_recheck_are_joined(transport_failure, cancel):
- async def scenario():
- expected = HyMemoryBridgeError("model_content_evidence_changed")
- lease = ControlledLease(blocked_call=2 if transport_failure else 1, error=expected)
- bound = _LeasedBridge(object(), lease, object(), None)
- wiring = ControlledWiring(
- error=HyPermissionTransportError("controlled_transport_failure")
- if transport_failure
- else None
- )
- bound.wiring = wiring
-
- async def checked():
- try:
- await bound._check()
- finally:
- await bound.close()
-
- async with blocked_operation(lease, checked) as pending:
- if cancel:
- pending.cancel()
- await asyncio.sleep(0.02)
- assert not pending.done() and wiring.closed == 0
- lease.release.set()
- if cancel:
- with pytest.raises(asyncio.CancelledError):
- await pending
- else:
- with pytest.raises(HyMemoryBridgeError) as raised:
- await pending
- assert raised.value is expected
- assert wiring.finished == int(transport_failure)
- assert wiring.closed == 1 and bound.finished
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("revoked", [False, True])
-@pytest.mark.parametrize("cancel", [False, True])
-def test_factory_error_check_holds_exclusive_access_until_joined(tmp_path, revoked, cancel):
- async def scenario():
- lease = ControlledLease()
- bridge = HyMemoryBridge(
- profile=lease.profile,
- api_key=None,
- data_dir=tmp_path,
- client=SimpleNamespace(close=lambda: None),
- )
- factory = permission_bridge_factory(lambda *_: bridge)
- expected = HyPermissionTransportError("controlled_transport_failure")
-
- async def failed_context():
- async with factory(lease, None, tmp_path):
- raise expected
-
- async with blocked_operation(lease, failed_context) as pending:
- assert bridge._access_lock.locked()
- lease.revoked = revoked
- if cancel:
- pending.cancel()
- await asyncio.sleep(0.02)
- assert not pending.done() and bridge._access_lock.locked()
- lease.release.set()
- if cancel:
- with pytest.raises(asyncio.CancelledError):
- await pending
- else:
- code = "model_content_permission_revoked" if revoked else expected.code
- with pytest.raises(HyMemoryBridgeError, match=f"^{code}$"):
- await pending
- assert bridge._access_owner is None and not bridge._access_lock.locked()
- await bridge.close()
- assert bridge._client is None
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_hy_lifecycle_sync.py b/memhub/apps/core/tests/test_hy_lifecycle_sync.py
deleted file mode 100644
index c19eefe..0000000
--- a/memhub/apps/core/tests/test_hy_lifecycle_sync.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""HY decides deletions; the ledger mirrors them. Pinned SDK, scripted HTTP."""
-
-import asyncio
-import json
-
-import httpx
-from hy_memory.models.memory import MemoryStatus as HyStatus
-
-from one_memory.memory_governance import DeletionStatus, MemoryKind, MemoryStatus
-from tests.test_hy_coding_lease import (
- TOOL_TEXT,
- CodingOracle,
- by_kind,
- run_coding_turn,
-)
-from tests.test_hy_permission_raw_context import (
- AGENT_TEXT,
- USER_TEXT,
- ScriptedOracle,
- assert_outputs,
- bind_oracle,
- grant_records,
- native_client,
- on_native_loop,
- put_turn,
- raw_client,
-)
-
-
-class DeletingOracle(ScriptedOracle):
- """HY's reconciler withdraws the user's claim and keeps the agent's."""
-
- def respond(self, request):
- response = super().respond(request)
- if self.stages[-1] != "reconcile":
- return response
- body = json.loads(response.content)
- ops = json.loads(body["choices"][0]["message"]["content"])
- for op in ops:
- if op["content"] == USER_TEXT and "memory_id" in op:
- op.clear()
- op.update({"op": "DELETE", "memory_id": self.withdrawn})
- body["choices"][0]["message"]["content"] = json.dumps(ops)
- return httpx.Response(200, json=body)
-
-
-def test_a_memory_hy_deletes_is_deleted_in_the_ledger_and_the_rest_stay(tmp_path):
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- first = supervisor.prepare_conversation(records[0])[0]
- client = await native_client(supervisor, first)
- bind_oracle(supervisor, ScriptedOracle(records))
- assert (await supervisor._create_runner().run(first.task_id)).status == "completed"
- await assert_outputs(supervisor, first, records, client)
- by_content = {m.content: m for m in supervisor.governance.list(user_id="local")}
- withdrawn, kept = by_content[USER_TEXT], by_content[AGENT_TEXT]
-
- added = put_turn(supervisor.evidence, "user-withdraw", "user", USER_TEXT, 3)
- records = (*records, added)
- grant_records(supervisor, records, permission.revision)
- second = supervisor.prepare_conversation(added)[0]
- oracle = DeletingOracle(records)
- oracle.withdrawn = withdrawn.memory_id
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(second.task_id)
- assert not oracle.errors, oracle.errors
- assert result.status == "completed", (result.last_error_code, oracle.stages)
-
- native = await on_native_loop(
- client, client._vector_store.get_by_id(withdrawn.memory_id)
- )
- assert native is not None and native.status == HyStatus.SHADOW
- mirrored = supervisor.governance.get(withdrawn.memory_id, user_id="local")
- assert mirrored.status is MemoryStatus.DELETED
- deletion = supervisor.governance.deletion(withdrawn.memory_id, user_id="local")
- assert deletion is not None and deletion.status is DeletionStatus.COMPLETED
- untouched = supervisor.governance.get(kept.memory_id, user_id="local")
- assert untouched.status is MemoryStatus.CONFIRMED
-
- asyncio.run(scenario())
-
-
-class RetiringCodingOracle(CodingOracle):
- def __init__(self, records, *, retire, **kwargs):
- super().__init__(records, **kwargs)
- self.retire = retire
-
- def respond(self, request):
- response = super().respond(request)
- if self.stages[-1] != "reconcile":
- return response
- body = json.loads(response.content)
- body["choices"][0]["message"]["content"] = json.dumps(
- {
- "ops": [
- {"action": "ADD", "draft_idx": 0},
- {
- "action": "DELETE",
- "draft_idx": 0,
- "target_experience_id": self.retire,
- "reason": "the new run explicitly replaces the old procedure",
- },
- ]
- }
- )
- return httpx.Response(200, json=body)
-
-
-def test_an_experience_hy_deletes_is_deleted_in_the_ledger(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission):
- _, first = await run_coding_turn(supervisor, records[0], CodingOracle(records))
- assert first.status == "completed", first.last_error_code
- (original,) = by_kind(supervisor)[MemoryKind.CODING]
- question = put_turn(supervisor.evidence, "user-again", "user", "Use tox now.", 4)
- tool = put_turn(supervisor.evidence, "tool-again", "tool", "[Bash] tox\nok", 5)
- grant_records(supervisor, (*records, question, tool), permission.revision)
- oracle = RetiringCodingOracle(
- (question, tool),
- retire=original.memory_id,
- solution="Run `tox` from the repository root.",
- statement="The agent switched the synthetic suite to tox.",
- )
- _, second = await run_coding_turn(supervisor, question, oracle)
- assert second.status == "completed", (second.last_error_code, oracle.stages)
- client = await native_client(supervisor, second)
- assert (
- await on_native_loop(client, client._coding_store.get_by_id(original.memory_id))
- is None
- )
-
- assert (
- supervisor.governance.get(original.memory_id, user_id="local").status
- is MemoryStatus.DELETED
- )
- live = [
- m
- for m in by_kind(supervisor)[MemoryKind.CODING]
- if m.status is MemoryStatus.CONFIRMED
- ]
- assert [m.content for m in live] == [
- "Run the synthetic test suite\nRun `tox` from the repository root."
- ]
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_hy_memory_bridge.py b/memhub/apps/core/tests/test_hy_memory_bridge.py
index 527fafe..1ecd68d 100644
--- a/memhub/apps/core/tests/test_hy_memory_bridge.py
+++ b/memhub/apps/core/tests/test_hy_memory_bridge.py
@@ -18,10 +18,12 @@ from one_memory.hy_memory_bridge import (
HyMemoryBridge,
HyMemoryBridgeError,
HyMemoryIngestRequest,
+ HyMemoryIngestResult,
HyMemoryTurn,
UnsupportedHyMemoryProviderError,
_basic_info_memories,
_collection_name,
+ _with_native_mutation_delta,
build_hy_memory_config,
configure_hy_memory_environment,
reattach_legacy_vector_collection,
@@ -1507,3 +1509,58 @@ def test_the_client_mode_follows_the_graph_store_switch(tmp_path, monkeypatch) -
hy_memory_bridge.create_hy_memory_client(profile, api_key=None, data_dir=tmp_path).close()
assert seen == ["pro", HY_MEMORY_MODE]
+
+
+def test_native_mutation_delta_reports_coding_deletes_and_supersedes() -> None:
+ raw = {
+ "ops": [
+ {
+ "action": "DELETE",
+ "target_experience_id": "old-experience",
+ }
+ ],
+ "fact_ops": [
+ {
+ "action": "SUPERSEDE",
+ "fact_id": "old-fact",
+ "superseded_by": "new-fact",
+ }
+ ],
+ }
+
+ result = asyncio.run(
+ _with_native_mutation_delta(
+ SimpleNamespace(),
+ HyMemoryIngestResult(success=True),
+ raw,
+ (),
+ )
+ )
+
+ assert result.retired_memory_ids == ("old-experience", "old-fact")
+ assert result.superseded_memory_ids == {"old-fact": ("new-fact",)}
+
+
+def test_native_mutation_delta_reads_chat_status_from_the_sdk() -> None:
+ class Client:
+ def get(self, memory_id: str) -> Mapping[str, object]:
+ assert memory_id == "old-chat"
+ return {
+ "memory_id": memory_id,
+ "content": "old",
+ "status": "superseded",
+ "superseded_by": ["new-chat"],
+ }
+
+ guard = SimpleNamespace(mutation_target_ids={"old-chat"})
+ result = asyncio.run(
+ _with_native_mutation_delta(
+ Client(),
+ HyMemoryIngestResult(success=True),
+ {},
+ (guard,),
+ )
+ )
+
+ assert result.retired_memory_ids == ("old-chat",)
+ assert result.superseded_memory_ids == {"old-chat": ("new-chat",)}
diff --git a/memhub/apps/core/tests/test_hy_native_write_chain.py b/memhub/apps/core/tests/test_hy_native_write_chain.py
deleted file mode 100644
index 0e3c03d..0000000
--- a/memhub/apps/core/tests/test_hy_native_write_chain.py
+++ /dev/null
@@ -1,208 +0,0 @@
-"""HY writes with its own prompts and judgment; the product adds no audit of its own."""
-
-import asyncio
-import json
-from types import SimpleNamespace
-
-import pytest
-from hy_memory.agent.reconciler import ReconcileOp, ReconcileResult
-
-from one_memory.hy_response_guard import HyReconcileGuard
-from one_memory.hy_source_context import HySourceContext, HySupportedMemory, HyWriteBindings
-from one_memory.hy_write_verification import HyExtractionGuard
-from one_memory.imports.storage import EvidenceStore
-from tests.test_hy_permission_raw_context import (
- ScriptedOracle,
- assert_outputs,
- bind_oracle,
- native_client,
- raw_client,
-)
-from tests.test_hy_source_context import source
-
-PRODUCT_RULE_MARKERS = ("# 来源与语气保真", "# 原始发言主体规则", "owner记录命题的陈述权威")
-
-
-def test_a_raw_turn_reaches_hy_with_its_native_prompts_and_no_product_audit(tmp_path):
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- task = supervisor.prepare_conversation(records[0])[0]
- oracle = ScriptedOracle(records)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(task.task_id)
- assert not oracle.errors, oracle.errors
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- assert "extract" in oracle.stages
- assert "review" not in oracle.stages
- assert "citations" not in oracle.stages
- sent = json.dumps([body for _path, body in oracle.requests], ensure_ascii=False)
- for marker in PRODUCT_RULE_MARKERS:
- assert marker not in sent, marker
- # Each memory still points at the words of its own author in this turn.
- client = await native_client(supervisor, task)
- await assert_outputs(supervisor, task, records, client)
-
- asyncio.run(scenario())
-
-
-def test_an_imported_memory_keeps_the_owner_hy_gives_it():
- class Native:
- received = None
-
- async def reconcile(self, **kwargs):
- self.received = kwargs["new_memories_with_meta"]
- return ReconcileResult(
- ops=[ReconcileOp(op="ADD", content="用户偏好简洁表达。", owner="user")]
- )
-
- native = Native()
- guard = HyReconcileGuard(native)
- guard.imported_contents = frozenset({"用户偏好简洁表达。"})
- result = asyncio.run(
- guard.reconcile(
- new_memories=["用户偏好简洁表达。"],
- new_memories_with_meta=[{"content": "用户偏好简洁表达。", "layer": "L2_FACT"}],
- )
- )
- assert "owner" not in native.received[0]
- assert result.ops[0].owner == "user"
- assert guard.failures == []
-
-
-def bound(tmp_path, *, known=False):
- store = EvidenceStore(tmp_path / "evidence.db")
- old = source(store, "old", "user", "Atlas用本地存储。")
- current = source(store, "now", "user", "Atlas也要能离线同步。")
- memories = (
- HySupportedMemory(
- hy_memory_id="prior",
- governed_memory_id="prior",
- revision=1,
- content="用户要求Atlas使用本地存储。",
- owner="user",
- evidence_ids=(old.evidence_id,),
- ),
- )
- context = HySourceContext(
- current=(current,), history=(old,), known_memories=memories if known else ()
- )
- return HyWriteBindings(context), old, current
-
-
-def test_the_turn_hy_extracts_must_be_the_turn_that_was_sent(tmp_path):
- bindings, _old, current = bound(tmp_path)
- calls = []
-
- class Native:
- async def extract(self, content, context, **kwargs):
- calls.append(content)
- return SimpleNamespace(success=True, info={"memory": []})
-
- guard = HyExtractionGuard(Native())
- guard.bindings = bindings
- sent = json.dumps([{"role": "user", "content": current.verbatim}], ensure_ascii=False)
- assert asyncio.run(guard.extract(sent, {})).success and calls == [sent]
- with pytest.raises(ValueError, match="input_mismatch"):
- asyncio.run(guard.extract(json.dumps([{"role": "user", "content": "别的内容"}]), {}))
- assert calls == [sent]
- assert guard.failures == ["hy_memory_write_verification_input_mismatch"]
-
-
-class PriorStore:
- async def get_by_id(self, key):
- return SimpleNamespace(
- user_id="local", agent_id="atlas", owner="user", content="用户要求Atlas使用本地存储。"
- )
-
-
-def merge_into_prior(content):
- class Native:
- async def reconcile(self, **kwargs):
- return ReconcileResult(
- ops=[ReconcileOp(op="UPDATE", memory_id="prior", owner="user", content=content)]
- )
-
- return Native()
-
-
-ARGS = {"user_id": "local", "agent_id": "atlas", "vector_store": PriorStore()}
-
-
-def test_hy_may_only_change_a_memory_this_write_was_allowed_to_see(tmp_path):
- allowed, _old, _current = bound(tmp_path / "allowed", known=True)
- guard = HyReconcileGuard(merge_into_prior("用户要求Atlas本地存储并支持离线同步。"))
- guard.bindings = allowed
- assert asyncio.run(guard.reconcile(**ARGS)).success
-
- unseen, _old, _current = bound(tmp_path / "unseen")
- guard = HyReconcileGuard(merge_into_prior("用户要求Atlas本地存储并支持离线同步。"))
- guard.bindings = unseen
- with pytest.raises(ValueError, match="target_unbound"):
- asyncio.run(guard.reconcile(**ARGS))
-
-
-def test_a_memory_hy_merges_cites_this_turn_and_what_it_already_cited(tmp_path):
- bindings, old, current = bound(tmp_path, known=True)
- merged = "用户要求Atlas本地存储并支持离线同步。"
- guard = HyReconcileGuard(merge_into_prior(merged))
- guard.bindings = bindings
- assert asyncio.run(guard.reconcile(**ARGS)).success
- assert bindings.support_for(merged, "user") == (current.evidence_id, old.evidence_id)
-
-
-HY_USER_ONLY_LINE = "只提取 NEW user messages 中首次出现的新信息。"
-
-
-def _section(prompt, heading):
- _, found, rest = prompt.partition(heading)
- assert found, heading
- return rest.partition("\n---\n")[0]
-
-
-def _served_extract_prompts(tmp_path):
- from one_memory.hy_memory_bridge import build_hy_memory_config
- from tests.test_memory_language import _profile
-
- config = build_hy_memory_config(_profile(), api_key=None, data_dir=tmp_path)
- return config.llm.extract_system_prompt_zh, config.llm.extract_system_prompt_en
-
-
-def test_hys_native_extraction_only_reads_what_the_user_said():
- from hy_memory.agent.prompts_zh import EXTRACT_SYSTEM_PROMPT_ZH
-
- assert HY_USER_ONLY_LINE in EXTRACT_SYSTEM_PROMPT_ZH
- # HY already knows how to keep an assistant's words apart from the person's.
- assert '`"agent"` —— AI 助手' in _section(EXTRACT_SYSTEM_PROMPT_ZH, "# OWNER(归属)\n")
-
-
-def test_hy_extracts_both_speakers_and_keeps_its_own_authorship_rules(tmp_path):
- from hy_memory.agent.prompts_zh import EXTRACT_SYSTEM_PROMPT_ZH as native
-
- for served in _served_extract_prompts(tmp_path):
- assert HY_USER_ONLY_LINE not in served
- principle = _section(served, "# 提取原则\n")
- assert "用户" in principle and "AI 助手" in principle
- assert "OWNER" in principle
- for heading in ("# OWNER(归属)\n", "# 主体规则\n"):
- assert _section(served, heading) == _section(native, heading), heading
- for marker in PRODUCT_RULE_MARKERS:
- assert marker not in served, marker
-
-
-def test_a_reworded_extraction_line_still_asks_for_both_speakers(tmp_path, monkeypatch, caplog):
- from hy_memory.agent.prompts_zh import EXTRACT_SYSTEM_PROMPT_ZH as native
-
- from one_memory import hy_memory_bridge
-
- class Reworded:
- EXTRACT_SYSTEM_PROMPT_ZH = native.replace(HY_USER_ONLY_LINE, "HY 换了提取原则的措辞。")
-
- real_import = hy_memory_bridge.import_module
- monkeypatch.setattr(
- hy_memory_bridge,
- "import_module",
- lambda name: Reworded if name == "hy_memory.agent.prompts_zh" else real_import(name),
- )
- served, _ = _served_extract_prompts(tmp_path)
- assert "AI 助手提供的" in served
- assert "hy_extraction_scope_rule_not_found" in caplog.text
diff --git a/memhub/apps/core/tests/test_hy_permission_context.py b/memhub/apps/core/tests/test_hy_permission_context.py
deleted file mode 100644
index 931141e..0000000
--- a/memhub/apps/core/tests/test_hy_permission_context.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""Whole native HY client with synthetic content and HTTP MockTransport, never cloud."""
-
-import asyncio
-import json
-from contextlib import contextmanager
-from pathlib import Path
-
-import httpx
-import pytest
-
-from one_memory.hy_permission_context import permission_bridge_factory
-from one_memory.imports.grants import SourceGrantStore
-from one_memory.imports.models import (
- EvidenceContentKind,
- EvidenceInput,
- EvidenceOrigin,
- OriginMetadata,
- WrittenBy,
-)
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.imports.storage import EvidenceStore
-from one_memory.memory_governance import MemoryGovernanceStore
-from one_memory.memory_processing import MemoryProcessingSupervisor
-from one_memory.memory_tasks import MemoryTaskStore
-from one_memory.model_content_permissions import ModelContentPermissionStore
-from one_memory.model_profiles import (
- ModelCapabilities,
- ModelProfileInput,
- ModelProfileStore,
- ProviderKind,
-)
-from one_memory.secrets import InMemorySecretStore
-
-
-@contextmanager
-def whole_client(tmp_path: Path):
- root = tmp_path / "source"
- root.mkdir()
- database = tmp_path / "one-memory.sqlite3"
- grants = SourceGrantStore(database, home=tmp_path)
- grants.grant(
- user_id="local", source_id="fixture", root=root, candidate_kind=RootCandidateKind.DEFAULT
- )
- evidence = EvidenceStore(database)
- text = "Synthetic supplied memory: review permissions before release."
- record = evidence.put(
- EvidenceInput(
- source_id="fixture",
- conversation_id="session",
- message_id="1",
- role="assistant",
- written_by=WrittenBy.UNKNOWN,
- content_kind=EvidenceContentKind.MEMORY_CONTENT,
- verbatim=text,
- origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata()},
- )
- ).record
- profiles, secrets = ModelProfileStore(database), InMemorySecretStore()
- profile = profiles.create(
- ModelProfileInput(
- name="Stub only",
- provider_kind=ProviderKind.OLLAMA,
- base_url="http://127.0.0.1:1/v1",
- model="test-generation",
- embedding_provider_kind=ProviderKind.OLLAMA,
- embedding_base_url="http://127.0.0.1:2/v1",
- embedding_model="test-embedding",
- ),
- secrets,
- )
- profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True, model_available=True, embedding_available=True
- ),
- secrets=secrets,
- )
- profiles.activate(profile.profile_id, secrets)
- permissions = ModelContentPermissionStore(database)
- snapshot = permissions.snapshot(
- user_id="local",
- source_ids=("fixture",),
- profile=profiles.snapshot(profile.profile_id, profile.revision),
- evidence=(record,),
- )
- permission = permissions.grant(snapshot, user_confirmed=True, expected_revision=0)
- supervisor = MemoryProcessingSupervisor(
- tasks=MemoryTaskStore(database),
- profiles=profiles,
- secrets=secrets,
- evidence=evidence,
- governance=MemoryGovernanceStore(database),
- data_dir=tmp_path,
- model_content_permissions=permissions,
- )
- yield supervisor, record, permissions, permission
-
-
-def test_native_memory_content_uses_guarded_sdk_then_returns_real_native_memory(tmp_path):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, _, _):
- sends = []
-
- def handler(request: httpx.Request):
- body = json.loads(request.content)
- sends.append({"path": request.url.path, "body": body})
- if request.url.path.endswith("embeddings"):
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": "test-embedding",
- "data": [
- {
- "index": i,
- "object": "embedding",
- "embedding": [1.0] + [0.0] * 383,
- }
- for i, _ in enumerate(body["input"])
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
- return httpx.Response(
- 200,
- json={
- "id": "fixture-response",
- "object": "chat.completion",
- "created": 1,
- "model": "test-generation",
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {
- "role": "assistant",
- "content": json.dumps(
- [
- {
- "op": "ADD",
- "content": record.verbatim,
- "layer": "L2_FACT",
- "owner": "agent",
- }
- ]
- ),
- },
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
-
- supervisor.permission_bridge_factory = permission_bridge_factory(
- supervisor._bridge, http_transport=httpx.MockTransport(handler)
- )
- tasks = supervisor.prepare_conversation(record)
- try:
- result = await supervisor._create_runner().run(tasks[0].task_id)
- assert result.status == "completed", result.last_error_code
- assert sends and all(
- item["path"].endswith(("embeddings", "chat/completions")) for item in sends
- )
- memories = supervisor.governance.list(user_id="local")
- assert len(memories) == 1 and memories[0].content == record.verbatim
- assert memories[0].status == "confirmed"
- assert supervisor.evidence.get(record.evidence_id).verbatim == record.verbatim
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_revoked_lease_never_creates_an_sdk_client_or_sends(tmp_path):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, permissions, permission):
- tasks = supervisor.prepare_conversation(record)
- permissions.revoke(
- user_id="local", expected_revision=permission.revision, user_confirmed=True
- )
- supervisor.permission_bridge_factory = permission_bridge_factory(
- supervisor._bridge,
- http_transport=httpx.MockTransport(lambda _: pytest.fail("unauthorized request")),
- )
- result = await supervisor._create_runner().run(tasks[0].task_id)
- assert result.last_error_code == "model_content_permission_revoked"
- assert supervisor._bridges == {}
- assert not supervisor.governance.list(user_id="local")
- await supervisor.shutdown()
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_hy_permission_context_races.py b/memhub/apps/core/tests/test_hy_permission_context_races.py
deleted file mode 100644
index a95bb2f..0000000
--- a/memhub/apps/core/tests/test_hy_permission_context_races.py
+++ /dev/null
@@ -1,380 +0,0 @@
-"""Bounded factory races with a temporary native HY client and no model calls."""
-
-import asyncio
-import json
-import threading
-
-import httpx
-import pytest
-from hy_memory.models.memory import MemoryLayer, MemoryNode
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.hy_permission_coding_store import HyPermissionCodingStore
-from one_memory.hy_permission_context import _Wiring, permission_bridge_factory
-from one_memory.hy_permission_store import HyPermissionVectorStore
-from one_memory.hy_permission_transport import HyPermissionTransportError
-from one_memory.memory_governance import MemoryDraft, MemoryKind
-from tests.person_door import as_person
-from tests.test_hy_permission_context import whole_client
-
-
-def test_cancelled_start_cannot_install_wiring_after_factory_releases_client(tmp_path):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, _, _):
- task = supervisor.prepare_conversation(record)[0]
- lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task)
- bridge = supervisor._bridge(lease.profile, None, tmp_path)
- client = await bridge._get_client()
- native_vector, native_graph = client._vector_store, client._graph_store
- original_coding = client._config.coding.enable
- run = client._loop_thread.run
- submitted, release, completed = (threading.Event() for _ in range(3))
- worker_errors = []
- bound_contexts = []
- sends = []
-
- def mock_send(request):
- sends.append(request)
- pytest.fail("This startup-only race must not make any model request")
-
- def delayed_run(coroutine):
- if coroutine.cr_code is not _Wiring.enter.__code__:
- return run(coroutine)
- # Model a worker being descheduled just before submitting to HY.
- submitted.set()
- try:
- if not release.wait(5):
- coroutine.close()
- raise TimeoutError("test did not release startup worker")
- return run(coroutine)
- except BaseException as error:
- worker_errors.append(error)
- raise
- finally:
- completed.set()
-
- client._loop_thread.run = delayed_run
- factory = permission_bridge_factory(
- supervisor._bridge, http_transport=httpx.MockTransport(mock_send)
- )
-
- async def ingest():
- async with factory(lease, None, tmp_path) as bound:
- bound_contexts.append(bound)
- await bound.ingest_extracted(request, (record.verbatim,))
-
- pending = asyncio.create_task(ingest())
- try:
- assert await asyncio.to_thread(submitted.wait, 5)
- pending.cancel()
- # A fixed factory may keep cancellation pending until it has
- # joined startup. Let both that behavior and early exit proceed.
- await asyncio.wait({pending}, timeout=0.1)
- released_before_start = not bridge._access_lock.locked()
- release.set()
- assert await asyncio.to_thread(completed.wait, 5)
- with pytest.raises(asyncio.CancelledError):
- await asyncio.wait_for(pending, 5)
- assert not worker_errors
- assert not sends
- assert bridge._access_owner is None
- assert not bridge._access_lock.locked()
- assert not isinstance(client._vector_store, HyPermissionVectorStore), (
- "Cancelled startup installed a live permission wrapper after the "
- "factory exited and released exclusive ownership; "
- f"released_before_start={released_before_start}"
- )
- assert client._vector_store is native_vector
- assert client._graph_store is native_graph
- assert client._config.coding.enable == original_coding
- finally:
- release.set()
- if not pending.done():
- pending.cancel()
- await asyncio.gather(pending, return_exceptions=True)
- assert await asyncio.to_thread(completed.wait, 5)
- client._loop_thread.run = run
- # The regression leaves finished=True with live wiring; force
- # cleanup directly so the failing test leaks no SDK resources.
- for bound in bound_contexts:
- if bound.wiring is not None:
- await asyncio.to_thread(run, bound.wiring.exit())
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_completed_start_restores_client_and_revokes_saved_vector_reference(tmp_path):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, _, _):
- task = supervisor.prepare_conversation(record)[0]
- lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task)
- bridge = supervisor._bridge(lease.profile, None, tmp_path)
- client = await bridge._get_client()
- vector, graph = client._vector_store, client._graph_store
- factory = permission_bridge_factory(
- supervisor._bridge,
- http_transport=httpx.MockTransport(lambda _: pytest.fail("unexpected send")),
- )
- try:
- async with factory(lease, None, tmp_path) as bound:
- await bound._start(request)
- wrapped = client._vector_store
- assert isinstance(wrapped, HyPermissionVectorStore)
- assert client._graph_store is None
- coding = client._coding_store
- assert isinstance(coding, HyPermissionCodingStore)
- assert client._coding_writer.reconciler.store is coding
- assert client._cache is None
- assert client._vector_store is vector
- assert client._graph_store is graph
- assert client._coding_writer.reconciler.store is client._coding_store
- assert not isinstance(client._coding_store, HyPermissionCodingStore)
- assert client._cache is not None
- with pytest.raises(HyMemoryBridgeError, match="hy_store_closed"):
- wrapped.recheck()
- with pytest.raises(HyMemoryBridgeError, match="hy_coding_store_closed"):
- coding.recheck()
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_graph_disabled_keeps_graph_absent_even_for_cross_evidence_operations(tmp_path):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, _, _):
- task = supervisor.prepare_conversation(record)[0]
- lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task)
- bridge = supervisor._bridge(lease.profile, None, tmp_path)
- client = await bridge._get_client()
- native_graph = client._graph_store
- assert native_graph is None
- factory = permission_bridge_factory(
- supervisor._bridge,
- http_transport=httpx.MockTransport(lambda _: pytest.fail("unexpected send")),
- )
- try:
- async with factory(lease, None, tmp_path) as bound:
- await bound._start(request)
- assert client._graph_store is None
- assert bound.wiring is not None and bound.wiring.graph is None
- assert client._graph_store is native_graph
-
- async with factory(lease, None, tmp_path) as digest_bound:
- await digest_bound._start_operation(
- user_id=request.user_id,
- agent_id=request.agent_id,
- request_id="digest-test",
- session_id="digest:test",
- cross_evidence_scope=True,
- )
- assert client._graph_store is None
- assert digest_bound.wiring is not None
- assert digest_bound.wiring.graph is None
- assert client._graph_store is native_graph
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_persisted_native_proof_does_not_authorize_deleted_governed_memory(tmp_path):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, permissions, permission):
- runner = supervisor._create_runner()
- task = supervisor.prepare_conversation(record)[0]
- lease, request, _ = runner._prepare_permission_lease(task)
- factory = permission_bridge_factory(
- supervisor._bridge,
- http_transport=httpx.MockTransport(lambda _: pytest.fail("unexpected send")),
- )
- bridge = supervisor._bridge(lease.profile, None, tmp_path)
- client = await bridge._get_client()
- run = client._loop_thread.run
- native = MemoryNode(
- node_id="synthetic-governed-memory",
- user_id=request.user_id,
- agent_id=request.agent_id,
- session_id=request.session_id,
- content=record.verbatim,
- owner="agent",
- layer=MemoryLayer.L2_FACT,
- embedding=[1.0] + [0.0] * 383,
- )
- try:
- # Seed only through the guarded native store; profile readback
- # persists the factory's own proof ledger, as in a completed run.
- async with factory(lease, None, tmp_path) as first:
- await first._start(request)
- store = first.wiring.vector
- assert await asyncio.to_thread(run, store.upsert(native)) == native.node_id
- positive = await asyncio.to_thread(run, store.get_by_id(native.node_id))
- assert positive.content == record.verbatim
- await first.profile_memories(user_id=request.user_id, agent_id=request.agent_id)
- governed = as_person(supervisor.governance).create(
- MemoryDraft(
- user_id=request.user_id,
- kind=MemoryKind.CHAT,
- content=record.verbatim,
- scopes=("synthetic-scope",),
- evidence_ids=(record.evidence_id,),
- ),
- request_id="synthetic-publication",
- memory_id=native.node_id,
- )
- rejected = as_person(supervisor.governance).delete(
- governed.memory_id,
- user_id=request.user_id,
- request_id="synthetic-rejection",
- expected_revision=governed.revision,
- )
- assert rejected.status == "deleted"
- # Explicit renewed consent permits the unchanged source text,
- # but must not reinstate a deleted derived native memory.
- snapshot = permissions.snapshot(
- user_id=request.user_id,
- source_ids=(record.source_id,),
- profile=lease.profile,
- evidence=(record,),
- )
- permissions.grant(
- snapshot, user_confirmed=True, expected_revision=permission.revision
- )
- next_task = supervisor.prepare_conversation(record)[0]
- assert next_task.task_id != task.task_id
- next_lease, next_request, _ = runner._prepare_permission_lease(next_task)
- assert next_lease.source_context is None
- assert next_lease.recheck()
- async with factory(next_lease, None, tmp_path) as second:
- await second._start(next_request)
- reused = await asyncio.to_thread(
- run, second.wiring.vector.get_by_id(native.node_id)
- )
- assert reused is None, (
- "Evidence-only proof reload exposed a deleted governed memory "
- "as native context in a new permitted task"
- )
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("rejection_point", ["after_start", "after_proof_load"])
-def test_deletion_during_proof_binding_blocks_next_physical_send(tmp_path, rejection_point):
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, _, _):
- task = supervisor.prepare_conversation(record)[0]
- lease, request, _ = supervisor._create_runner()._prepare_permission_lease(task)
- bridge = supervisor._bridge(lease.profile, None, tmp_path)
- client = await bridge._get_client()
- run = client._loop_thread.run
- sends = []
-
- def handler(outbound):
- body = json.loads(outbound.content)
- sends.append(body)
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": "test-embedding",
- "data": [
- {
- "object": "embedding",
- "index": i,
- "embedding": [1.0] + [0.0] * 383,
- }
- for i, _ in enumerate(body["input"])
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
-
- factory = permission_bridge_factory(
- supervisor._bridge, http_transport=httpx.MockTransport(handler)
- )
- native = MemoryNode(
- node_id="synthetic-rejection-race",
- user_id=request.user_id,
- agent_id=request.agent_id,
- session_id=request.session_id,
- content=record.verbatim,
- owner="agent",
- layer=MemoryLayer.L2_FACT,
- embedding=[1.0] + [0.0] * 383,
- )
- try:
- async with factory(lease, None, tmp_path) as first:
- await first._start(request)
- await asyncio.to_thread(run, first.wiring.vector.upsert(native))
- permitted = await asyncio.to_thread(
- run, first.wiring.vector.get_by_id(native.node_id)
- )
- assert permitted is not None
- await asyncio.to_thread(
- run, client._embed_service.embed(permitted.content, use_cache=False)
- )
- assert len(sends) == 1 and sends[0]["input"] == [record.verbatim]
- await first.profile_memories(user_id=request.user_id, agent_id=request.agent_id)
-
- governed = as_person(supervisor.governance).create(
- MemoryDraft(
- user_id=request.user_id,
- kind=MemoryKind.CHAT,
- content=record.verbatim,
- scopes=("synthetic-scope",),
- evidence_ids=(record.evidence_id,),
- ),
- request_id="synthetic-race-publication",
- memory_id=native.node_id,
- )
-
- def reject():
- result = as_person(supervisor.governance).delete(
- governed.memory_id,
- user_id=request.user_id,
- request_id="synthetic-race-rejection",
- expected_revision=governed.revision,
- )
- assert result.status == "deleted"
-
- try:
- async with factory(lease, None, tmp_path) as second:
- if rejection_point == "after_proof_load":
- load = second.proofs.load
-
- def reject_after_load(current_lease):
- approved = load(current_lease)
- assert native.node_id in approved
- # A governance write can commit between the two
- # separately awaited load/guard database reads.
- reject()
- return approved
-
- second.proofs.load = reject_after_load
- await second._start(request)
- if rejection_point == "after_start":
- reject()
- candidate = await asyncio.to_thread(
- run, second.wiring.vector.get_by_id(native.node_id)
- )
- if candidate is not None:
- await asyncio.to_thread(
- run,
- client._embed_service.embed(candidate.content, use_cache=False),
- )
- except (HyMemoryBridgeError, HyPermissionTransportError) as error:
- assert str(error).startswith("model_content_")
- assert (
- supervisor.governance.get(native.node_id, user_id="local").status == "deleted"
- )
- assert len(sends) == 1, (
- f"Deleted proof context reached the physical transport at {rejection_point}; "
- f"expected one approved control send, observed {len(sends)} sends"
- )
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_hy_permission_raw_context.py b/memhub/apps/core/tests/test_hy_permission_raw_context.py
deleted file mode 100644
index 237684f..0000000
--- a/memhub/apps/core/tests/test_hy_permission_raw_context.py
+++ /dev/null
@@ -1,834 +0,0 @@
-"""Real pinned HY SDK/Chroma and guards; scripted HTTP is not model acceptance."""
-
-import asyncio
-import json
-import sqlite3
-import traceback
-from contextlib import asynccontextmanager
-from datetime import UTC, datetime, timedelta
-
-import httpx
-import pytest
-from hy_memory.client import HyMemoryClient
-from hy_memory.data.vector_store_chroma import ChromaVectorStore
-from hy_memory.models.memory import MemoryLayer, MemoryNode
-from hy_memory.pipelines._retrieval.reconcile_retrieval import ReconcileHybridRetriever
-
-from one_memory.hy_permission_context import permission_bridge_factory
-from one_memory.hy_response_guard import HyReconcileGuard, HyResponseGuard
-from one_memory.hy_runtime_config import hy_memory_version
-from one_memory.hy_write_verification import HyExtractionGuard
-from one_memory.imports.grants import SourceGrantStore
-from one_memory.imports.models import (
- EvidenceContentKind,
- EvidenceInput,
- EvidenceOrigin,
- OriginMetadata,
- WrittenBy,
-)
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.imports.storage import EvidenceStore
-from one_memory.memory_governance import MemoryGovernanceStore
-from one_memory.memory_processing import MemoryProcessingSupervisor
-from one_memory.memory_tasks import MemoryTaskStore, evidence_ref
-from one_memory.model_content_permissions import ModelContentPermissionStore
-from one_memory.model_profiles import (
- ModelCapabilities,
- ModelProfileInput,
- ModelProfileStore,
- ProviderKind,
-)
-from one_memory.secrets import InMemorySecretStore
-
-USER_TEXT = "The user requires a permission review before the synthetic release."
-AGENT_TEXT = "The agent proposes a checklist for the synthetic release."
-CLAIMS = {USER_TEXT: "user", AGENT_TEXT: "agent"}
-DENIED_FACT = "DENIED_NATIVE_FACT_63a1: omit the permission review."
-DENIED_HISTORY = "DENIED_NATIVE_HISTORY_86b2: publish without asking."
-DENIED_TAG = "DENIED_NATIVE_TAG_91c3"
-DENIED_IDS = ("denied-native-fact", "denied-native-history")
-NOW = datetime(2026, 9, 13, 8, tzinfo=UTC)
-
-
-def vector(text):
- # Orthogonal synthetic vectors keep the two claims distinct under native dedup.
- axis = 1 if text == USER_TEXT else 2 if text == AGENT_TEXT else 0
- return [float(index == axis) for index in range(384)]
-
-
-def prompt_json(prompt, heading):
- payload = prompt_section(prompt, heading)
- starts = [index for marker in ("[", "{") if (index := payload.find(marker)) >= 0]
- assert starts, (heading, payload)
- payload = payload[min(starts) :]
- try:
- return json.JSONDecoder().raw_decode(payload)[0]
- except json.JSONDecodeError as error:
- raise AssertionError(f"expected JSON after {heading!r}, got {payload[:500]!r}") from error
-
-
-def prompt_section(prompt: str, heading: str) -> str:
- assert prompt.count(heading) == 1, heading
- section = prompt.split(heading, 1)[1]
- return section.split("\n---\n", 1)[0].strip()
-
-
-class ScriptedOracle:
- """Only the two fixture claims receive support, with exact supplied Evidence IDs."""
-
- def __init__(self, records, *, after_response=None):
- self.records = {record.evidence_id: record for record in records}
- self.requests = []
- self.stages = []
- self.reconciles = []
- self.errors = []
- self.after_response = after_response
- self.revoked_at = None
- self.limit = 24
-
- def __call__(self, request):
- try:
- return self.respond(request)
- except Exception:
- self.errors.append(traceback.format_exc())
- raise
-
- def respond(self, request):
- body = json.loads(request.content)
- self.requests.append((request.url.path, body))
- assert len(self.requests) <= self.limit, "scripted HTTP response budget exceeded"
- assert self.revoked_at is None, "native child request escaped after revocation"
- assert request.method == "POST"
- serialized = json.dumps(body)
- for forbidden in (DENIED_FACT, DENIED_HISTORY, DENIED_TAG, *DENIED_IDS):
- assert forbidden not in serialized, f"denied native context reached HTTP: {forbidden}"
- if request.url.path == "/v1/embeddings":
- assert str(request.url) == "http://127.0.0.1:2/v1/embeddings"
- assert body["model"] == "raw-fixture-embedding"
- texts = body["input"]
- assert isinstance(texts, list) and texts
- lines = [f"[{record.role}]: {record.verbatim}" for record in self.records.values()]
- # HY stores one raw copy per add, and each add is one turn.
- turns = {
- "\n".join(lines[start:end])
- for start in range(len(lines))
- for end in range(start + 1, len(lines) + 1)
- }
- for text in texts:
- if text not in CLAIMS:
- assert text in turns
- self.stages.append("embedding")
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": body["model"],
- "data": [
- {"index": i, "object": "embedding", "embedding": vector(text)}
- for i, text in enumerate(texts)
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
- assert str(request.url) == "http://127.0.0.1:1/v1/chat/completions"
- assert body["model"] == "raw-fixture-generation"
- prompt = body["messages"][-1]["content"]
- if prompt.startswith("You are a single-label scene classifier."):
- result = {"is_coding": False, "reason": "synthetic chat"}
- stage = "judge"
- elif "## New Messages\n" in prompt:
- self.check_turns(prompt_json(prompt, "## New Messages\n"))
- history_section = prompt_section(prompt, "## Last k Messages\n")
- history = (
- [] if history_section == "(none)" else prompt_json(prompt, "## Last k Messages\n")
- )
- for source in history:
- record = self.records[source["evidence_id"]]
- assert source["content"] == record.verbatim
- assert source["role"] == record.role
- assert source["revision"] == record.revision
- assert '"memory"' in prompt
- assert ('"intentions"' in prompt) is ("never" not in prompt)
- result = {
- "memory": [
- {"content": text, "owner": owner, "tags": []} for text, owner in CLAIMS.items()
- ],
- "basic_info": {},
- }
- if '"intentions"' in prompt:
- result["intentions"] = []
- stage = "extract"
- elif "## Existing memories\n" in prompt:
- existing = prompt_json(prompt, "## Existing memories\n")
- incoming = prompt_json(prompt, "## New memories\n")
- assert existing and incoming
- for item in (*existing, *incoming):
- assert CLAIMS[item["content"]] == item["owner"]
- assert not item.get("tags")
- self.reconciles.append(existing)
- by_content = {item["content"]: item for item in existing}
- result = [
- {
- "op": "UPDATE" if item["content"] in by_content else "ADD",
- "content": item["content"],
- "owner": item["owner"],
- "layer": "L2_FACT",
- "tags": [],
- **(
- {"memory_id": by_content[item["content"]]["memory_id"]}
- if item["content"] in by_content
- else {}
- ),
- }
- for item in incoming
- ]
- stage = "reconcile"
- else:
- if not prompt.lstrip().startswith("{"):
- raise AssertionError(f"unknown native child prompt: {prompt[:300]!r}")
- data = json.loads(prompt)
- if set(data) == {"items", "sources", "before"}:
- result = self.review(data)
- stage = "review"
- else:
- assert set(data) == {"proofs"}, "unknown native child prompt"
- result = self.cited_review(data)
- stage = "citations"
- self.stages.append(stage)
- response = httpx.Response(
- 200,
- json={
- "id": "synthetic-raw-response",
- "object": "chat.completion",
- "created": 1,
- "model": body["model"],
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {"role": "assistant", "content": json.dumps(result)},
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
- if self.after_response is not None and self.after_response(stage):
- self.revoked_at = len(self.requests)
- return response
-
- def check_turns(self, turns):
- assert turns and isinstance(turns, list)
- allowed = {(record.role, record.verbatim) for record in self.records.values()}
- assert all(set(turn) == {"role", "content"} for turn in turns)
- assert {(turn["role"], turn["content"]) for turn in turns} <= allowed
-
- def review(self, data):
- assert data["items"] and data["sources"]
- for source in data["sources"]:
- record = self.records[source["id"]]
- assert source["role"] == record.role
- assert source["revision"] == record.revision
- assert source["created_at"] == record.created_at.isoformat()
- assert "".join(span["text"] for span in source["spans"]) == record.verbatim
- reviews = []
- for item in data["items"]:
- assert CLAIMS[item["content"]] == item["owner"]
- role = "user" if item["owner"] == "user" else "assistant"
- source = next(
- source
- for source in data["sources"]
- if source["role"] == role and self.records[source["id"]].verbatim == item["content"]
- )
- reviews.append(
- {
- "id": item["id"],
- "clauses": [
- {
- "text": item["content"],
- "verdict": "supported",
- "authority": item["owner"],
- "citations": [
- {
- "source_id": source["id"],
- "span_ids": [span["id"] for span in source["spans"]],
- }
- ],
- "reason": "exact synthetic fixture claim",
- }
- ],
- }
- )
- for before in data["before"]:
- assert any(
- (item["content"], item["owner"]) == (before["content"], before["owner"])
- for item in data["items"]
- )
- return {
- "reviews": reviews,
- "coverage": [
- {"id": item["id"], "verdict": "preserved", "reason": "exact fixture match"}
- for item in data["before"]
- ],
- }
-
- def cited_review(self, data):
- assert data["proofs"]
- for proof in data["proofs"]:
- assert CLAIMS[proof["claim"]] == proof["owner"]
- assert proof["citations"] and not proof["context_citations"]
- for citation in proof["citations"]:
- record = self.records[citation["source_id"]]
- assert citation["text"] == record.verbatim == proof["claim"]
- assert citation["role"] == record.role
- assert citation["revision"] == record.revision
- assert citation["created_at"] == record.created_at.isoformat()
- return {
- "proofs": [
- {"id": proof["id"], "verdict": "supported", "reason": "exact fixture match"}
- for proof in data["proofs"]
- ]
- }
-
-
-def put_turn(evidence, message_id, role, text, ordinal, *, conversation_id="raw-session"):
- return evidence.put(
- EvidenceInput(
- user_id="local",
- source_id="raw-fixture",
- conversation_id=conversation_id,
- message_id=message_id,
- source_ordinal=str(ordinal),
- role=role,
- written_by={"user": WrittenBy.PERSON, "tool": WrittenBy.TOOL}.get(
- role, WrittenBy.AGENT
- ),
- content_kind=EvidenceContentKind.RAW_CONVERSATION,
- created_at=NOW + timedelta(seconds=ordinal),
- verbatim=text,
- origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata()},
- )
- ).record
-
-
-def grant_records(supervisor, records, expected_revision=0):
- profile = supervisor.profiles.active(supervisor.secrets)
- snapshot = supervisor.model_content_permissions.snapshot(
- user_id="local",
- source_ids=("raw-fixture",),
- profile=supervisor.profiles.snapshot(profile.profile_id, profile.revision),
- evidence=records,
- )
- return supervisor.model_content_permissions.grant(
- snapshot, user_confirmed=True, expected_revision=expected_revision
- )
-
-
-@asynccontextmanager
-async def raw_client(tmp_path, *, tool=None, conversation_id="raw-session"):
- assert hy_memory_version() == "2.0.2"
- root = tmp_path / "source"
- root.mkdir()
- database = tmp_path / "one-memory.sqlite3"
- grants = SourceGrantStore(database, home=tmp_path)
- grants.grant(
- user_id="local",
- source_id="raw-fixture",
- root=root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- evidence = EvidenceStore(database)
- records = (
- put_turn(
- evidence,
- "user-original",
- "user",
- USER_TEXT,
- 1,
- conversation_id=conversation_id,
- ),
- put_turn(
- evidence,
- "agent-original",
- "assistant",
- AGENT_TEXT,
- 2,
- conversation_id=conversation_id,
- ),
- *(
- (
- put_turn(
- evidence,
- "tool-original",
- "tool",
- tool,
- 3,
- conversation_id=conversation_id,
- ),
- )
- if tool
- else ()
- ),
- )
- profiles, secrets = ModelProfileStore(database), InMemorySecretStore()
- profile = profiles.create(
- ModelProfileInput(
- name="Synthetic HTTP only",
- provider_kind=ProviderKind.OLLAMA,
- base_url="http://127.0.0.1:1/v1",
- model="raw-fixture-generation",
- embedding_provider_kind=ProviderKind.OLLAMA,
- embedding_base_url="http://127.0.0.1:2/v1",
- embedding_model="raw-fixture-embedding",
- embedding_dimensions=384,
- ),
- secrets,
- )
- profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True, model_available=True, embedding_available=True
- ),
- secrets=secrets,
- )
- profiles.activate(profile.profile_id, secrets)
- supervisor = MemoryProcessingSupervisor(
- tasks=MemoryTaskStore(database),
- profiles=profiles,
- secrets=secrets,
- evidence=evidence,
- governance=MemoryGovernanceStore(database),
- data_dir=tmp_path,
- model_content_permissions=ModelContentPermissionStore(database),
- )
- try:
- permission = grant_records(supervisor, records)
- assert supervisor.governance.list(user_id="local") == []
- yield supervisor, records, permission
- finally:
- await supervisor.shutdown()
-
-
-def test_search_after_first_wb20_task_does_not_poison_the_second_task(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, conversation_id="wb-20") as (
- supervisor,
- records,
- permission,
- ):
- first = supervisor.prepare_conversation(records[0])[0]
- _, request, _ = supervisor._create_runner()._prepare_permission_lease(first)
- client = await native_client(supervisor, first)
- duplicate = MemoryNode(
- node_id="older-identical-memory",
- user_id=request.user_id,
- agent_id=request.agent_id,
- session_id="wb-19",
- layer=MemoryLayer.L2_FACT,
- content=USER_TEXT,
- owner="user",
- embedding=vector(USER_TEXT),
- gmt_created=NOW - timedelta(days=1),
- gmt_modified=NOW - timedelta(days=1),
- )
- await on_native_loop(client, client._vector_store.upsert(duplicate))
-
- first_oracle = ScriptedOracle(records)
- bind_oracle(supervisor, first_oracle)
- first_result = await supervisor._create_runner().run(first.task_id)
- assert first_result.status == "completed", first_result.last_error_code
-
- current = next(
- memory
- for memory in supervisor.governance.list(user_id="local")
- if memory.content == USER_TEXT
- )
- client._spawn_search_dedup(
- [
- {
- "memory_id": duplicate.node_id,
- "content": duplicate.content,
- "layer": duplicate.layer.value,
- "gmt_created": int(duplicate.gmt_created.timestamp()),
- },
- {
- "memory_id": current.memory_id,
- "content": current.content,
- "layer": MemoryLayer.L2_FACT.value,
- "gmt_created": int(NOW.timestamp()),
- },
- ],
- request_id="wb-20-search",
- user_id=request.user_id,
- agent_id=request.agent_id,
- )
- await asyncio.sleep(0.2)
-
- added = put_turn(
- supervisor.evidence,
- "wb-20-second",
- "user",
- USER_TEXT,
- 3,
- conversation_id="wb-20",
- )
- records = (*records, added)
- grant_records(supervisor, records, permission.revision)
- second = supervisor.prepare_conversation(added)[0]
- second_oracle = ScriptedOracle(records)
- bind_oracle(supervisor, second_oracle)
- second_result = await supervisor._create_runner().run(second.task_id)
-
- assert second_result.status == "completed", second_result.last_error_code
- assert second_result.last_error_code is None
-
- asyncio.run(scenario())
-
-
-async def native_client(supervisor, task):
- bridge = supervisor._bridge(task.profile_snapshot, None, supervisor.data_dir)
- client = await bridge._get_client()
- assert isinstance(client, HyMemoryClient)
- assert isinstance(client._vector_store, ChromaVectorStore)
- guards = client._one_memory_write_guards
- assert isinstance(guards[0], HyResponseGuard)
- assert isinstance(guards[2], HyReconcileGuard)
- assert isinstance(guards[3], HyExtractionGuard)
- return client
-
-
-def test_governance_write_runs_after_shared_hy_bridge_is_released(tmp_path):
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _permission):
- task = supervisor.prepare_conversation(records[0])[0]
- bind_oracle(supervisor, ScriptedOracle(records))
- await native_client(supervisor, task)
- bridge = next(
- value
- for key, value in supervisor._bridges.items()
- if key[:2] == (task.profile_id, task.profile_revision)
- )
- runner = supervisor._create_runner()
- original = runner._record_outputs
- observed = []
-
- def checked(*args, **kwargs):
- observed.append(bridge._access_owner)
- return original(*args, **kwargs)
-
- runner._record_outputs = checked
- result = await runner.run(task.task_id)
-
- assert result.status == "completed"
- assert observed == [None], "governance writes must not hold the shared HY bridge"
-
- asyncio.run(scenario())
-
-
-async def on_native_loop(client, operation):
- return await asyncio.to_thread(client._loop_thread.run, operation)
-
-
-async def seed_denied(client, request):
- store = client._vector_store
- nodes = (
- MemoryNode(
- node_id=DENIED_IDS[0],
- user_id=request.user_id,
- agent_id=request.agent_id,
- session_id=request.session_id,
- layer=MemoryLayer.L2_FACT,
- content=DENIED_FACT,
- owner="user",
- embedding=vector(USER_TEXT),
- tags=[DENIED_TAG],
- ),
- MemoryNode(
- node_id=DENIED_IDS[1],
- user_id=request.user_id,
- agent_id=request.agent_id,
- session_id=request.session_id,
- layer=MemoryLayer.L1_RAW,
- content=f"[user]: {DENIED_HISTORY}",
- owner="user",
- embedding=vector("raw"),
- tags=[DENIED_TAG],
- ),
- )
- await on_native_loop(client, store.upsert_batch(list(nodes)))
- retrieved = await on_native_loop(
- client,
- store.search(
- query_embedding=vector(USER_TEXT),
- user_id=request.user_id,
- agent_ids=[request.agent_id],
- layers=[MemoryLayer.L2_FACT],
- ),
- )
- assert {hit["node_id"] for hit in retrieved} == {DENIED_IDS[0]}
- history = await on_native_loop(
- client,
- store.list_by_user(
- user_id=request.user_id,
- agent_id=request.agent_id,
- layers=[MemoryLayer.L1_RAW],
- allow_raw_layer=True,
- ),
- )
- assert [node.node_id for node in history] == [DENIED_IDS[1]]
- candidates = await on_native_loop(
- client,
- ReconcileHybridRetriever().search_candidates(
- USER_TEXT,
- vector(USER_TEXT),
- vector_store=store,
- user_id=request.user_id,
- agent_id=request.agent_id,
- layers=[MemoryLayer.L2_FACT],
- ),
- )
- assert [hit["node"].node_id for hit in candidates] == [DENIED_IDS[0]]
- pipeline = await on_native_loop(client, client._registry.get_system2_writer())
- native_history = await on_native_loop(
- client,
- pipeline._writer._get_recent_history(store, request.user_id, request.agent_id),
- )
- native_tags = await on_native_loop(
- client,
- pipeline._writer._collect_existing_tags(store, request.user_id, request.agent_id),
- )
- assert DENIED_HISTORY in native_history
- assert DENIED_TAG in native_tags
- return await node_snapshots(client, DENIED_IDS)
-
-
-async def node_snapshots(client, identifiers):
- result = {}
- for identifier in identifiers:
- node = await on_native_loop(client, client._vector_store.get_by_id(identifier))
- assert node is not None, identifier
- result[identifier] = node.to_dict()
- return result
-
-
-def bind_oracle(supervisor, oracle):
- supervisor.permission_bridge_factory = permission_bridge_factory(
- supervisor._bridge, http_transport=httpx.MockTransport(oracle)
- )
-
-
-async def assert_outputs(supervisor, task, records, client, *, raw_records=None):
- outputs = supervisor.tasks.outputs(task.task_id)
- assert len(outputs) == 2
- memories = supervisor.governance.list(user_id="local")
- assert {memory.content for memory in memories} == set(CLAIMS)
- by_content = {memory.content: memory for memory in memories}
- for record in records[:2]:
- memory = by_content[record.verbatim]
- output = next(output for output in outputs if output.memory_id == memory.memory_id)
- assert output.owner == CLAIMS[record.verbatim]
- assert output.engine == "hy-memory" and output.engine_version == "2.0.2"
- assert output.hy_memory_id == memory.memory_id
- assert output.memory_revision == memory.revision
- assert output.support_refs == (evidence_ref(record),)
- assert memory.evidence_ids == (record.evidence_id,)
- # Usable either way; origin is what keeps an agent report from passing as the person.
- assert memory.status == "confirmed"
- assert memory.origin == ("agent" if output.owner == "agent" else "extracted")
- stored = await on_native_loop(client, client._vector_store.get_by_id(output.hy_memory_id))
- assert stored is not None
- assert stored.content == record.verbatim and stored.owner == output.owner
- for record in records:
- assert supervisor.evidence.get(record.evidence_id) == record
- raw = await on_native_loop(
- client,
- client._vector_store.list_by_user(
- user_id="local",
- layers=[MemoryLayer.L1_RAW],
- allow_raw_layer=True,
- ),
- )
- allowed_raw = [node for node in raw if node.node_id not in DENIED_IDS]
- assert allowed_raw
- # One raw copy per turn; together they hold every record.
- for record in records if raw_records is None else raw_records:
- line = f"[{record.role}]: {record.verbatim}"
- assert any(line in node.content for node in allowed_raw), line
- with sqlite3.connect(supervisor.data_dir / "one-memory.sqlite3") as db:
- proofs = db.execute("SELECT node_id, evidence_json FROM hy_node_permissions").fetchall()
- assert {output.hy_memory_id for output in outputs} <= {key for key, _ in proofs}
- assert not set(DENIED_IDS) & {key for key, _ in proofs}
- allowed_refs = [evidence_ref(record) for record in records]
- for _, refs in proofs:
- assert json.loads(refs) and all(ref in allowed_refs for ref in json.loads(refs))
- return {output.hy_memory_id for output in outputs}
-
-
-def record_receipt(test_node, supervisor, task, oracle, *, phase, hidden):
- test_node.user_properties.append(
- (
- phase,
- json.dumps(
- {
- "sdk": hy_memory_version(),
- "transport": "scripted-http-oracle-not-real-model",
- "request_count": len(oracle.requests),
- "response_limit": oracle.limit,
- "stages": oracle.stages,
- "oracle_errors": oracle.errors,
- "requests_after_revocation": len(oracle.requests) - oracle.revoked_at
- if oracle.revoked_at is not None
- else None,
- "hidden_native_fixture_checked": hidden,
- "status": task.status,
- "error": task.last_error_code,
- "outputs": [
- output.model_dump(mode="json")
- for output in supervisor.tasks.outputs(task.task_id)
- ],
- "governance_count": len(supervisor.governance.list(user_id="local")),
- }
- ),
- )
- )
-
-
-@pytest.mark.parametrize("hidden", [False, True], ids=["fresh-raw", "denied-native-context"])
-def test_native_raw_input_runs_extraction_guards_and_governance(tmp_path, hidden, request):
- test_node = request.node
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- tasks = supervisor.prepare_conversation(records[0])
- assert len(tasks) == 1
- task = tasks[0]
- runner = supervisor._create_runner()
- _, request, _ = runner._prepare_permission_lease(task)
- client = await native_client(supervisor, task)
- before = await seed_denied(client, request) if hidden else {}
- oracle = ScriptedOracle(records)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(task.task_id)
- assert not oracle.errors, oracle.errors
- if hidden:
- assert await node_snapshots(client, DENIED_IDS) == before
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- assert oracle.stages.count("extract") == 1
- assert "review" not in oracle.stages and "citations" not in oracle.stages
- assert "embedding" in oracle.stages
- assert "reconcile" not in oracle.stages
- await assert_outputs(supervisor, task, records, client)
- record_receipt(test_node, supervisor, result, oracle, phase="raw", hidden=hidden)
-
- asyncio.run(scenario())
-
-
-def test_a_tool_turn_hy_judges_as_chat_is_extracted_without_its_tool_messages(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool="[Bash] pytest -q\n12 passed") as (
- supervisor,
- records,
- _,
- ):
- tasks = supervisor.prepare_conversation(records[0])
- assert len(tasks) == 1
- oracle = ScriptedOracle(records)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(tasks[0].task_id)
- assert not oracle.errors, oracle.errors
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- assert oracle.stages[0] == "judge"
- prompt = next(
- body["messages"][-1]["content"]
- for _path, body in oracle.requests
- if "messages" in body and "## New Messages\n" in body["messages"][-1]["content"]
- )
- turns = prompt_json(prompt, "## New Messages\n")
- assert [(turn["role"], turn["content"]) for turn in turns] == [
- (record.role, record.verbatim) for record in records[:2]
- ]
- client = await native_client(supervisor, tasks[0])
- await assert_outputs(supervisor, tasks[0], records, client, raw_records=records[:2])
-
- asyncio.run(scenario())
-
-
-def test_revoke_during_native_extractor_response_stops_children_and_governance(tmp_path, request):
- test_node = request.node
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- task = supervisor.prepare_conversation(records[0])[0]
- _, request, _ = supervisor._create_runner()._prepare_permission_lease(task)
- client = await native_client(supervisor, task)
- before = await seed_denied(client, request)
-
- def revoke(stage):
- if stage != "extract":
- return False
- supervisor.model_content_permissions.revoke(
- user_id="local", expected_revision=permission.revision, user_confirmed=True
- )
- return True
-
- oracle = ScriptedOracle(records, after_response=revoke)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(task.task_id)
- assert not oracle.errors, oracle.errors
- assert result.status == "cancelled"
- assert oracle.revoked_at is not None and len(oracle.requests) == oracle.revoked_at
- assert oracle.stages.count("extract") == 1
- assert "review" not in oracle.stages and "citations" not in oracle.stages
- assert not supervisor.governance.list(user_id="local")
- assert not supervisor.tasks.outputs(task.task_id)
- assert await node_snapshots(client, DENIED_IDS) == before
- for record in records:
- assert supervisor.evidence.get(record.evidence_id) == record
- record_receipt(test_node, supervisor, result, oracle, phase="revoked", hidden=True)
- assert result.last_error_code == "model_content_permission_revoked", result
-
- asyncio.run(scenario())
-
-
-def test_approved_native_candidates_are_reused_without_disclosing_denied_nodes(tmp_path, request):
- test_node = request.node
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- first = supervisor.prepare_conversation(records[0])[0]
- _, request, _ = supervisor._create_runner()._prepare_permission_lease(first)
- client = await native_client(supervisor, first)
- before = await seed_denied(client, request)
- first_oracle = ScriptedOracle(records)
- bind_oracle(supervisor, first_oracle)
- first_result = await supervisor._create_runner().run(first.task_id)
- assert not first_oracle.errors, first_oracle.errors
- assert first_result.status == "completed", first_result.last_error_code
- ids = await assert_outputs(supervisor, first, records, client)
- record_receipt(
- test_node, supervisor, first_result, first_oracle, phase="initial", hidden=True
- )
- added = put_turn(supervisor.evidence, "user-repeat", "user", USER_TEXT, 3)
- records = (*records, added)
- grant_records(supervisor, records, permission.revision)
- second = supervisor.prepare_conversation(added)[0]
- assert second.task_id != first.task_id
- oracle = ScriptedOracle(records)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(second.task_id)
- assert not oracle.errors, oracle.errors
- assert await node_snapshots(client, DENIED_IDS) == before
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- # SDK 2.0.2 keeps the `limit=1` pre-check removed, so
- # skip reconcile, so the second write now compares against the
- # already-approved candidates instead of blindly ADDing them. The
- # contract is the reuse itself, not the absence of the call: one
- # reconcile pass, the returning ids are the ones already governed,
- # and no third/fourth memory appears.
- assert oracle.stages.count("reconcile") == 1
- assert {item["memory_id"] for item in oracle.reconciles[0]} == ids
- assert await assert_outputs(supervisor, second, records, client) == ids
- assert len(supervisor.governance.list(user_id="local")) == 2
- record_receipt(test_node, supervisor, result, oracle, phase="reuse", hidden=True)
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_hy_permission_store.py b/memhub/apps/core/tests/test_hy_permission_store.py
deleted file mode 100644
index 0631c54..0000000
--- a/memhub/apps/core/tests/test_hy_permission_store.py
+++ /dev/null
@@ -1,1210 +0,0 @@
-"""Native HY node/algorithm tests with synthetic data and an in-memory backend only."""
-
-import asyncio
-import math
-from copy import deepcopy
-from datetime import UTC, datetime, timedelta
-from types import SimpleNamespace
-
-import pytest
-from hy_memory.agent.tools.basic_profile import upsert_basic_profile
-from hy_memory.data.vector_store_chroma import ChromaVectorStore
-from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
-from hy_memory.pipelines._retrieval.dedup import DedupItem, execute_dedup
-from hy_memory.pipelines._retrieval.evolution import _trace_full_chain
-from hy_memory.pipelines.writer import MemoryWriter
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.hy_permission_store import (
- HyNodePermission,
- HyPermissionVectorStore,
- _native_write_lock,
- hy_node_fingerprint,
- native_store_read_slot,
-)
-from one_memory.hy_source_context import HySourceContext, HySupportedMemory
-from one_memory.imports.models import EvidenceContentKind, EvidenceRecord, WrittenBy
-
-
-def test_native_semantic_read_yields_immediately_to_an_active_write() -> None:
- async def scenario() -> tuple[bool, bool]:
- async with _native_write_lock(), native_store_read_slot() as during_write:
- pass
- async with native_store_read_slot() as after_write:
- pass
- return during_write, after_write
-
- assert asyncio.run(scenario()) == (False, True)
-
-
-def evidence(key):
- now = datetime(2026, 9, 13, tzinfo=UTC)
- return EvidenceRecord(
- evidence_id=key,
- user_id="local",
- source_id="cursor",
- workspace=None,
- project=None,
- conversation_id="session",
- message_id=key,
- correlation_id=None,
- source_ordinal=None,
- content_kind=EvidenceContentKind.RAW_CONVERSATION,
- role="user",
- written_by=WrittenBy.PERSON,
- created_at=now,
- verbatim=f"Synthetic evidence {key}",
- origin_meta={},
- content_hash="a" * 64,
- revision=1,
- imported_at=now,
- updated_at=now,
- )
-
-
-def node(key, **kwargs):
- return MemoryNode(
- **{
- "node_id": key,
- "user_id": "local",
- "agent_id": "agent",
- "session_id": "session",
- "content": f"Synthetic {key}",
- "owner": "user",
- "layer": MemoryLayer.L2_FACT,
- "embedding": [1.0, 0.0],
- **kwargs,
- }
- )
-
-
-class Lease:
- def __init__(self, known=()):
- self.task_id = "task"
- self.receipt = SimpleNamespace(user_id="local")
- self.records = (evidence("current"), evidence("support"))
- self.source_context = HySourceContext(
- current=self.records[:1],
- supporting=self.records[1:],
- known_memories=known,
- )
- self.revoked = False
- self.checks = 0
-
- def recheck(self):
- self.checks += 1
- if self.revoked:
- raise HyMemoryBridgeError("model_content_permission_revoked")
- return self.receipt
-
-
-class Backend:
- def __init__(self, *nodes):
- self.nodes = {n.node_id: deepcopy(n) for n in nodes}
- self.calls = []
- self.after_read = lambda: None
- self.corrupt_write = lambda _: None
-
- async def get_by_id(self, key):
- self.calls.append(("get", key))
- result = deepcopy(self.nodes.get(key))
- self.after_read()
- return result
-
- async def list_by_user(self, **kwargs):
- self.calls.append(("list", kwargs))
- # Deliberately return foreign/unsupported rows to exercise defense in depth.
- return deepcopy(list(self.nodes.values()))
-
- async def search(self, **kwargs):
- self.calls.append(("search", kwargs))
- hits = []
- for n in self.nodes.values():
- candidate = deepcopy(n)
- candidate.embedding = None # Native Chroma search omits the vector.
- hits.append(
- {
- "node_id": n.node_id,
- "node": candidate,
- "score": 0.9,
- "untrusted_extra": "DENIED_HIT_METADATA",
- }
- )
- return hits
-
- async def upsert(self, value):
- self.calls.append(("upsert", value.node_id))
- self.nodes[value.node_id] = deepcopy(value)
- self.corrupt_write(self.nodes[value.node_id])
- return value.node_id
-
- async def upsert_batch(self, values):
- return [await self.upsert(n) for n in values]
-
- async def update_payload(self, key, updates):
- self.calls.append(("update", key, deepcopy(updates)))
- self.nodes[key] = MemoryNode.from_dict({**self.nodes[key].to_dict(), **deepcopy(updates)})
- self.corrupt_write(self.nodes[key])
- return True
-
- async def update_embedding(self, key, embedding):
- self.calls.append(("embedding", key))
- self.nodes[key].embedding = deepcopy(embedding)
- return True
-
- async def delete(self, key):
- self.calls.append(("delete", key))
- self.nodes.pop(key, None)
- return True
-
-
-def permission(value, ids=("current", "support")):
- return HyNodePermission(fingerprint=hy_node_fingerprint(value), evidence_ids=ids)
-
-
-def wrapper(backend, *, lease=None, approved=(), refresh_permission=None):
- return HyPermissionVectorStore(
- backend,
- lease or Lease(),
- agent_id="agent",
- session_id="session",
- node_permissions={n.node_id: permission(n) for n in approved},
- refresh_permission=refresh_permission,
- )
-
-
-def test_cross_evidence_scope_is_opt_in_and_default_leases_stay_session_bound():
- lease = Lease()
- mismatched = lease.records[1].model_copy(update={"conversation_id": "other-conversation"})
- lease.records = (lease.records[0], mismatched)
- lease.source_context = HySourceContext(current=lease.records)
-
- with pytest.raises(HyMemoryBridgeError, match="scope"):
- HyPermissionVectorStore(
- Backend(),
- lease,
- agent_id="agent",
- session_id="session",
- )
-
- store = HyPermissionVectorStore(
- Backend(),
- lease,
- agent_id="agent",
- session_id="digest:task",
- cross_evidence_scope=True,
- )
- assert store is not None
-
-
-def test_cross_evidence_scope_does_not_relax_user_or_evidence_integrity():
- lease = Lease()
- foreign = lease.records[1].model_copy(update={"user_id": "someone-else"})
- lease.records = (lease.records[0], foreign)
- with pytest.raises(HyMemoryBridgeError, match="scope"):
- HyPermissionVectorStore(
- Backend(),
- lease,
- agent_id="agent",
- session_id="digest:task",
- cross_evidence_scope=True,
- )
-
- duplicate = Lease()
- duplicate.records = (duplicate.records[0], duplicate.records[0])
- with pytest.raises(HyMemoryBridgeError, match="scope"):
- HyPermissionVectorStore(
- Backend(),
- duplicate,
- agent_id="agent",
- session_id="digest:task",
- cross_evidence_scope=True,
- )
-
-
-def test_cross_evidence_scope_allows_digest_to_read_other_sessions_of_the_same_agent():
- lease = Lease()
- lease.source_context = HySourceContext(current=lease.records)
- existing = node("digest-source", session_id="note:one")
- backend = Backend(existing)
- approved = {existing.node_id: permission(existing)}
- regular = wrapper(backend, approved=(existing,))
- digest = HyPermissionVectorStore(
- backend,
- lease,
- agent_id="agent",
- session_id="digest:task",
- cross_evidence_scope=True,
- node_permissions=approved,
- )
-
- async def scenario():
- assert await regular.get_by_id(existing.node_id) is None
- found = await digest.get_by_id(existing.node_id)
- assert found is not None and found.node_id == existing.node_id
-
- asyncio.run(scenario())
-
-
-def test_only_digest_scope_may_advance_the_system2_evidence_counter_once():
- approved = node("digest-source", custom={})
- lease = Lease()
- lease.source_context = HySourceContext(current=lease.records)
- backend = Backend(approved)
- permissions = {approved.node_id: permission(approved)}
- regular = wrapper(backend, approved=(approved,))
- digest = HyPermissionVectorStore(
- backend,
- lease,
- agent_id="agent",
- session_id="digest:task",
- cross_evidence_scope=True,
- node_permissions=permissions,
- )
-
- async def scenario():
- with pytest.raises(HyMemoryBridgeError, match="update_fields"):
- await regular.update_payload(approved.node_id, {"custom": {"s2_evidence_count": 1}})
- assert await digest.update_payload(approved.node_id, {"custom": {"s2_evidence_count": 1}})
- with pytest.raises(HyMemoryBridgeError, match="update_fields"):
- await digest.update_payload(
- approved.node_id,
- {"custom": {"s2_evidence_count": 3, "extra": "DENIED"}},
- )
-
- asyncio.run(scenario())
-
-
-def test_known_content_alone_cannot_authorize_native_extras_or_mutation():
- allowed = node("known", tags=["DENIED_TAG"], custom={"note": "DENIED_CUSTOM"})
- known = HySupportedMemory(
- hy_memory_id=allowed.node_id,
- governed_memory_id="governed",
- revision=1,
- content=allowed.content,
- owner="user",
- evidence_ids=("current",),
- )
- backend = Backend(allowed)
- store = wrapper(backend, lease=Lease((known,)))
-
- async def scenario():
- assert await store.get_by_id("known") is None
- assert await store.list_by_user("local") == []
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- await store.update_payload("known", {"content": "changed"})
-
- asyncio.run(scenario())
- assert backend.nodes["known"].custom == allowed.custom
- assert not any(call[0] == "update" for call in backend.calls)
-
-
-def test_all_native_read_shapes_keep_allowed_ids_and_filter_foreign_payloads():
- allowed, denied = node("allowed"), node("denied", content="DENIED_CONTENT")
- foreign = node("foreign", user_id="someone-else")
- backend = Backend(allowed, denied, foreign)
- store = wrapper(backend, approved=(allowed, foreign))
-
- async def scenario():
- assert await store.get_by_id("denied") is None
- assert [n.node_id for n in await store.get_by_ids(["allowed", "denied"])] == ["allowed"]
- assert await store.get_embeddings(["denied", "allowed"]) == {"allowed": [1.0, 0.0]}
- assert [n.node_id for n in await store.list_by_user("local")] == ["allowed"]
- hits = await store.search([1.0, 0.0], user_id="local", agent_ids=["agent"])
- assert len(hits) == 1 and hits[0]["node_id"] == "allowed"
- assert set(hits[0]) == {"node_id", "node", "score"}
- hits[0]["node"].content = "caller mutation"
- assert (await store.get_by_id("allowed")).content == allowed.content
-
- asyncio.run(scenario())
- assert backend.nodes["allowed"].content == allowed.content
-
-
-@pytest.mark.parametrize(
- "kwargs",
- [{"user_id": "other"}, {"agent_ids": ["other"]}, {"isolation_key": "other::agent::session"}],
-)
-def test_search_cannot_override_the_bound_namespace(kwargs):
- backend = Backend()
- with pytest.raises(HyMemoryBridgeError, match="scope"):
- asyncio.run(wrapper(backend).search([1.0, 0.0], **kwargs))
- assert backend.calls == []
-
-
-def test_exact_proof_still_requires_approved_evidence_and_known_content_match():
- allowed = node("known")
- with pytest.raises(HyMemoryBridgeError, match="provenance"):
- HyPermissionVectorStore(
- Backend(allowed),
- Lease(),
- agent_id="agent",
- session_id="session",
- node_permissions={"known": permission(allowed, ("not-approved",))},
- )
- known = HySupportedMemory(
- hy_memory_id="known",
- governed_memory_id="governed",
- revision=1,
- content="Different confirmed content",
- owner="user",
- evidence_ids=("current",),
- )
- store = wrapper(Backend(allowed), lease=Lease((known,)), approved=(allowed,))
- with pytest.raises(HyMemoryBridgeError, match="node_changed"):
- asyncio.run(store.get_by_id("known"))
-
-
-@pytest.mark.parametrize(
- "field,value",
- [
- ("tags", ["changed"]),
- ("custom", {"other": "secret"}),
- ("embedding", [0.0, 1.0]),
- ("supersedes", ["foreign"]),
- ],
-)
-def test_full_fingerprint_detects_changes_not_just_content(field, value):
- allowed = node("allowed")
- backend = Backend(allowed)
- store = wrapper(backend, approved=(allowed,))
- setattr(backend.nodes["allowed"], field, value)
- with pytest.raises(HyMemoryBridgeError, match="node_changed"):
- asyncio.run(store.get_by_id("allowed"))
-
-
-def test_concurrent_authorized_write_can_refresh_a_stale_full_proof():
- allowed = node("allowed")
- backend = Backend(allowed)
- refreshed: list[str] = []
-
- async def refresh(node_id: str):
- refreshed.append(node_id)
- return permission(backend.nodes[node_id])
-
- store = wrapper(backend, approved=(allowed,), refresh_permission=refresh)
- backend.nodes["allowed"].embedding = [0.0, 1.0]
-
- assert asyncio.run(store.get_by_id("allowed")) is not None
- assert refreshed == ["allowed"]
-
-
-def test_refresh_cannot_authorize_a_node_that_was_not_in_the_original_lease():
- allowed = node("allowed")
- foreign = node("foreign")
- backend = Backend(allowed, foreign)
-
- async def refresh(_node_id: str):
- return permission(foreign, ("other-evidence",))
-
- store = wrapper(backend, approved=(allowed,), refresh_permission=refresh)
-
- assert asyncio.run(store.get_by_id("foreign")) is None
-
-
-@pytest.mark.parametrize(
- "field", ["supersedes", "superseded_by", "evidence_chain", "source_raw_memory_id"]
-)
-def test_full_proof_does_not_expand_permission_along_foreign_links(field):
- value = "foreign" if field == "source_raw_memory_id" else ["foreign"]
- head = node("head", **{field: value})
- backend = Backend(head, node("foreign", user_id="other"))
- store = wrapper(backend, approved=(head,))
- assert asyncio.run(store.get_by_id("head")) is None
- assert ("get", "foreign") not in backend.calls
-
-
-def test_approved_cyclic_evolution_chain_uses_native_algorithm_without_broadening():
- old = node("old", is_latest=False, status=MemoryStatus.SUPERSEDED, superseded_by=["head"])
- head = node("head", supersedes=["old"])
- store = wrapper(Backend(old, head), approved=(old, head))
-
- async def scenario():
- start = await store.get_by_id("head")
- chain = await _trace_full_chain(store, start)
- assert {n.node_id for n in chain} == {"old", "head"}
-
- asyncio.run(scenario())
-
-
-def test_new_raw_write_is_verified_before_exposure_and_tracks_full_lease_footprint():
- raw = node("new-raw", owner=None, layer=MemoryLayer.L1_RAW, content="[user]: Synthetic input")
- backend = Backend()
- lease = Lease()
- store = wrapper(backend, lease=lease)
-
- async def scenario():
- assert await store.get_by_id(raw.node_id) is None
- assert await store.upsert(raw) == raw.node_id
- assert (await store.get_by_id(raw.node_id)).content == raw.content
- proof = store.written_nodes[raw.node_id]
- assert proof.evidence_ids == tuple(r.evidence_id for r in lease.records)
- assert proof.fingerprint == hy_node_fingerprint(backend.nodes[raw.node_id])
- with pytest.raises(TypeError):
- store.written_nodes["forged"] = proof
- raw.status = MemoryStatus.SHADOW
- assert await store.upsert(raw) == raw.node_id
- assert (await store.get_by_id(raw.node_id)).status is MemoryStatus.SHADOW
-
- asyncio.run(scenario())
-
-
-def test_native_history_read_exposes_only_registered_raw_ids():
- raw = node("raw", layer=MemoryLayer.L1_RAW, owner=None, content="[user]: APPROVED_RAW")
- denied = node("denied", layer=MemoryLayer.L1_RAW, owner=None, content="[user]: DENIED_RAW")
- backend = Backend(denied)
- store = wrapper(backend)
-
- async def scenario():
- await store.upsert(raw)
- history = await MemoryWriter._get_recent_history(None, store, "local", "agent")
- assert "APPROVED_RAW" in history and "DENIED_RAW" not in history
-
- asyncio.run(scenario())
-
-
-def test_native_chroma_nullable_chain_roundtrip_preserves_exact_readback_proof():
- class SerializedBackend(Backend):
- async def upsert(self, value):
- result = await super().upsert(value)
- payload = ChromaVectorStore._node_to_payload(value)
- metadata = ChromaVectorStore._payload_to_chroma_metadata(payload)
- restored = ChromaVectorStore._chroma_metadata_to_payload(metadata)
- stored = ChromaVectorStore._payload_to_node(restored)
- stored.embedding = deepcopy(value.embedding)
- self.nodes[result] = stored
- return result
-
- backend = SerializedBackend()
- store = wrapper(backend)
- original = node("chroma-roundtrip", owner="agent")
-
- async def scenario():
- assert await store.upsert(original) == original.node_id
- actual = await store.get_by_id(original.node_id)
- assert actual.supersedes == actual.superseded_by == ""
- assert original.supersedes is original.superseded_by is None
- assert actual.content == original.content and actual.owner == "agent"
- assert hy_node_fingerprint(actual) != hy_node_fingerprint(original)
- assert store.written_nodes[original.node_id].fingerprint == hy_node_fingerprint(actual)
-
- asyncio.run(scenario())
-
-
-def test_real_cosine_chroma_accepts_dense_vector_roundtrip_without_loosening_node_proof(tmp_path):
- from one_memory.hy_memory_bridge import build_hy_memory_config
- from tests.test_memory_language import _profile
-
- config = build_hy_memory_config(
- _profile().model_copy(update={"embedding_dimensions": 512}),
- api_key=None,
- data_dir=tmp_path,
- )
- native = ChromaVectorStore(config)
- values = [math.sin(index + 1) / (index + 1) for index in range(512)]
- norm = math.sqrt(sum(value * value for value in values))
- original = node("dense-roundtrip", owner="user", embedding=[value / norm for value in values])
-
- async def scenario():
- await native.initialize()
- store = wrapper(native)
- try:
- assert await store.upsert(original) == original.node_id
- actual = await store.get_by_id(original.node_id)
- assert actual.content == original.content and actual.owner == original.owner
- assert store.written_nodes[actual.node_id].fingerprint == hy_node_fingerprint(actual)
- changed = deepcopy(actual)
- changed.embedding[20] += 0.001
- await native.upsert(changed)
- with pytest.raises(HyMemoryBridgeError, match="hy_store"):
- await store.get_by_id(original.node_id)
- finally:
- await store.close()
- await native.close()
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("mutation", ["upsert", "update_payload", "update_embedding", "delete"])
-def test_guessed_existing_id_is_never_authorized_as_a_new_write(mutation):
- foreign = node("guess", content="DENIED_EXISTING")
- backend = Backend(foreign)
- store = wrapper(backend)
-
- async def scenario():
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- if mutation == "upsert":
- await store.upsert(node("guess", content="replacement"))
- elif mutation == "update_payload":
- await store.update_payload("guess", {"content": "replacement"})
- elif mutation == "update_embedding":
- await store.update_embedding("guess", [0.0, 1.0])
- else:
- await store.delete("guess")
-
- asyncio.run(scenario())
- assert backend.nodes["guess"].content == foreign.content
- assert all(call[0] == "get" for call in backend.calls)
-
-
-@pytest.mark.parametrize(
- "changes",
- [
- {"user_id": "other"},
- {"agent_id": "other"},
- {"session_id": "other"},
- {"layer": MemoryLayer.L6_SCHEMA},
- {"supersedes": ["guessed"]},
- ],
-)
-def test_new_node_cannot_claim_other_scope_or_unapproved_chain(changes):
- backend = Backend()
- with pytest.raises(HyMemoryBridgeError):
- asyncio.run(wrapper(backend).upsert(node("new", **changes)))
- assert backend.nodes == {}
-
-
-def test_batch_checks_all_targets_before_any_native_write():
- denied = node("existing")
- backend = Backend(denied)
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- asyncio.run(wrapper(backend).upsert_batch([node("new"), node("existing")]))
- assert set(backend.nodes) == {"existing"}
-
-
-def test_approved_native_update_preserves_opaque_metadata_and_refreshes_fingerprint():
- allowed = node("allowed", custom={"opaque": {"source": "approved"}}, tags=["before"])
- backend = Backend(allowed)
- store = wrapper(backend, approved=(allowed,))
-
- async def scenario():
- assert await store.update_payload("allowed", {"content": "revised", "tags": ["after"]})
- assert await store.update_embedding("allowed", [0.0, 1.0])
- result = await store.get_by_id("allowed")
- assert result.content == "revised" and result.tags == ["after"]
- assert result.custom == allowed.custom and result.embedding == [0.0, 1.0]
- assert store.written_nodes["allowed"].fingerprint == hy_node_fingerprint(result)
-
- asyncio.run(scenario())
-
-
-def test_update_cannot_retarget_identity_or_add_denied_chain():
- for updates in (
- {"user_id": "other"},
- {"node_id": "other"},
- {"supersedes": ["outside"]},
- # `custom` remains an allowed field name for SDK 2.0.2; the field it
- # must not smuggle through is the digest-only System 2 counter.
- {"custom": {"s2_evidence_count": 1}},
- {"tag_ners": "not-a-list"},
- ):
- allowed = node("allowed")
- backend = Backend(allowed)
- with pytest.raises(HyMemoryBridgeError):
- asyncio.run(wrapper(backend, approved=(allowed,)).update_payload("allowed", updates))
- assert backend.nodes["allowed"].to_dict() == allowed.to_dict()
-
-
-def test_native_dedup_can_delete_approved_target_but_not_guessed_cascade():
- async def scenario(approved):
- keep, remove = node("keep"), node("remove")
- backend = Backend(keep, remove)
- store = wrapper(backend, approved=(keep, remove) if approved else (keep,))
- await execute_dedup(
- [
- DedupItem("keep", [1.0, 0.0], gmt_created=1),
- DedupItem("remove", [1.0, 0.0], gmt_created=2),
- ],
- vector_store=store,
- trigger="reconcile",
- threshold=0.95,
- )
- if approved:
- assert backend.nodes["remove"].status is MemoryStatus.SHADOW
- assert backend.nodes["remove"].is_latest is False
- assert backend.nodes["remove"].custom["hy_dedup"] == {
- "reason": "dedup:reconcile",
- "kept_id": "keep",
- }
- assert any(call[:2] == ("update", "remove") for call in backend.calls)
- else:
- assert "remove" in backend.nodes
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- store.recheck() # Native execute_dedup swallowed the failure.
-
- asyncio.run(scenario(True))
- asyncio.run(scenario(False))
-
-
-def test_native_dedup_shadows_head_without_destroying_its_history():
- keep = node("keep")
- head = node("remove-head", supersedes=["old"])
- old = node("old", superseded_by=[head.node_id], is_latest=False)
- backend = Backend(keep, head, old)
- store = wrapper(backend, approved=(keep, head, old))
-
- async def scenario():
- await execute_dedup(
- [
- DedupItem("keep", [1.0, 0.0], gmt_created=1, is_chain_head=True),
- DedupItem(
- head.node_id,
- [1.0, 0.0],
- gmt_created=2,
- is_chain_head=True,
- chain_node_ids=[head.node_id, "old"],
- ),
- ],
- vector_store=store,
- trigger="reconcile",
- threshold=0.95,
- )
- store.recheck()
- assert set(backend.nodes) == {"keep", "remove-head", "old"}
- assert backend.nodes["remove-head"].status is MemoryStatus.SHADOW
- assert backend.nodes["remove-head"].is_latest is False
- assert backend.nodes["old"].status is MemoryStatus.SHADOW
- assert backend.nodes["old"].is_latest is False
-
- asyncio.run(scenario())
-
-
-def test_forged_dedup_cascade_never_deletes_a_foreign_member():
- keep, head, foreign = node("keep"), node("head"), node("foreign", user_id="other")
- backend = Backend(keep, head, foreign)
- store = wrapper(backend, approved=(keep, head))
-
- async def scenario():
- await execute_dedup(
- [
- DedupItem("keep", [1.0, 0.0], gmt_created=1, is_chain_head=True),
- DedupItem(
- "head",
- [1.0, 0.0],
- gmt_created=2,
- is_chain_head=True,
- chain_node_ids=["head", "foreign"],
- ),
- ],
- vector_store=store,
- trigger="reconcile",
- threshold=0.95,
- )
- assert "foreign" in backend.nodes
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- store.recheck()
-
- asyncio.run(scenario())
-
-
-def test_basic_profile_native_write_and_supersede_stay_on_same_backend():
- old = node(
- "old-profile",
- owner=None,
- layer=MemoryLayer.L0_BASIC_INFO,
- content="name: Before",
- custom={"basic_info_kv": {"name": "Before"}},
- )
- backend = Backend(old)
- store = wrapper(backend, approved=(old,))
-
- class Embedder:
- async def embed_queued(self, text):
- return [1.0, 0.0]
-
- async def scenario():
- result = await upsert_basic_profile(
- user_id="local",
- kv={"name": "After"},
- vector_store=store,
- embed_service=Embedder(),
- allowed_fields=["name"],
- )
- assert result.success and result.changed
- assert set(backend.nodes) == {"old-profile", result.node_id}
- assert (await store.get_by_id(result.node_id)).supersedes == ["old-profile"]
- assert backend.nodes["old-profile"].superseded_by == [result.node_id]
-
- asyncio.run(scenario())
-
-
-def test_open05_can_migrate_an_approved_legacy_agent_profile_to_user_level():
- old = node(
- "old-profile",
- agent_id="legacy-agent",
- owner=None,
- layer=MemoryLayer.L0_BASIC_INFO,
- content="name: Before",
- custom={"basic_info_kv": {"name": "Before"}},
- )
- backend = Backend(old)
- store = wrapper(backend, approved=(old,))
-
- class Embedder:
- async def embed_queued(self, text):
- return [1.0, 0.0]
-
- async def scenario():
- result = await upsert_basic_profile(
- user_id="local",
- kv={"name": "After"},
- vector_store=store,
- embed_service=Embedder(),
- allowed_fields=["name"],
- )
- assert result.success and result.changed
- migrated = await store.get_by_id(result.node_id)
- assert migrated is not None
- assert migrated.agent_id == "" and migrated.session_id == ""
- assert migrated.supersedes == ["old-profile"]
-
- asyncio.run(scenario())
-
-
-def test_revocation_during_read_cannot_release_payload_or_perform_write():
- for mutation in (False, True):
- allowed = node("allowed")
- backend, lease = Backend(allowed), Lease()
- store = wrapper(backend, lease=lease, approved=(allowed,))
- backend.after_read = lambda lease=lease: setattr(lease, "revoked", True)
- with pytest.raises(HyMemoryBridgeError, match="permission_revoked"):
- asyncio.run(
- store.update_payload("allowed", {"content": "changed"})
- if mutation
- else store.get_by_id("allowed")
- )
- assert backend.nodes["allowed"].content == allowed.content
-
-
-def test_multi_node_read_rechecks_permission_at_public_boundary_not_per_node():
- nodes = tuple(node(f"allowed-{index}") for index in range(8))
- backend, lease = Backend(*nodes), Lease()
- store = wrapper(backend, lease=lease, approved=nodes)
- checks_after_construction = lease.checks
-
- result = asyncio.run(store.list_by_user("local"))
-
- assert {item.node_id for item in result} == {item.node_id for item in nodes}
- assert lease.checks - checks_after_construction == 2
-
-
-def test_corrupt_native_write_is_not_registered_or_exposed():
- backend = Backend()
- backend.corrupt_write = lambda n: setattr(n, "custom", {"unknown": "DENIED"})
- store = wrapper(backend)
- with pytest.raises(HyMemoryBridgeError, match="write_mismatch"):
- asyncio.run(store.upsert(node("new")))
- with pytest.raises(HyMemoryBridgeError, match="write_mismatch"):
- asyncio.run(store.get_by_id("new"))
-
-
-def test_successful_batch_and_deleted_ids_cannot_be_recreated_under_new_permission():
- backend = Backend()
- store = wrapper(backend)
-
- async def scenario():
- assert await store.upsert_batch([node("first"), node("second")]) == ["first", "second"]
- assert set(store.written_nodes) == {"first", "second"}
- await store.delete("first")
- with pytest.raises(HyMemoryBridgeError):
- await store.upsert(node("first"))
- assert set(backend.nodes) == {"second"}
-
- asyncio.run(scenario())
-
-
-def test_invalid_node_fingerprint_latches_even_if_native_caller_swallows_exception():
- backend = Backend()
- store = wrapper(backend)
- invalid = node("invalid", custom={"unsupported_object": object()})
- with pytest.raises(HyMemoryBridgeError, match="node_shape"):
- asyncio.run(store.upsert(invalid))
- with pytest.raises(HyMemoryBridgeError, match="node_shape"):
- store.recheck()
- assert backend.nodes == {}
-
-
-@pytest.mark.parametrize(
- ("expected_tags", "stored_tags"),
- [([], "[]"), (["release", "permission"], '["release", "permission"]')],
-)
-def test_public_sdk_tag_ners_chroma_roundtrip_is_allowed(expected_tags, stored_tags):
- expected = node("new")
- expected.tag_ners = expected_tags
- backend = Backend()
- backend.corrupt_write = lambda n: setattr(n, "tag_ners", stored_tags)
- store = wrapper(backend)
-
- assert asyncio.run(store.upsert(expected)) == "new"
- assert set(store.written_nodes) == {"new"}
-
-
-def test_malformed_tag_ners_remains_a_write_mismatch():
- expected = node("new")
- expected.tag_ners = ["release"]
- backend = Backend()
- backend.corrupt_write = lambda n: setattr(n, "tag_ners", "not-json")
- store = wrapper(backend)
-
- with pytest.raises(HyMemoryBridgeError, match="write_mismatch"):
- asyncio.run(store.upsert(expected))
-
-
-def test_public_sdk_list_options_stay_inside_the_leased_user_and_agent():
- allowed = node("allowed")
- backend = Backend(allowed)
- store = wrapper(backend, approved=(allowed,))
-
- rows = asyncio.run(
- store.list_by_user(
- user_id="local",
- agent_id="agent",
- user_ids=["local"],
- agent_ids=["agent"],
- only_latest=True,
- with_vectors=False,
- allow_raw_layer=True,
- )
- )
-
- assert [value.node_id for value in rows] == ["allowed"]
- assert next(call for call in backend.calls if call[0] == "list") == (
- "list",
- {
- "user_id": "local",
- "agent_id": "agent",
- "limit": 10000,
- "status_filter": None,
- "layers": None,
- "user_ids": ["local"],
- "agent_ids": ["agent"],
- "only_latest": True,
- "with_vectors": False,
- "allow_raw_layer": True,
- },
- )
-
- with pytest.raises(HyMemoryBridgeError, match="scope"):
- asyncio.run(store.list_by_user(user_ids=["other-user"]))
-
-
-def test_two_wrappers_never_share_new_node_permissions():
- backend = Backend()
- first, second = wrapper(backend), wrapper(backend)
-
- async def scenario():
- await first.upsert(node("first-only"))
- assert await second.get_by_id("first-only") is None
- assert (await first.get_by_id("first-only")).node_id == "first-only"
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize(
- "action",
- [
- "id",
- "ids",
- "embeddings",
- "list",
- "search",
- "upsert",
- "batch",
- "payload",
- "embedding",
- "delete",
- "initialize",
- ],
-)
-def test_revocation_is_checked_before_every_native_action_even_empty_reads(action):
- approved = node("approved")
- backend, lease = Backend(approved), Lease()
- store = wrapper(backend, lease=lease, approved=(approved,))
- lease.revoked = True
-
- async def scenario():
- calls = {
- "id": lambda: store.get_by_id("approved"),
- "ids": lambda: store.get_by_ids([]),
- "embeddings": lambda: store.get_embeddings([]),
- "list": lambda: store.list_by_user("local"),
- "search": lambda: store.search([1.0, 0.0]),
- "upsert": lambda: store.upsert(node("new")),
- "batch": lambda: store.upsert_batch([]),
- "payload": lambda: store.update_payload("approved", {}),
- "embedding": lambda: store.update_embedding("approved", [0.0, 1.0]),
- "delete": lambda: store.delete("approved"),
- "initialize": store.initialize,
- }
- with pytest.raises(HyMemoryBridgeError, match="permission_revoked"):
- await calls[action]()
- lease.revoked = False
- with pytest.raises(HyMemoryBridgeError, match="permission_revoked"):
- store.recheck()
-
- asyncio.run(scenario())
- assert backend.calls == []
-
-
-def test_identical_text_and_vector_never_authorize_a_different_native_id():
- allowed = node("allowed")
- denied = node("denied", content=allowed.content, embedding=allowed.embedding)
- backend = Backend(allowed, denied)
- store = wrapper(backend, approved=(allowed,))
-
- async def scenario():
- assert [hit["node_id"] for hit in await store.search([1.0, 0.0])] == ["allowed"]
- assert await store.get_by_id("denied") is None
-
- asyncio.run(scenario())
-
-
-def test_invalid_embedding_is_rejected_before_native_mutation():
- for mutation in ("payload", "embedding"):
- approved = node("approved")
- backend = Backend(approved)
- store = wrapper(backend, approved=(approved,))
- with pytest.raises(HyMemoryBridgeError, match="node_shape"):
- asyncio.run(
- store.update_payload("approved", {"embedding": [1e200]})
- if mutation == "payload"
- else store.update_embedding("approved", [1e200])
- )
- assert not any(call[0] in {"update", "embedding"} for call in backend.calls)
-
-
-def test_distinct_permission_wrappers_serialize_native_store_mutations():
- class ContendedBackend(Backend):
- def __init__(self):
- super().__init__()
- self.active = 0
- self.peak = 0
-
- async def upsert(self, value):
- self.active += 1
- self.peak = max(self.peak, self.active)
- try:
- await asyncio.sleep(0.02)
- return await super().upsert(value)
- finally:
- self.active -= 1
-
- async def scenario():
- backend = ContendedBackend()
- first = wrapper(backend)
- second = wrapper(backend)
- await asyncio.gather(first.upsert(node("first")), second.upsert(node("second")))
- assert backend.peak == 1
- assert set(backend.nodes) == {"first", "second"}
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("initial", ["new", "preexisting"])
-@pytest.mark.parametrize("mutation", ["upsert", "upsert_batch", "payload", "embedding"])
-def test_latest_write_proof_replaces_prior_snapshot_for_every_mutation(initial, mutation):
- original = node("changing")
- backend = Backend(original) if initial == "preexisting" else Backend()
- store = wrapper(backend, approved=(original,) if initial == "preexisting" else ())
-
- async def scenario():
- if initial == "new":
- await store.upsert(original)
- else:
- await store.update_payload(original.node_id, {"tags": ["first-update"]})
- before = store.written_nodes
- prior_fingerprint = before[original.node_id].fingerprint
- changed = await store.get_by_id(original.node_id)
- changed.content = "Synthetic revised content"
- if mutation == "upsert":
- await store.upsert(changed)
- elif mutation == "upsert_batch":
- await store.upsert_batch([changed])
- elif mutation == "payload":
- await store.update_payload(original.node_id, {"content": changed.content})
- else:
- await store.update_embedding(original.node_id, [0.0, 1.0])
-
- latest = store.written_nodes[original.node_id]
- assert latest.fingerprint != prior_fingerprint
- assert latest.fingerprint == hy_node_fingerprint(backend.nodes[original.node_id])
- assert latest.evidence_ids == ("current", "support")
- assert before[original.node_id].fingerprint == prior_fingerprint
- assert hy_node_fingerprint(await store.get_by_id(original.node_id)) == latest.fingerprint
-
- asyncio.run(scenario())
-
-
-def test_embedding_write_accepts_native_modified_time_and_refreshes_full_proof():
- original = node("modified-at")
- backend = Backend(original)
-
- async def update_embedding(key, embedding):
- backend.nodes[key].embedding = deepcopy(embedding)
- backend.nodes[key].gmt_modified += timedelta(seconds=1)
- return True
-
- backend.update_embedding = update_embedding
- store = wrapper(backend, approved=(original,))
-
- async def scenario():
- await store.update_embedding(original.node_id, [0.0, 1.0])
- assert store.written_nodes[original.node_id].fingerprint == hy_node_fingerprint(
- backend.nodes[original.node_id]
- )
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("initial", ["new", "preexisting"])
-def test_deletion_delta_removes_persisted_proof_without_replacing_unmodified_rows(initial):
- removed, untouched = node("removed"), node("untouched")
- backend = Backend(removed, untouched) if initial == "preexisting" else Backend(untouched)
- supplied = {untouched.node_id: permission(untouched)}
- if initial == "preexisting":
- supplied[removed.node_id] = permission(removed)
- store = HyPermissionVectorStore(
- backend,
- Lease(),
- agent_id="agent",
- session_id="session",
- node_permissions=supplied,
- )
-
- async def scenario():
- if initial == "new":
- await store.upsert(removed)
- previous_deleted = store.deleted_node_ids
- ledger = {**supplied, **store.written_nodes}
- await store.delete(removed.node_id)
- assert store.deleted_node_ids == frozenset({removed.node_id})
- assert previous_deleted == frozenset()
- assert removed.node_id not in store.written_nodes
- assert await store.get_by_id(removed.node_id) is None
-
- ledger.update(store.written_nodes)
- for key in store.deleted_node_ids:
- ledger.pop(key, None)
- assert set(ledger) == {untouched.node_id}
- await store.close()
- reopened = HyPermissionVectorStore(
- backend,
- Lease(),
- agent_id="agent",
- session_id="session",
- node_permissions=ledger,
- )
- assert (await reopened.get_by_id(untouched.node_id)).node_id == untouched.node_id
- assert await reopened.get_by_id(removed.node_id) is None
- await reopened.close()
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("snapshot", ["written_nodes", "deleted_node_ids"])
-def test_proof_snapshots_are_permission_checked_before_export(snapshot):
- lease = Lease()
- store = wrapper(Backend(), lease=lease)
- lease.revoked = True
- with pytest.raises(HyMemoryBridgeError, match="permission_revoked"):
- getattr(store, snapshot)
-
-
-def test_close_after_parent_commit_never_rechecks_invalidated_context_or_backend():
- class CommitSensitiveLease(Lease):
- committed = False
-
- def recheck(self):
- if self.committed:
- self.checks += 1
- raise HyMemoryBridgeError("model_content_context_changed")
- return super().recheck()
-
- backend, lease = Backend(), CommitSensitiveLease()
- store = wrapper(backend, lease=lease)
-
- async def scenario():
- await store.upsert(node("new"))
- snapshot = store.written_nodes
- deleted = store.deleted_node_ids
- lease.committed = True
- checks_before_close, calls_before_close = lease.checks, list(backend.calls)
- await store.close()
- await store.close()
- assert lease.checks == checks_before_close
- assert backend.calls == calls_before_close
- assert set(snapshot) == {"new"} and not deleted
- with pytest.raises(HyMemoryBridgeError, match="closed"):
- await store.get_by_id("new")
- assert lease.checks == checks_before_close
-
- asyncio.run(scenario())
-
-
-def test_wrapper_api_is_closed_and_close_does_not_close_or_modify_native_store():
- backend, lease = Backend(node("kept")), Lease()
- store = wrapper(backend, lease=lease)
- for attribute in (
- "_collection",
- "_client",
- "native",
- "backend",
- "_run",
- "get_node",
- "upsert_entity",
- "search_entities",
- "list_entities",
- "sweep",
- "digest",
- "delete_by_metadata",
- "delete_by_isolation_key",
- ):
- assert not hasattr(store, attribute), attribute
-
- async def scenario():
- await store.initialize()
- with pytest.raises(NotImplementedError):
- await store.has_tag_embedding("local", "tag")
- await store.close()
- with pytest.raises(HyMemoryBridgeError, match="closed"):
- await store.get_by_id("kept")
-
- asyncio.run(scenario())
- assert set(backend.nodes) == {"kept"} and backend.calls == []
-
-
-def test_allowing_owner_and_custom_does_not_reach_another_users_node():
- """The pinned public SDK's reconcile UPDATE carries `owner` / `custom`.
-
- Allowing those two field names is only safe while the store still resolves
- the target through the lease's own permissions and user scope. A node that
- belongs to a different user_id must remain unreachable, so the update is
- refused as a denied target rather than being applied.
- """
- foreign = node("foreign", user_id="someone-else")
- backend = Backend(foreign)
- store = wrapper(backend, approved=(foreign,))
-
- async def scenario():
- assert await store.get_by_id(foreign.node_id) is None
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- await store.update_payload(foreign.node_id, {"owner": "agent", "custom": {}})
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- await store.update_payload(foreign.node_id, {"custom": {"note": "DENIED"}})
-
- asyncio.run(scenario())
- assert backend.nodes[foreign.node_id] == foreign
- assert not any(call[0] == "update" for call in backend.calls)
-
-
-def test_owner_and_custom_updates_are_allowed_for_an_approved_own_node():
- """The two public SDK fields apply when the node is this lease's own."""
- owned = node("owned", custom={})
- backend = Backend(owned)
- store = wrapper(backend, approved=(owned,))
-
- async def scenario():
- assert await store.update_payload(owned.node_id, {"owner": "agent", "custom": {"n": 1}})
-
- asyncio.run(scenario())
- assert backend.nodes[owned.node_id].owner == "agent"
- assert backend.nodes[owned.node_id].custom == {"n": 1}
diff --git a/memhub/apps/core/tests/test_hy_permission_transport.py b/memhub/apps/core/tests/test_hy_permission_transport.py
deleted file mode 100644
index 1af0168..0000000
--- a/memhub/apps/core/tests/test_hy_permission_transport.py
+++ /dev/null
@@ -1,1039 +0,0 @@
-"""Native HY entry points, synthetic inputs and MockTransport only."""
-
-import asyncio
-import copy
-import inspect
-import json
-import logging
-import threading
-from dataclasses import dataclass
-from datetime import UTC, datetime
-from types import SimpleNamespace
-
-import httpx
-import pytest
-from hy_memory.agent.llm_provider import LLMProvider, LLMResponse, LLMRetryError
-from hy_memory.config import MemoryConfig
-from hy_memory.core.embed_service import EmbedService
-from one_memory.model_content_permissions import (
- ModelContentPermissionReceipt,
- ModelContentPermissionSnapshot,
- _recipients,
-)
-
-from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
-from one_memory.hy_response_guard import HyResponseGuard
-from one_memory.hy_runtime_config import HY_MEMORY_DISTRIBUTION, HY_MEMORY_VERSION
-from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking
-
-
-@dataclass
-class Lease:
- receipt: ModelContentPermissionReceipt
- profile: ModelProfileSnapshot
- checks: int = 0
- revoked: bool = False
- context_changed: bool = False
-
- def recheck(self):
- self.checks += 1
- if self.revoked or self.context_changed:
- raise RuntimeError("private permission error must not escape")
- return self.receipt
-
-
-def lease(*, local=False, thinking=False):
- from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS, BUILTIN_EMBEDDING_MODEL
-
- profile = ModelProfileSnapshot(
- profile_id="synthetic-profile",
- revision=1,
- provider_kind=ProviderKind.OPENAI_COMPATIBLE,
- base_url="https://models.example.test/approved/v1",
- model="synthetic-generation",
- embedding_provider_kind=ProviderKind.LOCAL_ONNX
- if local
- else ProviderKind.OPENAI_COMPATIBLE,
- embedding_base_url=None if local else "https://models.example.test/embedding/v1",
- embedding_model=BUILTIN_EMBEDDING_MODEL if local else "synthetic-embedding",
- embedding_dimensions=BUILTIN_EMBEDDING_DIMENSIONS if local else 2,
- timeout_seconds=5,
- max_concurrency=1,
- context_window=None,
- has_api_key=True,
- disable_thinking=thinking,
- )
- generation, embedding = _recipients(profile)
- now = datetime.now(UTC)
- snapshot = ModelContentPermissionSnapshot(
- user_id="synthetic-user",
- profile=profile,
- generation=generation,
- embedding=embedding,
- sources=[
- dict(
- source_id="one-memory-manual",
- authority="manual",
- granted_at=now,
- revision=1,
- root_identity=None,
- root_path="",
- requested_path=None,
- candidate_kind="default",
- )
- ],
- evidence=[
- dict(
- evidence_id="synthetic-evidence",
- source_id="one-memory-manual",
- revision=1,
- content_hash="a" * 64,
- identity_hash="b" * 64,
- origin_hash="c" * 64,
- imported_at=now,
- )
- ],
- )
- return Lease(
- ModelContentPermissionReceipt(
- user_id=snapshot.user_id,
- revision=1,
- revocation_revision=0,
- granted_at=now,
- revoked_at=None,
- snapshot=snapshot,
- ),
- profile,
- )
-
-
-def native(permission):
- config = MemoryConfig()
- config.llm.provider = "openai"
- config.llm.base_url = permission.receipt.snapshot.generation.endpoint
- config.llm.model = permission.profile.model
- config.llm.api_key = "synthetic-secret-never-log"
- config.llm.max_retries = 2
- config.llm.retry_delay = 0
- config.llm.extra_headers = None
- config.llm.extra_body = attach_disable_thinking(permission.profile, {}) or None
- config.embedder.provider = "openai"
- config.embedder.base_url = (
- permission.receipt.snapshot.embedding.endpoint or "http://127.0.0.1/local-onnx"
- )
- config.embedder.model = permission.profile.embedding_model
- config.embedder.api_key = "synthetic-secret-never-log"
- config.embedder.embedding_dims = permission.profile.embedding_dimensions
- config.embedder.max_retries = 2
- config.embedder.retry_delay = 0
- config.embedder.extra_headers = None
- config.embedder.extra_body = None
- provider, embedder = LLMProvider(config), EmbedService(config)
- embedder._embed_config.queue_batch_window_ms = 1
- return provider, embedder
-
-
-def response(request):
- if request.url.path.endswith("/embeddings"):
- inputs = json.loads(request.content)["input"]
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": "synthetic-embedding",
- "data": [
- {"object": "embedding", "index": i, "embedding": [0.25, 0.75]}
- for i, _ in enumerate(inputs)
- ],
- "usage": {"prompt_tokens": 3, "total_tokens": 3},
- },
- headers={"x-private-header": "private-response-metadata"},
- )
- return httpx.Response(
- 200,
- json={
- "id": "synthetic-completion",
- "object": "chat.completion",
- "created": 0,
- "model": "synthetic-generation",
- "choices": [
- {
- "index": 0,
- "finish_reason": "tool_calls",
- "message": {
- "role": "assistant",
- "content": "native result",
- "tool_calls": [
- {
- "id": "call-1",
- "type": "function",
- "function": {
- "name": "record",
- "arguments": '{"value":"unaltered"}',
- },
- }
- ],
- },
- }
- ],
- "usage": {"prompt_tokens": 7, "completion_tokens": 4, "total_tokens": 11},
- },
- )
-
-
-class Wire(httpx.MockTransport):
- def __init__(self, handler=None):
- self.requests = []
- self.closed = 0
- self.callback = handler
- super().__init__(self.send)
-
- async def send(self, request):
- self.requests.append(request)
- return self.callback(request, len(self.requests)) if self.callback else response(request)
-
- async def aclose(self):
- self.closed += 1
- await super().aclose()
-
-
-def test_native_entry_points_preserve_payload_response_and_restore_instances():
- async def run():
- permission = lease(thinking=True)
- provider, embedder = native(permission)
- original = dict(provider.__dict__)
- complete_signature = inspect.signature(provider.complete)
- wire = Wire()
- messages = [
- {"role": "system", "content": "exact system"},
- {"role": "user", "content": "exact user"},
- ]
- before = copy.deepcopy(messages)
- tools = [{"type": "function", "function": {"name": "record", "parameters": {}}}]
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- assert guard.bind_provider(provider) is provider
- assert inspect.signature(provider.complete) == complete_signature
- guard.bind_embed_service(embedder)
- saved_complete = provider.complete
- result = await provider.complete("exact native prompt\n spaces", stop=["STOP"])
- assert isinstance(result, LLMResponse)
- assert (
- result.content,
- result.tokens_used,
- result.prompt_tokens,
- result.completion_tokens,
- result.finish_reason,
- ) == ("native result", 11, 7, 4, "tool_calls")
- assert result.tool_calls[0]["function"]["arguments"] == '{"value":"unaltered"}'
- await provider.complete_messages(messages, tools=tools, tool_choice="auto")
- # Native dispatch itself also converges on the guarded leaf.
- await LLMProvider._call_llm(provider, "direct native dispatch", 12, 0)
- assert await embedder.embed("synthetic text", use_cache=False) == [0.25, 0.75]
- vector, meta = await embedder.embed_with_meta("another", use_cache=False)
- assert vector == [0.25, 0.75]
- assert set(meta) <= {"http_status", "model", "usage", "attempts", "cache_hit"}
- assert "private-response-metadata" not in repr(meta)
- assert permission.checks >= len(wire.requests)
- assert wire.closed == 1
- for name in ("complete", "complete_messages", "_call_llm", "_chat_completions"):
- assert provider.__dict__.get(name) is original.get(name)
- with pytest.raises(HyPermissionTransportError, match="ended"):
- await saved_complete("must not send")
- assert len(wire.requests) == 5
- bodies = [json.loads(request.content) for request in wire.requests]
- assert bodies[0]["messages"] == [
- {"role": "system", "content": ""},
- {"role": "user", "content": "exact native prompt\n spaces"},
- ]
- assert bodies[0]["stop"] == ["STOP"]
- assert bodies[0]["reasoning_effort"] == "none"
- assert bodies[1]["messages"] == before == messages
- assert bodies[1]["tools"] == tools and bodies[1]["tool_choice"] == "auto"
- assert str(wire.requests[3].url).endswith("/embedding/v1/embeddings")
- assert bodies[3]["dimensions"] == 2
-
- asyncio.run(run())
-
-
-def test_permission_transport_pins_the_public_sdk_identity():
- """The transport fails closed unless the audited SDK identity is installed."""
- assert HY_MEMORY_DISTRIBUTION == "hy-memory"
- assert HY_MEMORY_VERSION == "2.0.2"
-
-
-@pytest.mark.parametrize("entry", ["complete", "complete_messages", "embed"])
-@pytest.mark.parametrize("revoked", [False, True])
-def test_retry_rechecks_each_send_and_latches_across_fresh_child_providers(entry, revoked):
- async def run():
- permission = lease()
- provider, embedder = native(permission)
-
- def handler(request, count):
- if count == 1:
- permission.revoked = revoked
- if entry == "embed":
- raise httpx.ReadTimeout("private timeout details", request=request)
- return httpx.Response(500, json={"error": {"message": "private failure"}})
- return response(request)
-
- wire = Wire(handler)
- guard = HyPermissionTransport(permission, http_transport=wire)
- try:
- async with guard:
- guard.bind_provider(provider)
- guard.bind_embed_service(embedder)
- call = (
- provider.complete("prompt")
- if entry == "complete"
- else provider.complete_messages([{"role": "user", "content": "prompt"}])
- if entry == "complete_messages"
- else embedder.embed("text")
- )
- if not revoked:
- await call
- assert len(wire.requests) == 2
- else:
- with pytest.raises(HyPermissionTransportError):
- await call
- checks = permission.checks
- permission.revoked = False
- child, _ = native(permission)
- with pytest.raises(HyPermissionTransportError):
- guard.bind_provider(child)
- with pytest.raises(HyPermissionTransportError):
- await provider._call_llm("child", 10, 0)
- assert permission.checks == checks
- assert len(wire.requests) == 1
- except HyPermissionTransportError:
- assert revoked # Exit must also refuse a swallowed denial.
- assert wire.closed == 1
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize(
- "entry,expected", [("complete", 2), ("complete_messages", 2), ("embed", 3)]
-)
-def test_native_outer_attempt_budgets_no_openai_hidden_retries(entry, expected, caplog):
- """0.2.0 keeps retry ownership outside OpenAI's client: our leaf sends each attempt.
-
- The expected counts are intentionally unchanged from the old SDK pin: 0.2.0
- must not hide retries below this transport, or D84's per-send permission and
- Cookie/header checks would be bypassed.
- """
-
- async def run():
- permission = lease()
- provider, embedder = native(permission)
-
- def fail(request, _):
- raise httpx.ConnectError("private upstream body and secret", request=request)
-
- wire = Wire(fail)
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- guard.bind_embed_service(embedder)
- with pytest.raises((LLMRetryError, HyPermissionTransportError)) as error:
- if entry == "embed":
- await embedder.embed("private synthetic input")
- elif entry == "complete":
- await provider.complete("private synthetic input")
- else:
- await provider.complete_messages([{"role": "user", "content": "input"}])
- assert "private" not in str(error.value)
- assert len(wire.requests) == expected
- if entry != "embed":
- assert len(error.value.attempts) == expected
- assert provider._errors == expected
- assert "private" not in caplog.text
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize("entry", ["complete", "complete_messages"])
-@pytest.mark.parametrize(
- "failure,kind",
- [
- (httpx.ReadTimeout, "timeout"),
- (httpx.ConnectTimeout, "timeout"),
- (httpx.WriteTimeout, "timeout"),
- (httpx.PoolTimeout, "timeout"),
- (httpx.ConnectError, "connection"),
- ],
-)
-def test_generation_failure_category_survives_sanitized_native_attempts(
- entry, failure, kind, caplog
-):
- """0.2.0 still exposes sanitized attempt records with our five failure kinds.
-
- Timeout and connection categories must stay distinguishable after the SDK
- upgrade; otherwise real provider failures collapse into an unknown retry path.
- """
-
- async def run():
- permission = lease()
- provider, _ = native(permission)
-
- def fail(request, _count):
- raise failure("private upstream body secret must not escape", request=request)
-
- wire = Wire(fail)
- async with HyPermissionTransport(permission, http_transport=wire) as transport:
- transport.bind_provider(provider)
- guard = HyResponseGuard(provider)
- with pytest.raises(LLMRetryError) as raised:
- if entry == "complete":
- await guard.complete("private input")
- else:
- await guard.complete_messages([{"role": "user", "content": "private input"}])
- assert len(wire.requests) == 2
- assert len(raised.value.attempts) == 2
- assert all(item["failure_kind"] == kind for item in raised.value.attempts)
- assert all("status_code" not in item for item in raised.value.attempts)
- expected = (
- "hy_memory_model_timeout" if kind == "timeout" else "hy_memory_model_request_failed"
- )
- assert guard.failures == [expected]
- assert "private" not in json.dumps(raised.value.attempts)
- assert "private" not in caplog.text
- assert "synthetic-secret-never-log" not in caplog.text
- assert wire.closed == 1
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize("entry", ["complete", "complete_messages"])
-def test_malformed_generation_response_is_not_classified_as_timeout(entry):
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = Wire(lambda _request, _count: httpx.Response(200, json={"choices": []}))
- async with HyPermissionTransport(permission, http_transport=wire) as transport:
- transport.bind_provider(provider)
- guard = HyResponseGuard(provider)
- with pytest.raises(LLMRetryError) as raised:
- if entry == "complete":
- await guard.complete("synthetic input")
- else:
- await guard.complete_messages([{"role": "user", "content": "synthetic input"}])
- assert len(wire.requests) == 2
- assert all(item["failure_kind"] == "invalid_response" for item in raised.value.attempts)
- assert guard.failures == ["hy_memory_model_request_failed"]
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize(
- "change",
- ["host", "path", "query", "model", "backend", "dimensions", "headers", "body", "key", "kwargs"],
-)
-def test_config_drift_or_overrides_deny_before_send(change):
- async def run():
- permission = lease()
- provider, embedder = native(permission)
- wire = Wire()
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- guard.bind_embed_service(embedder)
- if change in {"host", "path", "query"}:
- provider._llm_config.base_url += {
- "host": ".evil.test",
- "path": "/other",
- "query": "?token=private",
- }[change]
- elif change == "model":
- provider._llm_config.model = "not-approved"
- elif change == "backend":
- # Public SDK 2.0.2 keeps LLMBackend narrowed to OPENAI, so the
- # drifted-backend case uses an illegal sentinel value
- # instead. It must differ from the approved openai backend,
- # otherwise no drift is constructed at all.
- provider._llm_config.backend = "eval-platform"
- elif change == "dimensions":
- embedder._embed_config.embedding_dims = 3
- elif change == "headers":
- provider._llm_config.extra_headers = {"Authorization": "not-approved"}
- elif change == "body":
- provider._llm_config.extra_body = {"model": "not-approved"}
- elif change == "key":
- provider._llm_config.api_key = "changed"
- if change == "dimensions":
- await embedder.embed("input")
- else:
- await provider.complete(
- "input", **({"model": "override"} if change == "kwargs" else {})
- )
- assert not wire.requests
- assert wire.closed == 1
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize(
- "change", ["method", "url", "model", "messages", "dimensions", "auth", "host", "stream"]
-)
-def test_physical_send_checks_final_request_after_httpx_hooks(change):
- async def run():
- permission = lease()
- provider, embedder = native(permission)
- wire = Wire()
-
- async def tamper(request):
- if change == "method":
- request.method = "PUT"
- elif change == "url":
- request.url = httpx.URL("https://unexpected.example.test/v1/chat/completions")
- elif change in {"model", "messages", "dimensions"}:
- body = json.loads(request.content)
- body[change] = 3 if change == "dimensions" else "override"
- request._content = json.dumps(body).encode()
- request.stream = httpx.ByteStream(request._content)
- elif change == "stream":
- body = json.loads(request.content)
- body["model"] = "stream-only-override"
- request.stream = httpx.ByteStream(json.dumps(body).encode())
- else:
- request.headers["authorization" if change == "auth" else "host"] = "override"
-
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- guard.bind_embed_service(embedder)
- guard._http_client.event_hooks["request"].append(tamper)
- if change == "dimensions":
- await embedder.embed("input")
- else:
- await provider.complete("input")
- assert not wire.requests
-
- asyncio.run(run())
-
-
-def test_redirect_is_terminal_and_cannot_reach_another_recipient():
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = Wire(
- lambda request, _: httpx.Response(
- 307,
- headers={
- "location": "https://unexpected.example.test/steal",
- },
- )
- )
- with pytest.raises(HyPermissionTransportError, match="redirect"):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- await provider.complete("input")
- assert len(wire.requests) == 1 and wire.closed == 1
-
- asyncio.run(run())
-
-
-def test_lease_revoked_during_successful_response_cannot_publish():
- async def run():
- permission = lease()
- provider, _ = native(permission)
-
- def revoke(request, _):
- permission.revoked = True
- return response(request)
-
- wire = Wire(revoke)
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- await provider.complete("input")
- assert len(wire.requests) == 1
-
- asyncio.run(run())
-
-
-def test_queue_and_cache_are_lease_owned_and_outstanding_work_is_cancelled():
- """0.2.0's EmbedService uses an LRU cache, so the lease supplies OrderedDict.
-
- The old dict still accepted plain assignment, but 0.2.0's cache-hit path
- calls move_to_end; a plain dict here fails the full embedding path and
- masks the real permission/Cookie behavior.
- """
-
- async def run():
- permission = lease()
- _, embedder = native(permission)
- assert isinstance(embedder._cache, dict)
- old_cache = embedder._cache
- old_cache[embedder._get_cache_key("input")] = [99.0, 99.0]
- wire = Wire()
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_embed_service(embedder)
- assert await embedder.embed_queued("input") == [0.25, 0.75]
- assert len(wire.requests) == 1
- embedder._embed_config.queue_batch_window_ms = 60000
- pending = asyncio.create_task(embedder.embed_queued("pending"))
- await asyncio.sleep(0)
- assert embedder._queue_pending
- assert pending.cancelled()
- assert not embedder._queue_pending
- assert not embedder._queue_flush_tasks
- assert embedder._cache is old_cache
- assert len(wire.requests) == 1 and wire.closed == 1
-
- asyncio.run(run())
-
-
-def test_pending_foreign_queue_and_double_install_are_rejected():
- async def run():
- permission = lease()
- provider, embedder = native(permission)
- future = asyncio.get_running_loop().create_future()
- embedder._queue_pending[("https://other.example/v1", "secret")] = [
- ("other lease input", future)
- ]
- wire = Wire()
- with pytest.raises(HyPermissionTransportError, match="queue"):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_embed_service(embedder)
- assert embedder._queue_pending == {
- ("https://other.example/v1", "secret"): [("other lease input", future)]
- }
- async with HyPermissionTransport(permission, http_transport=Wire()) as first:
- first.bind_provider(provider)
- with pytest.raises(HyPermissionTransportError, match="bound"):
- async with HyPermissionTransport(permission, http_transport=Wire()) as second:
- second.bind_provider(provider)
- await provider.complete("first still works")
- future.cancel()
-
- asyncio.run(run())
-
-
-def test_local_onnx_stays_local_and_remote_fallback_is_denied():
- from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS
- from one_memory.hy_memory_bridge import LocalHyEmbedService
-
- async def run():
- permission = lease(local=True)
- local = LocalHyEmbedService(
- SimpleNamespace(
- encode=lambda texts: [[0.5] * BUILTIN_EMBEDDING_DIMENSIONS for _ in texts],
- )
- )
- wire = Wire()
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_local_embed_service(local)
- assert len(await local.embed("input")) == BUILTIN_EMBEDDING_DIMENSIONS
- saved = local.embed
- with pytest.raises(HyPermissionTransportError, match="ended"):
- await saved("late")
- _, remote = native(permission)
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(permission, http_transport=Wire()) as guard:
- guard.bind_embed_service(remote)
- assert not wire.requests
-
- asyncio.run(run())
-
-
-def test_sdk_0_2_0_has_no_legacy_provider_specific_bypass_methods():
- """0.2.0 still exposes no provider-specific bypasses on LLMProvider.
-
- If a new SDK method could send outside complete/complete_messages/_call_llm,
- the transport closure would no longer cover every outbound path.
- """
- permission = lease()
- provider, _ = native(permission)
-
- assert not hasattr(provider, "_call_openai")
- assert not hasattr(provider, "_call_openai_messages")
- assert not hasattr(provider, "_call_eval_platform")
- assert not hasattr(provider, "_call_eval_platform_messages")
-
-
-@pytest.mark.parametrize("logger_name", ["openai._base_client", "httpcore.http11"])
-def test_wire_debug_logging_does_not_block_provider_calls(caplog, logger_name):
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = Wire()
- with caplog.at_level(logging.DEBUG, logger=logger_name):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- await provider.complete("private synthetic prompt")
- assert logging.getLogger(logger_name).isEnabledFor(logging.DEBUG)
- assert wire.requests
-
- asyncio.run(run())
-
-
-def test_revocation_in_last_request_hook_still_blocks_physical_send():
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = Wire()
-
- async def revoke(_request):
- permission.revoked = True
-
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- guard._http_client.event_hooks["request"].append(revoke)
- await provider.complete("input")
- assert not wire.requests and wire.closed == 1
-
- asyncio.run(run())
-
-
-def test_unenveloped_http_and_unsupported_leaf_arguments_fail_closed():
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = Wire()
- with pytest.raises(HyPermissionTransportError, match="unsupported_request"):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- await guard._http_client.post(
- permission.receipt.snapshot.generation.endpoint + "/chat/completions",
- json={"model": permission.profile.model},
- )
- assert not wire.requests
- with pytest.raises(HyPermissionTransportError, match="unsupported_operation"):
- async with HyPermissionTransport(permission, http_transport=Wire()) as guard:
- guard.bind_provider(provider)
- await provider.complete_messages([], 10, 0, stop=["not in native signature"])
-
- asyncio.run(run())
-
-
-def test_independent_instances_and_rebinding_do_not_change_global_methods():
- async def run():
- first_lease, second_lease = lease(), lease()
- first, _ = native(first_lease)
- second, _ = native(second_lease)
- class_methods = dict(LLMProvider.__dict__)
- wires = [Wire(), Wire(), Wire()]
- async with HyPermissionTransport(second_lease, http_transport=wires[1]) as second_guard:
- second_guard.bind_provider(second)
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(
- first_lease, http_transport=wires[0]
- ) as first_guard:
- first_guard.bind_provider(first)
- saved_first = first.complete
- await first.complete("first")
- first_lease.revoked = True
- await first.complete("denied")
- await second.complete("second still allowed")
- first_lease.revoked = False
- async with HyPermissionTransport(first_lease, http_transport=wires[2]) as fresh:
- fresh.bind_provider(first)
- with pytest.raises(HyPermissionTransportError, match="ended"):
- await saved_first("old captured method")
- await first.complete("new binding")
- assert [len(wire.requests) for wire in wires] == [1, 1, 1]
- assert all(wire.closed == 1 for wire in wires)
- assert dict(LLMProvider.__dict__) == class_methods
-
- asyncio.run(run())
-
-
-def test_cache_hit_and_queued_flush_cannot_outlive_revocation():
- async def run():
- permission = lease()
- _, service = native(permission)
- wire = Wire()
- with pytest.raises(HyPermissionTransportError):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_embed_service(service)
- await service.embed("cached input")
- permission.revoked = True
- await service.embed("cached input")
- assert len(wire.requests) == 1
-
- asyncio.run(run())
-
-
-def test_exit_cancels_inflight_send_before_restoring_native_methods():
- async def run():
- permission = lease()
- provider, _ = native(permission)
- entered, cancelled = asyncio.Event(), asyncio.Event()
-
- async def slow(request):
- entered.set()
- try:
- await asyncio.Event().wait()
- finally:
- cancelled.set()
-
- transport = httpx.MockTransport(slow)
- async with HyPermissionTransport(permission, http_transport=transport) as guard:
- guard.bind_provider(provider)
- child = asyncio.create_task(provider.complete("in flight"))
- await entered.wait()
- http_client = guard._http_client
- sdk_client = guard._clients[0]
- assert child.cancelled() and cancelled.is_set()
- assert http_client.is_closed and sdk_client.is_closed()
- assert provider.complete.__func__ is LLMProvider.complete
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize("field", ["data", "usage"])
-def test_malformed_embedding_response_does_not_publish_raw_metadata(field, caplog):
- async def run():
- permission = lease()
- _, service = native(permission)
-
- def invalid(request, _):
- data = json.loads(response(request).content)
- if field == "data":
- data["data"][0]["embedding"] = ["private-raw-provider-value", 0.5]
- else:
- data["usage"]["prompt_tokens"] = "private-raw-provider-value"
- return httpx.Response(200, json=data)
-
- wire = Wire(invalid)
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_embed_service(service)
- with pytest.raises(HyPermissionTransportError) as error:
- await service.embed_with_meta("input")
- assert "private-raw" not in str(error.value)
- assert "private-raw" not in repr(service._last_embed_meta)
- assert "private-raw" not in caplog.text
-
- asyncio.run(run())
-
-
-def test_local_encoder_failure_is_sanitized_without_cloud_fallback(caplog):
- from one_memory.hy_memory_bridge import LocalHyEmbedService
-
- async def run():
- permission = lease(local=True)
-
- def broken(_texts):
- raise RuntimeError("private-raw-encoder-error")
-
- service = LocalHyEmbedService(SimpleNamespace(encode=broken))
- wire = Wire()
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_local_embed_service(service)
- with pytest.raises(HyPermissionTransportError) as error:
- await service.embed("input")
- assert "private-raw" not in str(error.value)
- assert not wire.requests and "private-raw" not in caplog.text
-
- asyncio.run(run())
-
-
-def test_local_encoder_thread_is_drained_before_restoration():
- from one_memory.hy_memory_bridge import LocalHyEmbedService
-
- async def run():
- entered, release, finished = threading.Event(), threading.Event(), threading.Event()
-
- def encode(_texts):
- entered.set()
- assert release.wait(5)
- finished.set()
- return [[0.5, 0.5]]
-
- service = LocalHyEmbedService(SimpleNamespace(encode=encode))
- guard = await HyPermissionTransport(lease(local=True), http_transport=Wire()).__aenter__()
- guard.bind_local_embed_service(service)
- child = asyncio.create_task(service.embed("synthetic input"))
- try:
- assert await asyncio.to_thread(entered.wait, 5)
- exiting = asyncio.create_task(guard.__aexit__(None, None, None))
- while not child.cancelling():
- await asyncio.sleep(0)
- await asyncio.sleep(0)
- assert not child.done()
- assert not exiting.done()
- assert service.embed.__func__ is not LocalHyEmbedService.embed
- finally:
- release.set()
- await exiting
- assert finished.is_set() and child.cancelled()
- assert service.embed.__func__ is LocalHyEmbedService.embed
-
- asyncio.run(run())
-
-
-def test_seal_checks_full_lease_then_exit_allows_own_governance_projection():
- async def run():
- permission = lease()
- provider, embedder = native(permission)
- wire = Wire()
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- guard.bind_embed_service(embedder)
- await provider.complete("last SDK call")
- checks = permission.checks
- await guard.seal()
- assert guard.requests_sealed and permission.checks == checks + 1
- checks = permission.checks
- # Represents main's own expected version update AFTER full checking.
- # Main separately checks frozen permissions before its commit.
- permission.context_changed = True
- await guard.seal() # Idempotent; no full-context check after publication.
- for call in (
- provider.complete("late child"),
- provider._call_llm("late direct dispatch", 10, 0),
- embedder.embed("late embed"),
- guard._http_client.post("https://models.example.test/approved/v1/embeddings"),
- ):
- with pytest.raises(HyPermissionTransportError, match="sealed"):
- await call
- assert permission.checks == checks
- assert permission.checks == checks and wire.closed == 1
- assert len(wire.requests) == 1
-
- asyncio.run(run())
-
-
-def test_seal_does_not_skip_revocation_or_a_swallowed_denial():
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = Wire()
- with pytest.raises(HyPermissionTransportError, match="lease_denied"):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- await provider.complete("positive")
- permission.revoked = True
- with pytest.raises(HyPermissionTransportError, match="lease_denied"):
- await guard.seal()
- assert not guard.requests_sealed
- permission.revoked = False
- await guard.seal()
- assert wire.closed == 1
-
- asyncio.run(run())
-
-
-def test_seal_refuses_inflight_request_then_succeeds_only_after_join():
- async def run():
- permission = lease()
- provider, _ = native(permission)
- entered, release = asyncio.Event(), asyncio.Event()
-
- async def slow(request):
- entered.set()
- await release.wait()
- return response(request)
-
- async with HyPermissionTransport(
- permission, http_transport=httpx.MockTransport(slow)
- ) as guard:
- guard.bind_provider(provider)
- child = asyncio.create_task(provider.complete("in flight"))
- await entered.wait()
- with pytest.raises(HyPermissionTransportError, match="active_requests"):
- await guard.seal()
- assert not guard.requests_sealed
- release.set()
- await child
- await guard.seal()
- assert guard.requests_sealed
-
- asyncio.run(run())
-
-
-def test_seal_refuses_pending_native_queue_before_timer_has_started():
- async def run():
- permission = lease()
- _, service = native(permission)
- wire = Wire()
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_embed_service(service)
- child = asyncio.create_task(service.embed_queued("pending"))
- await asyncio.sleep(0)
- assert service._queue_pending
- with pytest.raises(HyPermissionTransportError, match="active_requests"):
- await guard.seal()
- assert not guard.requests_sealed
- await child
- # SDK 0.2.0 leaves the bucket key with a None task after the timer
- # has taken ownership of the batch. Waiting only on live tasks is
- # the same synchronization point the old SDK expressed by removing
- # the key.
- await asyncio.gather(
- *(task for task in service._queue_flush_tasks.values() if task is not None)
- )
- await guard.seal()
- assert len(wire.requests) == 1 and wire.closed == 1
-
- asyncio.run(run())
-
-
-def test_exit_joins_queue_timer_even_before_its_bound_coroutine_starts():
- async def run():
- permission = lease()
- _, service = native(permission)
- wire = Wire()
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_embed_service(service)
- child = asyncio.create_task(service.embed_queued("pending"))
- await asyncio.sleep(0)
- timer = next(iter(service._queue_flush_tasks.values()))
- assert timer not in guard._tasks
- assert child.cancelled()
- assert timer.done()
- # A timer that faults only because exit outran its first admission check
- # must be retrieved; otherwise asyncio logs an unhandled task exception.
- assert not timer._log_traceback
- assert not wire.requests and wire.closed == 1
-
- asyncio.run(run())
-
-
-@pytest.mark.parametrize("bind", [False, True])
-def test_close_failure_is_sanitized_and_cannot_skip_instance_restoration(bind):
- class BrokenClose(Wire):
- async def aclose(self):
- await super().aclose()
- raise RuntimeError("private-close-error")
-
- async def run():
- permission = lease()
- provider, _ = native(permission)
- wire = BrokenClose()
- with pytest.raises(HyPermissionTransportError, match="cleanup_failed"):
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- if bind:
- guard.bind_provider(provider)
- await provider.complete("positive")
- assert provider.complete.__func__ is LLMProvider.complete
- assert guard._http_client.is_closed
- assert wire.closed == 1
-
- asyncio.run(run())
-
-
-def test_a_gateway_cookie_does_not_deny_the_next_call_in_the_task():
- """Gateway cookies must not become model request headers.
-
- 0.2.0's client stack still shares the gateway response with the next call
- in the same task. The transport has to keep the CookieJar policy denying
- every domain so the second physical request remains header-identical.
- """
-
- def handler(request, count):
- reply = response(request)
- reply.headers["set-cookie"] = "x_host_key_access=abc; path=/; HttpOnly"
- return reply
-
- async def run():
- permission = lease()
- provider, _embedder = native(permission)
- wire = Wire(handler)
- async with HyPermissionTransport(permission, http_transport=wire) as guard:
- guard.bind_provider(provider)
- await provider.complete("judge")
- await provider.complete_messages([{"role": "user", "content": "extract"}])
- assert len(wire.requests) == 2
- assert all("cookie" not in request.headers for request in wire.requests)
-
- asyncio.run(run())
diff --git a/memhub/apps/core/tests/test_hy_state_source.py b/memhub/apps/core/tests/test_hy_state_source.py
deleted file mode 100644
index 0103a9e..0000000
--- a/memhub/apps/core/tests/test_hy_state_source.py
+++ /dev/null
@@ -1,547 +0,0 @@
-"""RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。
-
-产品只保留 HY 没有的三样:使用范围、来源撤权后的隐藏过滤、用户删除记录。
-这里钉住:HY 自己改了状态(导入写回从未见过的也算),读路径立刻按 HY 走;
-HY 读不到时读路径退回账本,而不是把"没读到"当成"全部有效"。
-"""
-
-import asyncio
-from pathlib import Path
-
-import pytest
-from hy_memory.models.memory import MemoryStatus as HyStatus
-
-from one_memory.hy_lifecycle import HyLifecycle, HyLifecycleUnreadable
-from one_memory.hy_memory_bridge import HyMemoryBridge
-from one_memory.imports.grants import SourceGrantStore
-from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.imports.storage import EvidenceStore
-from one_memory.memory_governance import (
- HyCreate,
- MemoryDraft,
- MemoryGovernanceStore,
- MemoryKind,
- MemoryMutationConflictError,
- MemoryStatus,
-)
-from one_memory.memory_library import read_memory_library
-from tests.person_door import as_person
-from tests.test_hy_permission_raw_context import (
- AGENT_TEXT,
- USER_TEXT,
- ScriptedOracle,
- assert_outputs,
- bind_oracle,
- native_client,
- on_native_loop,
- raw_client,
-)
-
-FACT = "用户的发布前检查要求一次权限评审"
-
-
-def _evidence(database: Path, message_id: str, *, conversation: str = "conversation") -> str:
- return (
- EvidenceStore(database)
- .put(
- EvidenceInput(
- source_id="cursor",
- conversation_id=conversation,
- message_id=message_id,
- correlation_id=message_id,
- role="user",
- written_by=WrittenBy.PERSON,
- verbatim=f"原话 {message_id}",
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- )
- .record.evidence_id
- )
-
-
-def _library(tmp_path: Path) -> tuple[Path, MemoryGovernanceStore, dict[str, str]]:
- """Four memories, newest last: a, b, c, d. Each rests on its own evidence."""
- database = tmp_path / "one-memory.sqlite3"
- SourceGrantStore(database, home=tmp_path)
- store = MemoryGovernanceStore(database)
- ids: dict[str, str] = {}
- for key in ("a", "b", "c", "d"):
- evidence_id = _evidence(database, key)
- as_person(store).create(
- MemoryDraft(
- user_id="local",
- kind=MemoryKind.CHAT,
- content=f"{FACT} {key}",
- scopes=("all-apps",),
- evidence_ids=(evidence_id,),
- ),
- request_id=f"create-{key}",
- memory_id=key,
- )
- ids[key] = evidence_id
- return database, store, ids
-
-
-def _ids(memories) -> list[str]:
- return [memory.memory_id for memory in memories]
-
-
-# ---------------------------------------------------------------- 账本读路径
-
-
-def test_what_hy_hides_leaves_every_current_read_and_shows_hy_state_by_id(tmp_path) -> None:
- _, store, _ = _library(tmp_path)
- # HY deduplicated d into b during a search, and replaced a with c.
- lifecycle = HyLifecycle(hidden={"d": ("b",), "a": ("c",)})
-
- assert _ids(store.list(user_id="local")) == ["d", "c", "b", "a"]
- assert _ids(store.list(user_id="local", lifecycle=lifecycle)) == ["c", "b"]
- found, more = store.find_confirmed_matching_terms(
- user_id="local", terms=(FACT,), lifecycle=lifecycle
- )
- assert _ids(found) == ["c", "b"] and more is False
-
- hidden = store.get("d", user_id="local", lifecycle=lifecycle)
- assert hidden.status is MemoryStatus.SUPERSEDED
- assert hidden.superseded_by == ("b",) and hidden.deleted_at is None
- kept = store.get("b", user_id="local", lifecycle=lifecycle)
- assert kept.status is MemoryStatus.CONFIRMED and kept.supersedes == ("d",)
- assert _ids(
- store.list(user_id="local", status=MemoryStatus.SUPERSEDED, lifecycle=lifecycle)
- ) == [
- "d",
- "a",
- ]
- assert _ids(
- store.list(user_id="local", status=MemoryStatus.CONFIRMED, lifecycle=lifecycle)
- ) == [
- "c",
- "b",
- ]
-
-
-def test_hidden_rows_are_excluded_before_the_page_is_cut(tmp_path) -> None:
- _, store, _ = _library(tmp_path)
- lifecycle = HyLifecycle(hidden={"d": ("b",), "c": ("b",)})
-
- assert _ids(store.list(user_id="local", limit=1, lifecycle=lifecycle)) == ["b"]
- assert _ids(store.list(user_id="local", limit=1, offset=1, lifecycle=lifecycle)) == ["a"]
- found, more = store.find_confirmed_matching_terms(
- user_id="local", terms=(FACT,), limit=1, lifecycle=lifecycle
- )
- assert _ids(found) == ["b"] and more is True
-
-
-def test_a_user_deletion_stays_a_deletion_whatever_hy_says(tmp_path) -> None:
- _, store, _ = _library(tmp_path)
- b = store.get("b", user_id="local")
- as_person(store).delete(
- "b", user_id="local", request_id="delete-b", expected_revision=b.revision
- )
- # HY still holds b as active until the deletion outbox reaches it.
- lifecycle = HyLifecycle(hidden={})
-
- assert "b" not in _ids(store.list(user_id="local", lifecycle=lifecycle))
- assert store.get("b", user_id="local", lifecycle=lifecycle).status is MemoryStatus.DELETED
- assert _ids(store.list(user_id="local", status=MemoryStatus.DELETED, lifecycle=lifecycle)) == [
- "b"
- ]
-
-
-def test_hy_restoring_a_memory_it_had_replaced_brings_it_back(tmp_path) -> None:
- """A row an older build mirrored as superseded is current again once HY says so.
-
- HY restores the predecessor when the head of a chain is deleted; no write
- reaches the ledger for that, so only reading HY can see it.
- """
- from one_memory.memory_governance import HySupersede
-
- _, store, _ = _library(tmp_path)
- a = store.get("a", user_id="local")
- store.write_from_hy(
- HySupersede(
- memory_id="a",
- request_id="hy-supersede:t:a",
- expected_revision=a.revision,
- superseded_by=("c",),
- ),
- user_id="local",
- derivation=store.hy_derivation(),
- )
- assert "a" not in _ids(store.list(user_id="local"))
-
- restored = HyLifecycle(hidden={})
- assert "a" in _ids(store.list(user_id="local", lifecycle=restored))
- memory = store.get("a", user_id="local", lifecycle=restored)
- assert memory.status is MemoryStatus.CONFIRMED and memory.superseded_by == ()
- assert store.get("c", user_id="local", lifecycle=restored).supersedes == ()
-
-
-def test_without_a_snapshot_the_ledger_reads_as_before(tmp_path) -> None:
- _, store, _ = _library(tmp_path)
-
- assert _ids(store.list(user_id="local", lifecycle=None)) == ["d", "c", "b", "a"]
- assert store.get("d", user_id="local", lifecycle=None).status is MemoryStatus.CONFIRMED
-
-
-def test_a_superseded_memory_does_not_come_back_after_reopen(tmp_path) -> None:
- """Reopening the library may not resurrect what HY already replaced.
-
- The ledger's own mirrored supersession is the floor that holds while HY is
- not open; a second read path must not treat "no snapshot yet" as "active".
- """
- from one_memory.memory_governance import HySupersede
-
- database, store, _ = _library(tmp_path)
- a = store.get("a", user_id="local")
- store.write_from_hy(
- HySupersede(
- memory_id="a",
- request_id="hy-supersede:reopen:a",
- expected_revision=a.revision,
- superseded_by=("c",),
- ),
- user_id="local",
- derivation=store.hy_derivation(),
- )
- reopened = MemoryGovernanceStore(database)
- assert "a" not in _ids(reopened.list(user_id="local"))
- page = read_memory_library(database, limit=100)
- assert "a" not in [item.memory_id for item in page.items]
-
-
-def test_revoked_source_stays_hidden_even_with_a_stale_lifecycle_snapshot(tmp_path) -> None:
- """Authority outranks an old HY snapshot: no snapshot may re-expose it."""
- database, store, _ = _library(tmp_path)
- stale = HyLifecycle(hidden={})
- assert _ids(store.list(user_id="local", lifecycle=stale)) == ["d", "c", "b", "a"]
-
- grants = SourceGrantStore(database, home=tmp_path)
- root = tmp_path / "cursor-root"
- root.mkdir()
- grants.grant(
- user_id="local",
- source_id="cursor",
- root=root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- grants.revoke(user_id="local", source_id="cursor")
- # Documented contract: the ledger itself still lists the rows it owns.
- # Revocation is enforced by the read projections and external authority,
- # so a stale lifecycle snapshot must not re-expose them there.
- assert _ids(store.list(user_id="local", lifecycle=stale)) == ["d", "c", "b", "a"]
- page = read_memory_library(database, source="cursor", limit=100, lifecycle=stale)
- assert page.total == 0
-
-
-def test_revocation_survives_reopen_and_keeps_the_ledger_intact(tmp_path) -> None:
- """Revocation hides reads; it does not delete, and it must outlive a restart.
-
- The governance store keeps its row by design, so a downstream consumer has
- to read through authority and must not treat the surviving row as current.
- """
- database, _, _ = _library(tmp_path)
- grants = SourceGrantStore(database, home=tmp_path)
- root = tmp_path / "cursor-root"
- root.mkdir()
- grants.grant(
- user_id="local",
- source_id="cursor",
- root=root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- grants.revoke(user_id="local", source_id="cursor")
-
- reopened = MemoryGovernanceStore(database)
- receipt = SourceGrantStore(database, home=tmp_path).receipt(user_id="local", source_id="cursor")
-
- assert receipt is not None and receipt.revoked_at is not None
- # Rows survive; they are simply not readable through this source.
- assert len(reopened.list(user_id="local")) == 4
- assert read_memory_library(database, source="cursor", limit=100).total == 0
-
-
-def test_the_memory_library_page_and_total_follow_hy(tmp_path) -> None:
- database, _, _ = _library(tmp_path)
- lifecycle = HyLifecycle(hidden={"d": ("b",)})
-
- page = read_memory_library(database, lifecycle=lifecycle)
- assert _ids(page.items) == ["c", "b", "a"] and page.total == 3
- replaced = read_memory_library(database, status=MemoryStatus.SUPERSEDED, lifecycle=lifecycle)
- assert _ids(replaced.items) == ["d"] and replaced.total == 1
- assert replaced.items[0].superseded_by == ("b",)
- active = read_memory_library(database, active_only=True, lifecycle=lifecycle, limit=1)
- assert _ids(active.items) == ["c"] and active.total == 3
-
-
-# ---------------------------------------------------------------- 删除语义
-
-
-def test_a_new_conversation_saying_a_deleted_fact_is_remembered_again(tmp_path) -> None:
- """Deleting removes that memory, not the fact: no content tombstone.
-
- Only the exact old evidence stays guarded (RC10); a different conversation
- saying the same thing later is a new statement.
- """
- database, store, _ = _library(tmp_path)
- a = store.get("a", user_id="local")
- as_person(store).delete(
- "a", user_id="local", request_id="delete-a", expected_revision=a.revision
- )
- again = _evidence(database, "again", conversation="later-conversation")
-
- created = store.write_from_hy(
- HyCreate(
- draft=MemoryDraft(
- user_id="local",
- kind=MemoryKind.CHAT,
- content=a.content,
- scopes=("all-apps",),
- evidence_ids=(again,),
- ),
- request_id="extract:later:new-a",
- memory_id="new-a",
- ),
- user_id="local",
- derivation=store.hy_derivation((again,)),
- )
-
- assert created.status is MemoryStatus.CONFIRMED and created.content == a.content
- assert "new-a" in _ids(store.list(user_id="local"))
- assert store.blocked_source_ids(user_id="local", evidence_ids=(again,)) == set()
-
-
-def test_the_store_uses_its_lifecycle_source_for_reads_and_person_edits(tmp_path) -> None:
- _, store, _ = _library(tmp_path)
- hidden = HyLifecycle(hidden={"d": ("b",)})
- calls: list[tuple[str, tuple[str, ...] | None]] = []
-
- def source(user_id: str, ids: tuple[str, ...] | None = None) -> HyLifecycle:
- calls.append((user_id, ids))
- return hidden
-
- store.read_lifecycle_from(source)
- assert _ids(store.list(user_id="local")) == ["c", "b", "a"]
- current = store.get("d", user_id="local")
- assert current is not None and current.status is MemoryStatus.SUPERSEDED
- assert calls == [("local", None), ("local", ("d",))]
- # Editing what HY has set aside would fork the chain it already replaced.
- with pytest.raises(MemoryMutationConflictError, match="memory_superseded"):
- as_person(store).correct(
- "d",
- user_id="local",
- request_id="correct-hidden",
- expected_revision=current.revision,
- content=f"{FACT} corrected",
- )
-
-
-def test_a_rejected_or_missing_source_answers_degraded_not_all_active(tmp_path) -> None:
- """Wiring contract for C0/C4: unreadable means unavailable, never 'active'.
-
- A source that raises must be visible as an error to the composition root; a
- source that answers `None` keeps the ledger floor rather than inventing
- freshness. Neither may be read as 'HY says everything is current'.
- """
- _, store, _ = _library(tmp_path)
-
- def broken(_user_id: str, _ids: tuple[str, ...] | None = None) -> HyLifecycle:
- raise HyLifecycleUnreadable("hy_lifecycle_unreadable")
-
- store.read_lifecycle_from(broken)
- with pytest.raises(HyLifecycleUnreadable):
- store.list(user_id="local")
- with pytest.raises(HyLifecycleUnreadable):
- store.get("d", user_id="local")
-
- # Before HY is open, `None` keeps the ledger's own supersession floor:
- # a memory HY replaced stays hidden because the write was observed here.
- from one_memory.memory_governance import HySupersede
-
- _, store, _ = _library(tmp_path)
- a = store.get("a", user_id="local")
- store.write_from_hy(
- HySupersede(
- memory_id="a",
- request_id="hy-supersede:floor:a",
- expected_revision=a.revision,
- superseded_by=("c",),
- ),
- user_id="local",
- derivation=store.hy_derivation(),
- )
- store.read_lifecycle_from(lambda _user_id, _ids=None: None)
- assert "a" not in _ids(store.list(user_id="local"))
-
-
-def test_one_snapshot_per_page_not_one_per_row(tmp_path) -> None:
- """Wiring contract for C4: a page read asks HY once, not once per row."""
- _, store, _ = _library(tmp_path)
- calls: list[tuple[str, tuple[str, ...] | None]] = []
-
- def source(user_id: str, ids: tuple[str, ...] | None = None) -> HyLifecycle:
- calls.append((user_id, ids))
- return HyLifecycle(hidden={"d": ("b",)})
-
- store.read_lifecycle_from(source)
- assert len(store.list(user_id="local")) == 3
- assert calls == [("local", None)]
-
-
-def test_an_unreadable_lifecycle_blocks_edits_but_not_a_local_delete(tmp_path) -> None:
- _, store, _ = _library(tmp_path)
- current = store.get("d", user_id="local", lifecycle=None)
- assert current is not None
-
- def unreadable(_user_id: str, _ids: tuple[str, ...] | None = None) -> HyLifecycle:
- raise HyLifecycleUnreadable("hy_lifecycle_unreadable")
-
- store.read_lifecycle_from(unreadable)
- with pytest.raises(HyLifecycleUnreadable):
- as_person(store).correct(
- "d",
- user_id="local",
- request_id="correct-unreadable",
- expected_revision=current.revision,
- content=f"{FACT} corrected",
- )
-
- deleted = as_person(store).delete(
- "d",
- user_id="local",
- request_id="delete-unreadable",
- expected_revision=current.revision,
- )
- assert deleted.status is MemoryStatus.DELETED
-
-
-# ---------------------------------------------------------------- 真 HY
-
-
-async def _first_turn(supervisor, records):
- first = supervisor.prepare_conversation(records[0])[0]
- client = await native_client(supervisor, first)
- bind_oracle(supervisor, ScriptedOracle(records))
- assert (await supervisor._create_runner().run(first.task_id)).status == "completed"
- await assert_outputs(supervisor, first, records, client)
- by_content = {m.content: m for m in supervisor.governance.list(user_id="local")}
- bridge = supervisor._bridge(first.profile_snapshot, None, supervisor.data_dir)
- return client, bridge, by_content[USER_TEXT], by_content[AGENT_TEXT]
-
-
-def test_hy_search_dedup_is_read_straight_from_hy(tmp_path) -> None:
- """HY shadows a duplicate while searching; no ingest write-back ever sees it."""
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- client, bridge, dup, kept = await _first_turn(supervisor, records)
- assert isinstance(bridge, HyMemoryBridge)
- assert bridge.lifecycle_now("local") == HyLifecycle(hidden={})
-
- await on_native_loop(
- client,
- client._vector_store.update_payload(
- dup.memory_id,
- {
- "status": HyStatus.SHADOW.value,
- "is_latest": False,
- "shadow_reason": "dedup:search",
- "dedup_kept_id": kept.memory_id,
- },
- ),
- )
-
- lifecycle = bridge.lifecycle_now("local")
- assert lifecycle == HyLifecycle(hidden={dup.memory_id: (kept.memory_id,)})
- assert _ids(supervisor.governance.list(user_id="local", lifecycle=lifecycle)) == [
- kept.memory_id
- ]
- assert bridge.lifecycle_now("someone-else") == HyLifecycle(hidden={})
-
- asyncio.run(scenario())
-
-
-def test_a_replacement_hy_records_on_the_old_node_is_read_from_hy(tmp_path) -> None:
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- client, bridge, old, head = await _first_turn(supervisor, records)
- await on_native_loop(
- client,
- client._vector_store.update_payload(
- old.memory_id,
- {
- "status": HyStatus.SUPERSEDED.value,
- "is_latest": False,
- "superseded_by": [head.memory_id],
- },
- ),
- )
-
- lifecycle = bridge.lifecycle_now("local")
- assert lifecycle == HyLifecycle(hidden={old.memory_id: (head.memory_id,)})
- memory = supervisor.governance.get(old.memory_id, user_id="local", lifecycle=lifecycle)
- assert memory.status is MemoryStatus.SUPERSEDED
- assert memory.superseded_by == (head.memory_id,)
-
- asyncio.run(scenario())
-
-
-def test_an_unreadable_hy_store_raises_instead_of_reading_as_all_active(tmp_path) -> None:
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- client, bridge, dup, kept = await _first_turn(supervisor, records)
- await on_native_loop(
- client,
- client._vector_store.update_payload(
- dup.memory_id,
- {"status": HyStatus.SHADOW.value, "is_latest": False},
- ),
- )
- # Positive first: this store does hold something hidden.
- assert bridge.lifecycle_now("local") == HyLifecycle(hidden={dup.memory_id: ()})
-
- collection = client._vector_store._collection
-
- def broken(*args, **kwargs):
- raise RuntimeError("chroma is gone")
-
- original = collection.get
- collection.get = broken
- try:
- with pytest.raises(HyLifecycleUnreadable):
- bridge.lifecycle_now("local")
- finally:
- collection.get = original
-
- asyncio.run(scenario())
-
-
-def test_hy_not_opened_yet_is_only_safe_when_no_store_exists(tmp_path) -> None:
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- first = supervisor.prepare_conversation(records[0])[0]
- bridge = supervisor._bridge(first.profile_snapshot, None, supervisor.data_dir)
- assert bridge.lifecycle_now("local") is None
-
- (supervisor.data_dir / "hy-memory" / "governed-v2" / "graph").mkdir(parents=True)
- with pytest.raises(HyLifecycleUnreadable, match="hy_lifecycle_not_ready"):
- bridge.lifecycle_now("local")
-
- asyncio.run(scenario())
-
-
-def test_malformed_hy_lifecycle_rows_fail_closed(tmp_path) -> None:
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, _):
- client, bridge, _dup, _kept = await _first_turn(supervisor, records)
- collection = client._vector_store._collection
- original = collection.get
- collection.get = lambda **_kwargs: {"ids": ["missing-metadata"], "metadatas": []}
- try:
- with pytest.raises(HyLifecycleUnreadable, match="hy_lifecycle_store_mismatch"):
- bridge.lifecycle_now("local")
- finally:
- collection.get = original
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_hy_step_timing.py b/memhub/apps/core/tests/test_hy_step_timing.py
deleted file mode 100644
index ce470f6..0000000
--- a/memhub/apps/core/tests/test_hy_step_timing.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""Where an HY write spends the time that is not a model call."""
-
-import asyncio
-import json
-import time
-
-import httpx
-
-from one_memory.diagnostic_log import configure_diagnostics, reset_diagnostics
-from one_memory.hy_permission_context import permission_bridge_factory
-from one_memory.hy_step_timing import HyStepTimer
-from tests.test_hy_permission_context import whole_client
-
-
-def _handler(record):
- def handler(request: httpx.Request):
- body = json.loads(request.content)
- if request.url.path.endswith("embeddings"):
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": "test-embedding",
- "data": [
- {"index": i, "object": "embedding", "embedding": [1.0] + [0.0] * 383}
- for i, _ in enumerate(body["input"])
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
- return httpx.Response(
- 200,
- json={
- "id": "fixture-response",
- "object": "chat.completion",
- "created": 1,
- "model": "test-generation",
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {
- "role": "assistant",
- "content": json.dumps(
- [
- {
- "op": "ADD",
- "content": record.verbatim,
- "layer": "L2_FACT",
- "owner": "agent",
- }
- ]
- ),
- },
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
-
- return handler
-
-
-def test_leased_ingest_logs_each_hy_step_with_duration_and_count(tmp_path, monkeypatch):
- # Per-step tracing is its own tier on top of the master switch, so a test
- # that reads the step events has to ask for both.
- monkeypatch.setenv("ONE_MEMORY_DIAGNOSTICS", "1")
- monkeypatch.setenv("ONE_MEMORY_HY_STEP_TRACE", "1")
- receipt = configure_diagnostics(tmp_path / "diagnostics")
-
- async def scenario():
- with whole_client(tmp_path) as (supervisor, record, _, _):
- supervisor.permission_bridge_factory = permission_bridge_factory(
- supervisor._bridge, http_transport=httpx.MockTransport(_handler(record))
- )
- tasks = supervisor.prepare_conversation(record)
- try:
- result = await supervisor._create_runner().run(tasks[0].task_id)
- assert result.status == "completed", result.last_error_code
- finally:
- await supervisor.shutdown()
- return record, tasks[0].task_id
-
- try:
- record, task_id = asyncio.run(scenario())
- finally:
- reset_diagnostics()
- written = receipt.path.read_text()
- events = [json.loads(line) for line in written.splitlines()]
- steps = {
- event["details"]["phase"]: event
- for event in events
- if event["event"] == "hy.step.completed"
- }
- # Memory content with no prior candidate takes HY's no-model ADD path.
- assert "model" not in steps
- expected = {
- "embedding": "hy.embedding",
- "vector_store_read": "hy.vector_store",
- "vector_store_write": "hy.vector_store",
- "permission_check": "hy.permission_store",
- "permission_store_read": "hy.permission_store",
- "permission_store_write": "hy.permission_store",
- "write_verification": "hy.write_verification",
- "reconcile": "hy.reconcile",
- }
- assert expected.items() <= {name: event["component"] for name, event in steps.items()}.items()
- for event in steps.values():
- assert event["task_id"] == task_id
- assert event["request_id"]
- assert isinstance(event["duration_ms"], int)
- assert event["details"]["call_count"] >= 1
- assert event["details"]["summed_ms"] + 1 >= event["duration_ms"]
- summaries = [event for event in events if event["event"] == "hy.steps.summary"]
- assert len(summaries) == 1
- summary = summaries[0]
- assert summary["component"] == "hy.bridge"
- assert summary["task_id"] == task_id
- details = summary["details"]
- assert details["model_ms"] == 0
- assert details["accounted_ms"] + details["unaccounted_ms"] == summary["duration_ms"]
- assert details["accounted_ms"] + 1 >= max(event["duration_ms"] for event in steps.values())
- assert record.verbatim not in written
-
-
-def test_nested_same_step_counts_once_and_overlap_counts_wall_clock_once():
- timer = HyStepTimer()
- with timer.step("permission_check"), timer.step("permission_check"):
- time.sleep(0.02)
-
- async def overlapping():
- async def one():
- with timer.step("embedding", items=2):
- await asyncio.sleep(0.05)
-
- await asyncio.gather(one(), one())
-
- asyncio.run(overlapping())
- measured = timer.measurements()
- assert measured["permission_check"].call_count == 1
- embedding = measured["embedding"]
- assert embedding.call_count == 2
- assert embedding.item_count == 4
- assert embedding.summed_ms >= 95
- assert 45 <= embedding.busy_ms < 90
diff --git a/memhub/apps/core/tests/test_hy_supersede_sync.py b/memhub/apps/core/tests/test_hy_supersede_sync.py
deleted file mode 100644
index 38be050..0000000
--- a/memhub/apps/core/tests/test_hy_supersede_sync.py
+++ /dev/null
@@ -1,453 +0,0 @@
-"""HY decides supersessions; the ledger mirrors them. Pinned SDK, scripted HTTP."""
-
-import asyncio
-import json
-from copy import deepcopy
-
-import httpx
-import pytest
-from hy_memory.agent.tools.basic_profile import upsert_basic_profile
-from hy_memory.coding.types import WorkingFact
-from hy_memory.models.memory import MemoryLayer
-from hy_memory.models.memory import MemoryStatus as HyStatus
-
-import tests.test_hy_permission_raw_context as raw_context
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.hy_permission_coding_store import HyPermissionCodingStore, hy_coding_fingerprint
-from one_memory.hy_permission_store import HyNodePermission
-from one_memory.memory_governance import MemoryKind, MemoryStatus
-from tests.test_hy_coding_lease import TOOL_TEXT, CodingOracle, by_kind, run_coding_turn
-from tests.test_hy_permission_raw_context import (
- AGENT_TEXT,
- USER_TEXT,
- ScriptedOracle,
- assert_outputs,
- bind_oracle,
- grant_records,
- native_client,
- on_native_loop,
- put_turn,
- raw_client,
-)
-from tests.test_hy_permission_store import Backend, Lease, node, wrapper
-
-NEW_TEXT = "The user now requires two permission reviews before the synthetic release."
-
-
-def _vector(text):
- axis = {USER_TEXT: 1, AGENT_TEXT: 2, NEW_TEXT: 3}.get(text, 0)
- return [float(index == axis) for index in range(384)]
-
-
-class SupersedingOracle(ScriptedOracle):
- """HY's reconciler replaces existing claims with the new one instead of updating them."""
-
- def __init__(self, records, *, chain):
- super().__init__(records)
- self.chain = chain
-
- def respond(self, request):
- response = super().respond(request)
- if self.stages[-1] != "reconcile":
- return response
- body = json.loads(response.content)
- ops = json.loads(body["choices"][0]["message"]["content"])
- by_content = {op["content"]: op for op in ops}
- supersede = {
- "op": "SUPERSEDE",
- "content": NEW_TEXT,
- "owner": "user",
- "layer": "L2_FACT",
- "tags": [],
- "supersede_reason": "the user raised the requirement",
- }
- if self.chain:
- supersede["memory_ids"] = [self.chain[0], self.chain[1]]
- replaced = {USER_TEXT, AGENT_TEXT}
- else:
- supersede["memory_id"] = by_content[USER_TEXT]["memory_id"]
- replaced = {USER_TEXT}
- ops = [op for op in ops if op["content"] not in replaced | {NEW_TEXT}] + [supersede]
- body["choices"][0]["message"]["content"] = json.dumps(ops)
- return httpx.Response(200, json=body)
-
-
-async def _first_turn(supervisor, records):
- first = supervisor.prepare_conversation(records[0])[0]
- client = await native_client(supervisor, first)
- bind_oracle(supervisor, ScriptedOracle(records))
- assert (await supervisor._create_runner().run(first.task_id)).status == "completed"
- await assert_outputs(supervisor, first, records, client)
- by_content = {m.content: m for m in supervisor.governance.list(user_id="local")}
- return client, by_content[USER_TEXT], by_content[AGENT_TEXT]
-
-
-async def _superseding_turn(supervisor, records, permission, *, chain=()):
- added = put_turn(supervisor.evidence, "user-raise", "user", NEW_TEXT, 3)
- records = (*records, added)
- grant_records(supervisor, records, permission.revision)
- second = supervisor.prepare_conversation(added)[0]
- oracle = SupersedingOracle(records, chain=chain)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(second.task_id)
- assert not oracle.errors, oracle.errors
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- return oracle
-
-
-def _head(supervisor):
- (head,) = [
- m for m in supervisor.governance.list(user_id="local", limit=500) if m.content == NEW_TEXT
- ]
- return head
-
-
-def test_a_memory_hy_supersedes_is_superseded_in_the_ledger_with_its_replacement(
- tmp_path, monkeypatch
-):
- monkeypatch.setattr(raw_context, "vector", _vector)
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- client, replaced, kept = await _first_turn(supervisor, records)
- monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user")
- await _superseding_turn(supervisor, records, permission)
-
- native = await on_native_loop(
- client, client._vector_store.get_by_id(replaced.memory_id)
- )
- assert native.status == HyStatus.SUPERSEDED
- head = _head(supervisor)
- assert native.superseded_by == [head.memory_id]
-
- old = supervisor.governance.get(replaced.memory_id, user_id="local")
- assert old.status is MemoryStatus.SUPERSEDED
- assert old.deleted_at is None and old.content == USER_TEXT
- assert old.superseded_by == (head.memory_id,)
- assert supervisor.governance.deletion(old.memory_id, user_id="local") is None
- assert head.status is MemoryStatus.CONFIRMED
- assert head.supersedes == (old.memory_id,)
- untouched = supervisor.governance.get(kept.memory_id, user_id="local")
- assert untouched.status is MemoryStatus.CONFIRMED
- assert untouched.supersedes == untouched.superseded_by == ()
- assert {m.memory_id for m in supervisor.governance.list(user_id="local")} == {
- head.memory_id,
- kept.memory_id,
- }
-
- asyncio.run(scenario())
-
-
-def test_a_chain_fold_supersedes_every_folded_memory(tmp_path, monkeypatch):
- monkeypatch.setattr(raw_context, "vector", _vector)
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- client, user, agent = await _first_turn(supervisor, records)
- monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user")
- await _superseding_turn(
- supervisor, records, permission, chain=(agent.memory_id, user.memory_id)
- )
-
- head = _head(supervisor)
- oldest = supervisor.governance.get(agent.memory_id, user_id="local")
- newer = supervisor.governance.get(user.memory_id, user_id="local")
- assert oldest.status is newer.status is MemoryStatus.SUPERSEDED
- assert oldest.superseded_by == (user.memory_id,)
- assert newer.superseded_by == (head.memory_id,)
- assert newer.supersedes == (agent.memory_id,)
- assert head.supersedes == (user.memory_id,)
- assert head.status is MemoryStatus.CONFIRMED
- assert [m.memory_id for m in supervisor.governance.list(user_id="local")] == [
- head.memory_id
- ]
- for key in (agent.memory_id, user.memory_id):
- native = await on_native_loop(client, client._vector_store.get_by_id(key))
- assert native.status == HyStatus.SUPERSEDED
-
- asyncio.run(scenario())
-
-
-async def _turn_after_supersede(supervisor, records, permission):
- """The user repeats the new requirement one turn after HY superseded the old one."""
- added = next(
- r
- for r in supervisor.evidence.list_conversation(records[0].evidence_id)
- if r.verbatim == NEW_TEXT
- )
- again = put_turn(supervisor.evidence, "user-again", "user", NEW_TEXT, 4)
- records = (*records, added, again)
- grant_records(supervisor, records, permission.revision + 1)
- third = supervisor.prepare_conversation(again)[0]
- oracle = ScriptedOracle(records)
- bind_oracle(supervisor, oracle)
- result = await supervisor._create_runner().run(third.task_id)
- assert not oracle.errors, oracle.errors
- return result, oracle
-
-
-def test_the_turn_after_a_supersede_still_completes(tmp_path, monkeypatch):
- monkeypatch.setattr(raw_context, "vector", _vector)
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- _, replaced, _ = await _first_turn(supervisor, records)
- monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user")
- await _superseding_turn(supervisor, records, permission)
-
- # The old memory's evidence is in this turn's window; being
- # superseded by HY is not the user changing what was said.
- result, oracle = await _turn_after_supersede(supervisor, records, permission)
-
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- old = supervisor.governance.get(replaced.memory_id, user_id="local")
- assert old.status is MemoryStatus.SUPERSEDED
-
- asyncio.run(scenario())
-
-
-def test_hy_still_sees_the_head_of_a_supersede_chain_on_the_next_turn(tmp_path, monkeypatch):
- monkeypatch.setattr(raw_context, "vector", _vector)
-
- async def scenario():
- async with raw_client(tmp_path) as (supervisor, records, permission):
- await _first_turn(supervisor, records)
- monkeypatch.setitem(raw_context.CLAIMS, NEW_TEXT, "user")
- await _superseding_turn(supervisor, records, permission)
- head = _head(supervisor)
-
- result, oracle = await _turn_after_supersede(supervisor, records, permission)
-
- assert result.status == "completed", (result.last_error_code, oracle.stages)
- existing = {item["memory_id"] for batch in oracle.reconciles for item in batch}
- assert head.memory_id in existing, (head.memory_id, oracle.reconciles)
- repeated = [
- m
- for m in supervisor.governance.list(user_id="local", limit=500)
- if m.content == NEW_TEXT
- ]
- assert [m.memory_id for m in repeated] == [head.memory_id]
-
- asyncio.run(scenario())
-
-
-class SupersedingFactOracle(CodingOracle):
- """HY's fact reconciler judges the new session to continue the old task."""
-
- def __init__(self, records, *, old_fact, **kwargs):
- super().__init__(records, **kwargs)
- self.old_fact = old_fact
-
- def respond(self, request):
- body = json.loads(request.content)
- if request.url.path != "/v1/embeddings":
- prompt = body["messages"][-1]["content"]
- if prompt.startswith("A user task in a working-memory store has progressed"):
- self.requests.append(body)
- self.stages.append("chain_summary")
- return _completion(body, "The suite moved from pytest to tox.")
- response = super().respond(request)
- if self.stages[-1] != "fact_reconcile":
- return response
- return _completion(
- body,
- json.dumps(
- {
- "ops": [
- {
- "draft_idx": 0,
- "supersedes": [self.old_fact],
- "reason": "continues the same test-suite task",
- }
- ]
- }
- ),
- )
-
-
-def _completion(body, content):
- return httpx.Response(
- 200,
- json={
- "id": "synthetic-coding-response",
- "object": "chat.completion",
- "created": 1,
- "model": body["model"],
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {"role": "assistant", "content": content},
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
-
-
-def test_a_working_fact_hy_supersedes_is_superseded_in_the_ledger(tmp_path):
- async def scenario():
- async with raw_client(tmp_path, tool=TOOL_TEXT) as (supervisor, records, permission):
- _, first = await run_coding_turn(supervisor, records[0], CodingOracle(records))
- assert first.status == "completed", first.last_error_code
- (old_fact,) = by_kind(supervisor)[MemoryKind.WORKING]
- question = put_turn(supervisor.evidence, "user-again", "user", "Use tox now.", 4)
- tool = put_turn(supervisor.evidence, "tool-again", "tool", "[Bash] tox\nok", 5)
- grant_records(supervisor, (*records, question, tool), permission.revision)
- oracle = SupersedingFactOracle(
- (question, tool),
- old_fact=old_fact.memory_id,
- solution="Run `tox` from the repository root.",
- statement="The agent switched the synthetic suite to tox.",
- )
- _, second = await run_coding_turn(supervisor, question, oracle)
- assert second.status == "completed", (second.last_error_code, oracle.stages)
- assert "fact_reconcile" in oracle.stages
- client = await native_client(supervisor, second)
- (native_old,) = await on_native_loop(
- client, client._coding_store.get_facts([old_fact.memory_id], user_id="local")
- )
- assert native_old.status == "superseded"
-
- old = supervisor.governance.get(old_fact.memory_id, user_id="local")
- assert old.status is MemoryStatus.SUPERSEDED
- assert old.superseded_by == (native_old.fact_link,)
- new = supervisor.governance.get(native_old.fact_link, user_id="local")
- assert new is not None and new.status is MemoryStatus.CONFIRMED
- assert new.kind is MemoryKind.WORKING
- assert new.supersedes == (old.memory_id,)
- working = [
- m.memory_id
- for m in supervisor.governance.list(user_id="local")
- if m.kind is MemoryKind.WORKING
- ]
- assert working == [new.memory_id]
-
- asyncio.run(scenario())
-
-
-def test_the_chat_store_reports_what_hy_superseded_and_nothing_else():
- head = node("head", supersedes=["old"])
- old = node("old")
- kept = node("kept")
- backend = Backend(old, kept)
- store = wrapper(backend, approved=(old, kept))
-
- async def scenario():
- # Positive control and negative in one store: an ordinary update is not reported.
- await store.update_payload("kept", {"content": "Synthetic kept, refined"})
- assert store.superseded_nodes == {}
- await store.update_payload("old", {"is_latest": False, "status": "superseded"})
- await store.upsert(head)
- await store.update_payload("old", {"superseded_by": ["head"]})
- assert store.superseded_nodes == {"old": ("head",)}
- assert "old" not in store.retired_node_ids
-
- asyncio.run(scenario())
-
-
-def test_a_superseded_link_written_only_on_the_head_is_still_reported():
- old = node("old")
- backend = Backend(old)
- store = wrapper(backend, approved=(old,))
-
- async def scenario():
- await store.update_payload("old", {"is_latest": False, "status": "superseded"})
- await store.upsert(node("head", supersedes=["old"]))
- assert store.superseded_nodes == {"old": ("head",)}
-
- asyncio.run(scenario())
-
-
-def test_a_basic_profile_supersede_is_reported_for_the_ledger():
- old = node(
- "old-profile",
- agent_id="",
- session_id="",
- owner=None,
- layer=MemoryLayer.L0_BASIC_INFO,
- content="name: Before",
- custom={"basic_info_kv": {"name": "Before"}},
- )
- backend = Backend(old)
- store = wrapper(backend, approved=(old,))
-
- class Embedder:
- async def embed_queued(self, text):
- return [1.0, 0.0]
-
- async def scenario():
- result = await upsert_basic_profile(
- user_id="local",
- kv={"name": "After"},
- vector_store=store,
- embed_service=Embedder(),
- allowed_fields=["name"],
- )
- assert result.success and result.changed
- assert store.superseded_nodes == {"old-profile": (result.node_id,)}
-
- asyncio.run(scenario())
-
-
-def _fact(key, **kwargs):
- return WorkingFact(
- fact_id=key, user_id="local", agent_id="agent", session_id="session", **kwargs
- )
-
-
-class _NativeFacts:
- def __init__(self, *facts):
- self.facts = {fact.fact_id: deepcopy(fact) for fact in facts}
-
- async def get_facts(self, keys, user_id=None):
- return [deepcopy(self.facts[key]) for key in keys if key in self.facts]
-
- async def supersede_fact(self, old_id, new_id):
- row = self.facts[old_id]
- row.status, row.fact_link = "superseded", new_id
- return True
-
- async def insert_fact(self, fact):
- self.facts[fact.fact_id] = deepcopy(fact)
-
-
-def _coding_store(native, *approved):
- return HyPermissionCodingStore(
- native,
- Lease(),
- agent_id="agent",
- session_id="session",
- node_permissions={
- fact.fact_id: HyNodePermission(hy_coding_fingerprint(fact), ("current", "support"))
- for fact in approved
- },
- )
-
-
-def test_the_coding_store_lets_hy_supersede_a_fact_before_inserting_its_successor():
- old = _fact("old-fact")
- store = _coding_store(_NativeFacts(old), old)
-
- async def scenario():
- assert store.superseded_ids == {}
- assert await store.supersede_fact("old-fact", "new-fact")
- await store.insert_fact(_fact("new-fact"))
- store.recheck()
- assert store.superseded_ids == {"old-fact": ("new-fact",)}
- assert "old-fact" not in store.retired_ids
-
- asyncio.run(scenario())
-
-
-def test_the_coding_store_never_links_a_fact_to_an_existing_unapproved_one():
- old, foreign = _fact("old-fact"), _fact("foreign-fact", statement="DENIED")
- native = _NativeFacts(old, foreign)
- store = _coding_store(native, old)
-
- async def scenario():
- with pytest.raises(HyMemoryBridgeError, match="target_denied"):
- await store.supersede_fact("old-fact", "foreign-fact")
-
- asyncio.run(scenario())
- assert native.facts["old-fact"].status == "active"
diff --git a/memhub/apps/core/tests/test_hy_turn_input.py b/memhub/apps/core/tests/test_hy_turn_input.py
deleted file mode 100644
index 55a7dbb..0000000
--- a/memhub/apps/core/tests/test_hy_turn_input.py
+++ /dev/null
@@ -1,433 +0,0 @@
-"""HY gets one turn at a time, exactly as it happened, and picks its own route.
-
-A turn is one question from the person plus everything that answered it:
-assistant replies and the tool calls made along the way. HY's `add` decides
-chat or coding itself when it sees tool messages (`scene=None`); forcing
-`scene="chat"` made it strip them before it ever looked.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import sqlite3
-from datetime import UTC, datetime
-
-from one_memory.hy_memory_bridge import (
- HyMemoryBridge,
- HyMemoryIngestRequest,
- HyMemoryOutput,
- HyMemoryTurn,
-)
-from one_memory.hy_source_context import HySourceContext, HyWriteBindings
-from one_memory.imports.grants import GRANT_SCHEMA
-from one_memory.imports.models import WrittenBy
-from one_memory.memory_processing import MemoryProcessingSupervisor, _conversation_chunks
-from one_memory.memory_tasks import MemoryTaskKind, MemoryTaskStatus
-from one_memory.model_profiles import ProviderKind
-from tests.test_hy_memory_bridge import FakeHyMemoryClient, snapshot
-from tests.test_memory_processing import FakeBridge, _put, _runner, _setup, _task
-from tests.test_model_processing_permissions import Harness
-
-
-def _turn(evidence, index: int, *, tool: str | None = None):
- records = [
- _put(
- evidence,
- message_id=f"u{index}",
- role="user",
- written_by=WrittenBy.PERSON,
- content=f"第{index}个问题。",
- ),
- _put(
- evidence,
- message_id=f"a{index}",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content=f"第{index}个回答。",
- ),
- ]
- if tool is not None:
- records.append(
- _put(
- evidence,
- message_id=f"t{index}",
- role="tool",
- written_by=WrittenBy.TOOL,
- content=tool,
- )
- )
- records.append(
- _put(
- evidence,
- message_id=f"a{index}-after-tool",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content=f"第{index}个结论。",
- )
- )
- return records
-
-
-def _supervisor(tmp_path, stores, bridge=None):
- evidence, profiles, tasks, governance, secrets, _profile = stores
- return MemoryProcessingSupervisor(
- tasks=tasks,
- profiles=profiles,
- secrets=secrets,
- evidence=evidence,
- governance=governance,
- data_dir=tmp_path,
- runner_factory=lambda: _runner(tmp_path, stores, bridge or FakeBridge(outputs=())),
- )
-
-
-def _sent_turns(tmp_path, stores, created):
- bridge = FakeBridge(outputs=())
- runner = _runner(tmp_path, stores, bridge)
- for task in created:
- asyncio.run(runner.run(task.task_id))
- return [[turn.content for turn in request.turns] for request in bridge.requests]
-
-
-def test_each_turn_goes_to_hy_on_its_own(tmp_path) -> None:
- evidence = _setup(tmp_path)[0]
- first = _turn(evidence, 1, tool="[Bash] ls")
- second = _turn(evidence, 2)
-
- chunks = _conversation_chunks(evidence.list_conversation(first[0].evidence_id))
-
- assert [[r.evidence_id for r in chunk] for chunk in chunks] == [
- [r.evidence_id for r in first],
- [r.evidence_id for r in second],
- ]
-
-
-def test_what_came_before_the_first_question_joins_the_turn_that_answers_it(tmp_path) -> None:
- """An opener with no reply yet is not a turn of its own.
-
- C3's real-authority drift showed splitting `assistant` proposal from the
- user turn that adopts it sends HY an answer without what it answers, so an
- unanswered assistant prefix joins the following turn. The prefix is still
- sent, once, and in order — it is just no longer a window of its own.
- """
- evidence = _setup(tmp_path)[0]
- opener = _put(
- evidence,
- message_id="a0",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="你好,我能帮你做什么?",
- )
- first = _turn(evidence, 1)
-
- chunks = _conversation_chunks(evidence.list_conversation(opener.evidence_id))
-
- assert [[r.evidence_id for r in chunk] for chunk in chunks] == [
- [opener.evidence_id, *[r.evidence_id for r in first]]
- ]
-
-
-def test_a_turn_too_long_for_one_window_is_split_but_every_window_keeps_its_question(
- tmp_path, monkeypatch
-) -> None:
- # D74 sends this turn whole; the invariant still holds on the split path.
- monkeypatch.setenv("ONE_MEMORY_SPLIT_TURNS", "1")
- evidence = _setup(tmp_path)[0]
- question = _put(
- evidence,
- message_id="u1",
- role="user",
- written_by=WrittenBy.PERSON,
- content="把这个仓库的测试跑一遍。",
- )
- calls = [
- _put(
- evidence,
- message_id=f"t{index}",
- role="tool",
- written_by=WrittenBy.TOOL,
- content=f"[Bash] {index} " + "x" * 1500,
- )
- for index in range(12)
- ]
-
- chunks = _conversation_chunks(evidence.list_conversation(question.evidence_id))
-
- assert len(chunks) > 1
- assert all(chunk[0].evidence_id == question.evidence_id for chunk in chunks)
- assert all(sum(len(r.verbatim) for r in chunk) <= 12_000 for chunk in chunks)
- assert {r.evidence_id for chunk in chunks for r in chunk} == {
- question.evidence_id,
- *(call.evidence_id for call in calls),
- }
-
-
-def test_a_split_turn_never_sends_a_tool_only_window(tmp_path, monkeypatch) -> None:
- """WorkBuddy/Codex can answer, then emit many tool calls.
-
- If the answer fills the first window, the remaining calls used to become a
- tool-only window; HY rejected it with ``content or messages is required``.
- The tool calls must stay with the preceding non-tool window instead.
- D74 sends this turn whole; the invariant still holds on the split path.
- """
- monkeypatch.setenv("ONE_MEMORY_SPLIT_TURNS", "1")
- evidence = _setup(tmp_path)[0]
- question = _put(
- evidence,
- message_id="u1",
- role="user",
- written_by=WrittenBy.PERSON,
- content="先回答,再调用很多工具。",
- )
- answer = _put(
- evidence,
- message_id="a1",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="好。" + "z" * 7_000,
- )
- calls = [
- _put(
- evidence,
- message_id=f"t{index}",
- role="tool",
- written_by=WrittenBy.TOOL,
- content=f"[Bash] {index} " + "x" * 1_500,
- )
- for index in range(4)
- ]
-
- chunks = _conversation_chunks(evidence.list_conversation(question.evidence_id))
-
- assert chunks
- assert all(any(record.role != "tool" for record in chunk) for chunk in chunks)
- assert all(sum(len(record.verbatim) for record in chunk) <= 12_000 for chunk in chunks)
- assert list(dict.fromkeys(record.evidence_id for chunk in chunks for record in chunk)) == [
- question.evidence_id,
- answer.evidence_id,
- *(call.evidence_id for call in calls),
- ]
- assert any(record.role == "tool" for chunk in chunks for record in chunk)
-
-
-def test_tool_calls_travel_with_their_turn_redacted_and_as_tool_messages(tmp_path) -> None:
- stores = _setup(tmp_path)
- evidence, _profiles, tasks, _governance, _secrets, profile = stores
- records = _turn(
- evidence, 1, tool="[Bash] docker login --username kristinhe --password exposed-token-value"
- )
- bridge = FakeBridge()
-
- asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, records[0]).task_id))
-
- request = bridge.requests[0]
- assert [turn.role for turn in request.turns] == ["user", "assistant", "tool", "assistant"]
- assert "exposed-token-value" not in request.turns[2].content
- assert "" in request.turns[2].content
- assert records[2].evidence_id in request.evidence_ids
-
-
-def test_the_next_turn_reaches_hy_with_the_previous_turn_as_history(tmp_path) -> None:
- stores = _setup(tmp_path)
- evidence, _profiles, _tasks, _governance, _secrets, profile = stores
- with sqlite3.connect(tmp_path / "one-memory.sqlite3") as db:
- db.executescript(GRANT_SCHEMA)
- db.execute(
- "INSERT INTO source_grants VALUES ('local','cursor','/authorized','default',?,NULL)",
- (datetime.now(UTC).isoformat(),),
- )
- first = _turn(evidence, 1, tool="[Bash] 12 passed")
- second = _turn(evidence, 2)
- supervisor = _supervisor(tmp_path, stores)
- bridge = FakeBridge(outputs=())
- runner = _runner(tmp_path, stores, bridge)
-
- for turn in (first, second):
- task = supervisor._enqueue_chunk(tuple(turn), profile, freeze_inputs=True)
- asyncio.run(runner.run(task.task_id))
-
- context = bridge.requests[1].source_context
- assert context is not None
- assert [r.evidence_id for r in context.current] == [r.evidence_id for r in second]
- assert [r.evidence_id for r in context.history] == [r.evidence_id for r in first]
-
-
-def test_each_later_turn_carries_the_earlier_turns_as_history(tmp_path) -> None:
- """Later windows in one conversation must not arrive at HY alone.
-
- The frozen live pair expects later batches to grow because a window is sent
- with the turns behind it as history. A run where every request holds only the
- current turn loses the context the authority/adoption gate reads, which is
- what the cross-window real run showed.
- """
- stores = _setup(tmp_path)
- evidence, _profiles, _tasks, _governance, _secrets, profile = stores
- with sqlite3.connect(tmp_path / "one-memory.sqlite3") as db:
- db.executescript(GRANT_SCHEMA)
- db.execute(
- "INSERT INTO source_grants VALUES ('local','cursor','/authorized','default',?,NULL)",
- (datetime.now(UTC).isoformat(),),
- )
- turns = [_turn(evidence, index) for index in (1, 2, 3)]
- supervisor = _supervisor(tmp_path, stores)
- bridge = FakeBridge(outputs=())
- runner = _runner(tmp_path, stores, bridge)
-
- for turn in turns:
- task = supervisor._enqueue_chunk(tuple(turn), profile, freeze_inputs=True)
- asyncio.run(runner.run(task.task_id))
-
- assert len(bridge.requests) == 3
- for index, request in enumerate(bridge.requests):
- context = request.source_context
- assert context is not None
- assert [r.evidence_id for r in context.current] == [r.evidence_id for r in turns[index]]
- # Every turn after the first is sent with the ones behind it.
- expected = [r.evidence_id for turn in turns[:index] for r in turn]
- assert [r.evidence_id for r in context.history] == expected
-
-
-def test_the_bridge_hands_hy_the_turn_and_lets_hy_choose_the_route(tmp_path) -> None:
- client = FakeHyMemoryClient({"success": True, "memory_ids": []})
- bridge = HyMemoryBridge(
- profile=snapshot(ProviderKind.OLLAMA, base_url="http://127.0.0.1:11434", has_api_key=False),
- api_key=None,
- data_dir=tmp_path,
- client=client,
- )
- request = HyMemoryIngestRequest(
- request_id="turn-1",
- user_id="local",
- source_id="codex",
- session_id="conversation-1",
- turns=(
- HyMemoryTurn(role="user", content="跑一下测试"),
- HyMemoryTurn(role="tool", content="[Bash] 12 passed"),
- HyMemoryTurn(role="assistant", content="全部通过。"),
- ),
- evidence_ids=("u", "t", "a"),
- )
-
- asyncio.run(bridge.ingest(request))
- asyncio.run(bridge.close())
-
- assert client.calls[0]["scene"] is None
- assert [item["role"] for item in client.calls[0]["data"]] == ["user", "tool", "assistant"]
-
-
-def test_write_verification_accepts_the_tool_messages_hy_extracts_from(tmp_path) -> None:
- evidence = _setup(tmp_path)[0]
- question, _reply, call, _conclusion = _turn(evidence, 1, tool="[Bash] 12 passed")
- bindings = HyWriteBindings(HySourceContext(current=(question, call)))
-
- bindings.check_content(
- json.dumps(
- [
- {"role": "user", "content": question.verbatim},
- {"role": "tool", "content": call.verbatim},
- ],
- ensure_ascii=False,
- )
- )
-
- assert [source["role"] for source in bindings.sources] == ["user", "tool"]
-
-
-def test_every_turn_gets_its_own_task_when_nothing_was_queued_before(tmp_path) -> None:
- stores = _setup(tmp_path)
- evidence = stores[0]
- turns = [_turn(evidence, index) for index in (1, 2, 3)]
-
- created = _supervisor(tmp_path, stores).prepare_conversation(turns[2][0])
-
- assert _sent_turns(tmp_path, stores, created) == [[r.verbatim for r in turn] for turn in turns]
-
-
-def _earlier_policy_window(stores, records, *, succeeded: bool):
- _evidence, _profiles, tasks, _governance, _secrets, profile = stores
- task = tasks.create(
- request_id=f"conversation:hy-native-v22:cursor:{records[0].evidence_id}",
- user_id=records[0].user_id,
- kind=MemoryTaskKind.EXTRACT,
- input_ref=records[0].evidence_id,
- profile=profile,
- input_records=tuple(records),
- )
- _claimed, token = tasks.claim(task.task_id)
- if succeeded:
- tasks.complete(task.task_id, runner_token=token)
- else:
- tasks.fail(task.task_id, runner_token=token, error_code="hy_memory_unavailable")
- return tasks.require(task.task_id)
-
-
-def test_turns_an_earlier_policy_already_extracted_are_not_sent_again(tmp_path) -> None:
- stores = _setup(tmp_path)
- evidence = stores[0]
- old = [*_turn(evidence, 1), *_turn(evidence, 2)]
- assert _earlier_policy_window(stores, old, succeeded=True).status is (
- MemoryTaskStatus.COMPLETED
- )
- new = _turn(evidence, 3)
-
- created = _supervisor(tmp_path, stores).prepare_conversation(new[0])
-
- assert _sent_turns(tmp_path, stores, created) == [[r.verbatim for r in new]]
-
-
-def test_renewed_consent_still_prepares_a_turn_the_current_policy_completed(tmp_path) -> None:
- h = Harness(tmp_path)
- h.bridge.fail = False
- turn = h.put_closed_turn("selected", conversation="renewed-consent")
- h.grant(turn)
- supervisor = h.supervisor()
-
- async def scenario():
- try:
- first = supervisor.prepare_conversation(turn[0])[0]
- done = await supervisor._create_runner().run(first.task_id)
- assert done.status is MemoryTaskStatus.COMPLETED
- h.grant(turn)
- return first, supervisor.prepare_conversation(turn[0])
- finally:
- await supervisor.shutdown()
-
- first, again = asyncio.run(scenario())
-
- assert len(again) == 1 and again[0].task_id != first.task_id
-
-
-def test_a_failed_earlier_window_does_not_count_as_already_sent(tmp_path) -> None:
- stores = _setup(tmp_path)
- old = _turn(stores[0], 1)
- assert _earlier_policy_window(stores, old, succeeded=False).status is MemoryTaskStatus.FAILED
-
- created = _supervisor(tmp_path, stores).prepare_conversation(old[0])
-
- assert _sent_turns(tmp_path, stores, created) == [[r.verbatim for r in old]]
-
-
-def test_a_tool_heavy_turn_reaches_hy_once_and_still_becomes_a_memory(tmp_path) -> None:
- """D74's full criterion: one call to HY, and a memory comes out of it.
-
- The half that was only ever checked by hand is the second one. On 17:14
- the acceptance run for this ended with `completed=false` and zero
- evidence, so nothing proved the memory half either way; the window count
- alone leaves it open.
- """
- stores = _setup(tmp_path)
- evidence, _profiles, tasks, governance, _secrets, profile = stores
- records = _turn(evidence, 1, tool="x" * 50_000)
- task = _task(tasks, profile, records[0])
- bridge = FakeBridge(
- outputs=(HyMemoryOutput(memory_id="hy-tool-1", content="用户跑过一次全量测试。"),)
- )
-
- asyncio.run(_runner(tmp_path, stores, bridge).run(task.task_id))
-
- # One window, so one call.
- assert len(_conversation_chunks(records)) == 1
- assert len(bridge.requests) == 1
- # And the memory that call produced actually landed.
- assert [item.content for item in governance.list(user_id="local")] == ["用户跑过一次全量测试。"]
diff --git a/memhub/apps/core/tests/test_incremental_memory_backfill.py b/memhub/apps/core/tests/test_incremental_memory_backfill.py
deleted file mode 100644
index c738856..0000000
--- a/memhub/apps/core/tests/test_incremental_memory_backfill.py
+++ /dev/null
@@ -1,672 +0,0 @@
-import asyncio
-import threading
-from contextlib import suppress
-
-import pytest
-
-from one_memory.imports.models import EvidenceContentKind, WrittenBy
-from one_memory.memory_processing import MemoryProcessingSupervisor, _merge_historical_chunks
-from one_memory.memory_tasks import MemoryTaskStatus
-from one_memory.model_profiles import ModelProfilePatch
-from tests.test_memory_processing import _put, _setup
-from tests.test_model_processing_permissions import Harness
-
-
-def setup(tmp_path, count=3):
- evidence, profiles, tasks, governance, secrets, _profile = _setup(tmp_path)
- records = [
- _put(
- evidence,
- message_id=f"message-{index}",
- conversation_id=f"conversation-{index}",
- role="user",
- written_by=WrittenBy.PERSON,
- content=f"Synthetic conversation {index}",
- content_kind=EvidenceContentKind.MEMORY_CONTENT,
- )
- for index in range(count)
- ]
- supervisor = MemoryProcessingSupervisor(
- tasks=tasks,
- profiles=profiles,
- secrets=secrets,
- evidence=evidence,
- governance=governance,
- data_dir=tmp_path,
- )
- return supervisor, records
-
-
-def test_accept_backfill_returns_after_first_batch_while_later_history_is_still_read(
- tmp_path, monkeypatch
-):
- monkeypatch.setattr("one_memory.memory_processing._BACKFILL_START_GRACE_SECONDS", 0)
-
- async def scenario():
- supervisor, records = setup(tmp_path)
- later_entered, release = threading.Event(), threading.Event()
- scheduled = []
- reads = []
- original = supervisor.evidence.list_conversation
-
- def blocked(evidence_id):
- reads.append(evidence_id)
- if len(reads) == 2:
- later_entered.set()
- assert release.wait(5)
- return original(evidence_id)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- try:
- accepted = asyncio.create_task(supervisor.accept_backfill_off_thread())
- assert await asyncio.to_thread(later_entered.wait, 3)
- await asyncio.wait_for(accepted, 1)
- assert len(scheduled) == 1
- assert accepted.done()
- assert supervisor._live_backfill is not None
- assert not supervisor._live_backfill.done()
- finally:
- release.set()
- if supervisor._live_backfill is not None:
- await supervisor._live_backfill
- await supervisor.shutdown()
- assert len(scheduled) == len(records)
-
- asyncio.run(scenario())
-
-
-def test_first_task_is_dispatched_before_later_conversation_is_prepared(tmp_path, monkeypatch):
- async def scenario():
- supervisor, records = setup(tmp_path)
- later_entered, release = threading.Event(), threading.Event()
- scheduled = []
- reads = []
- original = supervisor.evidence.list_conversation
-
- def blocked(evidence_id):
- reads.append(evidence_id)
- if len(reads) == 2:
- later_entered.set()
- assert release.wait(5)
- return original(evidence_id)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- task = asyncio.create_task(supervisor.backfill_off_thread())
- try:
- assert await asyncio.to_thread(later_entered.wait, 3)
- assert len(scheduled) == 1, "first task still waits behind later conversation reads"
- assert supervisor.tasks.require(scheduled[0].task_id).input_ref in {
- record.evidence_id for record in records
- }
- finally:
- release.set()
- await task
- await supervisor.shutdown()
- assert len(scheduled) == len(records)
- assert {supervisor.tasks.require(item.task_id).input_ref for item in scheduled} == {
- record.evidence_id for record in records
- }
-
- asyncio.run(scenario())
-
-
-def test_cancellation_joins_the_preparation_thread_before_return(tmp_path, monkeypatch):
- async def scenario():
- supervisor, _records = setup(tmp_path)
- entered, release, exited = threading.Event(), threading.Event(), threading.Event()
- original = supervisor.evidence.list_conversation
- scheduled = []
-
- def blocked(evidence_id):
- entered.set()
- assert release.wait(5)
- result = original(evidence_id)
- exited.set()
- return result
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- task = asyncio.create_task(supervisor.backfill_off_thread())
- try:
- assert await asyncio.to_thread(entered.wait, 3)
- task.cancel()
- await asyncio.sleep(0.03)
- task.cancel()
- await asyncio.sleep(0.03)
- assert not task.done(), "backfill abandoned a thread that can still write tasks"
- finally:
- release.set()
- with suppress(asyncio.CancelledError):
- await task
- await supervisor.shutdown()
- assert exited.is_set()
- assert scheduled == []
-
- asyncio.run(scenario())
-
-
-def test_later_failure_keeps_the_first_committed_batch_visible(tmp_path, monkeypatch):
- async def scenario():
- supervisor, _records = setup(tmp_path)
- scheduled, reads = [], []
- original = supervisor.evidence.list_conversation
-
- def broken(evidence_id):
- reads.append(evidence_id)
- if len(reads) == 2:
- raise RuntimeError("synthetic later read failure")
- return original(evidence_id)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", broken)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- try:
- with pytest.raises(RuntimeError, match="synthetic later"):
- await supervisor.backfill_off_thread()
- assert len(scheduled) == 1
- assert len(supervisor.tasks.list()) == 1
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("change", ["revoke", "regrant"])
-def test_permission_change_between_batches_stops_without_new_tasks(tmp_path, monkeypatch, change):
- async def scenario():
- h = Harness(tmp_path)
- records = [
- h.put("first", conversation="first", memory_content=True),
- h.put("second", conversation="second", memory_content=True),
- ]
- receipt = h.grant(records)
- supervisor = h.supervisor()
- scheduled = []
-
- def dispatch(work):
- scheduled.extend(work)
- if len(scheduled) == 1:
- if change == "revoke":
- h.permissions.revoke(
- user_id="local", user_confirmed=True, expected_revision=receipt.revision
- )
- else:
- h.grant(records)
-
- monkeypatch.setattr(supervisor, "_queue_validated_work", dispatch)
- try:
- with pytest.raises(RuntimeError, match="model_content_permission_"):
- await supervisor.backfill_off_thread(expected_permission_revision=receipt.revision)
- assert len(h.tasks.list()) == len(scheduled) == 1
- assert h.factory_calls == [] and h.bridge.calls == []
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_profile_change_after_first_batch_preserves_old_task_identity(tmp_path, monkeypatch):
- async def scenario():
- h = Harness(tmp_path)
- h.grant(
- [
- h.put("first", conversation="first", memory_content=True),
- h.put("second", conversation="second", memory_content=True),
- ]
- )
- supervisor = h.supervisor()
- scheduled = []
-
- def dispatch(work):
- scheduled.extend(work)
- h.profiles.update(
- h.profile.profile_id,
- ModelProfilePatch(model="changed-model", expected_revision=h.profile.revision),
- h.secrets,
- )
-
- monkeypatch.setattr(supervisor, "_queue_validated_work", dispatch)
- try:
- with pytest.raises(RuntimeError, match="model_content_profile_changed"):
- await supervisor.backfill_off_thread()
- assert len(h.tasks.list()) == len(scheduled) == 1
- assert h.tasks.list()[0].profile_revision == h.profile.revision
- assert h.factory_calls == []
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_batched_and_sync_preparation_keep_all_task_inputs_and_idempotency(tmp_path, monkeypatch):
- async def scenario():
- supervisor, records = setup(tmp_path, count=21)
- scheduled = []
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda batch: scheduled.append(batch)
- )
- try:
- expected = supervisor.prepare_backfill()
- expected_ids = {work.task_id for work in expected}
- assert len(expected_ids) == 21
- before = {
- record.evidence_id: supervisor.evidence.get(record.evidence_id)
- for record in records
- }
- await supervisor.backfill_off_thread()
- assert len(scheduled[0]) == 1
- assert all(len(batch) <= 8 for batch in scheduled)
- assert {work.task_id for batch in scheduled for work in batch} == expected_ids
- assert len(supervisor.tasks.list()) == 21
- assert {
- record.evidence_id: supervisor.evidence.get(record.evidence_id)
- for record in records
- } == before
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_history_backfill_merges_adjacent_unsent_turns_in_one_conversation(tmp_path):
- supervisor, records = setup(tmp_path, count=0)
- profile = supervisor.profiles.active(supervisor.secrets)
- assert profile is not None
- conversation = []
- for index in range(2):
- conversation.extend(
- (
- _put(
- supervisor.evidence,
- message_id=f"user-{index}",
- role="user",
- written_by=WrittenBy.PERSON,
- content=f"问题 {index}",
- conversation_id="shared-history",
- ),
- _put(
- supervisor.evidence,
- message_id=f"assistant-{index}",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content=f"回答 {index}",
- conversation_id="shared-history",
- ),
- )
- )
-
- chunks = supervisor._prepare_conversation_chunks(conversation, profile, None)
- merged = _merge_historical_chunks(chunks)
-
- assert len(chunks) == 2
- assert len(merged) == 1
- assert {record.evidence_id for record in merged[0]} == {
- record.evidence_id for record in conversation
- }
-
-
-def test_backfill_holds_an_unanswered_raw_turn_until_its_reply_arrives(tmp_path):
- supervisor, _records = setup(tmp_path, count=0)
- question = _put(
- supervisor.evidence,
- message_id="open-question",
- role="user",
- written_by=WrittenBy.PERSON,
- content="发布包上传了吗?",
- conversation_id="open-turn",
- )
-
- assert supervisor.prepare_conversation(question) == []
- assert supervisor.prepare_backfill() == []
-
- answer = _put(
- supervisor.evidence,
- message_id="closing-answer",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="已经上传完成。",
- conversation_id="open-turn",
- )
-
- prepared = supervisor.prepare_conversation(answer)
-
- assert len(prepared) == 1
- assert prepared[0].input_ref == question.evidence_id
-
-
-def test_history_merge_supersedes_an_already_queued_shorter_session_task(tmp_path):
- supervisor, _records = setup(tmp_path, count=0)
- first = _put(
- supervisor.evidence,
- message_id="merge-user-1",
- role="user",
- written_by=WrittenBy.PERSON,
- content="第一问",
- conversation_id="growing-history",
- )
- first_reply = _put(
- supervisor.evidence,
- message_id="merge-assistant-1",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="第一答",
- conversation_id="growing-history",
- )
- profile = supervisor.profiles.active(supervisor.secrets)
- assert profile is not None
- old = supervisor._enqueue_chunk((first, first_reply), profile, freeze_inputs=True)
- second = _put(
- supervisor.evidence,
- message_id="merge-user-2",
- role="user",
- written_by=WrittenBy.PERSON,
- content="第二问",
- conversation_id="growing-history",
- )
- second_reply = _put(
- supervisor.evidence,
- message_id="merge-assistant-2",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="第二答",
- conversation_id="growing-history",
- )
-
- chunks = supervisor._prepare_conversation_chunks(
- supervisor.evidence.list_conversation(second.evidence_id), profile, None
- )
- merged_chunk = _merge_historical_chunks(chunks)[0]
- merged_task = supervisor._enqueue_chunk(merged_chunk, profile, freeze_inputs=True)
-
- pending = supervisor.tasks.list(statuses=(MemoryTaskStatus.PENDING,))
- merged = next(task for task in supervisor.tasks.list() if task.task_id != old.task_id)
- assert supervisor.tasks.require(old.task_id).status.value == "cancelled"
- assert [task.task_id for task in pending] == [merged.task_id]
- assert {ref["evidence_id"] for ref in supervisor.tasks.inputs(merged.task_id)} == {
- first.evidence_id,
- first_reply.evidence_id,
- second.evidence_id,
- second_reply.evidence_id,
- }
- assert merged_task.task_id == merged.task_id
-
-
-def test_backfill_budget_yields_between_rounds_and_resumes_same_cursor(tmp_path, monkeypatch):
- async def scenario():
- supervisor, records = setup(tmp_path, count=3)
- scheduled = []
- clock = iter((0.0, 0.0, 331.0, 331.0, 331.0, 662.0, 662.0, 662.0))
- supervisor._backfill_clock = lambda: next(clock)
- supervisor._backfill_round_budget_seconds = 330.0
- releases = []
-
- async def schedule(work):
- assert supervisor._backfill_lock.locked()
- scheduled.extend(work)
-
- original_sleep = asyncio.sleep
-
- async def observed_sleep(delay):
- if delay == 0:
- releases.append(not supervisor._backfill_lock.locked())
- await original_sleep(0)
-
- monkeypatch.setattr(supervisor, "_schedule_tasks_off_thread", schedule)
- monkeypatch.setattr("one_memory.memory_processing.asyncio.sleep", observed_sleep)
- try:
- await supervisor.backfill_off_thread()
- finally:
- await supervisor.shutdown()
-
- assert releases == [True, True]
- assert len(scheduled) == len(records)
- assert len({work.task_id for work in scheduled}) == len(records)
-
- asyncio.run(scenario())
-
-
-def test_shutdown_waits_for_backfill_preparation_and_never_dispatches_tail(tmp_path, monkeypatch):
- async def scenario():
- supervisor, _records = setup(tmp_path)
- entered, release = threading.Event(), threading.Event()
- scheduled = []
- original = supervisor.evidence.list_conversation
-
- def blocked(evidence_id):
- entered.set()
- assert release.wait(5)
- return original(evidence_id)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- task = asyncio.create_task(supervisor.backfill_off_thread())
- try:
- assert await asyncio.to_thread(entered.wait, 3)
- stopping = asyncio.create_task(supervisor.shutdown())
- await asyncio.sleep(0.03)
- assert not stopping.done()
- finally:
- release.set()
- await task
- await stopping
- assert scheduled == []
- assert supervisor._backfill_preparations == set()
- assert supervisor.tasks.list() == []
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("count", [0, 1, 8, 9, 18])
-def test_empty_and_batch_boundaries_preserve_all_inputs(tmp_path, monkeypatch, count):
- async def scenario():
- supervisor, records = setup(tmp_path, count=count)
- scheduled = []
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- active_calls = []
- active = supervisor.profiles.active
-
- def counted(secrets):
- active_calls.append(True)
- return active(secrets)
-
- monkeypatch.setattr(supervisor.profiles, "active", counted)
- try:
- await supervisor.backfill_off_thread()
- assert len(active_calls) == 1, "batch identity checks should not reaccess credentials"
- assert {supervisor.tasks.require(work.task_id).input_ref for work in scheduled} == {
- record.evidence_id for record in records
- }
- assert len(supervisor.tasks.list()) == count
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_empty_denied_batches_do_not_end_the_remaining_traversal(tmp_path, monkeypatch):
- async def scenario():
- supervisor, records = setup(tmp_path, count=19)
- scheduled = []
- original = supervisor.evidence.list_conversation
- earliest = records[0].evidence_id
- monkeypatch.setattr(
- supervisor.evidence,
- "list_conversation",
- lambda identity: original(identity) if identity == earliest else [],
- )
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.append(work)
- )
- try:
- await supervisor.backfill_off_thread()
- assert scheduled[:2] == [[], []]
- useful = [work for batch in scheduled for work in batch]
- assert len(useful) == 1
- assert supervisor.tasks.require(useful[0].task_id).input_ref == earliest
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_replacement_backfill_waits_for_cancelled_writer_and_remains_idempotent(
- tmp_path, monkeypatch
-):
- async def scenario():
- supervisor, records = setup(tmp_path)
- entered, release = threading.Event(), threading.Event()
- reads, scheduled = [], []
- original = supervisor.evidence.list_conversation
-
- def blocked(evidence_id):
- reads.append(evidence_id)
- if len(reads) == 1:
- entered.set()
- assert release.wait(5)
- return original(evidence_id)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- old = asyncio.create_task(supervisor.backfill_off_thread())
- try:
- assert await asyncio.to_thread(entered.wait, 3)
- old.cancel()
- replacement = asyncio.create_task(supervisor.backfill_off_thread())
- await asyncio.sleep(0.03)
- assert not old.done() and not replacement.done()
- assert len(reads) == 1
- finally:
- release.set()
- with suppress(asyncio.CancelledError):
- await old
- await replacement
- await supervisor.shutdown()
- assert len(supervisor.tasks.list()) == len(records)
- assert len({work.task_id for work in scheduled}) == len(scheduled) == len(records)
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("unclaimable", ["completed", "deferred"])
-def test_unclaimable_first_seed_does_not_delay_first_useful_batch(
- tmp_path, monkeypatch, unclaimable
-):
- async def scenario():
- supervisor, records = setup(tmp_path)
- first = supervisor.prepare_conversation(records[-1])[0]
- if unclaimable == "completed":
- _, token = supervisor.tasks.claim(first.task_id)
- supervisor.tasks.complete(first.task_id, runner_token=token)
- else:
- supervisor.tasks.defer_outside_window(first.task_id)
- scheduled, reads = [], []
- entered, release = threading.Event(), threading.Event()
- original = supervisor.evidence.list_conversation
-
- def blocked(identity):
- reads.append(identity)
- if len(reads) == 3:
- entered.set()
- assert release.wait(5)
- return original(identity)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- monkeypatch.setattr(
- supervisor, "_queue_validated_work", lambda work: scheduled.extend(work)
- )
- task = asyncio.create_task(supervisor.backfill_off_thread())
- try:
- assert await asyncio.to_thread(entered.wait, 3)
- assert len(scheduled) == 1
- assert scheduled[0].task_id != first.task_id
- finally:
- release.set()
- await task
- await supervisor.shutdown()
-
- asyncio.run(scenario())
-
-
-def test_cancelled_shutdown_waits_for_inflight_generator_and_preserves_cleanup(
- tmp_path, monkeypatch
-):
- async def scenario():
- supervisor, _records = setup(tmp_path)
- entered, release = threading.Event(), threading.Event()
- original = supervisor.evidence.list_conversation
-
- def blocked(identity):
- entered.set()
- assert release.wait(5)
- return original(identity)
-
- monkeypatch.setattr(supervisor.evidence, "list_conversation", blocked)
- backfill = asyncio.create_task(supervisor.backfill_off_thread())
- try:
- assert await asyncio.to_thread(entered.wait, 3)
- stopping = asyncio.create_task(supervisor.shutdown())
- await asyncio.sleep(0.02)
- stopping.cancel()
- await asyncio.sleep(0.02)
- stopping.cancel()
- await asyncio.sleep(0.02)
- assert not stopping.done() and not backfill.done()
- assert len(supervisor._backfill_preparations) == 1
- finally:
- release.set()
- await asyncio.gather(stopping, backfill, return_exceptions=True)
- assert not backfill.cancelled() and backfill.exception() is None
- assert supervisor._backfill_preparations == set()
- assert supervisor.tasks.list() == []
-
- asyncio.run(scenario())
-
-
-def test_exact_backfill_keeps_granted_prefix_when_same_conversation_grows(tmp_path):
- async def scenario():
- h = Harness(tmp_path)
- first = h.put("first", memory_content=True)
- second = h.put("second", memory_content=True)
- receipt = h.grant([first, second])
- later = h.put("later", memory_content=True)
- other = h.put("other", conversation="other", memory_content=True)
- supervisor = h.supervisor()
- try:
- await supervisor.backfill_off_thread(expected_permission_revision=receipt.revision)
- tasks = supervisor.tasks.list()
- # One task per turn; both granted turns, nothing after them.
- assert len(tasks) == 1
- assert {task.input_ref for task in tasks} == {second.evidence_id}
- refs = {
- item["evidence_id"]
- for task in tasks
- for item in supervisor.tasks.inputs(task.task_id)
- }
- assert {first.evidence_id, second.evidence_id} <= refs
- assert later.evidence_id not in refs
- assert other.evidence_id not in refs
- # The first turn may already be running; whatever reached the model was granted.
- assert all(
- {record.evidence_id for record in lease.records}
- <= {first.evidence_id, second.evidence_id}
- for lease, _data_dir in h.factory_calls
- )
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_manual_memory_via_hy.py b/memhub/apps/core/tests/test_manual_memory_via_hy.py
deleted file mode 100644
index b9926f3..0000000
--- a/memhub/apps/core/tests/test_manual_memory_via_hy.py
+++ /dev/null
@@ -1,460 +0,0 @@
-"""T04: what the person explicitly asks to remember is written through HY's import path.
-
-Whole native HY client behind the production permission context; only the model
-endpoints are scripted through an httpx MockTransport, never a network.
-"""
-
-import asyncio
-import hashlib
-import json
-import sqlite3
-from contextlib import asynccontextmanager, closing
-from datetime import UTC, datetime
-from uuid import uuid4
-
-import httpx
-import pytest
-from httpx import ASGITransport, AsyncClient
-from pydantic import SecretStr
-
-from one_memory import continuous_policy, ingest_journal
-from one_memory.api import create_app
-from one_memory.config import Settings
-from one_memory.hy_memory_bridge import HyMemoryIngestResult, HyMemoryOutput
-from one_memory.hy_permission_context import permission_bridge_factory
-from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
-from one_memory.matters.selection import select_evidence
-from one_memory.memory_notes import SOURCE_ID
-from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ProviderKind
-from one_memory.secrets import InMemorySecretStore
-from tests.source_consent import confirm_model_content
-from tests.test_memory_processing import FakeBridge, _runner, _setup, _task
-
-TEXT = "请记住:项目周会固定不排在周五。"
-
-
-def _vector(text: str) -> list[float]:
- digest = hashlib.sha256(text.encode()).digest()
- values = [float(byte) + 1.0 for byte in digest * 12]
- norm = sum(value * value for value in values) ** 0.5
- return [value / norm for value in values]
-
-
-class ScriptedModel:
- """The model endpoints HY reaches. Reconcile says "nothing to change" unless told."""
-
- def __init__(self) -> None:
- self.reconcile_prompts: list[str] = []
- self.other_prompts: list[str] = []
- self.embeddings_down = False
-
- def __call__(self, request: httpx.Request) -> httpx.Response:
- body = json.loads(request.content)
- if request.url.path.endswith("embeddings"):
- if self.embeddings_down:
- return httpx.Response(503, json={"error": "down"})
- return httpx.Response(
- 200,
- json={
- "object": "list",
- "model": "test-embedding",
- "data": [
- {"index": i, "object": "embedding", "embedding": _vector(text)}
- for i, text in enumerate(body["input"])
- ],
- "usage": {"prompt_tokens": 1, "total_tokens": 1},
- },
- )
- prompt = "\n".join(str(message.get("content")) for message in body["messages"])
- reconcile = "Existing memories" in prompt
- (self.reconcile_prompts if reconcile else self.other_prompts).append(prompt)
- # Extraction or reconcile: the model finds nothing worth writing.
- content = "[]" if reconcile else '{"memory": [], "identity": [], "intentions": []}'
- return httpx.Response(
- 200,
- json={
- "id": "scripted",
- "object": "chat.completion",
- "created": 1,
- "model": "test-generation",
- "choices": [
- {
- "index": 0,
- "finish_reason": "stop",
- "message": {"role": "assistant", "content": content},
- }
- ],
- "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
- },
- )
-
-
-@pytest.fixture
-def hy_calls(monkeypatch):
- from hy_memory.client import HyMemoryClient
-
- calls: list[tuple[str, object]] = []
- original_add = HyMemoryClient.add
- original_extracted = HyMemoryClient.add_extracted
-
- def add(self, data, **kwargs):
- calls.append(("add", [turn["content"] for turn in data]))
- return original_add(self, data, **kwargs)
-
- def add_extracted(self, contents, **kwargs):
- raw = original_extracted(self, contents, **kwargs)
- calls.append(("add_extracted", {"contents": list(contents), "raw": raw}))
- return raw
-
- monkeypatch.setattr(HyMemoryClient, "add", add)
- monkeypatch.setattr(HyMemoryClient, "add_extracted", add_extracted)
- return calls
-
-
-def _application(tmp_path):
- return create_app(
- Settings(
- data_dir=tmp_path / "data",
- source_home=tmp_path,
- api_token=SecretStr("t04"),
- imports_enabled=False,
- automatic_memory_processing=False,
- live_sync_enabled=False,
- ),
- secret_store=InMemorySecretStore(),
- )
-
-
-async def _configure(app, model: ScriptedModel) -> None:
- profiles, secrets = app.state.model_profile_store, app.state.secret_store
- profile = profiles.create(
- ModelProfileInput(
- name="Scripted",
- provider_kind=ProviderKind.OLLAMA,
- base_url="http://127.0.0.1:1/v1",
- model="test-generation",
- embedding_provider_kind=ProviderKind.OLLAMA,
- embedding_base_url="http://127.0.0.1:2/v1",
- embedding_model="test-embedding",
- ),
- secrets,
- )
- profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True, model_available=True, embedding_available=True
- ),
- secrets=secrets,
- )
- profiles.activate(profile.profile_id, secrets)
- processor = app.state.memory_processing_supervisor
- processor.permission_bridge_factory = permission_bridge_factory(
- processor._bridge, http_transport=httpx.MockTransport(model)
- )
-
-
-async def _remember(app, client, text: str) -> dict:
- saved = (
- await client.post(
- "/api/v1/memory-notes",
- json={"request_id": str(uuid4()), "text": text, "scope": "all-apps"},
- )
- ).json()
- await confirm_model_content(
- client,
- source_ids=("one-memory-manual",),
- evidence_ids=(saved["evidence_id"],),
- future_additions=False,
- )
- started = await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process")
- assert started.status_code == 200, started.text
- await _wait_for_note_job(app, saved["evidence_id"])
- return (await client.get(f"/api/v1/memory-notes/{saved['evidence_id']}")).json()
-
-
-async def _wait_for_note_job(app, evidence_id: str, *, timeout: float = 10) -> str:
- """Wait for the production durable note job, not the legacy worker map."""
- deadline = asyncio.get_running_loop().time() + timeout
- key = f"entry:{evidence_id}"
- while asyncio.get_running_loop().time() < deadline:
- with sqlite3.connect(app.state.evidence_store.database_path) as db:
- row = db.execute(
- "SELECT state FROM memory_write_jobs WHERE idempotency_key=?",
- (key,),
- ).fetchone()
- if row is not None and row[0] not in {"pending", "running"}:
- return str(row[0])
- await asyncio.sleep(0.05)
- raise AssertionError(f"note job for {evidence_id} never settled")
-
-
-def _client(app):
- return AsyncClient(
- transport=ASGITransport(app=app),
- base_url="http://test",
- headers={"Authorization": "Bearer t04"},
- )
-
-
-def test_note_is_written_through_hy_import_even_when_the_model_would_extract_nothing(
- tmp_path, hy_calls
-):
- async def scenario():
- app, model = _application(tmp_path), ScriptedModel()
- async with app.router.lifespan_context(app), _client(app) as client:
- await _configure(app, model)
- receipt = await _remember(app, client, TEXT)
-
- assert receipt["state"] == "ready", (receipt, [name for name, _ in hy_calls])
- assert [name for name, _ in hy_calls] == ["add_extracted"]
- assert hy_calls[0][1]["contents"] == [TEXT]
- assert len(receipt["memory_ids"]) == 1
- memory = app.state.memory_governance_store.get(
- receipt["memory_ids"][0], user_id="local"
- )
- assert memory is not None and memory.content == TEXT
- assert memory.status == "confirmed" and memory.scopes == ("all-apps",)
- assert receipt["evidence_id"] in memory.evidence_ids
- assert memory.memory_id in hy_calls[0][1]["raw"]["memory_ids"]
- assert app.state.evidence_store.get(receipt["evidence_id"]).verbatim == TEXT
- # Still the person's own words: matter discovery keeps reading the note.
- selected = select_evidence(app.state.evidence_store.database_path)
- assert receipt["evidence_id"] in {record.evidence_id for record in selected.records}
-
- asyncio.run(scenario())
-
-
-def test_enabled_continuous_policy_processes_a_saved_note_without_separate_consent(
- tmp_path, hy_calls
-):
- async def scenario():
- app, model = _application(tmp_path), ScriptedModel()
- async with app.router.lifespan_context(app), _client(app) as client:
- await _configure(app, model)
- app.state.memory_note_store.intake = None
- profile = app.state.model_profile_store.active(app.state.secret_store)
- assert profile is not None
- with app.state.evidence_store._connect(writing=True) as db:
- ingest_journal.initialize(db)
- continuous_policy.initialize(db)
- continuous_policy.enable(
- db,
- profile=app.state.model_profile_store.snapshot(
- profile.profile_id, profile.revision
- ),
- expected_revision=0,
- expected_legacy_revision=0,
- operation_id=str(uuid4()),
- backlog_mode="none",
- user_confirmed=True,
- )
-
- saved = await client.post(
- "/api/v1/memory-notes",
- json={"request_id": str(uuid4()), "text": TEXT, "scope": "all-apps"},
- )
- assert saved.status_code == 201, saved.text
- processor = app.state.memory_processing_supervisor
- await asyncio.gather(*tuple(processor._workers.values()))
- receipt = (
- await client.get(f"/api/v1/memory-notes/{saved.json()['evidence_id']}")
- ).json()
-
- assert receipt["state"] == "ready"
- assert [name for name, _ in hy_calls] == ["add_extracted"]
- with app.state.evidence_store._connect() as db:
- assert (
- db.execute("SELECT COUNT(*) FROM model_content_permissions").fetchone()[0] == 0
- )
-
- asyncio.run(scenario())
-
-
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "T04 wait-user: a note's permission lease covers only that note's evidence, so HY "
- "cannot see the memory an earlier note produced and adds a second one. Letting it "
- "see earlier notes means sending them to the model with this one: a consent-scope "
- "decision. The bridge's no-ids fallback also cannot find it (decorated search "
- "content; the leased vector store has no keyword_search)."
- ),
-)
-def test_identical_second_note_points_at_the_memory_hy_already_holds(tmp_path, hy_calls):
- async def scenario():
- app, model = _application(tmp_path), ScriptedModel()
- async with app.router.lifespan_context(app), _client(app) as client:
- await _configure(app, model)
- first = await _remember(app, client, TEXT)
- assert first["state"] == "ready", first
- second = await _remember(app, client, TEXT)
-
- # HY really deduplicated: reconcile saw the first memory and wrote nothing new.
- assert len(model.reconcile_prompts) == 1 and TEXT in model.reconcile_prompts[0]
- assert first["memory_ids"][0] in model.reconcile_prompts[0]
- assert not hy_calls[-1][1]["raw"].get("memory_ids")
- assert second["state"] == "ready", second
- assert second["memory_ids"] == first["memory_ids"]
- memories = app.state.memory_governance_store.list(user_id="local")
- assert [item.memory_id for item in memories] == first["memory_ids"]
- assert {first["evidence_id"], second["evidence_id"]} <= set(memories[0].evidence_ids)
-
- asyncio.run(scenario())
-
-
-def test_note_saved_before_t04_is_imported_too_and_keeps_all_apps_scope(tmp_path):
- """Notes stored before T04 are not migrated; retrying one must still write it as said."""
- calls = []
-
- class Bridge:
- async def ingest(self, request):
- pytest.fail("A note the person asked to keep must be imported, not re-extracted")
-
- async def ingest_extracted(self, request, contents):
- calls.append(list(contents))
- return HyMemoryIngestResult(
- success=True,
- memories=(
- HyMemoryOutput(
- memory_id="legacy-note-memory",
- content=TEXT,
- owner="user",
- layer="L2_FACT",
- ),
- ),
- )
-
- async def profile_memories(self, **kwargs):
- return ()
-
- async def close(self):
- pass
-
- async def scenario():
- app, model = _application(tmp_path), ScriptedModel()
- async with app.router.lifespan_context(app), _client(app) as client:
- await _configure(app, model)
- request_id = str(uuid4())
- record = app.state.evidence_store.put(
- EvidenceInput(
- source_id=SOURCE_ID,
- conversation_id=f"note:{request_id}",
- message_id=request_id,
- role="user",
- written_by=WrittenBy.PERSON,
- created_at=datetime.now(UTC),
- verbatim=TEXT,
- origin_meta={
- EvidenceOrigin.MANUAL_IMPORT: OriginMetadata(
- raw_meta={
- "input_mode": "direct_memory_note",
- "recall_scope": "all-apps",
- }
- )
- },
- )
- ).record
- database = app.state.evidence_store.database_path
- with closing(sqlite3.connect(database)) as db, db:
- db.execute(
- "INSERT INTO source_grants VALUES ('local',?,?,'default',?,NULL)",
- (SOURCE_ID, str(database.parent), datetime.now(UTC).isoformat()),
- )
- await confirm_model_content(
- client,
- source_ids=(SOURCE_ID,),
- evidence_ids=(record.evidence_id,),
- future_additions=False,
- )
- processor = app.state.memory_processing_supervisor
-
- @asynccontextmanager
- async def isolated(lease, _secret, _data):
- lease.recheck()
- yield Bridge()
-
- processor.permission_bridge_factory = isolated
- app.state.memory_write_drain.stop()
- await app.state.memory_write_drain_task
- started = await client.post(f"/api/v1/memory-notes/{record.evidence_id}/process")
- assert started.status_code == 200, started.text
- assert await app.state.memory_write_drain.run_one()
- deadline = asyncio.get_running_loop().time() + 5
- while True:
- await asyncio.gather(*tuple(processor._workers.values()))
- receipt = (await client.get(f"/api/v1/memory-notes/{record.evidence_id}")).json()
- if receipt["state"] != "running" or asyncio.get_running_loop().time() >= deadline:
- break
- await asyncio.sleep(0.01)
- assert calls == [[TEXT]]
- assert receipt["state"] == "ready" and receipt["memory_ids"] == ["legacy-note-memory"]
- memory = app.state.memory_governance_store.get("legacy-note-memory", user_id="local")
- assert memory.scopes == ("all-apps",) and memory.content == TEXT
- assert record.evidence_id in memory.evidence_ids
-
- asyncio.run(scenario())
-
-
-def test_model_outage_keeps_the_original_words_and_a_retry_writes_them(tmp_path, hy_calls):
- async def scenario():
- app, model = _application(tmp_path), ScriptedModel()
- async with app.router.lifespan_context(app), _client(app) as client:
- await _configure(app, model)
- model.embeddings_down = True
- failed = await _remember(app, client, TEXT)
- assert failed["state"] in {"waiting_for_model", "failed"}, failed
- assert failed["memory_ids"] == []
- assert app.state.evidence_store.get(failed["evidence_id"]).verbatim == TEXT
- assert app.state.memory_governance_store.list(user_id="local") == []
-
- model.embeddings_down = False
- retried = await client.post(
- "/api/v1/memory-processing/retry-unknown", json={"confirm": True}
- )
- assert retried.status_code == 200, retried.text
- await _wait_for_note_job(app, failed["evidence_id"])
- receipt = (await client.get(f"/api/v1/memory-notes/{failed['evidence_id']}")).json()
- assert receipt["state"] == "ready", receipt
- assert hy_calls[-1][0] == "add_extracted" and hy_calls[-1][1]["contents"] == [TEXT]
- memory = app.state.memory_governance_store.get(
- receipt["memory_ids"][0], user_id="local"
- )
- assert memory.content == TEXT and failed["evidence_id"] in memory.evidence_ids
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize(
- ("raw_meta", "imported"),
- [
- ({"input_mode": "direct_memory_note", "recall_scope": "all-apps"}, True),
- ({"matter_id": "matter-1"}, False),
- ],
- ids=["memory-note", "matter-note"],
-)
-def test_only_a_note_saved_as_memory_skips_extraction(tmp_path, raw_meta, imported):
- """Matter notes share the manual source but are context for a matter, not a memory."""
- stores = _setup(tmp_path)
- evidence, _profiles, tasks, governance, _secrets, profile = stores
- record = evidence.put(
- EvidenceInput(
- source_id=SOURCE_ID,
- conversation_id=f"note:{uuid4()}",
- message_id=str(uuid4()),
- role="user",
- written_by=WrittenBy.PERSON,
- created_at=datetime.now(UTC),
- verbatim=TEXT,
- origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata(raw_meta=raw_meta)},
- )
- ).record
- bridge = FakeBridge()
-
- asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, record).task_id))
-
- assert len(bridge.requests) == 1
- assert bridge.extracted_contents == ([(TEXT,)] if imported else [])
- if imported:
- (memory,) = governance.list(user_id="local")
- assert memory.content == TEXT and record.evidence_id in memory.evidence_ids
diff --git a/memhub/apps/core/tests/test_memory_notes.py b/memhub/apps/core/tests/test_memory_notes.py
index 458ff16..dbe0c44 100644
--- a/memhub/apps/core/tests/test_memory_notes.py
+++ b/memhub/apps/core/tests/test_memory_notes.py
@@ -1,6 +1,5 @@
import asyncio
import sqlite3
-from contextlib import asynccontextmanager
from uuid import uuid4
import pytest
@@ -9,9 +8,7 @@ from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
-from one_memory.hy_memory_bridge import HyMemoryBridgeError, HyMemoryIngestResult, HyMemoryOutput
from one_memory.imports.models import WrittenBy
-from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ProviderKind
from one_memory.secrets import InMemorySecretStore
@@ -37,30 +34,6 @@ def client_for(app):
)
-async def wait_for_note_job(app, evidence_id, *, timeout=10.0):
- """Wait for the durable note job to leave pending/running.
-
- The note now reaches HY through the shared durable queue
- (`entry:`), so the drain - not the supervisor's in-memory
- workers - is what completes it. Waiting on `processor._workers` directly
- races that drain and can observe zero requests.
- """
- import time as _time
-
- deadline = _time.monotonic() + timeout
- key = f"entry:{evidence_id}"
- while _time.monotonic() < deadline:
- with sqlite3.connect(app.state.evidence_store.database_path) as db:
- row = db.execute(
- "SELECT state FROM memory_write_jobs WHERE idempotency_key=?",
- (key,),
- ).fetchone()
- if row is not None and row[0] not in {"pending", "running"}:
- return row[0]
- await asyncio.sleep(0.05)
- raise AssertionError(f"note job for {evidence_id} never settled")
-
-
def test_empty_library_can_save_a_note_without_a_matter_or_configured_model(tmp_path):
async def scenario():
app = application(tmp_path)
@@ -153,12 +126,6 @@ def test_saved_note_evidence_returns_raw_and_consent_reference_from_same_record(
assert value["evidence_id"] == saved["evidence_id"] and value["revision"] == 1
assert value["role"] == "user" and value["written_by"] == "person"
assert value["available"] is True and value["source_id"] == "one-memory-manual"
- from one_memory.model_content_permissions import model_content_evidence_snapshot
-
- record = app.state.evidence_store.get(saved["evidence_id"])
- reference = model_content_evidence_snapshot(record).model_dump(mode="json")
- assert all(value[key] == expected for key, expected in reference.items())
- assert app.state.model_content_permissions.receipt(user_id="local") is None
assert app.state.memory_task_store.list() == []
assert (await client.get("/api/v1/memory-notes/missing/evidence")).status_code == 404
app.state.source_grant_store.revoke(user_id="local", source_id="one-memory-manual")
@@ -210,128 +177,3 @@ def test_revoked_note_source_is_not_silently_reauthorized(tmp_path):
assert (await client.get("/api/v1/memory-notes")).json() == []
asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("fail", [False, True])
-def test_note_reuses_hy_queue_and_never_reports_saved_raw_text_as_extracted(tmp_path, fail):
- async def scenario():
- app = application(tmp_path)
- requests = []
-
- class Bridge:
- async def ingest(self, request):
- pytest.fail("A note the person asked to keep must be imported, not re-extracted")
-
- async def ingest_extracted(self, request, contents):
- assert list(contents) == [turn.content for turn in request.turns]
- requests.append(request)
- if fail:
- raise HyMemoryBridgeError("hy_memory_unavailable")
- return HyMemoryIngestResult(
- success=True,
- memories=(
- HyMemoryOutput(
- memory_id="note-memory",
- content="用户不在周五安排项目周会。",
- owner="user",
- layer="L2_FACT",
- ),
- ),
- )
-
- async def profile_memories(self, **kwargs):
- return ()
-
- async def close(self):
- pass
-
- async with app.router.lifespan_context(app), client_for(app) as client:
- profiles, secrets = app.state.model_profile_store, app.state.secret_store
- profile = profiles.create(
- ModelProfileInput(name="Test", provider_kind=ProviderKind.OLLAMA, model="fixture"),
- secrets,
- )
- profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True, model_available=True, embedding_available=True
- ),
- secrets=secrets,
- )
- profiles.activate(profile.profile_id, secrets)
- processor = app.state.memory_processing_supervisor
-
- @asynccontextmanager
- async def isolated_bridge(lease, _secret, _data):
- lease.recheck()
- yield Bridge()
-
- processor.permission_bridge_factory = isolated_bridge
- response = await client.post(
- "/api/v1/memory-notes",
- json={
- "request_id": str(uuid4()),
- "text": "请记住:项目周会不排在周五。",
- "scope": "all-apps",
- },
- )
- assert response.status_code == 201
- saved = response.json()
- assert saved["state"] == "waiting_for_permission"
- assert requests == []
- # Reproduce E5: the source is revoked while this unsent durable
- # note is parked, so the queue records a terminal cancellation.
- # Explicit processing after a fresh grant must safely revive it.
- with sqlite3.connect(app.state.evidence_store.database_path) as db:
- db.execute(
- "UPDATE memory_write_jobs SET state='cancelled',"
- "last_error='model_content_permission_revoked',hy_invoked=0 "
- "WHERE idempotency_key=?",
- (f"entry:{saved['evidence_id']}",),
- )
- preview = await client.post(
- "/api/v1/model-content-permission/preview",
- json={
- "profile_id": profile.profile_id,
- "profile_revision": profile.revision,
- "source_ids": ["one-memory-manual"],
- "evidence_ids": [saved["evidence_id"]],
- },
- )
- assert preview.status_code == 200
- consent = await client.post(
- "/api/v1/model-content-permission",
- json={
- "snapshot": preview.json(),
- "expected_revision": 0,
- "user_confirmed": True,
- },
- )
- assert consent.status_code == 200
- started = await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process")
- assert started.status_code == 200
- # The note is completed by the shared durable queue now; wait on its
- # job rather than the supervisor's in-memory workers.
- await wait_for_note_job(app, saved["evidence_id"])
- receipt = (await client.get(f"/api/v1/memory-notes/{saved['evidence_id']}")).json()
- assert (
- len(requests) == 1
- and requests[0].turns[0].content == "请记住:项目周会不排在周五。"
- )
- assert app.state.evidence_store.count() == 1
- if fail:
- assert receipt["state"] == "waiting_for_model" and not receipt["memory_ids"]
- assert receipt["error"] == "hy_memory_unavailable"
- else:
- assert receipt["state"] == "ready" and receipt["memory_ids"] == ["note-memory"]
- memory = app.state.memory_governance_store.get("note-memory", user_id="local")
- assert memory.scopes == ("all-apps",)
- recall = await client.post("/api/v1/recall", json={"query": "周五 项目周会"})
- assert recall.status_code == 200
- assert any(hit["memory_id"] == "note-memory" for hit in recall.json()["hits"])
- await client.post(f"/api/v1/memory-notes/{saved['evidence_id']}/process", json={})
- await wait_for_note_job(app, saved["evidence_id"])
- assert len(requests) == 1
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_memory_processing.py b/memhub/apps/core/tests/test_memory_processing.py
index d2f395d..d124b4a 100644
--- a/memhub/apps/core/tests/test_memory_processing.py
+++ b/memhub/apps/core/tests/test_memory_processing.py
@@ -2925,7 +2925,7 @@ def test_frozen_harness_refuses_new_processing_before_a_task_or_model_call(tmp_p
supervisor._bridges[(profile.profile_id, profile.revision, None)] = bridge
assert supervisor._source_may_write([user]) is False
- with pytest.raises(HyMemoryBridgeError, match="model_content_source_disabled"):
+ with pytest.raises(HyMemoryBridgeError, match="hy_memory_source_disabled"):
asyncio.run(supervisor.process_records((user, reply)))
assert asyncio.run(supervisor.enqueue_conversation_off_thread(user)) == []
assert supervisor.prepare_backfill() == []
@@ -2999,7 +2999,7 @@ def test_revoked_grant_refuses_new_processing(tmp_path) -> None:
supervisor._bridges[(profile.profile_id, profile.revision, None)] = bridge
assert supervisor._source_may_write([user]) is False
- with pytest.raises(HyMemoryBridgeError, match="model_content_source_disabled"):
+ with pytest.raises(HyMemoryBridgeError, match="hy_memory_source_disabled"):
asyncio.run(supervisor.process_records((user, reply)))
assert asyncio.run(supervisor.enqueue_conversation_off_thread(user)) == []
assert supervisor.prepare_backfill() == []
diff --git a/memhub/apps/core/tests/test_memory_server_config_state.py b/memhub/apps/core/tests/test_memory_server_config_state.py
index 4592f22..cf72117 100644
--- a/memhub/apps/core/tests/test_memory_server_config_state.py
+++ b/memhub/apps/core/tests/test_memory_server_config_state.py
@@ -13,7 +13,6 @@ def test_sync_config_defaults_off_and_survives_restart(tmp_path):
saved = json.loads((tmp_path / "config.json").read_text())
assert saved == {
- "model_content_allowed": False,
"schema_version": 1,
"sources": {"cursor": {"sync_enabled": True}},
}
@@ -31,19 +30,27 @@ def test_sync_config_updates_one_source_without_losing_another(tmp_path):
assert reopened.sync_enabled("claude-code")
-def test_model_content_permission_is_one_global_final_state(tmp_path):
+def test_legacy_model_content_permission_is_ignored_on_upgrade(tmp_path):
+ (tmp_path / "config.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "model_content_allowed": False,
+ "sources": {"cursor": {"sync_enabled": True}},
+ }
+ )
+ )
+
store = ConfigStore(tmp_path)
- assert not store.model_content_allowed()
-
- store.set_model_content_allowed(True)
- store.set_sync("cursor", True)
- reopened = ConfigStore(tmp_path)
- assert reopened.model_content_allowed()
- assert reopened.sync_enabled("cursor")
-
- reopened.set_model_content_allowed(False)
- assert not ConfigStore(tmp_path).model_content_allowed()
- assert ConfigStore(tmp_path).sync_enabled("cursor")
+ assert store.sync_enabled("cursor")
+ store.set_sync("codex", True)
+ assert json.loads((tmp_path / "config.json").read_text()) == {
+ "schema_version": 1,
+ "sources": {
+ "codex": {"sync_enabled": True},
+ "cursor": {"sync_enabled": True},
+ },
+ }
def test_invalid_config_fails_loudly_instead_of_silently_disabling_sources(tmp_path):
diff --git a/memhub/apps/core/tests/test_memory_server_durable_queue.py b/memhub/apps/core/tests/test_memory_server_durable_queue.py
index 9b22470..c6a91c2 100644
--- a/memhub/apps/core/tests/test_memory_server_durable_queue.py
+++ b/memhub/apps/core/tests/test_memory_server_durable_queue.py
@@ -3267,103 +3267,6 @@ def test_idle_maintenance_database_failure_is_sampled_without_database_error(
assert events[-1]["error_type"] == "RuntimeError"
-@pytest.mark.slow
-@pytest.mark.integration
-@pytest.mark.serial
-def test_no_model_permission_parks_without_sending(tmp_path) -> None:
- """Model egress permission is separate from source read permission.
-
- With a permission guard installed but no grant, the durable job must park
- in `waiting_for_permission` and never reach HY: the Evidence is committed
- locally, but no private verbatim is sent to a model. It must not read as
- `failed` (a user switch is not a pipeline break), and a later grant must be
- able to re-admit it.
- """
- from datetime import UTC, datetime
-
- from one_memory.imports.models import (
- EvidenceInput,
- EvidenceOrigin,
- OriginMetadata,
- WrittenBy,
- )
- from one_memory.imports.storage import EvidenceStore
- from one_memory.memory_governance import MemoryGovernanceStore
- from one_memory.memory_processing import MemoryProcessingSupervisor
- from one_memory.memory_tasks import MemoryTaskStore
- from one_memory.memory_server.config_state import ConfigStore
- from one_memory.model_profiles import (
- ModelCapabilities,
- ModelProfileInput,
- ModelProfileStore,
- ProviderKind,
- )
- from one_memory.secrets import InMemorySecretStore
-
- database = tmp_path / "one-memory.sqlite3"
- secrets = InMemorySecretStore()
- store = EvidenceStore(database)
- record = store.put(
- EvidenceInput(
- source_id="cursor",
- conversation_id="c",
- message_id="m-private",
- role="user",
- written_by=WrittenBy.PERSON,
- verbatim="private verbatim that must not be sent",
- created_at=datetime.now(UTC),
- origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
- )
- ).record
-
- profiles = ModelProfileStore(database)
- profile = profiles.create(
- ModelProfileInput(
- name="Local",
- provider_kind=ProviderKind.OLLAMA,
- model="hy-bot-phi4",
- embedding_model="nomic-embed-text",
- ),
- secrets,
- )
- profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True,
- model_available=True,
- structured_output=True,
- embedding_available=True,
- ),
- secrets=secrets,
- )
- profiles.activate(profile.profile_id, secrets)
-
- config = ConfigStore(tmp_path)
- config.set_model_content_allowed(False)
- supervisor = MemoryProcessingSupervisor(
- tasks=MemoryTaskStore(database),
- profiles=profiles,
- secrets=secrets,
- evidence=store,
- governance=MemoryGovernanceStore(database),
- data_dir=tmp_path,
- model_content_config=config,
- )
-
- queue = DurableWriteQueue(database)
- submitted = queue.submit(_request(evidence=(record.evidence_id,)))
- before = queue.get(submitted.request_id)
- assert before.state == "pending"
- _claim_through_drain(queue, submitted.job_id)
-
- asyncio.run(execute_job(submitted.job_id, queue=queue, supervisor=supervisor, evidence=store))
-
- settled = queue.get(submitted.request_id)
- assert settled.state == "waiting_for_permission", settled.state
- assert "permission" in settled.detail.lower()
-
-
def test_an_unknown_is_retired_only_by_an_inquiry(tmp_path) -> None:
"""`unknown` must be resolvable, but never by a blind retry.
diff --git a/memhub/apps/core/tests/test_memory_server_migrations.py b/memhub/apps/core/tests/test_memory_server_migrations.py
index 17a6851..490feec 100644
--- a/memhub/apps/core/tests/test_memory_server_migrations.py
+++ b/memhub/apps/core/tests/test_memory_server_migrations.py
@@ -2103,7 +2103,47 @@ def test_migration_49_resets_only_cursor_history_when_conversation_time_is_missi
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
- assert result.applied == (49, 50)
+ assert result.applied == (49, 50, 51)
with main_database(path).read() as connection:
assert connection.execute("SELECT COUNT(*) FROM source_cursors").fetchone()[0] == 0
assert connection.execute("SELECT COUNT(*) FROM live_source_cursors").fetchone()[0] == 1
+
+
+def test_upgrade_drops_obsolete_hy_node_permissions_without_touching_memories(
+ tmp_path,
+) -> None:
+ import sqlite3
+
+ from one_memory.memory_server.migration_runner import SqliteMigrationStore
+
+ path = tmp_path / "existing.sqlite3"
+ asyncio.run(
+ migrate(
+ SqliteMigrationStore(path),
+ tuple(migration for migration in MIGRATIONS if migration.version <= 50),
+ )
+ )
+ with sqlite3.connect(path) as connection:
+ connection.execute(
+ "CREATE TABLE hy_node_permissions ("
+ "node_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, evidence_json TEXT NOT NULL)"
+ )
+ connection.execute(
+ "INSERT INTO hy_node_permissions(node_id,fingerprint,evidence_json) "
+ "VALUES ('legacy-node', ?, '[]')",
+ ("a" * 64,),
+ )
+
+ result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
+
+ assert result.applied == (51,)
+ with sqlite3.connect(path) as connection:
+ tables = {
+ row[0]
+ for row in connection.execute(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ )
+ }
+ assert "hy_node_permissions" not in tables
+ assert "governed_memories" in tables
+ assert "evidence" in tables
diff --git a/memhub/apps/core/tests/test_model_content_bulk.py b/memhub/apps/core/tests/test_model_content_bulk.py
deleted file mode 100644
index 01075b4..0000000
--- a/memhub/apps/core/tests/test_model_content_bulk.py
+++ /dev/null
@@ -1,303 +0,0 @@
-"""Bulk consent reads preserve exact identities and SQLite snapshot boundaries."""
-
-import asyncio
-import sqlite3
-
-import pytest
-from fastapi import FastAPI
-from httpx import ASGITransport, AsyncClient
-
-from one_memory.database_access import close_main_databases
-from one_memory.imports.models import EvidenceOrigin, OriginMetadata, WrittenBy
-from one_memory.model_content_permissions import (
- ModelContentPermissionError,
- ModelContentPermissionSnapshot,
- model_content_evidence_snapshot,
-)
-from one_memory.model_content_routes import model_content_router
-from tests.test_model_content_permissions import (
- SOURCE,
- USER,
- evidence_value,
-)
-from tests.test_model_content_permissions import (
- env as environment_fixture,
-)
-
-
-@pytest.fixture
-def env(tmp_path):
- return environment_fixture.__wrapped__(tmp_path)
-
-
-def many_records(environment, count):
- records = [environment.record]
- with environment.evidence._connect(writing=True) as connection:
- for index in range(1, count):
- value = evidence_value(str(index), text=f"Synthetic {index}: " + "x" * 128)
- if index % 2:
- value = value.model_copy(
- update={
- "role": "assistant",
- "written_by": WrittenBy.AGENT,
- "origin_meta": {
- EvidenceOrigin.FILE: OriginMetadata(raw_meta={"nested": [1, "text"]}),
- EvidenceOrigin.HOOK: OriginMetadata(source_version="fixture-v1"),
- },
- }
- )
- records.append(environment.evidence.put(value, connection=connection).record)
- return records
-
-
-def app_for(environment):
- app = FastAPI()
- app.state.model_profile_store = environment.profiles
- app.state.evidence_store = environment.evidence
- app.state.model_content_permissions = environment.permissions
- app.include_router(model_content_router([]))
- return app
-
-
-def preview(environment, **changes):
- async def request():
- async with AsyncClient(
- transport=ASGITransport(app=app_for(environment)), base_url="http://test"
- ) as client:
- return await client.post(
- "/api/v1/model-content-permission/preview",
- json={
- "profile_id": environment.profile.profile_id,
- "profile_revision": environment.profile.revision,
- "source_ids": [SOURCE],
- "all_imported_from_selected_sources": True,
- **changes,
- },
- )
-
- return asyncio.run(request())
-
-
-def observe_connections(monkeypatch):
- connects, statements = [], []
- original = sqlite3.connect
-
- def observed(*args, **kwargs):
- connection = original(*args, **kwargs)
- connects.append(connection)
- connection.set_trace_callback(lambda sql: statements.append(sql))
- return connection
-
- close_main_databases() # pooled connections would skip the patch
-
- monkeypatch.setattr(sqlite3, "connect", observed)
- return connects, statements
-
-
-def test_preview_uses_bounded_bulk_reads_without_per_id_connections(env, monkeypatch):
- records = many_records(env, 1000)
- expected = env.snapshot(records=tuple(records))
- connects, statements = observe_connections(monkeypatch)
- response = preview(env)
- assert response.status_code == 200
- actual = ModelContentPermissionSnapshot.model_validate(response.json())
- assert actual == expected
- assert len(connects) <= 2, "preview still opens one connection per Evidence ID"
- selects = [sql for sql in statements if sql.lstrip().upper().startswith("SELECT")]
- assert len(selects) <= 20, "preview still rereads each record separately"
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_hot_check_is_bulk_and_reuses_same_transaction_verified_refs(env, monkeypatch):
- records = many_records(env, 1000)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- connects, statements = observe_connections(monkeypatch)
- assert env.check(receipt, records=(records[0],)) == receipt
- assert len(connects) == 1
- selects = [sql for sql in statements if sql.lstrip().upper().startswith("SELECT")]
- assert len(selects) <= 20, (
- "checking one input still performs per-record SQL for the entire scope"
- )
-
-
-def test_all_source_preview_and_future_boundary_share_one_read_snapshot(env, monkeypatch):
- records = many_records(env, 4)
- expected = env.snapshot(records=tuple(records), future=True)
- source = env.permissions._source
- changed = False
-
- def concurrent_insert(connection, user_id, source_id):
- nonlocal changed
- result = source(connection, user_id, source_id)
- if not changed:
- changed = True
- env.evidence.put(evidence_value("arrived-during-preview"))
- return result
-
- monkeypatch.setattr(env.permissions, "_source", concurrent_insert)
- response = preview(env, include_future_additions=True)
- assert response.status_code == 200
- actual = ModelContentPermissionSnapshot.model_validate(response.json())
- assert changed and actual == expected, "preview mixed old IDs with a newer future watermark"
- receipt = env.permissions.grant(actual, expected_revision=0, user_confirmed=True)
- assert receipt.snapshot == expected
- added = env.evidence.put(evidence_value("arrived-during-preview")).record
- assert added.evidence_id not in {ref.evidence_id for ref in actual.evidence}
- assert env.check(receipt, records=(added,)) == receipt
-
-
-@pytest.mark.parametrize("mutation", ["body", "author", "origins", "delete"])
-def test_hot_check_still_rejects_changed_unrequested_scope_record(env, mutation):
- records = many_records(env, 4)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- other = records[-1]
- with sqlite3.connect(env.database) as db:
- sql = {
- "body": (
- "UPDATE evidence SET verbatim='changed without revision or hash' "
- "WHERE evidence_id=?"
- ),
- "author": "UPDATE evidence SET written_by='unknown' WHERE evidence_id=?",
- "origins": "UPDATE evidence_origins SET source_version='changed' WHERE evidence_id=?",
- "delete": "DELETE FROM evidence WHERE evidence_id=?",
- }[mutation]
- db.execute(sql, (other.evidence_id,))
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt, records=(records[0],))
-
-
-@pytest.mark.parametrize(
- "selection,code",
- [
- ("missing", "evidence_not_found"),
- ("other_user", "evidence_not_found"),
- ("other_source", "invalid_content_scope"),
- ("duplicate", "invalid_content_scope"),
- ("ambiguous", "ambiguous_content_scope"),
- ],
-)
-def test_bulk_preview_keeps_exact_selection_failures(env, selection, code):
- other_user = env.evidence.put(evidence_value("other-user", user_id="other")).record
- other_source = env.evidence.put(evidence_value("other-source", source_id="cursor")).record
- identifiers = {
- "missing": ["not-present"],
- "other_user": [other_user.evidence_id],
- "other_source": [other_source.evidence_id],
- "duplicate": [env.record.evidence_id, env.record.evidence_id],
- "ambiguous": [env.record.evidence_id],
- }[selection]
- response = preview(
- env, evidence_ids=identifiers, all_imported_from_selected_sources=selection == "ambiguous"
- )
- assert response.status_code == 409
- assert response.json()["detail"] == f"model_content_{code}"
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_bulk_snapshot_preserves_every_existing_hash_field(env):
- records = many_records(env, 8)
- expected = tuple(
- sorted(
- (model_content_evidence_snapshot(record) for record in records),
- key=lambda ref: ref.evidence_id,
- )
- )
- actual = ModelContentPermissionSnapshot.model_validate(preview(env).json())
- assert actual.evidence == expected
- assert not any(record.verbatim in actual.model_dump_json() for record in records)
-
-
-def test_identifier_selection_does_not_mix_a_newer_future_watermark(env, monkeypatch):
- records = many_records(env, 4)
- expected = env.snapshot(records=tuple(records), future=True)
- original_connect = sqlite3.connect
- inserted = False
-
- class ConcurrentInsert(sqlite3.Connection):
- def execute(self, sql, parameters=()):
- nonlocal inserted
- cursor = super().execute(sql, parameters)
- if sql.startswith("SELECT evidence_id FROM evidence WHERE user_id=") and not inserted:
- inserted = True
- env.evidence.put(evidence_value("after-identifier-selection"))
- return cursor
-
- def connect(*args, **kwargs):
- return original_connect(*args, **kwargs, factory=ConcurrentInsert)
-
- close_main_databases() # pooled connections would skip the patch
-
- monkeypatch.setattr(sqlite3, "connect", connect)
- response = preview(env, include_future_additions=True)
- assert inserted and response.status_code == 200
- snapshot = ModelContentPermissionSnapshot.model_validate(response.json())
- assert snapshot == expected
- receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- assert receipt.snapshot == expected
- added = env.evidence.put(evidence_value("after-identifier-selection")).record
- assert added.evidence_id not in {ref.evidence_id for ref in snapshot.evidence}
- assert env.check(receipt, records=(added,)) == receipt
-
-
-def test_bulk_batches_keep_one_snapshot_and_next_check_observes_an_edit(env, monkeypatch):
- records = many_records(env, 1001)
- snapshot = env.snapshot(records=tuple(records))
- receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- # Large grants now build a bounded scope seal. Invalidate it explicitly to
- # exercise the full-snapshot fallback and its single read transaction.
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE model_content_scope_seals SET valid=0 WHERE user_id=? AND revision=?",
- (receipt.user_id, receipt.revision),
- )
- ordered = sorted(records, key=lambda record: record.evidence_id)
- later = ordered[-1]
- original_batch = env.permissions._evidence_batch
- calls = []
-
- def edit_after_first_batch(connection, identifiers):
- yield from original_batch(connection, identifiers)
- calls.append(len(identifiers))
- if len(calls) == 1:
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE evidence SET verbatim='edited but same stored hash' "
- "WHERE evidence_id=?",
- (later.evidence_id,),
- )
-
- with monkeypatch.context() as scoped:
- scoped.setattr(env.permissions, "_evidence_batch", edit_after_first_batch)
- assert env.check(receipt, records=(ordered[0],)) == receipt
- assert calls == [500, 500, 1]
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt, records=(ordered[0],))
-
-
-def test_same_transaction_reuse_does_not_trust_forged_request_metadata(env):
- receipt = env.grant()
- forged = env.record.model_copy(update={"written_by": WrittenBy.AGENT})
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt, records=(forged,))
- with pytest.raises(ModelContentPermissionError, match="invalid_content_scope"):
- env.check(receipt, records=(env.record, env.record))
-
-
-@pytest.mark.parametrize(
- "column,value",
- [
- ("raw_meta_json", "not-json"),
- ("origin", "unknown-origin"),
- ("source_version", "x" * 129),
- ],
-)
-def test_bulk_corrupt_origins_fail_closed(env, column, value):
- receipt = env.grant()
- with sqlite3.connect(env.database) as db:
- db.execute(f"UPDATE evidence_origins SET {column}=?", (value,))
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
diff --git a/memhub/apps/core/tests/test_model_content_parse_cost.py b/memhub/apps/core/tests/test_model_content_parse_cost.py
deleted file mode 100644
index 6812617..0000000
--- a/memhub/apps/core/tests/test_model_content_parse_cost.py
+++ /dev/null
@@ -1,326 +0,0 @@
-"""Memoize parsing only; every permission decision still reads current SQLite state."""
-
-import json
-import sqlite3
-from concurrent.futures import ThreadPoolExecutor
-from datetime import timedelta
-from sys import getsizeof
-from threading import Barrier
-from time import perf_counter
-from unittest.mock import patch
-
-import pytest
-from pydantic import ValidationError
-
-import one_memory.model_content_permissions as content
-from one_memory.database_access import close_main_databases
-from one_memory.model_content_permissions import (
- ModelContentPermissionError,
- ModelContentPermissionSnapshot,
- ModelContentPermissionStore,
-)
-from tests.test_model_content_permissions import SOURCE, USER, evidence_value
-from tests.test_model_content_permissions import env as env
-
-
-def repeated_receipt_measurement(store, *, repeats=6):
- original = ModelContentPermissionSnapshot.model_validate_json
- with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy:
- durations = []
- receipts = []
- for _ in range(repeats):
- started = perf_counter()
- receipts.append(store.receipt(user_id=USER))
- durations.append(perf_counter() - started)
- assert receipts[0] is not None
- assert all(receipt == receipts[0] for receipt in receipts)
- return {
- "references": len(receipts[0].snapshot.evidence),
- "reads": repeats,
- "snapshot_parses": spy.call_count,
- "seconds_per_read": durations,
- }
-
-
-def synthetic_large_receipt(env, count=12_223):
- """Only the parsing benchmark uses synthetic refs without corresponding bodies."""
- snapshot = env.snapshot()
- template = snapshot.evidence[0]
- snapshot = snapshot.model_copy(
- update={
- "evidence": tuple(
- template.model_copy(update={"evidence_id": f"synthetic-evidence-{index:06d}"})
- for index in range(count)
- ),
- }
- )
- receipt = content.ModelContentPermissionReceipt(
- user_id=USER,
- revision=1,
- revocation_revision=0,
- granted_at=template.imported_at,
- revoked_at=None,
- snapshot=snapshot,
- )
- with env.permissions._connect(writing=True) as db:
- env.permissions._append(db, receipt)
- return receipt
-
-
-def test_large_unchanged_receipt_is_parsed_once_not_once_per_poll(env, record_property):
- synthetic_large_receipt(env)
- measured = repeated_receipt_measurement(env.permissions)
- record_property("parse_benchmark", json.dumps(measured))
- print(json.dumps(measured), flush=True)
- assert measured["references"] == 12_223
- assert measured["snapshot_parses"] == 1, measured
-
-
-def test_concurrent_cold_reads_share_only_identical_parse_work(env):
- receipt = env.grant()
- barrier = Barrier(4)
- original = ModelContentPermissionSnapshot.model_validate_json
-
- def read(_):
- barrier.wait(timeout=5)
- return env.permissions.receipt(user_id=USER)
-
- with (
- patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy,
- ThreadPoolExecutor(max_workers=4) as pool,
- ):
- results = list(pool.map(read, range(4)))
- assert results == [receipt] * 4
- assert spy.call_count == 1
-
-
-@pytest.mark.parametrize("mutation", ["corrupt_json", "owner", "hash", "generation"])
-def test_same_revision_snapshot_tampering_cannot_reuse_a_cached_parse(env, mutation):
- receipt = env.grant()
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- raw = db.execute("SELECT snapshot_json FROM model_content_permissions").fetchone()[0]
- body = json.loads(raw)
- if mutation == "owner":
- body["user_id"] = "other-user"
- elif mutation == "hash":
- body["evidence"][0]["content_hash"] = "0" * 64
- elif mutation == "generation":
- body["generation"]["model"] = "tampered-model"
- changed = "{}" if mutation == "corrupt_json" else json.dumps(body)
- db.execute("UPDATE model_content_permissions SET snapshot_json=?", (changed,))
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
- # Returning to exactly the stored bytes is allowed to reuse parsing, not authority.
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_content_permissions SET snapshot_json=?", (raw,))
- assert env.check(receipt) == receipt
-
-
-@pytest.mark.parametrize(
- "field", ["user_id", "revision", "revocation_revision", "granted_at", "revoked_at"]
-)
-def test_cached_receipt_is_keyed_by_all_database_metadata(env, field):
- receipt = env.grant()
- assert env.permissions.receipt(user_id=USER) == receipt
- values = {
- "user_id": "other-user",
- "revision": 2,
- "revocation_revision": 1,
- "granted_at": (receipt.granted_at + timedelta(seconds=1)).isoformat(),
- "revoked_at": receipt.granted_at.isoformat(),
- }
- with sqlite3.connect(env.database) as db:
- db.execute(f"UPDATE model_content_permissions SET {field}=?", (values[field],))
- if field == "user_id":
- assert env.permissions.receipt(user_id=USER) is None
- with pytest.raises(ModelContentPermissionError, match="permission_snapshot_invalid"):
- env.permissions.receipt(user_id="other-user")
- return
- changed = env.permissions.receipt(user_id=USER)
- assert changed is not None and changed != receipt
- if field == "granted_at":
- assert changed.granted_at == receipt.granted_at + timedelta(seconds=1)
- else:
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
-
-
-def test_byte_different_json_is_reparsed_even_when_semantically_equal(env):
- receipt = env.grant()
- original = ModelContentPermissionSnapshot.model_validate_json
- with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy:
- assert env.permissions.receipt(user_id=USER) == receipt
- with sqlite3.connect(env.database) as db:
- raw = db.execute("SELECT snapshot_json FROM model_content_permissions").fetchone()[0]
- changed = json.dumps(json.loads(raw), indent=1)
- assert raw != changed
- db.execute("UPDATE model_content_permissions SET snapshot_json=?", (changed,))
- assert env.permissions.receipt(user_id=USER) == receipt
- assert env.permissions.receipt(user_id=USER) == receipt
- assert spy.call_count == 2
-
-
-@pytest.mark.parametrize(
- "mutation", ["revoke", "delete", "profile", "root", "source", "evidence", "origins"]
-)
-def test_cache_hit_still_rechecks_live_authority_and_all_evidence(env, mutation):
- other = env.evidence.put(evidence_value("not-the-requested-record")).record
- receipt = env.permissions.grant(
- env.snapshot(records=(env.record, other)), expected_revision=0, user_confirmed=True
- )
- assert env.check(receipt) == receipt
- if mutation == "revoke":
- env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True)
- elif mutation == "root":
- env.root.rename(env.root.with_name("old-projects"))
- env.root.mkdir()
- elif mutation == "source":
- env.sources.revoke(user_id=USER, source_id=SOURCE)
- else:
- with sqlite3.connect(env.database) as db:
- if mutation == "delete":
- db.execute("DELETE FROM model_content_permissions")
- elif mutation == "profile":
- db.execute("UPDATE model_profiles SET model='changed-without-revision'")
- elif mutation == "evidence":
- db.execute(
- "UPDATE evidence SET verbatim='changed without revision' WHERE evidence_id=?",
- (other.evidence_id,),
- )
- else:
- db.execute(
- "UPDATE evidence_origins SET source_version='changed' WHERE evidence_id=?",
- (other.evidence_id,),
- )
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
-
-
-def test_warm_parse_and_evidence_checks_share_the_current_transaction(env, monkeypatch):
- receipt = env.grant()
- assert env.check(receipt) == receipt
- validate = env.permissions._validate_snapshot_authority
- changed = False
-
- def edit_after_receipt_read(connection, snapshot):
- nonlocal changed
- if not changed:
- changed = True
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence SET verbatim='changed during validation'")
- return validate(connection, snapshot)
-
- with monkeypatch.context() as scoped:
- scoped.setattr(env.permissions, "_validate_snapshot_authority", edit_after_receipt_read)
- # The receipt SELECT already opened this transaction's SQLite snapshot.
- assert env.check(receipt) == receipt
- assert changed
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-def test_repeated_check_reuses_parse_but_reads_and_validates_each_transaction(env, monkeypatch):
- receipt = env.grant()
- original_connect = sqlite3.connect
- statements = []
- connections = []
-
- def connect(*args, **kwargs):
- db = original_connect(*args, **kwargs)
- connections.append(db)
- db.set_trace_callback(statements.append)
- return db
-
- close_main_databases() # pooled connections would skip the patch
-
- monkeypatch.setattr(sqlite3, "connect", connect)
- original = ModelContentPermissionSnapshot.model_validate_json
- with (
- patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy,
- patch.object(
- env.permissions,
- "_validate_snapshot_authority",
- wraps=env.permissions._validate_snapshot_authority,
- ) as validate,
- ):
- assert env.check(receipt) == receipt
- # A fresh connection per check, so each transaction is observed.
- close_main_databases()
- assert env.check(receipt) == receipt
- assert spy.call_count == 1
- assert validate.call_count == 2
- assert len(connections) == 2
- assert sum("FROM model_content_permissions WHERE user_id=" in sql for sql in statements) == 2
- assert all(
- call.args[0] is db for call, db in zip(validate.call_args_list, connections, strict=True)
- )
-
-
-def test_historical_cached_receipt_never_becomes_current_after_revoke(env):
- first = env.grant()
- assert env.check(first) == first
- revoked = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True)
- assert env.permissions.receipt(user_id=USER, revision=1) == first
- assert env.permissions.receipt(user_id=USER) == revoked
- with pytest.raises(ModelContentPermissionError, match="permission_revoked"):
- env.check(first)
-
-
-def test_cached_parsed_snapshot_is_deeply_immutable(env):
- env.grant()
- receipt = env.permissions.receipt(user_id=USER)
- for model, field, value in (
- (receipt, "revoked_at", receipt.granted_at),
- (receipt.snapshot, "evidence", ()),
- (receipt.snapshot.evidence[0], "revision", 9),
- (receipt.snapshot.sources[0], "revision", 9),
- (receipt.snapshot.profile, "model", "changed"),
- (receipt.snapshot.generation, "model", "changed"),
- ):
- with pytest.raises(ValidationError, match="frozen_instance"):
- setattr(model, field, value)
- assert env.permissions.receipt(user_id=USER) == receipt
-
-
-def test_parse_cache_is_entry_bounded_and_store_local(env, monkeypatch):
- monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_LIMIT", 2, raising=False)
- first = env.grant()
- second = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True)
- third = env.permissions.grant(env.snapshot(), expected_revision=2, user_confirmed=True)
- store = ModelContentPermissionStore(env.database)
- original = ModelContentPermissionSnapshot.model_validate_json
- with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy:
- for revision, expected in ((1, first), (2, second), (3, third), (1, first), (1, first)):
- assert store.receipt(user_id=USER, revision=revision) == expected
- assert spy.call_count == 4
- fresh = ModelContentPermissionStore(env.database)
- assert fresh.receipt(user_id=USER, revision=1) == first
- assert spy.call_count == 5
-
-
-def test_oversized_parse_result_is_not_retained(env, monkeypatch):
- monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_BYTES", 1, raising=False)
- env.grant()
- measured = repeated_receipt_measurement(env.permissions, repeats=2)
- assert measured["snapshot_parses"] == 2
-
-
-def test_parse_cache_total_bytes_are_bounded(env, monkeypatch):
- receipt = env.grant()
- with sqlite3.connect(env.database) as db:
- row = db.execute("SELECT * FROM model_content_permissions").fetchone()
- entry_size = getsizeof(row) + sum(getsizeof(value) for value in row)
- monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_BYTES", entry_size * 2 + 64)
- monkeypatch.setattr(content, "_RECEIPT_PARSE_CACHE_LIMIT", 8)
- store = ModelContentPermissionStore(env.database)
- original = ModelContentPermissionSnapshot.model_validate_json
- with patch.object(ModelContentPermissionSnapshot, "model_validate_json", wraps=original) as spy:
- for offset in (0, 1, 2, 0, 0):
- stamp = receipt.granted_at + timedelta(seconds=offset)
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE model_content_permissions SET granted_at=?", (stamp.isoformat(),)
- )
- assert store.receipt(user_id=USER).granted_at == stamp
- assert spy.call_count == 4
diff --git a/memhub/apps/core/tests/test_model_content_permissions.py b/memhub/apps/core/tests/test_model_content_permissions.py
deleted file mode 100644
index bf6c9fe..0000000
--- a/memhub/apps/core/tests/test_model_content_permissions.py
+++ /dev/null
@@ -1,791 +0,0 @@
-"""Content permission tests use only a temporary database and synthetic sources."""
-
-import sqlite3
-from concurrent.futures import ThreadPoolExecutor
-from dataclasses import dataclass
-from datetime import UTC, datetime
-from pathlib import Path
-from threading import Barrier
-
-import pytest
-from pydantic import ValidationError
-
-from one_memory.imports.grants import SourceGrantStore
-from one_memory.imports.models import (
- EvidenceInput,
- EvidenceOrigin,
- EvidenceRecord,
- OriginMetadata,
- WrittenBy,
-)
-from one_memory.imports.sources.registry import RootCandidateKind
-from one_memory.imports.storage import EvidenceStore
-from one_memory.memory_server.config_state import ConfigStore
-from one_memory.model_content_permissions import (
- ModelContentCostEstimate,
- ModelContentPermissionError,
- ModelContentPermissionReceipt,
- ModelContentPermissionSnapshot,
- ModelContentPermissionStore,
-)
-from one_memory.model_profiles import (
- ModelCapabilities,
- ModelProfileInput,
- ModelProfilePatch,
- ModelProfileSnapshot,
- ModelProfileStore,
- ProviderKind,
-)
-from one_memory.secrets import InMemorySecretStore
-
-USER = "local"
-SOURCE = "claude-code"
-MANUAL = "one-memory-manual"
-
-
-def evidence_value(
- message: str,
- *,
- source_id: str = SOURCE,
- user_id: str = USER,
- text: str | None = None,
-) -> EvidenceInput:
- return EvidenceInput(
- user_id=user_id,
- source_id=source_id,
- conversation_id="synthetic-conversation",
- message_id=message,
- role="user",
- written_by=WrittenBy.PERSON,
- verbatim=text or f"Synthetic content {message}",
- origin_meta={EvidenceOrigin.MANUAL_IMPORT: OriginMetadata()},
- )
-
-
-@dataclass
-class Environment:
- database: Path
- root: Path
- sources: SourceGrantStore
- evidence: EvidenceStore
- profiles: ModelProfileStore
- profile: ModelProfileSnapshot
- permissions: ModelContentPermissionStore
- record: EvidenceRecord
- secrets: InMemorySecretStore
-
- def snapshot(
- self,
- *,
- records: tuple[EvidenceRecord, ...] | None = None,
- source_ids: tuple[str, ...] = (SOURCE,),
- future: bool = False,
- ) -> ModelContentPermissionSnapshot:
- return self.permissions.snapshot(
- user_id=USER,
- source_ids=source_ids,
- profile=self.profile,
- evidence=records if records is not None else (self.record,),
- include_future_additions=future,
- )
-
- def grant(self, *, future: bool = False) -> ModelContentPermissionReceipt:
- return self.permissions.grant(
- self.snapshot(future=future), user_confirmed=True, expected_revision=0
- )
-
- def check(
- self,
- receipt: ModelContentPermissionReceipt,
- *,
- records: tuple[EvidenceRecord, ...] | None = None,
- profile: ModelProfileSnapshot | None = None,
- user_id: str = USER,
- ) -> ModelContentPermissionReceipt:
- return self.permissions.check(
- user_id=user_id,
- expected_revision=receipt.revision,
- expected_revocation_revision=receipt.revocation_revision,
- profile=profile or self.profile,
- evidence=records if records is not None else (self.record,),
- )
-
-
-@pytest.fixture
-def env(tmp_path: Path) -> Environment:
- database = tmp_path / "one-memory.sqlite3"
- home = tmp_path / "home"
- root = home / ".claude" / "projects"
- root.mkdir(parents=True)
- sources = SourceGrantStore(database, home=home)
- sources.grant(
- user_id=USER, source_id=SOURCE, root=root, candidate_kind=RootCandidateKind.DEFAULT
- )
- evidence = EvidenceStore(database)
- record = evidence.put(evidence_value("selected")).record
- profiles = ModelProfileStore(database)
- secrets = InMemorySecretStore()
- profile = profiles.create(
- ModelProfileInput(
- name="Synthetic local model",
- provider_kind=ProviderKind.OLLAMA,
- base_url="http://127.0.0.1:11434/",
- model="test-generation",
- embedding_provider_kind=ProviderKind.OLLAMA,
- embedding_base_url="http://127.0.0.1:11435/",
- embedding_model="test-embedding",
- ),
- secrets,
- )
- profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True, model_available=True, embedding_available=True
- ),
- secrets=secrets,
- )
- profiles.activate(profile.profile_id, secrets)
- return Environment(
- database,
- root,
- sources,
- evidence,
- profiles,
- profiles.snapshot(profile.profile_id, profile.revision),
- ModelContentPermissionStore(database),
- record,
- secrets,
- )
-
-
-def test_model_selection_and_source_grant_are_not_content_consent(env: Environment) -> None:
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_cost_disclosure_is_explicit_unknown_without_a_pinned_price_source(
- env: Environment,
-) -> None:
- snapshot = env.snapshot()
- assert snapshot.cost_estimate.status == "unknown"
- assert snapshot.cost_estimate.currency is None
- assert snapshot.cost_estimate.amount is None
- assert snapshot.cost_estimate.price_source is None
- with pytest.raises(ValidationError):
- ModelContentCostEstimate(status="estimated", currency="USD", amount=1.0)
- with pytest.raises(ValidationError):
- ModelContentCostEstimate(status="unknown", amount=0.0)
- estimate = ModelContentCostEstimate(
- status="estimated",
- currency="USD",
- amount=0.25,
- price_source="https://example.invalid/pinned-price",
- )
- assert estimate.amount == 0.25
- with pytest.raises(ModelContentPermissionError, match="permission_required"):
- env.permissions.check(
- user_id=USER,
- expected_revision=0,
- expected_revocation_revision=0,
- profile=env.profile,
- evidence=(env.record,),
- )
- snapshot = env.snapshot()
- for confirmation in (False, 1, "true", None):
- with pytest.raises(ModelContentPermissionError, match="confirmation_required"):
- env.permissions.grant(snapshot, user_confirmed=confirmation, expected_revision=0)
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_snapshot_is_immutable_precise_and_durable_in_the_same_database(env: Environment) -> None:
- receipt = env.grant()
- assert receipt.revision == 1 and receipt.revocation_revision == 0
- assert receipt.revoked_at is None
- assert env.check(receipt) == receipt
- scope = receipt.snapshot
- assert scope.profile == env.profile
- assert scope.generation.endpoint == "http://127.0.0.1:11434/v1"
- assert scope.embedding.endpoint == "http://127.0.0.1:11435/v1"
- assert scope.generation.model == "test-generation"
- assert scope.embedding.model == "test-embedding"
- source = env.sources.require_receipt(user_id=USER, source_id=SOURCE)
- assert scope.sources[0].granted_at == source.granted_at
- assert scope.sources[0].revision == source.revision
- assert scope.sources[0].root_identity == source.root_id
- assert scope.evidence[0].evidence_id == env.record.evidence_id
- assert scope.evidence[0].revision == env.record.revision
- assert scope.evidence[0].content_hash == env.record.content_hash
- assert scope.evidence[0].identity_hash
- assert env.record.verbatim not in receipt.model_dump_json()
- with pytest.raises(ValidationError):
- scope.evidence[0].revision = 999
- with pytest.raises(ValidationError):
- scope.sources[0].root_identity = "replacement"
- with pytest.raises(ValidationError):
- scope.profile.model = "replacement"
- env.permissions = ModelContentPermissionStore(env.database)
- assert env.permissions.receipt(user_id=USER) == receipt
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- assert db.execute("SELECT count(*) FROM model_content_permissions").fetchone()[0] == 1
- assert db.execute("SELECT count(*) FROM evidence").fetchone()[0] == 1
- assert db.execute("SELECT count(*) FROM source_grants").fetchone()[0] == 1
-
-
-def test_revoke_and_regrant_have_distinct_monotonic_revisions(env: Environment) -> None:
- first = env.grant()
- with pytest.raises(ModelContentPermissionError, match="confirmation_required"):
- env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=False)
- revoked = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True)
- assert revoked.revision == 2 and revoked.revocation_revision == 1
- assert revoked.revoked_at is not None
- assert revoked.snapshot == first.snapshot and first.revoked_at is None
- env.permissions = ModelContentPermissionStore(env.database)
- with pytest.raises(ModelContentPermissionError, match="permission_revoked"):
- env.check(revoked)
- with pytest.raises(ModelContentPermissionError):
- env.check(first)
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- env.permissions.grant(env.snapshot(), expected_revision=1, user_confirmed=True)
- renewed = env.permissions.grant(env.snapshot(), expected_revision=2, user_confirmed=True)
- assert renewed.revision == 3 and renewed.revocation_revision == 1
- assert env.check(renewed) == renewed
- assert env.permissions.receipt(user_id=USER, revision=1) == first
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- env.permissions.check(
- user_id=USER,
- expected_revision=3,
- expected_revocation_revision=0,
- profile=env.profile,
- evidence=(env.record,),
- )
-
-
-def test_permission_changes_are_compare_and_swap_and_do_not_expand_old_receipts(
- env: Environment,
-) -> None:
- first = env.grant()
- other = env.evidence.put(evidence_value("second-selected")).record
- changed = env.snapshot(records=(env.record, other))
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- env.permissions.grant(changed, expected_revision=0, user_confirmed=True)
- assert env.check(first) == first
- second = env.permissions.grant(changed, expected_revision=1, user_confirmed=True)
- assert env.check(second, records=(other,)) == second
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- env.check(first)
- assert len(first.snapshot.evidence) == 1
-
-
-def test_two_concurrent_confirmations_cannot_both_replace_the_same_revision(
- env: Environment,
-) -> None:
- snapshot = env.snapshot()
- barrier = Barrier(2)
-
- def confirm() -> str:
- barrier.wait(timeout=5)
- try:
- env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- return "granted"
- except ModelContentPermissionError as error:
- return error.code
-
- with ThreadPoolExecutor(max_workers=2) as pool:
- results = list(pool.map(lambda _: confirm(), range(2)))
- assert sorted(results) == ["granted", "permission_changed"]
-
-
-@pytest.mark.parametrize(
- "mutation",
- [
- "revoke",
- "regrant",
- "grant_revision",
- "grant_granted_at",
- "root_identity",
- "requested_path",
- "root_path",
- "candidate_kind",
- "zero_revision",
- "unknown_identity",
- "missing_grant",
- "replace_directory",
- ],
-)
-def test_source_authority_changes_invalidate_existing_consent(
- env: Environment,
- mutation: str,
-) -> None:
- receipt = env.grant()
- assert env.check(receipt) == receipt
- if mutation == "revoke":
- env.sources.revoke(user_id=USER, source_id=SOURCE)
- elif mutation == "regrant":
- env.sources.revoke(user_id=USER, source_id=SOURCE)
- env.sources.grant(
- user_id=USER,
- source_id=SOURCE,
- root=env.root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- elif mutation == "replace_directory":
- env.root.rename(env.root.with_name("original-projects"))
- env.root.mkdir()
- else:
- sql = {
- "grant_revision": "UPDATE source_grants SET revision=revision+1",
- "grant_granted_at": "UPDATE source_grants SET granted_at='2099-01-01'",
- "root_identity": "UPDATE source_grants SET root_identity='root:v2:replaced'",
- "requested_path": "UPDATE source_grants SET requested_path='/missing-root'",
- "root_path": "UPDATE source_grants SET root_path='/another-root'",
- "candidate_kind": "UPDATE source_grants SET candidate_kind='environment_override'",
- "zero_revision": "UPDATE source_grants SET revision=0",
- "unknown_identity": "UPDATE source_grants SET root_identity='root:v99:unknown'",
- "missing_grant": "DELETE FROM source_grants",
- }[mutation]
- with sqlite3.connect(env.database) as db:
- db.execute(sql)
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
- assert env.permissions.receipt(user_id=USER) == receipt
-
-
-def test_sync_permission_is_independent_and_does_not_grant_model_content(env: Environment) -> None:
- config = ConfigStore(env.database.parent)
- config.set_sync(SOURCE, True)
- assert env.permissions.receipt(user_id=USER) is None
- receipt = env.grant()
- config.set_sync(SOURCE, False)
- assert env.check(receipt) == receipt
-
-
-def test_missing_source_grant_cannot_be_confirmed(env: Environment) -> None:
- with sqlite3.connect(env.database) as db:
- db.execute("DELETE FROM source_grants")
- with pytest.raises(ModelContentPermissionError):
- env.snapshot()
- assert env.permissions.receipt(user_id=USER) is None
-
-
-@pytest.mark.parametrize("mutation", ["source", "profile", "evidence"])
-def test_confirmation_revalidates_the_displayed_snapshot(
- env: Environment,
- mutation: str,
-) -> None:
- snapshot = env.snapshot()
- if mutation == "source":
- env.sources.grant(
- user_id=USER,
- source_id=SOURCE,
- root=env.root,
- candidate_kind=RootCandidateKind.DEFAULT,
- )
- elif mutation == "profile":
- env.profiles.update(
- env.profile.profile_id,
- ModelProfilePatch(expected_revision=1, model="changed"),
- env.secrets,
- )
- else:
- env.evidence.put(evidence_value("selected", text="Changed after the consent preview"))
- with pytest.raises(ModelContentPermissionError):
- env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- assert env.permissions.receipt(user_id=USER) is None
-
-
-@pytest.mark.parametrize(
- "field,value",
- [
- ("base_url", "http://127.0.0.1:19000/v1"),
- ("model", "different-generation"),
- ("embedding_base_url", "http://127.0.0.1:19001/v1"),
- ("embedding_model", "different-embedding"),
- ("embedding_provider_kind", "local_onnx"),
- ("embedding_dimensions", 777),
- ("timeout_seconds", 120),
- ("revision", 2),
- ],
-)
-def test_changed_profile_is_rejected_even_when_caller_supplies_the_old_snapshot(
- env: Environment,
- field: str,
- value: object,
-) -> None:
- receipt = env.grant()
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- db.execute(
- f"UPDATE model_profiles SET {field}=? WHERE profile_id=?", # fixed test fields
- (value, env.profile.profile_id),
- )
- with pytest.raises(ModelContentPermissionError, match="profile_changed"):
- env.check(receipt)
-
-
-def test_other_profile_or_recipient_cannot_be_substituted_in_a_request(env: Environment) -> None:
- receipt = env.grant()
- for mutation in (
- {"profile_id": "not-the-approved-profile"},
- {"base_url": "https://different.example/v1"},
- {"embedding_base_url": "https://vectors.example/v1"},
- ):
- with pytest.raises(ModelContentPermissionError, match="profile_changed"):
- env.check(receipt, profile=env.profile.model_copy(update=mutation))
- tampered = receipt.snapshot.model_copy(
- update={
- "generation": receipt.snapshot.generation.model_copy(
- update={
- "endpoint": "https://different.example/v1",
- }
- ),
- }
- )
- with pytest.raises(ModelContentPermissionError):
- env.permissions.grant(tampered, expected_revision=1, user_confirmed=True)
- assert env.check(receipt) == receipt
-
-
-def test_switching_or_deleting_the_current_profile_invalidates_checks(env: Environment) -> None:
- receipt = env.grant()
- other = env.profiles.create(
- ModelProfileInput(name="Other", provider_kind=ProviderKind.OLLAMA, model="other"),
- env.secrets,
- )
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_profiles SET is_active=0")
- db.execute("UPDATE model_profiles SET is_active=1 WHERE profile_id=?", (other.profile_id,))
- with pytest.raises(ModelContentPermissionError, match="profile_changed"):
- env.check(receipt)
- with sqlite3.connect(env.database) as db:
- db.execute("DELETE FROM model_profiles WHERE profile_id=?", (env.profile.profile_id,))
- with pytest.raises(ModelContentPermissionError, match="profile_changed"):
- env.check(receipt)
-
-
-@pytest.mark.parametrize(
- "mutation",
- ["revision", "verbatim", "written_by", "workspace", "source", "user", "origins", "delete"],
-)
-def test_changed_evidence_never_passes_a_saved_exact_snapshot(
- env: Environment,
- mutation: str,
-) -> None:
- receipt = env.grant()
- assert env.check(receipt) == receipt
- sql = {
- "revision": "UPDATE evidence SET revision=revision+1",
- "verbatim": "UPDATE evidence SET verbatim='same revision but different body'",
- "written_by": "UPDATE evidence SET written_by='agent'",
- "workspace": "UPDATE evidence SET workspace='different-workspace'",
- "source": "UPDATE evidence SET source_id='cursor'",
- "user": "UPDATE evidence SET user_id='other-user'",
- "origins": 'UPDATE evidence_origins SET raw_meta_json=\'{"extra":"changed"}\'',
- "delete": "DELETE FROM evidence",
- }[mutation]
- with sqlite3.connect(env.database) as db:
- db.execute(sql)
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
- current = env.evidence.get(env.record.evidence_id)
- if current is not None:
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(current,))
-
-
-def test_missing_cross_user_and_unselected_evidence_scopes_are_denied(env: Environment) -> None:
- receipt = env.grant()
- other = env.evidence.put(evidence_value("unselected")).record
- other_source = env.evidence.put(evidence_value("other-source", source_id="cursor")).record
- other_user = env.evidence.put(evidence_value("other-user", user_id="someone-else")).record
- for records in ((), (other,), (other_source,), (other_user,), (env.record, other)):
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=records)
- with pytest.raises(ModelContentPermissionError, match="permission_required"):
- env.check(receipt, user_id="someone-else")
- with pytest.raises(ModelContentPermissionError):
- env.snapshot(records=(other_user,))
- with pytest.raises(ModelContentPermissionError):
- env.snapshot(source_ids=())
- with pytest.raises(ModelContentPermissionError):
- env.snapshot(records=())
-
-
-def test_exact_mode_does_not_silently_authorize_later_imports(env: Environment) -> None:
- receipt = env.grant()
- later = env.evidence.put(evidence_value("after-confirmation")).record
- assert env.check(receipt) == receipt
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt, records=(later,))
-
-
-def test_future_mode_only_authorizes_explicit_sources_and_truly_new_additions(
- env: Environment,
-) -> None:
- old = env.evidence.put(evidence_value("older-unselected")).record
- receipt = env.grant(future=True)
- assert receipt.snapshot.include_future_additions is True
- assert receipt.snapshot.future_after_sequence is not None
- later = env.evidence.put(evidence_value("new-addition")).record
- assert env.check(receipt, records=(later,)) == receipt
- env.permissions = ModelContentPermissionStore(env.database)
- assert env.check(receipt, records=(env.record, later)) == receipt
- outside = env.evidence.put(evidence_value("outside", source_id="cursor")).record
- for record in (old, outside):
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt, records=(record,))
- edited = env.evidence.put(evidence_value("new-addition", text="Edited, not added")).record
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(later,))
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(edited,))
-
-
-def test_future_only_consent_never_backfills_preexisting_evidence(env: Environment) -> None:
- future = env.snapshot(records=(), future=True)
- receipt = env.permissions.grant(future, user_confirmed=True, expected_revision=0)
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt)
- later = env.evidence.put(evidence_value("first-future")).record
- assert env.check(receipt, records=(later,)) == receipt
-
-
-def test_future_sequence_is_not_based_on_source_timestamps_or_reused_rowids(
- env: Environment,
-) -> None:
- before = env.evidence.put(evidence_value("before")).record
- receipt = env.grant(future=True)
- with sqlite3.connect(env.database) as db:
- db.execute("PRAGMA foreign_keys=ON")
- db.execute("DELETE FROM evidence WHERE evidence_id=?", (before.evidence_id,))
- after = env.evidence.put(evidence_value("after")).record
- assert env.check(receipt, records=(after,)) == receipt
- reinserted = env.evidence.put(evidence_value("before")).record
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt, records=(reinserted,))
-
-
-@pytest.mark.parametrize(
- "mutation",
- ["verbatim", "workspace", "origins", "new_origin", "deleted_origin"],
-)
-def test_future_additions_reject_mutations_even_without_a_revision_bump(
- env: Environment,
- mutation: str,
-) -> None:
- receipt = env.grant(future=True)
- later = env.evidence.put(evidence_value("later")).record
- assert env.check(receipt, records=(later,)) == receipt
- with sqlite3.connect(env.database) as db:
- if mutation == "verbatim":
- db.execute(
- "UPDATE evidence SET verbatim='changed' WHERE evidence_id=?", (later.evidence_id,)
- )
- elif mutation == "workspace":
- db.execute(
- "UPDATE evidence SET workspace='changed' WHERE evidence_id=?", (later.evidence_id,)
- )
- elif mutation == "origins":
- db.execute(
- "UPDATE evidence_origins SET raw_meta_json='{}', source_version='new' "
- "WHERE evidence_id=?",
- (later.evidence_id,),
- )
- elif mutation == "deleted_origin":
- db.execute("DELETE FROM evidence_origins WHERE evidence_id=?", (later.evidence_id,))
- else:
- db.execute(
- "INSERT INTO evidence_origins VALUES (?,'hook','{}',NULL,?)",
- (later.evidence_id, datetime.now(UTC).isoformat()),
- )
- current = env.evidence.get(later.evidence_id)
- assert current is not None
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(current,))
-
-
-def test_identical_reimport_does_not_consume_or_expand_future_consent(env: Environment) -> None:
- receipt = env.grant(future=True)
- later = env.evidence.put(evidence_value("later")).record
- assert env.check(receipt, records=(later,)) == receipt
- duplicate = env.evidence.put(evidence_value("later")).record
- assert env.check(receipt, records=(duplicate,)) == receipt
-
-
-def test_future_only_consent_does_not_reclassify_deleted_legacy_evidence(
- env: Environment,
-) -> None:
- receipt = env.permissions.grant(
- env.snapshot(records=(), future=True),
- expected_revision=0,
- user_confirmed=True,
- )
- with sqlite3.connect(env.database) as db:
- db.execute("PRAGMA foreign_keys=ON")
- db.execute("DELETE FROM evidence WHERE evidence_id=?", (env.record.evidence_id,))
- recreated = env.evidence.put(evidence_value("selected")).record
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt, records=(recreated,))
-
-
-@pytest.mark.parametrize(
- "trigger", ["model_content_track_evidence_addition", "model_content_invalidate_evidence_update"]
-)
-def test_future_tracking_that_cannot_prove_additions_is_denied(
- env: Environment,
- trigger: str,
-) -> None:
- receipt = env.grant(future=True)
- later = env.evidence.put(evidence_value("later")).record
- assert env.check(receipt, records=(later,)) == receipt
- with sqlite3.connect(env.database) as db:
- db.execute(f"DROP TRIGGER {trigger}") # fixed test identifiers
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(later,))
-
-
-def test_unknown_saved_policy_schema_is_rejected(env: Environment) -> None:
- receipt = env.grant()
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE model_content_permissions SET snapshot_json="
- "json_set(snapshot_json, '$.schema_version', 999)"
- )
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
-
-
-def test_profile_version_row_cannot_diverge_from_the_current_profile(env: Environment) -> None:
- receipt = env.grant()
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_profile_versions SET embedding_base_url='http://127.0.0.1:19999'")
- with pytest.raises(ModelContentPermissionError, match="profile_changed"):
- env.check(receipt)
-
-
-def test_all_selected_sources_and_evidence_are_bound_even_for_a_subset_request(
- env: Environment,
-) -> None:
- second_root = env.root.parent / "cursor"
- second_root.mkdir()
- env.sources.grant(
- user_id=USER, source_id="cursor", root=second_root, candidate_kind=RootCandidateKind.DEFAULT
- )
- other = env.evidence.put(evidence_value("cursor-turn", source_id="cursor")).record
- snapshot = env.snapshot(source_ids=("cursor", SOURCE), records=(env.record, other))
- receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- assert env.check(receipt) == receipt
- env.sources.revoke(user_id=USER, source_id="cursor")
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
-
-
-def test_permission_rescoping_replaces_instead_of_unions_previous_sources(env: Environment) -> None:
- first = env.grant()
- second_root = env.root.parent / "cursor"
- second_root.mkdir()
- env.sources.grant(
- user_id=USER, source_id="cursor", root=second_root, candidate_kind=RootCandidateKind.DEFAULT
- )
- other = env.evidence.put(evidence_value("cursor-turn", source_id="cursor")).record
- changed = env.snapshot(source_ids=("cursor",), records=(other,))
- receipt = env.permissions.grant(changed, expected_revision=first.revision, user_confirmed=True)
- assert env.check(receipt, records=(other,)) == receipt
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
-
-
-def test_forged_future_watermark_and_edited_preview_are_denied(env: Environment) -> None:
- env.evidence.put(evidence_value("not-selected-before-preview"))
- snapshot = env.snapshot(future=True)
- forged = snapshot.model_copy(update={"future_after_sequence": 0})
- with pytest.raises(ModelContentPermissionError):
- env.permissions.grant(forged, expected_revision=0, user_confirmed=True)
- env.evidence.put(evidence_value("selected", text="Changed since preview"))
- env.evidence.put(evidence_value("arrived-after-preview"))
- with pytest.raises(ModelContentPermissionError):
- env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_manual_source_uses_exact_evidence_without_filesystem_authority(env: Environment) -> None:
- now = datetime.now(UTC).isoformat()
- with sqlite3.connect(env.database) as db:
- db.execute(
- "INSERT INTO source_grants VALUES (?,?,?,'default',?,NULL)",
- (USER, MANUAL, "/deliberately-not-a-filesystem-source", now),
- )
- manual = env.evidence.put(evidence_value("manual-note", source_id=MANUAL)).record
- snapshot = env.snapshot(records=(manual,), source_ids=(MANUAL,))
- assert snapshot.sources[0].authority == "manual"
- assert snapshot.sources[0].root_identity is None and snapshot.sources[0].revision == 0
- receipt = env.permissions.grant(snapshot, user_confirmed=True, expected_revision=0)
- assert env.check(receipt, records=(manual,)) == receipt
- with pytest.raises(ModelContentPermissionError):
- env.snapshot(records=(manual,), source_ids=(MANUAL,), future=True)
- env.sources.revoke(user_id=USER, source_id=MANUAL)
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(manual,))
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE source_grants SET revoked_at=NULL, granted_at=? WHERE source_id=?",
- (datetime.now(UTC).isoformat(), MANUAL),
- )
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt, records=(manual,))
-
-
-def test_local_embedding_recipient_is_explicit_and_still_needs_consent(env: Environment) -> None:
- updated = env.profiles.update(
- env.profile.profile_id,
- ModelProfilePatch(expected_revision=1, embedding_provider_kind=ProviderKind.LOCAL_ONNX),
- env.secrets,
- )
- env.profile = env.profiles.snapshot(updated.profile_id, updated.revision)
- snapshot = env.snapshot()
- assert snapshot.embedding.provider_kind is ProviderKind.LOCAL_ONNX
- assert snapshot.embedding.endpoint is None
- assert env.permissions.receipt(user_id=USER) is None
- receipt = env.permissions.grant(snapshot, user_confirmed=True, expected_revision=0)
- assert env.check(receipt) == receipt
-
-
-@pytest.mark.parametrize(
- "endpoint",
- [
- "https://user:pass@example.test/v1",
- "https://example.test/v1?token=example",
- "https://example.test/v1#fragment",
- "not-an-endpoint",
- ],
-)
-def test_ambiguous_or_credential_bearing_recipients_fail_closed(
- env: Environment,
- endpoint: str,
-) -> None:
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_profiles SET base_url=?", (endpoint,))
- profile = env.profile.model_copy(update={"base_url": endpoint})
- with pytest.raises(ModelContentPermissionError):
- env.permissions.snapshot(
- user_id=USER,
- source_ids=(SOURCE,),
- profile=profile,
- evidence=(env.record,),
- )
-
-
-def test_no_dependencies_or_corrupt_receipt_never_produce_authority(tmp_path: Path) -> None:
- store = ModelContentPermissionStore(tmp_path / "empty.sqlite3")
- assert store.receipt(user_id=USER) is None
- with pytest.raises(ModelContentPermissionError, match="permission_required"):
- store.revoke(user_id=USER, expected_revision=0, user_confirmed=True)
-
-
-def test_corrupt_or_unknown_snapshot_versions_fail_closed(env: Environment) -> None:
- receipt = env.grant()
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_content_permissions SET snapshot_json='{}'")
- with pytest.raises(ModelContentPermissionError):
- env.check(receipt)
diff --git a/memhub/apps/core/tests/test_model_content_preview_races.py b/memhub/apps/core/tests/test_model_content_preview_races.py
deleted file mode 100644
index 5868d16..0000000
--- a/memhub/apps/core/tests/test_model_content_preview_races.py
+++ /dev/null
@@ -1,187 +0,0 @@
-"""Confirmation must tolerate append-only import without broadening consent."""
-
-from concurrent.futures import ThreadPoolExecutor
-from threading import Event
-
-import pytest
-
-import one_memory.model_content_permissions as content
-from one_memory.model_content_permissions import ModelContentPermissionError
-from tests.test_model_content_permissions import SOURCE, USER, Environment, evidence_value
-from tests.test_model_content_permissions import env as env
-
-
-def test_confirmation_validation_does_not_hold_the_only_writer(
- env: Environment, monkeypatch: pytest.MonkeyPatch
-) -> None:
- snapshot = env.snapshot(future=True)
- validating = Event()
- release = Event()
- original = env.permissions._validate_snapshot
-
- def paused_validation(connection, value):
- result = original(connection, value)
- validating.set()
- assert release.wait(2)
- return result
-
- monkeypatch.setattr(env.permissions, "_validate_snapshot", paused_validation)
- with ThreadPoolExecutor(2) as workers:
- confirmation = workers.submit(
- env.permissions.grant,
- snapshot,
- user_confirmed=True,
- expected_revision=0,
- )
- assert validating.wait(1)
- imported = workers.submit(env.evidence.put, evidence_value("during-long-validation"))
- try:
- assert imported.result(timeout=0.5).record.message_id == "during-long-validation"
- finally:
- release.set()
- assert confirmation.result(timeout=2).snapshot == snapshot
-
-
-def test_confirmation_rejects_evidence_changed_after_read_validation(
- env: Environment, monkeypatch: pytest.MonkeyPatch
-) -> None:
- snapshot = env.snapshot()
- validated = Event()
- release = Event()
- original = env.permissions._validate_snapshot
-
- def paused_validation(connection, value):
- result = original(connection, value)
- validated.set()
- assert release.wait(2)
- return result
-
- monkeypatch.setattr(env.permissions, "_validate_snapshot", paused_validation)
- with ThreadPoolExecutor(1) as worker:
- confirmation = worker.submit(
- env.permissions.grant,
- snapshot,
- user_confirmed=True,
- expected_revision=0,
- )
- assert validated.wait(1)
- env.evidence.put(evidence_value("selected", text="changed after validation"))
- release.set()
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- confirmation.result(timeout=2)
- assert env.permissions.receipt(user_id=USER) is None
-
-
-@pytest.mark.parametrize("via_ids", [False, True])
-@pytest.mark.parametrize("source_id", [SOURCE, "cursor"])
-def test_issued_future_preview_survives_new_originals(
- env: Environment, source_id: str, via_ids: bool
-) -> None:
- snapshot = (
- env.permissions.snapshot_from_ids(
- user_id=USER,
- source_ids=(SOURCE,),
- profile=env.profile,
- all_imported_from_selected_sources=True,
- include_future_additions=True,
- )
- if via_ids
- else env.snapshot(future=True)
- )
- added = env.evidence.put(evidence_value("during-confirmation", source_id=source_id)).record
- receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- assert receipt.snapshot == snapshot
- assert env.check(receipt) == receipt
- if source_id == SOURCE:
- assert env.check(receipt, records=(added,)) == receipt
- else:
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt, records=(added,))
-
-
-def test_a_preview_does_not_authorize_omitted_preexisting_originals(env: Environment) -> None:
- omitted = env.evidence.put(evidence_value("omitted-old")).record
- snapshot = env.snapshot(future=True)
- added = env.evidence.put(evidence_value("new-during-confirmation")).record
- receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- assert env.check(receipt, records=(added,)) == receipt
- with pytest.raises(ModelContentPermissionError, match="evidence_not_permitted"):
- env.check(receipt, records=(omitted,))
-
-
-def test_forged_watermark_cannot_reuse_an_issued_preview(env: Environment) -> None:
- env.evidence.put(evidence_value("omitted-old"))
- snapshot = env.snapshot(future=True)
- env.evidence.put(evidence_value("new-during-confirmation"))
- forged = snapshot.model_copy(update={"future_after_sequence": 0})
- with pytest.raises(ModelContentPermissionError, match="content_scope_changed"):
- env.permissions.grant(forged, expected_revision=0, user_confirmed=True)
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_a_different_exact_selection_cannot_reuse_preview_issuance(env: Environment) -> None:
- old = env.evidence.put(evidence_value("second-selected")).record
- snapshot = env.snapshot(records=(env.record, old), future=True)
- env.evidence.put(evidence_value("new-during-confirmation"))
- changed = snapshot.model_copy(update={"evidence": (snapshot.evidence[0],)})
- with pytest.raises(ModelContentPermissionError, match="content_scope_changed"):
- env.permissions.grant(changed, expected_revision=0, user_confirmed=True)
-
-
-def test_changed_original_still_invalidates_issued_preview(env: Environment) -> None:
- snapshot = env.snapshot(future=True)
- env.evidence.put(evidence_value("selected", text="Revised original"))
- env.evidence.put(evidence_value("new-during-confirmation"))
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
-
-
-def test_restart_requires_repreview_but_does_not_destroy_existing_consent(env: Environment) -> None:
- snapshot = env.snapshot(future=True)
- env.evidence.put(evidence_value("new-during-confirmation"))
- env.permissions = content.ModelContentPermissionStore(env.database)
- with pytest.raises(ModelContentPermissionError, match="content_scope_changed"):
- env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
- current = env.snapshot(future=True)
- receipt = env.permissions.grant(current, expected_revision=0, user_confirmed=True)
- env.permissions = content.ModelContentPermissionStore(env.database)
- assert env.check(receipt) == receipt
-
-
-def test_issued_preview_is_bound_to_the_consent_revision(env: Environment) -> None:
- snapshot = env.snapshot(future=True)
- first = env.grant()
- revoked = env.permissions.revoke(
- user_id=USER, expected_revision=first.revision, user_confirmed=True
- )
- env.evidence.put(evidence_value("new-after-revocation"))
- with pytest.raises(ModelContentPermissionError, match="content_scope_changed"):
- env.permissions.grant(snapshot, expected_revision=revoked.revision, user_confirmed=True)
-
-
-def test_preview_issuance_expires_without_changing_accepted_scope(
- env: Environment, monkeypatch: pytest.MonkeyPatch
-) -> None:
- clock = [1000.0]
- monkeypatch.setattr(content, "monotonic", lambda: clock[0])
- snapshot = env.snapshot(future=True)
- env.evidence.put(evidence_value("new-during-confirmation"))
- clock[0] += content.PREVIEW_ISSUANCE_TTL_SECONDS + 1
- with pytest.raises(ModelContentPermissionError, match="content_scope_changed"):
- env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
-
-
-def test_preview_issuance_storage_is_bounded(
- env: Environment, monkeypatch: pytest.MonkeyPatch
-) -> None:
- monkeypatch.setattr(content, "PREVIEW_ISSUANCE_LIMIT", 2)
- first = env.snapshot(future=True)
- env.evidence.put(evidence_value("second"))
- env.snapshot(future=True)
- env.evidence.put(evidence_value("third"))
- latest = env.snapshot(future=True)
- env.evidence.put(evidence_value("fourth"))
- with pytest.raises(ModelContentPermissionError, match="content_scope_changed"):
- env.permissions.grant(first, expected_revision=0, user_confirmed=True)
- receipt = env.permissions.grant(latest, expected_revision=0, user_confirmed=True)
- assert receipt.snapshot == latest
diff --git a/memhub/apps/core/tests/test_model_content_routes.py b/memhub/apps/core/tests/test_model_content_routes.py
deleted file mode 100644
index f6a4006..0000000
--- a/memhub/apps/core/tests/test_model_content_routes.py
+++ /dev/null
@@ -1,88 +0,0 @@
-import asyncio
-
-from fastapi import FastAPI
-from httpx import ASGITransport, AsyncClient
-from pydantic import SecretStr
-
-from one_memory.api import create_app
-from one_memory.config import Settings
-from one_memory.memory_server.config_state import ConfigStore
-from one_memory.model_content_routes import model_content_router
-from one_memory.secrets import InMemorySecretStore
-
-
-def test_permission_route_reads_and_writes_only_config_final_state(tmp_path) -> None:
- async def scenario() -> None:
- backfills = 0
-
- def start_backfill() -> None:
- nonlocal backfills
- backfills += 1
-
- app = FastAPI()
- app.state.config_store = ConfigStore(tmp_path)
- app.state.start_memory_backfill = start_backfill
- app.include_router(model_content_router([]))
-
- async with AsyncClient(
- transport=ASGITransport(app=app),
- base_url="http://test",
- ) as client:
- assert (await client.get("/api/v1/model-content-permission")).json() == {
- "allowed": False
- }
- enabled = await client.post(
- "/api/v1/model-content-permission",
- json={"allowed": True},
- )
- assert enabled.status_code == 200
- assert enabled.json() == {"allowed": True}
- assert backfills == 1
- assert ConfigStore(tmp_path).model_content_allowed()
-
- disabled = await client.post(
- "/api/v1/model-content-permission",
- json={"allowed": False},
- )
- assert disabled.status_code == 200
- assert disabled.json() == {"allowed": False}
- assert backfills == 1
-
- legacy = await client.post(
- "/api/v1/model-content-permission/preview",
- json={},
- )
- assert legacy.status_code == 404
-
- asyncio.run(scenario())
-
-
-def test_core_composition_starts_with_config_permission_and_durable_drain(tmp_path) -> None:
- async def scenario() -> None:
- app = create_app(
- Settings(
- _env_file=None,
- data_dir=tmp_path,
- source_home=tmp_path,
- api_token=SecretStr("test-token"),
- automatic_memory_processing=False,
- live_sync_enabled=False,
- ),
- source_catalog={},
- secret_store=InMemorySecretStore(),
- )
- async with (
- app.router.lifespan_context(app),
- AsyncClient(
- transport=ASGITransport(app=app),
- base_url="http://test",
- headers={"Authorization": "Bearer test-token"},
- ) as client,
- ):
- response = await client.get("/api/v1/model-content-permission")
- assert response.status_code == 200
- assert response.json() == {"allowed": False}
- assert app.state.memory_write_drain_task.done() is False
- assert app.state.processing_coordinator is None
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_model_content_scope_seals.py b/memhub/apps/core/tests/test_model_content_scope_seals.py
deleted file mode 100644
index 06d45f7..0000000
--- a/memhub/apps/core/tests/test_model_content_scope_seals.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""Indexed scope invalidation retains complete-snapshot semantics without rereading it."""
-
-import sqlite3
-from unittest.mock import patch
-
-import pytest
-
-from one_memory.model_content_permissions import ModelContentPermissionError
-from tests.test_model_content_bulk import many_records
-from tests.test_model_content_permissions import env as env
-from tests.test_model_content_permissions import evidence_value
-
-
-def seal(env, count=6):
- records = many_records(env, count)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records), future=True),
- expected_revision=0,
- user_confirmed=True,
- _track_scope=True,
- )
- return records, receipt
-
-
-def test_sealed_scope_only_hydrates_outbound_records(env):
- records, receipt = seal(env, 1000)
- with patch.object(
- env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch
- ) as batch:
- assert env.check(receipt) == receipt
- assert sum(len(call.args[1]) for call in batch.call_args_list) == 1
- added = env.evidence.put(evidence_value("after-grant")).record
- with patch.object(
- env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch
- ) as batch:
- assert env.check(receipt, records=(added,)) == receipt
- assert sum(len(call.args[1]) for call in batch.call_args_list) == 1
- assert len(records) == 1000
-
-
-@pytest.mark.parametrize(
- "mutation", ["body", "role", "owner", "time", "delete", "origin", "origin_delete", "origin_add"]
-)
-def test_unrequested_member_changes_invalidate_even_without_revision(env, mutation):
- records, receipt = seal(env)
- target = records[-1]
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- if mutation == "origin_add":
- db.execute(
- "INSERT INTO evidence_origins"
- "(evidence_id,origin,raw_meta_json,source_version,observed_at) "
- "VALUES (?,'manual_import','{}',NULL,?)",
- (target.evidence_id, target.imported_at.isoformat()),
- )
- else:
- sql = {
- "body": "UPDATE evidence SET verbatim='edited without hash' WHERE evidence_id=?",
- "role": "UPDATE evidence SET role='system' WHERE evidence_id=?",
- "owner": "UPDATE evidence SET user_id='other' WHERE evidence_id=?",
- "time": "UPDATE evidence SET created_at='2026-09-01T00:00:00Z' WHERE evidence_id=?",
- "delete": "DELETE FROM evidence WHERE evidence_id=?",
- "origin": "UPDATE evidence_origins SET raw_meta_json='{\"new\":true}' "
- "WHERE evidence_id=?",
- "origin_delete": "DELETE FROM evidence_origins WHERE evidence_id=?",
- }[mutation]
- db.execute(sql, (target.evidence_id,))
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-@pytest.mark.parametrize("tracking", ["missing", "altered"])
-def test_missing_or_modified_trigger_uses_full_validation(env, tracking):
- records, receipt = seal(env)
- with sqlite3.connect(env.database) as db:
- db.execute("DROP TRIGGER model_content_seal_evidence_update")
- if tracking == "altered":
- db.execute(
- "CREATE TRIGGER model_content_seal_evidence_update "
- "AFTER UPDATE ON evidence BEGIN SELECT 1; END"
- )
- db.execute(
- "UPDATE evidence SET verbatim='untracked edit' WHERE evidence_id=?",
- (records[-1].evidence_id,),
- )
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-def test_legacy_unsealed_receipt_still_checks_full_scope(env):
- records = many_records(env, 6)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- with sqlite3.connect(env.database) as db:
- db.execute("DELETE FROM model_content_scope_seals")
- db.execute("DELETE FROM model_content_scope_members")
- with patch.object(
- env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch
- ) as batch:
- assert env.check(receipt) == receipt
- assert sum(len(call.args[1]) for call in batch.call_args_list) == 6
-
-
-def test_seal_never_substitutes_for_current_source_or_profile(env):
- _, receipt = seal(env)
- env.root.rename(env.root.with_name("old-root"))
- env.root.mkdir()
- with pytest.raises(ModelContentPermissionError, match="source_"):
- env.check(receipt)
-
-
-def test_corrupt_receipt_with_same_revision_cannot_reuse_seal(env):
- _, receipt = seal(env)
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE model_content_permissions SET "
- "snapshot_json=json_set(snapshot_json,'$.evidence[0].content_hash',?)",
- ("e" * 64,),
- )
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
diff --git a/memhub/apps/core/tests/test_model_content_snapshot_cost.py b/memhub/apps/core/tests/test_model_content_snapshot_cost.py
deleted file mode 100644
index 811acf5..0000000
--- a/memhub/apps/core/tests/test_model_content_snapshot_cost.py
+++ /dev/null
@@ -1,219 +0,0 @@
-"""Reuse derived fingerprints only after reading every raw row in this transaction."""
-
-import sqlite3
-from concurrent.futures import ThreadPoolExecutor
-from unittest.mock import patch
-
-import pytest
-
-import one_memory.model_content_permissions as content
-from one_memory.database_access import close_main_databases
-from one_memory.model_content_permissions import ModelContentPermissionError
-from tests.test_model_content_bulk import many_records
-from tests.test_model_content_permissions import USER
-from tests.test_model_content_permissions import env as env
-
-
-def test_warm_scope_reuses_derivation_but_reads_complete_evidence_and_origins(env, monkeypatch):
- records = many_records(env, 40)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- connects, statements = [], []
- original_connect = sqlite3.connect
-
- def connect(*args, **kwargs):
- db = original_connect(*args, **kwargs)
- connects.append(db)
- db.set_trace_callback(statements.append)
- return db
-
- close_main_databases() # pooled connections would skip the patch
-
- monkeypatch.setattr(sqlite3, "connect", connect)
- with patch.object(
- content, "model_content_evidence_snapshot", wraps=content.model_content_evidence_snapshot
- ) as derive:
- assert env.check(receipt) == receipt
- # A fresh connection per check, so each transaction is observed.
- close_main_databases()
- assert env.check(receipt) == receipt
- assert derive.call_count == 2, "Only the actual supplied input needs fresh derivation"
- assert len(connects) == 2
- assert sum("FROM evidence " in sql for sql in statements) == 2
- assert sum("FROM evidence_origins " in sql for sql in statements) == 2
- assert all("verbatim" in sql for sql in statements if "FROM evidence " in sql)
- assert all("raw_meta_json" in sql for sql in statements if "FROM evidence_origins " in sql)
-
-
-@pytest.mark.parametrize("mutation", ["body", "author", "time", "origin", "delete", "user"])
-def test_warm_cache_detects_unrequested_raw_mutations_without_hash_revision_changes(env, mutation):
- records = many_records(env, 4)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- assert env.check(receipt) == receipt
- sql = {
- "body": "UPDATE evidence SET verbatim='changed raw text' WHERE evidence_id=?",
- "author": "UPDATE evidence SET written_by='unknown' WHERE evidence_id=?",
- "time": "UPDATE evidence SET created_at='2026-09-01T00:00:00Z' WHERE evidence_id=?",
- "origin": (
- "UPDATE evidence_origins SET raw_meta_json='{\"changed\":true}' WHERE evidence_id=?"
- ),
- "delete": "DELETE FROM evidence WHERE evidence_id=?",
- "user": "UPDATE evidence SET user_id='other' WHERE evidence_id=?",
- }[mutation]
- with sqlite3.connect(env.database) as db:
- db.execute(sql, (records[-1].evidence_id,))
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-def test_derived_cache_is_bounded_and_store_local(env, monkeypatch):
- monkeypatch.setattr(content, "_EVIDENCE_SNAPSHOT_CACHE_LIMIT", 1, raising=False)
- records = many_records(env, 4)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- assert env.check(receipt) == receipt
- assert len(env.permissions._evidence_snapshot_cache) <= 1
- fresh = content.ModelContentPermissionStore(env.database)
- assert len(fresh._evidence_snapshot_cache) == 0
- assert (
- fresh.check(
- user_id=USER,
- expected_revision=receipt.revision,
- expected_revocation_revision=receipt.revocation_revision,
- profile=env.profile,
- evidence=(env.record,),
- )
- == receipt
- )
- assert len(fresh._evidence_snapshot_cache) <= 1
-
-
-def test_derived_cache_payload_limit_can_disable_retention(env, monkeypatch):
- monkeypatch.setattr(content, "_EVIDENCE_SNAPSHOT_CACHE_BYTES", 1, raising=False)
- receipt = env.grant()
- assert env.check(receipt) == receipt
- assert len(env.permissions._evidence_snapshot_cache) == 0
-
-
-def test_larger_realistic_receipt_fits_bounded_parse_cache(env):
- from tests.test_model_content_parse_cost import (
- repeated_receipt_measurement,
- synthetic_large_receipt,
- )
-
- synthetic_large_receipt(env, count=45_419)
- result = repeated_receipt_measurement(env.permissions, repeats=2)
- assert result["snapshot_parses"] == 1
-
-
-def test_large_grant_builds_a_valid_scope_seal_in_bounded_batches(env, monkeypatch):
- records = many_records(env, 1001)
- snapshot = env.snapshot(records=tuple(records))
- receipt = env.permissions.grant(snapshot, expected_revision=0, user_confirmed=True)
-
- with sqlite3.connect(env.database) as db:
- assert db.execute(
- "SELECT count(*) FROM model_content_scope_members WHERE user_id=? AND revision=?",
- (receipt.user_id, receipt.revision),
- ).fetchone()[0] == len(records)
- assert (
- db.execute(
- "SELECT valid FROM model_content_scope_seals WHERE user_id=? AND revision=?",
- (receipt.user_id, receipt.revision),
- ).fetchone()[0]
- == 1
- )
-
- def must_not_rescan(*_args, **_kwargs):
- raise AssertionError("a sealed large scope must not rescan every Evidence row")
-
- monkeypatch.setattr(env.permissions, "_validate_snapshot", must_not_rescan)
- assert env.check(receipt, records=(records[0],)) == receipt
-
-
-def test_cached_metadata_cannot_hide_corrupt_origin_json(env):
- receipt = env.grant()
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence_origins SET raw_meta_json='not-json'")
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-@pytest.mark.parametrize("mutation", ["insert", "delete", "version", "kind"])
-def test_cached_scope_checks_entire_origin_row_membership(env, mutation):
- records = many_records(env, 4)
- receipt = env.permissions.grant(
- env.snapshot(records=tuple(records)), expected_revision=0, user_confirmed=True
- )
- assert env.check(receipt) == receipt
- other = records[-1]
- with sqlite3.connect(env.database) as db:
- if mutation == "insert":
- db.execute(
- "INSERT INTO evidence_origins "
- "(evidence_id,origin,raw_meta_json,source_version,observed_at) "
- "VALUES (?,'manual_import','{}',NULL,?)",
- (other.evidence_id, other.imported_at.isoformat()),
- )
- else:
- sql = {
- "delete": "DELETE FROM evidence_origins WHERE evidence_id=? AND origin='file'",
- "version": (
- "UPDATE evidence_origins SET source_version='changed' "
- "WHERE evidence_id=? AND origin='file'"
- ),
- "kind": (
- "UPDATE evidence_origins SET origin='unknown' "
- "WHERE evidence_id=? AND origin='file'"
- ),
- }[mutation]
- db.execute(sql, (other.evidence_id,))
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-def test_old_transaction_cannot_poison_new_transaction_derivation(env, monkeypatch):
- receipt = env.grant()
- old_batch = env.permissions._evidence_batch
- changed = False
-
- def write_while_old_snapshot_open(connection, identifiers):
- nonlocal changed
- if identifiers and not changed:
- changed = True
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence SET verbatim='changed without revision'")
- # A new check is another caller, so it runs on a thread of its own.
- with ThreadPoolExecutor(1) as other_caller:
- later = other_caller.submit(env.check, receipt)
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- later.result()
- yield from old_batch(connection, identifiers)
-
- with monkeypatch.context() as scoped:
- scoped.setattr(env.permissions, "_evidence_batch", write_while_old_snapshot_open)
- assert env.check(receipt) == receipt
- assert changed
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-def test_cache_key_is_not_delimiter_ambiguous(env):
- receipt = env.grant()
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence SET workspace='a,b',project='c'")
- updated = env.evidence.get(env.record.evidence_id)
- next_receipt = env.permissions.grant(
- env.snapshot(records=(updated,)), expected_revision=1, user_confirmed=True
- )
- assert env.check(next_receipt, records=(updated,)) == next_receipt
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence SET workspace='a',project='b,c'")
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(next_receipt, records=(updated,))
diff --git a/memhub/apps/core/tests/test_model_policy_activation.py b/memhub/apps/core/tests/test_model_policy_activation.py
deleted file mode 100644
index 3eff09e..0000000
--- a/memhub/apps/core/tests/test_model_policy_activation.py
+++ /dev/null
@@ -1,422 +0,0 @@
-import sqlite3
-import threading
-from concurrent.futures import ThreadPoolExecutor
-from uuid import uuid4
-
-import pytest
-
-from one_memory import continuous_policy as policy
-from one_memory.model_policy_activation import ModelPolicyActivation
-from one_memory.model_profiles import ModelCapabilities, ModelProfileInput, ModelProfilePatch
-from one_memory.secrets import SecretStoreUnavailableError
-from tests.test_model_content_permissions import env as env # noqa: F401
-
-
-def request(env, **overrides):
- values = dict(
- operation_id=str(uuid4()),
- profile_id=env.profile.profile_id,
- profile_revision=env.profile.revision,
- expected_policy_revision=0,
- expected_legacy_revision=0,
- backlog_mode="none",
- user_confirmed=True,
- )
- values.update(overrides)
- return values
-
-
-def service(env):
- return ModelPolicyActivation(env.database, env.secrets)
-
-
-def new_profile(env, *, cloud=False, key=None):
- values = dict(
- name="Other",
- provider_kind="openai_compatible" if cloud else "ollama",
- base_url="https://model.example/v1" if cloud else "http://127.0.0.1:11434",
- model="other-model",
- embedding_provider_kind="local_onnx",
- embedding_model="fixture-local-vector",
- embedding_dimensions=512,
- )
- if key is not None:
- values["api_key"] = key
- profile = env.profiles.create(ModelProfileInput(**values), env.secrets)
- env.profiles.record_capabilities(
- profile.profile_id,
- expected_revision=profile.revision,
- capabilities=ModelCapabilities(
- endpoint_reachable=True, model_available=True, embedding_available=True
- ),
- secrets=env.secrets,
- )
- return profile
-
-
-def test_current_is_read_only_without_policy_tables(env):
- before = env.database.read_bytes()
- assert service(env).current() is None
- assert env.database.read_bytes() == before
- with sqlite3.connect(env.database) as db:
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'"
- ).fetchone()
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='evidence_ingest_events'"
- ).fetchone()
-
-
-def test_switch_and_policy_commit_together_without_old_activate(env, monkeypatch):
- other = new_profile(env)
- monkeypatch.setattr(env.profiles, "activate", lambda *args: pytest.fail("old activate"))
- result = service(env).enable(**request(env, profile_id=other.profile_id))
- assert result.created is True
- assert result.policy.profile.profile_id == other.profile_id
- assert env.profiles.active_identity() == (other.profile_id, 1)
- assert service(env).current() == result.policy
- assert env.permissions.receipt(user_id="local") is None
-
-
-def test_policy_append_failure_rolls_back_active_and_initialization(env, monkeypatch):
- other = new_profile(env)
- monkeypatch.setattr(
- policy, "_append", lambda *args: (_ for _ in ()).throw(RuntimeError("forced"))
- )
- with pytest.raises(RuntimeError):
- service(env).enable(**request(env, profile_id=other.profile_id))
- assert env.profiles.active_identity() == (env.profile.profile_id, 1)
- assert service(env).current() is None
- with sqlite3.connect(env.database) as db:
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='evidence_ingest_events'"
- ).fetchone()
-
-
-def test_duplicate_operation_returns_same_receipt_without_reactivation(env):
- active = service(env)
- args = request(env)
- first = active.enable(**args)
- second = active.enable(**args)
- assert first.policy == second.policy
- assert second.created is False
- with sqlite3.connect(env.database) as db:
- assert db.execute("SELECT COUNT(*) FROM continuous_policy_history").fetchone()[0] == 1
- other = new_profile(env)
- env.profiles.activate(other.profile_id, env.secrets)
- with pytest.raises(RuntimeError):
- active.enable(**args)
- assert env.profiles.active_identity() == (other.profile_id, 1)
-
-
-def test_duplicate_operation_payload_and_superseded_revoke_rejected(env):
- active = service(env)
- args = request(env)
- active.enable(**args)
- with pytest.raises(RuntimeError, match="payload_changed"):
- active.enable(**{**args, "backlog_mode": "include"})
- active.transition(
- action="revoke", expected_policy_revision=1, operation_id=str(uuid4()), user_confirmed=True
- )
- with pytest.raises(RuntimeError, match="superseded"):
- active.enable(**args)
- assert active.current().state == "revoked"
-
-
-@pytest.mark.parametrize(
- "change", ["revision", "capability", "version", "missing_key", "unreadable_key"]
-)
-def test_model_preconditions_fail_closed_and_preserve_old_active(env, change):
- other = new_profile(
- env,
- cloud=change in {"missing_key", "unreadable_key"},
- key="test-only-key" if change == "unreadable_key" else None,
- )
- if change == "revision":
- env.profiles.update(
- other.profile_id, ModelProfilePatch(expected_revision=1, model="changed"), env.secrets
- )
- elif change in {"capability", "version"}:
- with sqlite3.connect(env.database) as db:
- if change == "capability":
- db.execute(
- "UPDATE model_profiles SET model_available=0 WHERE profile_id=?",
- (other.profile_id,),
- )
- else:
- db.execute(
- "UPDATE model_profile_versions SET model='tampered' WHERE profile_id=?",
- (other.profile_id,),
- )
- elif change == "unreadable_key":
- env.secrets.delete(env.profiles.secret_ref(other.profile_id))
- with pytest.raises(RuntimeError):
- service(env).enable(**request(env, profile_id=other.profile_id))
- assert env.profiles.active_identity() == (env.profile.profile_id, 1)
- assert service(env).current() is None
-
-
-def test_stale_legacy_revision_rejects_switch_and_old_revoked_grant_is_not_modified(env):
- old = env.grant()
- revoked = env.permissions.revoke(
- user_id="local", expected_revision=old.revision, user_confirmed=True
- )
- other = new_profile(env)
- with pytest.raises(RuntimeError, match="policy_revision_changed"):
- service(env).enable(
- **request(env, profile_id=other.profile_id, expected_legacy_revision=old.revision)
- )
- assert env.profiles.active_identity() == (env.profile.profile_id, 1)
- result = service(env).enable(
- **request(env, profile_id=other.profile_id, expected_legacy_revision=revoked.revision)
- )
- assert result.policy.legacy_revision == revoked.revision
- assert env.permissions.receipt(user_id="local") == revoked
-
-
-def test_pause_resume_revoke_cas_and_resume_does_not_switch_model(env):
- active = service(env)
- active.enable(**request(env))
- op = str(uuid4())
- paused = active.transition(
- action="pause", expected_policy_revision=1, operation_id=op, user_confirmed=True
- )
- assert paused.policy.state == "paused"
- assert (
- active.transition(
- action="pause", expected_policy_revision=1, operation_id=op, user_confirmed=True
- ).created
- is False
- )
- other = new_profile(env)
- env.profiles.activate(other.profile_id, env.secrets)
- with pytest.raises(RuntimeError):
- active.transition(
- action="resume",
- expected_policy_revision=2,
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
- assert active.current().state == "paused"
- active.transition(
- action="revoke", expected_policy_revision=2, operation_id=str(uuid4()), user_confirmed=True
- )
- with pytest.raises(RuntimeError, match="requires_new_enable"):
- active.transition(
- action="resume",
- expected_policy_revision=3,
- operation_id=str(uuid4()),
- user_confirmed=True,
- )
-
-
-def test_same_operation_concurrent_submission_commits_once(env):
- args = request(env)
- with ThreadPoolExecutor(max_workers=2) as pool:
- results = list(pool.map(lambda _: service(env).enable(**args), range(2)))
- assert sum(result.created for result in results) == 1
- assert results[0].policy == results[1].policy
-
-
-def test_no_sources_can_enable_future_and_backlog_is_explicit(env):
- with sqlite3.connect(env.database) as db:
- db.execute("DELETE FROM evidence")
- db.execute("DELETE FROM source_grants")
- result = service(env).enable(**request(env))
- assert result.policy.after_seq == 0
- assert result.policy.backlog_events == ()
- assert result.policy.state == "enabled"
-
-
-def test_pre_d57_none_policy_is_upgraded_once_to_compact_backlog(env):
- activation = service(env)
- old = activation.enable(**request(env)).policy
- old = old.model_copy(update={"changed_at": "2026-10-06T12:00:00+00:00"})
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=? AND revision=?",
- (old.model_dump_json(), old.user_id, old.revision),
- )
-
- upgraded = activation.upgrade_legacy_none_policy()
-
- assert upgraded is not None and upgraded.created
- assert upgraded.policy.backlog_mode == "include"
- assert upgraded.policy.automatic_backlog_upgrade is True
- assert upgraded.policy.backlog_count == 1
- assert upgraded.policy.backlog_conversation_count == 1
- assert activation.upgrade_legacy_none_policy() is None
-
-
-@pytest.mark.parametrize(
- ("failure", "error_code"),
- [
- (
- SecretStoreUnavailableError("Operating system secure storage read backoff"),
- "secure_storage_unavailable",
- ),
- (RuntimeError("unexpected startup failure"), "legacy_backlog_upgrade_failed"),
- ],
-)
-def test_legacy_policy_upgrade_defers_startup_failures(env, monkeypatch, failure, error_code):
- activation = service(env)
- old = activation.enable(**request(env)).policy.model_copy(
- update={"changed_at": "2026-10-06T12:00:00+00:00"}
- )
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=? AND revision=?",
- (old.model_dump_json(), old.user_id, old.revision),
- )
-
- events = []
- monkeypatch.setattr(activation, "enable", lambda **_kwargs: (_ for _ in ()).throw(failure))
- monkeypatch.setattr(
- "one_memory.model_policy_activation.diagnostic_event",
- lambda **event: events.append(event),
- )
-
- assert activation.upgrade_legacy_none_policy() is None
- assert len(events) == 1
- assert events[0]["level"] == "warn"
- assert events[0]["event"] == "policy.legacy_backlog_upgrade_deferred"
- assert events[0]["error_code"] == error_code
- assert activation.current() == old
-
-
-def test_post_d57_explicit_none_policy_is_not_upgraded(env):
- activation = service(env)
- current = activation.enable(**request(env)).policy
- assert current.backlog_mode == "none"
- assert activation.upgrade_legacy_none_policy() is None
- assert activation.current() == current
-
-
-@pytest.mark.parametrize(
- "field,value",
- [
- ("user_confirmed", False),
- ("user_confirmed", 1),
- ("backlog_mode", "all"),
- ("profile_revision", True),
- ("operation_id", "invalid"),
- ],
-)
-def test_invalid_input_never_initializes_or_switches(env, field, value):
- with pytest.raises((RuntimeError, ValueError)):
- service(env).enable(**request(env, **{field: value}))
- assert service(env).current() is None
-
-
-def test_cloud_credential_is_checked_without_keychain_write(env, monkeypatch):
- other = new_profile(env, cloud=True, key="fixture-key")
- monkeypatch.setattr(env.secrets, "put", lambda *args: pytest.fail("no credential write"))
- monkeypatch.setattr(env.secrets, "delete", lambda *args: pytest.fail("no credential delete"))
- receipt = service(env).enable(**request(env, profile_id=other.profile_id))
- assert receipt.policy.profile.profile_id == other.profile_id
-
-
-def test_profile_change_after_secret_preflight_does_not_commit_policy(env, monkeypatch):
- other = new_profile(env, cloud=True, key="fixture-key")
- original_has = env.secrets.has
-
- def changed(secret_ref):
- answer = original_has(secret_ref)
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE model_profiles SET model_available=0 WHERE profile_id=?",
- (other.profile_id,),
- )
- return answer
-
- monkeypatch.setattr(env.secrets, "has", changed)
- with pytest.raises(RuntimeError, match="probe_required"):
- service(env).enable(**request(env, profile_id=other.profile_id))
- assert env.profiles.active_identity() == (env.profile.profile_id, 1)
- assert service(env).current() is None
-
-
-def test_same_operation_does_not_switch_or_accept_lost_probe_readiness(env):
- active = service(env)
- args = request(env)
- active.enable(**args)
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_profiles SET embedding_available=0")
- with pytest.raises(RuntimeError, match="probe_required"):
- active.enable(**args)
- assert active.current().revision == 1
-
-
-def test_two_different_operations_same_cas_only_one_commits(env):
- requests = [request(env), request(env)]
-
- def submit(value):
- try:
- return service(env).enable(**value).created
- except policy.ContinuousPolicyError:
- return False
-
- with ThreadPoolExecutor(max_workers=2) as pool:
- assert sum(pool.map(submit, requests)) == 1
- assert service(env).current().revision == 1
-
-
-def test_backlog_scan_does_not_hold_the_single_writer(env, monkeypatch):
- active = service(env)
- entered = threading.Event()
- release = threading.Event()
- original = policy.snapshot_backlog
-
- def paused_snapshot(db, *, user_id="local"):
- snapshot = original(db, user_id=user_id)
- entered.set()
- assert release.wait(3)
- return snapshot
-
- monkeypatch.setattr(policy, "snapshot_backlog", paused_snapshot)
- with ThreadPoolExecutor(max_workers=1) as pool:
- pending = pool.submit(active.enable, **request(env, backlog_mode="include"))
- if not entered.wait(3):
- release.set()
- pending.result(timeout=3)
- pytest.fail("backlog snapshot was not reached")
- writer = sqlite3.connect(env.database, timeout=0.2, isolation_level=None)
- try:
- writer.execute("BEGIN IMMEDIATE")
- writer.execute("ROLLBACK")
- finally:
- writer.close()
- release.set()
- assert pending.result(timeout=3).created is True
-
-
-def test_activation_helper_is_transactional_and_restores_row_factory(env, monkeypatch):
- from one_memory.model_content_permissions import ModelContentPermissionStore
- from one_memory.model_profiles import ModelProfileStore
-
- other = new_profile(env)
- snapshot = env.profiles.snapshot(other.profile_id, 1)
- with sqlite3.connect(env.database) as db:
- with pytest.raises(RuntimeError, match="caller_transaction_required"):
- ModelProfileStore.activate_in_connection(db, snapshot, expected_secret_ref=None)
- db.execute("BEGIN IMMEDIATE")
- monkeypatch.setattr(
- ModelContentPermissionStore,
- "_validate_profile",
- lambda *args: (_ for _ in ()).throw(RuntimeError("recipient rejected")),
- )
- with pytest.raises(RuntimeError, match="recipient rejected"):
- ModelProfileStore.activate_in_connection(db, snapshot, expected_secret_ref=None)
- assert db.row_factory is None
- assert (
- db.execute("SELECT profile_id FROM model_profiles WHERE is_active=1").fetchone()[0]
- == env.profile.profile_id
- )
- assert db.in_transaction
-
-
-def test_missing_database_get_does_not_create_it(tmp_path, env):
- database = tmp_path / "must-not-be-created.sqlite3"
- with pytest.raises(sqlite3.Error):
- ModelPolicyActivation(database, env.secrets).current()
- assert not database.exists()
diff --git a/memhub/apps/core/tests/test_model_policy_routes.py b/memhub/apps/core/tests/test_model_policy_routes.py
deleted file mode 100644
index 2d171d5..0000000
--- a/memhub/apps/core/tests/test_model_policy_routes.py
+++ /dev/null
@@ -1,206 +0,0 @@
-import sqlite3
-from uuid import uuid4
-
-import pytest
-from fastapi import Depends, FastAPI, Header, HTTPException
-from fastapi.testclient import TestClient
-
-from one_memory.model_policy_activation import ModelPolicyActivation
-from one_memory.model_policy_routes import continuous_policy_router
-from tests.test_model_content_permissions import env as env # noqa: F401
-from tests.test_model_policy_activation import request
-
-PREFIX = "/api/v1/continuous-processing-policy"
-SAFE_KEYS = {
- "schema_version",
- "user_id",
- "revision",
- "epoch",
- "state",
- "after_seq",
- "profile_id",
- "profile_revision",
- "legacy_revision",
- "backlog_mode",
- "backlog_count",
- "operation_id",
- "changed_at",
-}
-
-
-def client(env, *, enabled=False, callback=None):
- app = FastAPI()
- app.state.model_policy_activation = ModelPolicyActivation(env.database, env.secrets)
-
- def auth(authorization: str | None = Header(None)):
- if authorization != "Bearer fixture":
- raise HTTPException(401, "unauthorized")
-
- app.include_router(
- continuous_policy_router([Depends(auth)], on_enabled=callback, enabled=enabled)
- )
- return TestClient(app, headers={"Authorization": "Bearer fixture"})
-
-
-def test_get_does_not_initialize_and_capability_defaults_false(env):
- with client(env) as http:
- data = http.get(PREFIX).json()
- assert data == {
- "available": True,
- "policy": None,
- "legacy_revision": 0,
- "capability": {"enable": False, "reason": "processing_guards_not_integrated"},
- "backlog_upgrade_notice": {
- "pending": False,
- "epoch": None,
- "conversation_count": 0,
- "profile_id": None,
- },
- }
- assert http.post(PREFIX + "/enable", json=request(env)).status_code == 409
- with sqlite3.connect(env.database) as db:
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'"
- ).fetchone()
-
-
-@pytest.mark.parametrize("route", ["", "/enable", "/pause", "/resume", "/revoke"])
-def test_all_routes_require_auth(env, route):
- with client(env, enabled=True) as http:
- response = (
- http.get(PREFIX, headers={"Authorization": "bad"})
- if not route
- else http.post(PREFIX + route, json=request(env), headers={"Authorization": "bad"})
- )
- assert response.status_code == 401
-
-
-def test_factory_refuses_no_auth_dependency():
- with pytest.raises(ValueError, match="authentication_required"):
- continuous_policy_router([])
-
-
-@pytest.mark.parametrize(
- "mutation",
- [
- {"backlog_mode": None},
- {"user_confirmed": "true"},
- {"profile_revision": True},
- {"profile_revision": 1.0},
- {"operation_id": "not-uuid"},
- {"api_key": "forbidden"},
- ],
-)
-def test_request_validation_does_not_coerce_and_rejects_secret_fields(env, mutation):
- with client(env, enabled=True) as http:
- value = {**request(env), **mutation}
- assert http.post(PREFIX + "/enable", json=value).status_code == 422
- assert http.get(PREFIX).json()["policy"] is None
-
-
-def test_backlog_is_required_not_defaulted(env):
- with client(env, enabled=True) as http:
- value = request(env)
- del value["backlog_mode"]
- assert http.post(PREFIX + "/enable", json=value).status_code == 422
-
-
-def test_enable_and_transition_safe_shape_and_callback_once_after_commit(env):
- calls = []
-
- async def notified(receipt):
- assert ModelPolicyActivation(env.database, env.secrets).current() == receipt
- calls.append(receipt.revision)
-
- with client(env, enabled=True, callback=notified) as http:
- value = request(env, backlog_mode="include")
- first = http.post(PREFIX + "/enable", json=value)
- assert first.status_code == 200, first.text
- assert set(first.json()) == SAFE_KEYS
- assert first.json()["backlog_count"] == 1
- assert "secret_ref" not in first.text and '"profile":' not in first.text
- assert http.post(PREFIX + "/enable", json=value).json() == first.json()
- assert calls == [1]
- for revision, action in [(1, "pause"), (2, "resume"), (3, "revoke")]:
- result = http.post(
- PREFIX + "/" + action,
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": revision,
- "user_confirmed": True,
- },
- )
- assert result.status_code == 200, result.text
- assert set(result.json()) == SAFE_KEYS
- assert calls == [1, 3]
- assert http.get(PREFIX).json()["policy"]["state"] == "revoked"
-
-
-def test_status_exposes_only_automatic_backlog_upgrade_notice(env):
- activation = service = ModelPolicyActivation(env.database, env.secrets)
- old = activation.enable(**request(env)).policy
- old = old.model_copy(update={"changed_at": "2026-10-06T12:00:00+00:00"})
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE continuous_policy_history SET receipt_json=? WHERE user_id=? AND revision=?",
- (old.model_dump_json(), old.user_id, old.revision),
- )
- upgraded = activation.upgrade_legacy_none_policy()
- assert upgraded is not None
-
- app = FastAPI()
- app.state.model_policy_activation = service
-
- def auth():
- return None
-
- app.include_router(continuous_policy_router([Depends(auth)], enabled=True))
- with TestClient(app) as http:
- notice = http.get(PREFIX).json()["backlog_upgrade_notice"]
- assert notice == {
- "pending": True,
- "epoch": upgraded.policy.epoch,
- "conversation_count": 1,
- "profile_id": env.profile.profile_id,
- }
-
-
-def test_notification_failure_does_not_rollback_or_repeat_enable(env):
- calls = []
-
- async def fail_notify(receipt):
- calls.append(receipt.revision)
- raise RuntimeError("private runtime failure")
-
- with client(env, enabled=True, callback=fail_notify) as http:
- value = request(env)
- assert http.post(PREFIX + "/enable", json=value).status_code == 200
- assert http.post(PREFIX + "/enable", json=value).status_code == 200
- assert calls == [1]
- assert http.get(PREFIX).json()["policy"]["state"] == "enabled"
-
-
-def test_gate_off_allows_revoke_but_not_resume(env):
- ModelPolicyActivation(env.database, env.secrets).enable(**request(env))
- with client(env) as http:
- value = {
- "operation_id": str(uuid4()),
- "expected_policy_revision": 1,
- "user_confirmed": True,
- }
- assert http.post(PREFIX + "/pause", json=value).status_code == 200
- value.update(operation_id=str(uuid4()), expected_policy_revision=2)
- assert http.post(PREFIX + "/resume", json=value).status_code == 409
- assert http.post(PREFIX + "/revoke", json=value).status_code == 200
-
-
-def test_store_failure_has_no_raw_errors_or_fake_empty_policy(env, monkeypatch):
- monkeypatch.setattr(
- ModelPolicyActivation,
- "read_current",
- lambda self: (_ for _ in ()).throw(sqlite3.OperationalError("secret raw error")),
- )
- with client(env) as http:
- result = http.get(PREFIX)
- assert result.status_code == 503
- assert "secret" not in result.text and '"policy":null' not in result.text
diff --git a/memhub/apps/core/tests/test_model_processing_permissions.py b/memhub/apps/core/tests/test_model_processing_permissions.py
deleted file mode 100644
index 2ff914e..0000000
--- a/memhub/apps/core/tests/test_model_processing_permissions.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from types import SimpleNamespace
-
-import pytest
-
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.memory_server.config_state import ConfigStore
-from one_memory.model_processing_permission_guard import ModelProcessingPermissionGuard
-from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
-
-
-def snapshot(profile_id: str = "profile") -> ModelProfileSnapshot:
- return ModelProfileSnapshot(
- profile_id=profile_id,
- revision=1,
- provider_kind=ProviderKind.OLLAMA,
- base_url="http://127.0.0.1:11434",
- model="test-model",
- embedding_model="test-embedding",
- embedding_provider_kind=ProviderKind.OLLAMA,
- embedding_base_url="http://127.0.0.1:11434",
- timeout_seconds=30,
- max_concurrency=1,
- context_window=None,
- has_api_key=False,
- )
-
-
-def test_lease_reads_current_allowed_before_each_boundary(tmp_path) -> None:
- config = ConfigStore(tmp_path)
- profile = snapshot()
- profiles = SimpleNamespace(active_identity=lambda: (profile.profile_id, profile.revision))
- guard = ModelProcessingPermissionGuard(config, SimpleNamespace(), profiles)
- task = SimpleNamespace(
- task_id="task",
- user_id="local",
- profile_snapshot=profile,
- )
-
- with pytest.raises(HyMemoryBridgeError, match="model_content_permission_required"):
- guard.lease(task, (), None)
-
- config.set_model_content_allowed(True)
- lease = guard.lease(task, (), None)
- lease.recheck()
-
- config.set_model_content_allowed(False)
- with pytest.raises(HyMemoryBridgeError, match="model_content_permission_required"):
- lease.recheck()
-
-
-def test_lease_refuses_a_task_profile_that_is_no_longer_active(tmp_path) -> None:
- config = ConfigStore(tmp_path)
- config.set_model_content_allowed(True)
- profile = snapshot()
- profiles = SimpleNamespace(active_identity=lambda: ("new-profile", 1))
- guard = ModelProcessingPermissionGuard(config, SimpleNamespace(), profiles)
-
- with pytest.raises(HyMemoryBridgeError, match="model_content_profile_changed"):
- guard.check(profile)
diff --git a/memhub/apps/core/tests/test_model_request_budget.py b/memhub/apps/core/tests/test_model_request_budget.py
index 6f39c4f..ec0fcbd 100644
--- a/memhub/apps/core/tests/test_model_request_budget.py
+++ b/memhub/apps/core/tests/test_model_request_budget.py
@@ -96,44 +96,3 @@ def test_runtime_limit_is_opt_in_and_bounds_are_validated():
assert Settings(_env_file=None).model_request_limit is None
with pytest.raises(ValueError):
Settings(_env_file=None, model_request_limit=0)
-
-
-def test_api_wires_optional_budget_to_the_real_permission_factory(tmp_path, monkeypatch):
- import importlib
- from contextlib import asynccontextmanager
- from types import SimpleNamespace
-
- module = importlib.import_module("one_memory.api")
- captured = []
- marker = object()
-
- def create(resolve, **kwargs):
- captured.append(kwargs.get("http_transport"))
-
- @asynccontextmanager
- async def enter(*args):
- yield marker
-
- return enter
-
- async def scenario():
- app = SimpleNamespace(
- state=SimpleNamespace(
- model_request_budget=budget(tmp_path),
- memory_processing_supervisor=SimpleNamespace(_bridge=lambda *args: None),
- )
- )
- monkeypatch.setattr(
- module, "import_module", lambda name: SimpleNamespace(permission_bridge_factory=create)
- )
- lease = SimpleNamespace(records=())
- async with module._permission_context(app, lease, None, tmp_path) as result:
- assert result is marker
- assert isinstance(captured[0], BudgetedModelTransport)
- await captured[0].aclose()
- app.state.model_request_budget = None
- async with module._permission_context(app, lease, None, tmp_path):
- pass
- assert captured[1] is None
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_model_request_budget_integration.py b/memhub/apps/core/tests/test_model_request_budget_integration.py
deleted file mode 100644
index 09b8359..0000000
--- a/memhub/apps/core/tests/test_model_request_budget_integration.py
+++ /dev/null
@@ -1,328 +0,0 @@
-"""Real HY leaf retries plus durable budget accounting; no network or provider."""
-
-import asyncio
-import sqlite3
-import threading
-from contextlib import suppress
-
-import httpx
-import pytest
-from hy_memory.agent.llm_provider import LLMRetryError
-
-from one_memory.config import Settings
-from one_memory.database_access import close_main_databases
-from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
-from one_memory.model_request_budget import (
- BudgetedModelTransport,
- ModelRequestBudget,
- ModelRequestBudgetError,
-)
-from tests.test_hy_permission_transport import Wire, lease, native
-from tests.test_model_processing_permissions import Harness
-
-
-@pytest.fixture(autouse=True)
-def forbid_network(monkeypatch):
- def denied(*args, **kwargs):
- pytest.fail("Real network is forbidden in budget integration tests")
-
- monkeypatch.setattr("socket.socket.connect", denied)
- monkeypatch.setattr("socket.getaddrinfo", denied)
-
-
-def budget(database, limit=2, body_limit=4096):
- return ModelRequestBudget(
- database, request_limit=limit, body_byte_limit=body_limit, output_token_limit=64
- )
-
-
-def reservations(database):
- with sqlite3.connect(database) as db:
- return db.execute(
- "SELECT sequence,body_bytes,output_tokens,reserved_at "
- "FROM model_request_budget_events ORDER BY sequence"
- ).fetchall()
-
-
-@pytest.mark.parametrize("failure", ["http-status", "connection"])
-def test_native_hy_retries_consume_durable_budget_and_latch_denial(tmp_path, failure):
- database = tmp_path / "budget.sqlite3"
- limits = budget(database)
-
- def fail(request, _number):
- if failure == "connection":
- raise httpx.ConnectError("synthetic connection failure", request=request)
- return httpx.Response(503)
-
- wire = Wire(fail)
-
- async def scenario():
- permission = lease()
- provider, _ = native(permission)
- provider._llm_config.max_retries = 6
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- async with HyPermissionTransport(
- permission, http_transport=BudgetedModelTransport(limits, inner=wire)
- ) as transport:
- transport.bind_provider(provider)
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- await provider.complete("synthetic sensitive content", max_tokens=16)
- assert len(wire.requests) == 2
- assert transport.denied_code == "model_content_transport_budget_exceeded"
- assert len(reservations(database)) == 2
- for call in (
- lambda: provider.complete("try again", max_tokens=1),
- lambda: provider.complete_messages(
- [{"role": "user", "content": "again"}], max_tokens=1
- ),
- transport.seal,
- ):
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- await call()
- assert len(wire.requests) == 2
-
- # A fresh budget object and a fresh lease still see the consumed rows.
- fresh_permission = lease()
- fresh_provider, _ = native(fresh_permission)
- fresh_wire = Wire()
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- async with HyPermissionTransport(
- fresh_permission,
- http_transport=BudgetedModelTransport(budget(database), inner=fresh_wire),
- ) as transport:
- transport.bind_provider(fresh_provider)
- await fresh_provider.complete("new lease", max_tokens=16)
- assert fresh_wire.requests == []
-
- asyncio.run(scenario())
- assert len(reservations(database)) == 2
- assert wire.closed == 1
- persisted = database.read_bytes()
- assert b"synthetic sensitive content" not in persisted
- assert b"synthetic-secret-never-log" not in persisted
-
-
-def test_parallel_native_leases_share_one_budget_before_sending(tmp_path):
- database = tmp_path / "budget.sqlite3"
- budgets = [budget(database, limit=3) for _ in range(8)]
- wires = [Wire() for _ in budgets]
-
- async def attempt(limits, wire):
- permission = lease()
- provider, _ = native(permission)
- try:
- async with HyPermissionTransport(
- permission, http_transport=BudgetedModelTransport(limits, inner=wire)
- ) as transport:
- transport.bind_provider(provider)
- await provider.complete("parallel synthetic request", max_tokens=16)
- return True
- except HyPermissionTransportError as error:
- assert error.code == "model_content_transport_budget_exceeded"
- return False
-
- async def scenario():
- return await asyncio.gather(
- *(attempt(limits, wire) for limits, wire in zip(budgets, wires, strict=True))
- )
-
- assert sum(asyncio.run(scenario())) == 3
- assert sum(len(wire.requests) for wire in wires) == len(reservations(database)) == 3
-
-
-def test_cancelled_physical_attempt_is_not_refunded_on_reopen(tmp_path):
- database = tmp_path / "budget.sqlite3"
-
- async def scenario():
- entered = asyncio.Event()
-
- async def wait_forever(request):
- entered.set()
- await asyncio.Event().wait()
-
- transport = BudgetedModelTransport(
- budget(database, limit=1), inner=httpx.MockTransport(wait_forever)
- )
- async with httpx.AsyncClient(transport=transport) as client:
- request = asyncio.create_task(
- client.post("https://synthetic.test", json={"max_tokens": 16})
- )
- await asyncio.wait_for(entered.wait(), 2)
- assert len(reservations(database)) == 1
- request.cancel()
- with suppress(asyncio.CancelledError):
- await request
- with pytest.raises(ModelRequestBudgetError, match="exhausted"):
- budget(database, limit=1).reserve(b'{"max_tokens":16}')
-
- asyncio.run(scenario())
- assert len(reservations(database)) == 1
-
-
-@pytest.mark.parametrize("violation", ["output", "body", "remote-embedding"])
-def test_payload_budget_denial_is_terminal_before_any_physical_send(
- tmp_path, violation, monkeypatch
-):
- database = tmp_path / "budget.sqlite3"
- wire = Wire()
- monkeypatch.setattr("hy_memory.core.embed_service._truncate_for_embedding", lambda text: text)
-
- async def scenario():
- permission = lease()
- provider, embedder = native(permission)
- limits = budget(database, body_limit=100 if violation == "body" else 4096)
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- async with HyPermissionTransport(
- permission, http_transport=BudgetedModelTransport(limits, inner=wire)
- ) as transport:
- transport.bind_provider(provider)
- transport.bind_embed_service(embedder)
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- if violation == "remote-embedding":
- await embedder.embed("synthetic embedding", use_cache=False)
- else:
- await provider.complete(
- "synthetic body" * 20,
- max_tokens=65 if violation == "output" else 16,
- )
- with pytest.raises(HyPermissionTransportError, match="budget_exceeded"):
- await provider.complete("smaller", max_tokens=1)
- assert wire.requests == [] and reservations(database) == []
-
- asyncio.run(scenario())
-
-
-def test_budget_store_failure_cannot_send_without_a_reservation(tmp_path, monkeypatch):
- database = tmp_path / "budget.sqlite3"
- limits = budget(database)
- wire = Wire()
-
- def fail_to_open(*args, **kwargs):
- raise sqlite3.OperationalError("synthetic inaccessible budget store")
-
- async def scenario():
- permission = lease()
- provider, _ = native(permission)
- with pytest.raises(LLMRetryError, match="hy_memory_model_request_failed"):
- async with HyPermissionTransport(
- permission, http_transport=BudgetedModelTransport(limits, inner=wire)
- ) as transport:
- transport.bind_provider(provider)
- with monkeypatch.context() as scoped:
- # Pooled connections would outlive the patch; start cold.
- close_main_databases()
- scoped.setattr(sqlite3, "connect", fail_to_open)
- await provider.complete("must not send", max_tokens=16)
- assert wire.requests == []
-
- asyncio.run(scenario())
- assert reservations(database) == []
-
-
-def test_lease_rejection_precedes_budget_reservation(tmp_path):
- database = tmp_path / "budget.sqlite3"
- limits = budget(database)
- wire = Wire()
-
- async def scenario():
- permission = lease()
- provider, _ = native(permission)
- with pytest.raises(HyPermissionTransportError, match="lease_denied"):
- async with HyPermissionTransport(
- permission, http_transport=BudgetedModelTransport(limits, inner=wire)
- ) as transport:
- transport.bind_provider(provider)
- permission.revoked = True
- await provider.complete("revoked request", max_tokens=16)
- assert wire.requests == []
-
- asyncio.run(scenario())
- assert reservations(database) == []
-
-
-@pytest.mark.parametrize("limit", [None, 2])
-def test_actual_core_startup_creates_budget_only_when_opted_in(tmp_path, monkeypatch, limit):
- from one_memory.api import create_app
- from one_memory.secrets import InMemorySecretStore
-
- monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
- settings = Settings(
- _env_file=None,
- data_dir=tmp_path / "data",
- source_home=tmp_path / "home",
- automatic_memory_processing=False,
- imports_enabled=False,
- live_sync_enabled=False,
- live_sync_watch_enabled=False,
- model_request_limit=limit,
- )
- app = create_app(settings, source_catalog={}, secret_store=InMemorySecretStore())
-
- async def scenario():
- async with app.router.lifespan_context(app):
- assert (app.state.model_request_budget is None) is (limit is None)
- with sqlite3.connect(settings.data_dir / "one-memory.sqlite3") as db:
- table = db.execute(
- "SELECT name FROM sqlite_master WHERE name='model_request_budget_events'"
- ).fetchone()
- assert (table is None) is (limit is None)
- assert db.execute("SELECT COUNT(*) FROM model_profiles").fetchone()[0] == 0
- assert db.execute("SELECT COUNT(*) FROM memory_tasks").fetchone()[0] == 0
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("stop_mode", ["cancel", "cancel-shutdown"])
-def test_offthread_dispatch_drains_real_positive_check_without_late_queue(
- tmp_path, monkeypatch, stop_mode
-):
- h = Harness(tmp_path)
- h.grant()
- supervisor = h.supervisor()
- prepared = supervisor.prepare_conversation(h.record)
- assert len(prepared) == 1
- real_guard = supervisor._guard_task
- entered, release, exited = (threading.Event() for _ in range(3))
- checks = []
-
- async def scenario():
- loop_thread = threading.get_ident()
-
- def checked(task):
- assert threading.get_ident() != loop_thread
- allowed = real_guard(task)
- checks.append(allowed)
- assert allowed
- entered.set()
- assert release.wait(4)
- exited.set()
- return allowed
-
- monkeypatch.setattr(supervisor, "_guard_task", checked)
- pending = asyncio.create_task(supervisor.resume_off_thread())
- stopping = None
- try:
- assert await asyncio.to_thread(entered.wait, 3)
- stopping = (
- asyncio.create_task(supervisor.shutdown())
- if stop_mode == "cancel-shutdown"
- else pending
- )
- await asyncio.sleep(0.02)
- stopping.cancel()
- await asyncio.sleep(0.02)
- stopping.cancel()
- await asyncio.sleep(0.02)
- assert not pending.done() and not stopping.done()
- assert len(supervisor._dispatch_checks) == 1
- finally:
- release.set()
- await asyncio.gather(*({pending, stopping} - {None}), return_exceptions=True)
- await supervisor.shutdown()
- assert checks == [True] and exited.is_set()
- assert supervisor._dispatch_checks == set()
- assert len(supervisor._queue) == 0 and supervisor._workers == {}
- assert h.factory_calls == [] and h.bridge.calls == []
- assert h.tasks.list()[0].attempts == 0
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_model_scope_lifecycle.py b/memhub/apps/core/tests/test_model_scope_lifecycle.py
deleted file mode 100644
index add6770..0000000
--- a/memhub/apps/core/tests/test_model_scope_lifecycle.py
+++ /dev/null
@@ -1,205 +0,0 @@
-"""Bound derived/temporary data without changing consent or original history."""
-
-import sqlite3
-from concurrent.futures import ThreadPoolExecutor
-from threading import Barrier
-from unittest.mock import patch
-
-import pytest
-
-import one_memory.model_content_permissions as content
-from one_memory.model_content_permissions import ModelContentPermissionError
-from tests.test_model_content_permissions import USER, evidence_value
-from tests.test_model_content_permissions import env as env
-
-
-def rows(env, table):
- with sqlite3.connect(env.database) as db:
- return db.execute(f"SELECT * FROM {table} ORDER BY 1,2").fetchall()
-
-
-def tracked_grant(env, revision):
- return env.permissions.grant(
- env.snapshot(), expected_revision=revision, user_confirmed=True, _track_scope=True
- )
-
-
-def test_regrant_retires_only_old_derived_index_and_keeps_history(env):
- receipts = [tracked_grant(env, revision) for revision in range(3)]
- assert [(r[0], r[1]) for r in rows(env, "model_content_scope_seals")] == [(USER, 3)]
- assert [(r[0], r[1]) for r in rows(env, "model_content_scope_members")] == [(USER, 3)]
- for receipt in receipts:
- assert env.permissions.receipt(user_id=USER, revision=receipt.revision) == receipt
- with pytest.raises(ModelContentPermissionError, match="permission_changed"):
- env.check(receipts[0])
- with patch.object(
- env.permissions, "_evidence_batch", wraps=env.permissions._evidence_batch
- ) as batch:
- assert env.check(receipts[-1]) == receipts[-1]
- assert sum(len(call.args[1]) for call in batch.call_args_list) == 1
- assert len(rows(env, "evidence")) == 1
-
-
-def test_revoke_retires_derived_index_without_deleting_receipts_or_other_users(env):
- receipt = tracked_grant(env, 0)
- with sqlite3.connect(env.database) as db:
- db.execute(
- "INSERT INTO model_content_scope_seals "
- "SELECT 'other',revision,snapshot_hash,schema_version,valid "
- "FROM model_content_scope_seals"
- )
- db.execute(
- "INSERT INTO model_content_scope_members "
- "SELECT 'other',revision,evidence_id FROM model_content_scope_members"
- )
- before = {table: rows(env, table) for table in ("evidence", "source_grants")}
- revoked = env.permissions.revoke(user_id=USER, expected_revision=1, user_confirmed=True)
- assert [r[0] for r in rows(env, "model_content_scope_seals")] == ["other"]
- assert [r[0] for r in rows(env, "model_content_scope_members")] == ["other"]
- assert env.permissions.receipt(user_id=USER, revision=1) == receipt
- assert env.permissions.receipt(user_id=USER) == revoked
- assert before == {table: rows(env, table) for table in before}
- with pytest.raises(ModelContentPermissionError, match="permission_revoked"):
- env.check(receipt)
-
-
-def test_failed_replacement_rolls_back_index_cleanup_and_receipt(env, monkeypatch):
- receipt = tracked_grant(env, 0)
- tables = (
- "model_content_permissions",
- "model_content_scope_seals",
- "model_content_scope_members",
- )
- before = {table: rows(env, table) for table in tables}
-
- def fail_record(*_args, **_kwargs):
- raise sqlite3.OperationalError("controlled write failure")
-
- monkeypatch.setattr(content.model_content_seals, "record", fail_record)
- with pytest.raises(ModelContentPermissionError, match="permission_store_unavailable"):
- tracked_grant(env, 1)
- assert before == {table: rows(env, table) for table in tables}
- assert env.check(receipt) == receipt
-
-
-def test_unsealed_replacement_does_not_inherit_prior_seal(env):
- tracked_grant(env, 0)
- receipt = env.permissions.grant(env.snapshot(), expected_revision=1, user_confirmed=True)
- with sqlite3.connect(env.database) as db:
- db.execute("DELETE FROM model_content_scope_seals")
- db.execute("DELETE FROM model_content_scope_members")
- assert rows(env, "model_content_scope_seals") == []
- assert rows(env, "model_content_scope_members") == []
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE evidence SET verbatim='changed without revision'")
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
-
-
-def test_preview_byte_budget_evicts_earliest_without_changing_survivor(env, monkeypatch):
- snapshot = env.snapshot(future=True)
- size = len(snapshot.model_dump_json().encode())
- monkeypatch.setattr(content, "_RETAINED_PREVIEW_BYTES", size * 2, raising=False)
- previews = [env.permissions.retain_preview(snapshot) for _ in range(3)]
- saved = rows(env, "model_content_previews")
- assert len(saved) == 2
- assert {r[0] for r in saved} == {p["preview_id"] for p in previews[1:]}
- assert sum(len(r[4].encode()) for r in saved) <= size * 2
- with pytest.raises(ModelContentPermissionError, match="preview_expired"):
- env.permissions.grant_preview(
- previews[0]["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- receipt = env.permissions.grant_preview(
- previews[-1]["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- assert receipt.snapshot == snapshot and env.check(receipt) == receipt
-
-
-def test_single_oversized_preview_does_not_delete_valid_previous_preview(env, monkeypatch):
- snapshot = env.snapshot()
- first = env.permissions.retain_preview(snapshot)
- before = rows(env, "model_content_previews")
- monkeypatch.setattr(content, "_RETAINED_PREVIEW_BYTES", 1, raising=False)
- with pytest.raises(ModelContentPermissionError, match="content_scope_too_large"):
- env.permissions.retain_preview(snapshot)
- assert rows(env, "model_content_previews") == before
- assert first["preview_id"] == before[0][0]
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_preview_count_limit_and_expiry_keep_no_authority(env):
- snapshot = env.snapshot()
- first = env.permissions.retain_preview(snapshot)
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE model_content_previews SET expires_at='2000-01-01T00:00:00+00:00'")
- previews = [env.permissions.retain_preview(snapshot) for _ in range(17)]
- assert len(rows(env, "model_content_previews")) == 16
- for preview in (first, previews[0]):
- with pytest.raises(ModelContentPermissionError, match="preview_expired"):
- env.permissions.grant_preview(
- preview["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- assert env.permissions.receipt(user_id=USER) is None
- assert len(rows(env, "evidence")) == 1
-
-
-def test_preview_eviction_rolls_back_when_new_insert_fails(env, monkeypatch):
- snapshot = env.snapshot()
- monkeypatch.setattr(
- content, "_RETAINED_PREVIEW_BYTES", len(snapshot.model_dump_json().encode())
- )
- first = env.permissions.retain_preview(snapshot)
- with sqlite3.connect(env.database) as db:
- db.execute(
- "CREATE TRIGGER fail_preview_insert BEFORE INSERT ON model_content_previews "
- "BEGIN SELECT RAISE(ABORT,'controlled preview failure'); END"
- )
- before = rows(env, "model_content_previews")
- with pytest.raises(ModelContentPermissionError, match="permission_store_unavailable"):
- env.permissions.retain_preview(snapshot)
- assert rows(env, "model_content_previews") == before
- receipt = env.permissions.grant_preview(
- first["preview_id"], user_id=USER, expected_revision=0, user_confirmed=True
- )
- assert env.check(receipt) == receipt
-
-
-def test_concurrent_previews_respect_shared_byte_budget(env, monkeypatch):
- snapshot = env.snapshot(future=True)
- size = len(snapshot.model_dump_json().encode())
- monkeypatch.setattr(content, "_RETAINED_PREVIEW_BYTES", 2 * size)
- ready = Barrier(8)
-
- def retain(_index):
- ready.wait(timeout=5)
- return env.permissions.retain_preview(snapshot)
-
- with ThreadPoolExecutor(max_workers=8) as pool:
- returned = list(pool.map(retain, range(8)))
- saved = rows(env, "model_content_previews")
- assert len(saved) == 2
- assert sum(len(row[4].encode()) for row in saved) == 2 * size
- assert {row[0] for row in saved} <= {preview["preview_id"] for preview in returned}
- assert all(row[4] == snapshot.model_dump_json() for row in saved)
- assert env.permissions.receipt(user_id=USER) is None
-
-
-def test_regrant_cleanup_keeps_current_unrequested_member_invalidation(env):
- other = env.evidence.put(evidence_value("unrequested")).record
- for revision in range(3):
- receipt = env.permissions.grant(
- env.snapshot(records=(env.record, other)),
- expected_revision=revision,
- user_confirmed=True,
- _track_scope=True,
- )
- assert len(rows(env, "model_content_scope_seals")) == 1
- assert len(rows(env, "model_content_scope_members")) == 2
- assert env.check(receipt) == receipt
- with sqlite3.connect(env.database) as db:
- db.execute(
- "UPDATE evidence SET verbatim='changed but same revision/hash' WHERE evidence_id=?",
- (other.evidence_id,),
- )
- with pytest.raises(ModelContentPermissionError, match="evidence_changed"):
- env.check(receipt)
diff --git a/memhub/apps/core/tests/test_policy_api_wiring.py b/memhub/apps/core/tests/test_policy_api_wiring.py
index 1ca04a3..176e711 100644
--- a/memhub/apps/core/tests/test_policy_api_wiring.py
+++ b/memhub/apps/core/tests/test_policy_api_wiring.py
@@ -11,11 +11,10 @@ from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
-def _application(tmp_path, *, allowed: bool, sync_enabled: bool):
+def _application(tmp_path, *, sync_enabled: bool):
database = tmp_path / "one-memory.sqlite3"
evidence = EvidenceStore(database)
config = ConfigStore(tmp_path)
- config.set_model_content_allowed(allowed)
config.set_sync("cursor", sync_enabled)
queue = DurableWriteQueue(database, evidence=evidence)
return SimpleNamespace(
@@ -59,18 +58,8 @@ def _put_complete_turn(app) -> tuple[str, str]:
return tuple(values)
-def test_global_model_permission_off_does_not_backfill(tmp_path) -> None:
- app = _application(tmp_path, allowed=False, sync_enabled=True)
- _put_complete_turn(app)
-
- asyncio.run(_run_backfill(app))
-
- with sqlite3.connect(app.state.evidence_store.database_path) as connection:
- assert connection.execute("SELECT COUNT(*) FROM memory_write_jobs").fetchone()[0] == 0
-
-
def test_backfill_puts_history_on_write_jobs_without_scheduling_memory_tasks(tmp_path) -> None:
- app = _application(tmp_path, allowed=True, sync_enabled=True)
+ app = _application(tmp_path, sync_enabled=True)
evidence_ids = _put_complete_turn(app)
asyncio.run(_run_backfill(app))
@@ -93,7 +82,7 @@ def test_backfill_puts_history_on_write_jobs_without_scheduling_memory_tasks(tmp
def test_source_pause_skips_history_until_the_source_is_reenabled(tmp_path) -> None:
- app = _application(tmp_path, allowed=True, sync_enabled=False)
+ app = _application(tmp_path, sync_enabled=False)
_put_complete_turn(app)
asyncio.run(_run_backfill(app))
diff --git a/memhub/apps/core/tests/test_policy_operation_lookup.py b/memhub/apps/core/tests/test_policy_operation_lookup.py
deleted file mode 100644
index 40710bc..0000000
--- a/memhub/apps/core/tests/test_policy_operation_lookup.py
+++ /dev/null
@@ -1,248 +0,0 @@
-import json
-import sqlite3
-from contextlib import closing, contextmanager
-from uuid import uuid4
-
-import pytest
-from fastapi import Depends, FastAPI, Header, HTTPException
-from fastapi.testclient import TestClient
-
-from one_memory.model_policy_activation import ModelPolicyActivation
-from one_memory.model_policy_routes import continuous_policy_router, safe_policy
-from tests.test_model_content_permissions import env as env # noqa: F401
-from tests.test_model_policy_activation import request
-
-ROOT = "/api/v1/continuous-processing-policy/operations/"
-
-
-def client(env, callback=None):
- app = FastAPI()
- app.state.model_policy_activation = ModelPolicyActivation(env.database, env.secrets)
-
- def auth(authorization: str | None = Header(None)):
- if authorization != "Bearer fixture":
- raise HTTPException(401, "unauthorized")
-
- app.include_router(continuous_policy_router([Depends(auth)], on_enabled=callback))
- return TestClient(app, headers={"Authorization": "Bearer fixture"})
-
-
-def activate(env):
- return ModelPolicyActivation(env.database, env.secrets).enable(**request(env)).policy
-
-
-def database_dump(database):
- # WAL checkpoints can change the main file bytes on closing a read handle.
- # Compare actual schema/data instead, keeping all rows rather than just counts.
- with closing(sqlite3.connect(database)) as db:
- return tuple(db.iterdump())
-
-
-def test_missing_table_returns_not_found_without_initializing_or_reading_secrets(env, monkeypatch):
- def forbidden(*args):
- pytest.fail("lookup must not read or mutate secrets")
-
- for name in ("has", "resolve", "put", "delete"):
- monkeypatch.setattr(env.secrets, name, forbidden)
- before = database_dump(env.database)
- with client(env) as http:
- result = http.get(ROOT + str(uuid4()))
- assert result.status_code == 200, result.text
- assert result.json() == {"state": "not_found", "receipt": None, "current_revision": 0}
- assert database_dump(env.database) == before
- with sqlite3.connect(env.database) as db:
- assert not db.execute(
- "SELECT 1 FROM sqlite_master WHERE name='continuous_policy_history'"
- ).fetchone()
-
-
-def test_current_operation_uses_safe_receipt_without_callback_or_writes(env, monkeypatch):
- receipt = activate(env)
-
- async def forbidden_callback(_receipt):
- pytest.fail("GET cannot invoke activation callback")
-
- monkeypatch.setattr(env.secrets, "has", lambda *args: pytest.fail("no credential read"))
- before = database_dump(env.database)
- with client(env, forbidden_callback) as http:
- result = http.get(ROOT + receipt.operation_id)
- assert result.status_code == 200, result.text
- assert result.json() == {
- "state": "current",
- "receipt": safe_policy(receipt).model_dump(),
- "current_revision": 1,
- }
- assert not {"profile", "secret_ref", "backlog_events"} & result.json()["receipt"].keys()
- assert database_dump(env.database) == before
-
-
-def test_superseded_operation_preserves_historical_receipt_not_current_activation(env):
- first = activate(env)
- latest = (
- ModelPolicyActivation(env.database, env.secrets)
- .transition(
- operation_id=str(uuid4()),
- expected_policy_revision=1,
- action="revoke",
- user_confirmed=True,
- )
- .policy
- )
- with client(env) as http:
- historical = http.get(ROOT + first.operation_id)
- assert historical.status_code == 200, historical.text
- assert historical.json() == {
- "state": "superseded",
- "receipt": safe_policy(first).model_dump(),
- "current_revision": latest.revision,
- }
- result = http.get(ROOT + latest.operation_id).json()
- assert result["state"] == "current" and result["receipt"]["state"] == "revoked"
- assert http.get(ROOT + str(uuid4())).json() == {
- "state": "not_found",
- "receipt": None,
- "current_revision": latest.revision,
- }
- assert ModelPolicyActivation(env.database, env.secrets).current() == latest
-
-
-@pytest.mark.parametrize(
- "value",
- [
- "invalid",
- "a" * 32,
- "ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB",
- "{abcdefab-cdef-abcd-efab-cdefabcdefab}",
- ],
-)
-def test_noncanonical_uuid_is_422(env, value):
- with client(env) as http:
- result = http.get(ROOT + value)
- assert result.status_code == 422, result.text
-
-
-def test_lookup_requires_authentication(env):
- receipt = activate(env)
- with client(env) as http:
- result = http.get(ROOT + receipt.operation_id, headers={"Authorization": "bad"})
- assert result.status_code == 401
- assert receipt.profile.profile_id not in result.text
-
-
-def test_other_user_operation_is_not_disclosed(env):
- own = activate(env)
- other_op = str(uuid4())
- with sqlite3.connect(env.database) as db:
- data = {**own.model_dump(mode="json"), "user_id": "other", "operation_id": other_op}
- db.execute(
- "INSERT INTO continuous_policy_history VALUES(?,?,?,?,?)",
- ("other", 1, other_op, "a" * 64, json.dumps(data)),
- )
- with client(env) as http:
- result = http.get(ROOT + other_op)
- assert result.status_code == 200
- assert result.json() == {"state": "not_found", "receipt": None, "current_revision": 1}
-
-
-@pytest.mark.parametrize(
- "field,value",
- [
- ("revision", 99),
- ("revision", "1"),
- ("operation_id", "different"),
- ("user_id", "other"),
- ("secret_ref", "must-not-leak"),
- ],
-)
-def test_row_receipt_mismatch_or_extra_fields_returns_safe_503(env, field, value):
- receipt = activate(env)
- with sqlite3.connect(env.database) as db:
- data = {**receipt.model_dump(mode="json"), field: value}
- db.execute("UPDATE continuous_policy_history SET receipt_json=?", (json.dumps(data),))
- with client(env) as http:
- result = http.get(ROOT + receipt.operation_id)
- assert result.status_code == 503, result.text
- assert "must-not-leak" not in result.text
- assert "receipt" not in result.json()
-
-
-def test_corrupt_current_row_cannot_make_history_or_missing_operation_look_safe(env):
- first = activate(env)
- ModelPolicyActivation(env.database, env.secrets).transition(
- operation_id=str(uuid4()), expected_policy_revision=1, action="pause", user_confirmed=True
- )
- with sqlite3.connect(env.database) as db:
- db.execute("UPDATE continuous_policy_history SET receipt_json='broken' WHERE revision=2")
- with client(env) as http:
- assert http.get(ROOT + first.operation_id).status_code == 503
- assert http.get(ROOT + str(uuid4())).status_code == 503
-
-
-def test_sql_failure_is_503_not_not_found(env):
- activate(env)
- with sqlite3.connect(env.database) as db:
- db.execute("ALTER TABLE continuous_policy_history RENAME COLUMN receipt_json TO broken")
- with client(env) as http:
- result = http.get(ROOT + str(uuid4()))
- assert result.status_code == 503
- assert "broken" not in result.text
-
-
-def test_missing_database_is_503_and_is_not_created(env):
- missing = env.database.parent / "missing-policy.sqlite3"
- with client(env) as http:
- http.app.state.model_policy_activation = ModelPolicyActivation(missing, env.secrets)
- response = http.get(ROOT + str(uuid4()))
- assert response.status_code == 503
- assert not missing.exists()
-
-
-def test_lookup_connection_rejects_writes_and_preserves_database(env, monkeypatch):
- receipt = activate(env)
- service = ModelPolicyActivation(env.database, env.secrets)
- exists = service._exists
- rejected = []
-
- def check_read_only(db):
- assert db.in_transaction
- with pytest.raises(sqlite3.OperationalError, match="readonly"):
- db.execute("DELETE FROM continuous_policy_history")
- rejected.append(True)
- return exists(db)
-
- monkeypatch.setattr(service, "_exists", check_read_only)
- assert service.lookup_operation(receipt.operation_id).receipt == receipt
- assert rejected == [True]
- assert ModelPolicyActivation(env.database, env.secrets).current() == receipt
-
-
-def test_lookup_reads_operation_and_current_in_the_same_snapshot(env, monkeypatch):
- receipt = activate(env)
- service = ModelPolicyActivation(env.database, env.secrets)
- connect = service._connect
- writes = []
-
- @contextmanager
- def observing_connect(*, write=False):
- assert not write
-
- def trace(sql):
- if "AND operation_id=" in sql and not writes:
- writes.append("concurrent pause")
- ModelPolicyActivation(env.database, env.secrets).transition(
- operation_id=str(uuid4()),
- expected_policy_revision=1,
- action="pause",
- user_confirmed=True,
- )
-
- with connect() as db:
- db.set_trace_callback(trace)
- yield db
-
- monkeypatch.setattr(service, "_connect", observing_connect)
- result = service.lookup_operation(receipt.operation_id)
- assert writes == ["concurrent pause"]
- assert result.state == "current" and result.current_revision == 1
- assert result.receipt == receipt
- assert ModelPolicyActivation(env.database, env.secrets).current().revision == 2
diff --git a/memhub/apps/core/tests/test_processing_coordinator.py b/memhub/apps/core/tests/test_processing_coordinator.py
deleted file mode 100644
index 050863a..0000000
--- a/memhub/apps/core/tests/test_processing_coordinator.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import asyncio
-from types import SimpleNamespace
-
-from one_memory.memory_server.config_state import ConfigStore
-from one_memory.processing_coordinator import ProcessingCoordinator
-
-
-class Tasks:
- def __init__(self) -> None:
- self.value = {}
-
- def counts(self):
- return dict(self.value)
-
-
-def test_monitor_is_retained_and_observes_config_plus_tasks(tmp_path) -> None:
- async def scenario() -> None:
- config = ConfigStore(tmp_path)
- tasks = Tasks()
- resumed = 0
-
- async def resume_allowed() -> None:
- nonlocal resumed
- resumed += 1
-
- supervisor = SimpleNamespace(tasks=tasks, resume_allowed=resume_allowed)
- coordinator = ProcessingCoordinator(config, supervisor)
- coordinator.POLL_SECONDS = 0.01
- coordinator.RESCAN_SECONDS = 60
- try:
- await coordinator.recover()
- await asyncio.sleep(0.03)
- assert coordinator.status()["monitoring"] is True
- assert resumed == 0
-
- config.set_model_content_allowed(True)
- await asyncio.sleep(0.03)
- assert resumed == 1
-
- tasks.value = {"pending": 1}
- await asyncio.sleep(0.03)
- assert resumed == 2
-
- config.set_model_content_allowed(False)
- tasks.value = {"pending": 2}
- await asyncio.sleep(0.03)
- assert resumed == 2
- assert coordinator.status()["allowed"] is False
- finally:
- await coordinator.shutdown()
-
- assert coordinator.status()["monitoring"] is False
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_processing_merged_context.py b/memhub/apps/core/tests/test_processing_merged_context.py
deleted file mode 100644
index f232e85..0000000
--- a/memhub/apps/core/tests/test_processing_merged_context.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import asyncio
-from uuid import uuid4
-
-import pytest
-
-from one_memory.memory_governance import HyLink, MemoryDraft, MemoryKind
-from one_memory.memory_tasks import MemoryTaskOutput, MemoryTaskStatus, evidence_ref
-from tests.person_door import as_person
-from tests.test_continuous_processing_bridge import enable
-from tests.test_model_processing_permissions import h as h # noqa: F401
-
-
-@pytest.mark.parametrize("merged_support", [False, True])
-def test_new_chunk_omits_known_memory_with_changed_support_but_keeps_original_history(
- h, merged_support
-):
- enable(h)
- old = h.put("completed")
- supervisor = h.supervisor()
- h.bridge.fail = False
-
- async def scenario():
- try:
- first = supervisor._enqueue_chunk((old,), h.profile)
- assert (
- await supervisor._create_runner().run(first.task_id)
- ).status == MemoryTaskStatus.COMPLETED
- memory = as_person(h.governance).create(
- MemoryDraft(
- user_id="local",
- kind=MemoryKind.CHAT,
- content="Synthetic shared preference",
- evidence_ids=(old.evidence_id,),
- scopes=("all-apps",),
- ),
- request_id=str(uuid4()),
- )
- h.tasks.save_output(
- MemoryTaskOutput(
- task_id=first.task_id,
- memory_id=memory.memory_id,
- memory_revision=memory.revision,
- hy_memory_id="synthetic-known-memory",
- owner="user",
- layer="L2_FACT",
- engine_version="controlled-test",
- support_refs=(evidence_ref(old),),
- )
- )
- outside = h.put("other-conversation", conversation="another")
- if merged_support:
- h.governance.write_from_hy(
- HyLink(memory_id=memory.memory_id, evidence_id=outside.evidence_id),
- user_id="local",
- derivation=h.governance.hy_derivation((outside.evidence_id,)),
- )
- changed = h.governance.get(memory.memory_id, user_id="local")
- assert changed.revision == memory.revision
- assert set(changed.evidence_ids) == {old.evidence_id, outside.evidence_id}
- fresh = h.put("next")
- next_task = supervisor._enqueue_chunk((fresh,), h.profile)
- result = await supervisor._create_runner().run(next_task.task_id)
- assert result.status == MemoryTaskStatus.COMPLETED
- context = h.leases[-1].source_context
- assert context.current == (fresh,)
- assert context.history == (old,)
- assert len(context.known_memories) == (0 if merged_support else 1)
- assert outside.evidence_id not in {
- record.evidence_id for record in h.leases[-1].records
- }
- finally:
- await supervisor.shutdown()
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_recall_fusion_live.py b/memhub/apps/core/tests/test_recall_fusion_live.py
index b63b3e6..8abb525 100644
--- a/memhub/apps/core/tests/test_recall_fusion_live.py
+++ b/memhub/apps/core/tests/test_recall_fusion_live.py
@@ -36,7 +36,7 @@ from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.recall import _terms
from one_memory.secrets import KeyringSecretStore
-from tests.source_consent import confirm_model_content, connection_request
+from tests.source_consent import connection_request
PROFILE_DB = os.environ.get("ONE_MEMORY_MODEL_TEST_DB")
pytestmark = [
@@ -127,7 +127,6 @@ def test_both_lanes_answer_through_one_real_hy_instance(tmp_path, monkeypatch):
linked.raise_for_status()
job = await app.state.import_supervisor.wait(linked.json()["jobs"][0]["job_id"])
assert job.stored == 1 and job.errors == 0, job
- await confirm_model_content(client, source_ids=(receipt["source_id"],))
processor = app.state.memory_processing_supervisor
await asyncio.gather(*tuple(processor._debounces.values()), return_exceptions=True)
await asyncio.wait_for(asyncio.gather(*tuple(processor._workers.values())), timeout=300)
diff --git a/memhub/apps/core/tests/test_schema_ownership_gate.py b/memhub/apps/core/tests/test_schema_ownership_gate.py
index ff3f791..e88b79c 100644
--- a/memhub/apps/core/tests/test_schema_ownership_gate.py
+++ b/memhub/apps/core/tests/test_schema_ownership_gate.py
@@ -88,10 +88,6 @@ KNOWN_INLINE_DDL = frozenset(
# migration 29, and `test_call_event_ddl_matches_the_store_that_compares_it`
# pins the two together.
"call_event_store.py",
- # Direct store construction predates the migration runner in narrow
- # tests and repair tools. The canonical table also lives in migration
- # 48; this mirrored bootstrap is pinned until those callers migrate.
- "model_content_permissions.py",
# Deliberate exception, not an oversight (D93 A). These two tables are
# a derived cache of a decision the read path already makes, and they
# must NOT go through a migration: `schema_gate` derives the build's
diff --git a/memhub/apps/core/tests/test_settings_policy_lifecycle.py b/memhub/apps/core/tests/test_settings_policy_lifecycle.py
deleted file mode 100644
index 5255494..0000000
--- a/memhub/apps/core/tests/test_settings_policy_lifecycle.py
+++ /dev/null
@@ -1,385 +0,0 @@
-"""Settings data-plane lifecycle: real temporary Core, fake probe/HY, no UI or package."""
-
-import asyncio
-import sqlite3
-from contextlib import closing
-from uuid import uuid4
-
-import httpx
-import pytest
-
-from one_memory.continuous_permission_bridge import ContinuousPermissionReceipt
-from one_memory.hy_memory_bridge import HyMemoryBridgeError
-from one_memory.memory_tasks import MemoryTaskStatus
-from one_memory.model_probe import ModelProber
-from tests.test_continuous_processing_integration import API_ROOT
-from tests.test_continuous_processing_integration import core as core # noqa: F401
-
-
-async def save(core, name="model-a", *, probe=True):
- response = await core.client.post(
- "/api/v1/model-profiles",
- json={
- "name": name,
- "provider_kind": "openai_compatible",
- "base_url": f"https://{name}.invalid/v1",
- "model": name,
- "api_key": "synthetic-key-not-a-credential",
- "embedding_provider_kind": "openai_compatible",
- "embedding_base_url": f"https://{name}.invalid/v1",
- "embedding_model": "integration-vector",
- "disable_thinking": True,
- "max_concurrency": 1,
- },
- )
- assert response.status_code == 200, response.text
- profile = response.json()
- assert profile["is_active"] is False
- return await probe_profile(core, profile) if probe else profile
-
-
-async def probe_profile(core, profile):
- # Extend the fixture's synthetic model list for two genuinely distinct models;
- # all other probe stages keep the existing controlled boundary and payload checks.
- def respond(request):
- if request.url.path.endswith("/models"):
- core.probe_requests.append((request.url.path, ""))
- return httpx.Response(200, json={"data": [{"id": profile["model"]}]})
- return core.probe_transport(request)
-
- core.app.state.model_prober = ModelProber(
- store=core.app.state.model_profile_store,
- secrets=core.secrets,
- transport=httpx.MockTransport(respond),
- )
- result = await core.client.post(f"/api/v1/model-profiles/{profile['profile_id']}/probe")
- assert result.status_code == 200, result.text
- assert result.json()["input_profile_revision"] == profile["revision"]
- profiles = await core.client.get("/api/v1/model-profiles")
- assert profiles.status_code == 200
- measured = next(p for p in profiles.json() if p["profile_id"] == profile["profile_id"])
- assert measured["revision"] == result.json()["profile_revision"]
- assert measured["embedding_dimensions"] == 3
- assert all(
- measured["capabilities"][key]
- for key in ("endpoint_reachable", "model_available", "embedding_available")
- )
- return measured
-
-
-async def current(core):
- response = await core.client.get(API_ROOT)
- assert response.status_code == 200, response.text
- return response.json()
-
-
-async def enable(core, profile, **overrides):
- before = await current(core)
- body = {
- "operation_id": str(uuid4()),
- "profile_id": profile["profile_id"],
- "profile_revision": profile["revision"],
- "expected_policy_revision": before["policy"]["revision"] if before["policy"] else 0,
- "expected_legacy_revision": before["legacy_revision"],
- "backlog_mode": "none",
- "user_confirmed": True,
- **overrides,
- }
- return await core.client.post(API_ROOT + "/enable", json=body), body
-
-
-async def enabled(core, profile, **overrides):
- response, body = await enable(core, profile, **overrides)
- assert response.status_code == 200, response.text
- value = response.json()
- # One DB read snapshot observes policy and active model together.
- with closing(sqlite3.connect(core.database)) as db:
- db.execute("BEGIN")
- active = db.execute("SELECT profile_id,revision FROM model_profiles WHERE is_active=1")
- assert active.fetchall() == [(profile["profile_id"], profile["revision"])]
- saved = db.execute(
- "SELECT receipt_json FROM continuous_policy_history WHERE user_id='local' "
- "ORDER BY revision DESC LIMIT 1"
- ).fetchone()
- assert saved is not None
- assert core.policy_current().profile.profile_id == value["profile_id"]
- assert value["profile_id"] == profile["profile_id"]
- assert value["profile_revision"] == profile["revision"]
- return value, body
-
-
-def guard(core):
- result = core.app.state.memory_processing_supervisor._permission_guard
- assert result is not None
- return result
-
-
-def check_current(core, record):
- authority = guard(core).current("local")
- assert isinstance(authority, ContinuousPermissionReceipt), "Must never fall back to legacy"
- guard(core).check(authority, authority.policy.profile, (record,))
- return authority
-
-
-async def assert_processed_by(core, record, profile):
- tasks = await core.run_prepared(record)
- assert tasks and all(task.status == MemoryTaskStatus.COMPLETED for task in tasks)
- assert all(
- (task.profile_id, task.profile_revision) == (profile["profile_id"], profile["revision"])
- for task in tasks
- )
- assert core.bridge.requests and record.evidence_id in core.bridge.requests[-1].evidence_ids
- assert core.bridge.leases[-1].profile.profile_id == profile["profile_id"]
- await core.assert_governed(record)
-
-
-async def legacy_grant(core, profile):
- active = await core.client.post(f"/api/v1/model-profiles/{profile['profile_id']}/activate")
- assert active.status_code == 200, active.text
- preview = await core.client.post(
- "/api/v1/model-content-permission/preview",
- json={
- "profile_id": profile["profile_id"],
- "profile_revision": profile["revision"],
- "source_ids": ["cursor"],
- "all_imported_from_selected_sources": True,
- "include_future_additions": True,
- },
- )
- assert preview.status_code == 200, preview.text
- result = await core.client.post(
- "/api/v1/model-content-permission",
- json={"snapshot": preview.json(), "expected_revision": 0, "user_confirmed": True},
- )
- assert result.status_code == 200, result.text
- return core.app.state.model_content_permissions.receipt(user_id="local")
-
-
-def test_no_onboarding_or_legacy_permission_required_for_save_probe_enable(core):
- async def scenario():
- async with core.running():
- onboarding = (await core.client.get("/api/v1/onboarding")).json()
- assert onboarding["status"] != "completed"
- profile = await save(core)
- assert profile["revision"] == 2 # probe measured width from unspecified to 3
- assert (await current(core))["policy"] is None
- assert not core.bridge.requests and not core.app.state.memory_task_store.list()
- await enabled(core, profile)
- assert (await core.client.get("/api/v1/onboarding")).json() == onboarding
- assert core.app.state.model_content_permissions.receipt(user_id="local") is None
- core.grant_source()
- record = core.put_complete("settings-first", conversation="settings-first")
- check_current(core, record)
- await assert_processed_by(core, record, profile)
-
- asyncio.run(scenario())
-
-
-def test_saved_unprobed_draft_does_not_activate_or_create_policy(core):
- async def scenario():
- async with core.running():
- core.grant_source()
- record = core.put("draft-only")
- profile = await save(core, probe=False)
- assert not profile["is_active"]
- assert core.app.state.model_profile_store.active_identity() is None
- assert (await current(core))["policy"] is None
- with pytest.raises(HyMemoryBridgeError, match="permission_required"):
- await asyncio.to_thread(
- core.app.state.memory_processing_supervisor.prepare_conversation, record
- )
- assert not core.probe_requests and not core.bridge.requests
- assert core.app.state.memory_task_store.list() == []
-
- asyncio.run(scenario())
-
-
-def test_second_saved_probed_profile_does_not_replace_enabled_model(core):
- async def scenario():
- async with core.running():
- first = await save(core)
- original, _ = await enabled(core, first)
- second = await save(core, "model-b")
- assert second["profile_id"] != first["profile_id"]
- assert (await current(core))["policy"] == original
- assert core.app.state.model_profile_store.active_identity() == (
- first["profile_id"],
- first["revision"],
- )
- assert not core.bridge.requests
- core.grant_source()
- record = core.put_complete("after-second-draft", conversation="second-draft")
- await assert_processed_by(core, record, first)
-
- asyncio.run(scenario())
-
-
-def test_switch_distinct_profiles_atomically_binds_new_policy_and_rejects_old_authority(core):
- async def scenario():
- async with core.running():
- first = await save(core)
- old, old_body = await enabled(core, first)
- core.grant_source()
- before = core.put_complete("before-switch", conversation="old-model")
- old_authority = check_current(core, before)
- second = await save(core, "model-b")
- new, body = await enabled(core, second)
- assert new["revision"] == old["revision"] + 1 and new["epoch"] != old["epoch"]
- with pytest.raises(HyMemoryBridgeError):
- guard(core).check(old_authority, old_authority.policy.profile, (before,))
- repeat = await core.client.post(API_ROOT + "/enable", json=body)
- assert repeat.status_code == 200 and repeat.json() == new
- assert (await core.client.post(API_ROOT + "/enable", json=old_body)).status_code == 409
- fresh = core.put_complete("after-switch", conversation="new-model")
- await assert_processed_by(core, fresh, second)
- assert all(before.evidence_id not in req.evidence_ids for req in core.bridge.requests)
-
- asyncio.run(scenario())
-
-
-def test_failed_policy_cas_leaves_previous_profile_and_policy_unchanged(core):
- async def scenario():
- async with core.running():
- first = await save(core)
- original, _ = await enabled(core, first)
- second = await save(core, "model-b")
- rejected, _ = await enable(core, second, expected_policy_revision=0)
- assert rejected.status_code == 409, rejected.text
- assert (await current(core))["policy"] == original
- assert core.app.state.model_profile_store.active_identity() == (
- first["profile_id"],
- first["revision"],
- )
- assert not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-def test_unprobed_new_model_cannot_replace_a_working_policy(core):
- async def scenario():
- async with core.running():
- first = await save(core)
- original, _ = await enabled(core, first)
- second = await save(core, "model-b", probe=False)
- rejected, _ = await enable(core, second)
- assert rejected.status_code == 409 and "probe_required" in rejected.text
- assert (await current(core))["policy"] == original
- assert core.app.state.model_profile_store.active_identity() == (
- first["profile_id"],
- first["revision"],
- )
- assert not core.bridge.requests
-
- asyncio.run(scenario())
-
-
-def test_active_profile_edit_invalidates_guard_until_explicit_reenable(core):
- async def scenario():
- async with core.running():
- first = await save(core)
- original, _ = await enabled(core, first)
- core.grant_source()
- patch = await core.client.patch(
- f"/api/v1/model-profiles/{first['profile_id']}",
- json={"expected_revision": first["revision"], "model": "edited-model"},
- )
- assert patch.status_code == 200, patch.text
- edited = patch.json()
- assert not edited["is_active"] and edited["revision"] == first["revision"] + 1
- blocked = core.put_complete("after-edit", conversation="edited")
- with pytest.raises(HyMemoryBridgeError):
- check_current(core, blocked)
- edited = await probe_profile(core, edited)
- assert (await current(core))["policy"] == original
- with pytest.raises(HyMemoryBridgeError):
- check_current(core, blocked)
- assert not core.bridge.requests
- renewed, _ = await enabled(core, edited, backlog_mode="include")
- assert renewed["revision"] == original["revision"] + 1
- await core.wait_completed(timeout=4)
- await core.assert_governed(blocked)
- assert all(lease.profile.model == "edited-model" for lease in core.bridge.leases)
-
- asyncio.run(scenario())
-
-
-def test_revoke_then_new_enable_changes_epoch_without_restoring_revoked_period(core):
- async def scenario():
- async with core.running():
- first = await save(core)
- original, _ = await enabled(core, first)
- revoked = await core.client.post(
- API_ROOT + "/revoke",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": original["revision"],
- "user_confirmed": True,
- },
- )
- assert revoked.status_code == 200, revoked.text
- denied = await core.client.post(
- API_ROOT + "/resume",
- json={
- "operation_id": str(uuid4()),
- "expected_policy_revision": revoked.json()["revision"],
- "user_confirmed": True,
- },
- )
- assert denied.status_code == 409 and "requires_new_enable" in denied.text
- core.grant_source()
- excluded = core.put("during-revoke", conversation="revoked-period")
- with pytest.raises(HyMemoryBridgeError):
- check_current(core, excluded)
- second = await save(core, "model-b")
- renewed, _ = await enabled(core, second)
- assert renewed["epoch"] != original["epoch"]
- with pytest.raises(HyMemoryBridgeError, match="outside_policy"):
- check_current(core, excluded)
- fresh = core.put_complete("after-reenable", conversation="renewed")
- await assert_processed_by(core, fresh, second)
- assert all(excluded.evidence_id not in req.evidence_ids for req in core.bridge.requests)
-
- asyncio.run(scenario())
-
-
-@pytest.mark.parametrize("change", ["delete", "clear_key"])
-def test_unavailable_policy_profile_never_falls_back_to_real_legacy_grant(core, change):
- async def scenario():
- async with core.running():
- core.grant_source()
- first = await save(core)
- legacy = await legacy_grant(core, first)
- original, _ = await enabled(core, first)
- assert original["legacy_revision"] == legacy.revision
- initial = core.put("positive-authority", conversation="before-unavailable")
- authority = check_current(core, initial)
- patch = await core.client.patch(
- f"/api/v1/model-profiles/{first['profile_id']}",
- json={"expected_revision": first["revision"], "clear_api_key": True},
- )
- assert patch.status_code == 200 and not patch.json()["is_active"]
- if change == "delete":
- deleted = await core.client.delete(f"/api/v1/model-profiles/{first['profile_id']}")
- assert deleted.status_code == 204, deleted.text
- assert (
- await core.client.post(f"/api/v1/model-profiles/{first['profile_id']}/probe")
- ).status_code == 404
- else:
- missing = await core.client.post(
- f"/api/v1/model-profiles/{first['profile_id']}/probe"
- )
- assert missing.status_code == 200
- assert missing.json()["stages"][0]["code"] == "missing_api_key"
- assert core.app.state.model_content_permissions.receipt(user_id="local") == legacy
- assert core.policy_current().epoch == original["epoch"]
- next_record = core.put("after-unavailable", conversation="after-unavailable")
- with pytest.raises(HyMemoryBridgeError):
- guard(core).check(authority, authority.policy.profile, (next_record,))
- with pytest.raises(HyMemoryBridgeError):
- await asyncio.to_thread(
- core.app.state.memory_processing_supervisor.prepare_conversation, next_record
- )
- assert not core.bridge.requests
- assert core.app.state.memory_governance_store.list(user_id="local") == []
-
- asyncio.run(scenario())
diff --git a/memhub/apps/core/tests/test_simplified_model_permission_migrations.py b/memhub/apps/core/tests/test_simplified_model_permission_migrations.py
deleted file mode 100644
index 75d1868..0000000
--- a/memhub/apps/core/tests/test_simplified_model_permission_migrations.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import asyncio
-import sqlite3
-
-from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
-from one_memory.memory_server.migrations import MIGRATIONS
-
-
-def test_fresh_schema_omits_removed_permission_state_machines(tmp_path) -> None:
- path = tmp_path / "state.sqlite3"
- asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
-
- removed = {
- "continuous_policy_history",
- "processing_requests",
- "model_content_scope_seals",
- "model_content_scope_members",
- "memory_task_model_permissions",
- "memory_task_continuous_permissions",
- "model_content_permissions",
- "model_content_evidence_additions",
- "model_content_previews",
- "model_content_authority_state",
- }
- with sqlite3.connect(path) as connection:
- tables = {
- row[0]
- for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
- }
-
- assert removed.isdisjoint(tables)
- assert "memory_tasks" in tables
diff --git a/memhub/apps/core/tests/test_subagent_sessions.py b/memhub/apps/core/tests/test_subagent_sessions.py
deleted file mode 100644
index f7924f8..0000000
--- a/memhub/apps/core/tests/test_subagent_sessions.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""D98: a sub agent's transcript is its own conversation, not part of the main one.
-
-Claude Code writes sidechain records into the same jsonl as the main session.
-Mixing them in gives HY one long conversation whose questions are not the
-person's, and dropping them loses the sub agent's work. Each sidechain has to
-become a conversation of its own; the main one keeps only its own records.
-"""
-
-import asyncio
-import json
-
-from one_memory.imports.sources.claude_code import create_claude_code_adapter
-from tests.test_primary_source_adapters import evidence, scan
-from tests.test_tool_evidence_hy_turns import _grant
-
-
-def test_a_sidechain_record_gets_its_own_conversation(tmp_path) -> None:
- root = tmp_path / "claude"
- (root / "project").mkdir(parents=True)
- transcript = root / "project" / "session.jsonl"
-
- def line(**overrides: object) -> dict[str, object]:
- record: dict[str, object] = {
- "type": "user",
- "sessionId": "session",
- "cwd": "/private/work/acme",
- "timestamp": "2026-09-01T00:00:00Z",
- "message": {"content": [{"type": "text", "text": "一句话"}]},
- }
- record.update(overrides)
- return record
-
- transcript.write_text(
- "\n".join(
- json.dumps(record, ensure_ascii=False)
- for record in (
- line(uuid="m1"),
- line(uuid="m2", type="assistant"),
- # Real sidechain records carry the agent they belong to; the
- # fixture has to, or the parser cannot group them.
- line(uuid="s1", isSidechain=True, agentId="agent-a"),
- line(uuid="s2", type="assistant", isSidechain=True, agentId="agent-a"),
- line(uuid="m3", type="assistant"),
- )
- )
- + "\n",
- encoding="utf-8",
- )
-
- records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root)))))
- by_message = {item.evidence.message_id: item.evidence for item in records}
-
- main_ids = {"m1", "m2", "m3"}
- sub_ids = {"s1", "s2"}
- assert by_message.keys() >= main_ids | sub_ids, sorted(by_message)
-
- main_conversations = {by_message[message_id].conversation_id for message_id in main_ids}
- sub_conversations = {by_message[message_id].conversation_id for message_id in sub_ids}
- assert len(main_conversations) == 1
- assert len(sub_conversations) == 1
- # Separate, so HY never sees the sub agent's turns as part of the person's.
- assert main_conversations.isdisjoint(sub_conversations)
-
-
-def test_the_switch_off_skips_sub_agent_transcripts(tmp_path, monkeypatch) -> None:
- """`ONE_MEMORY_IMPORT_SUBAGENT_TRACE=0`: the sub agent's trace is not imported.
-
- The privacy-preserving option — the sub-agent trace need not be sent at all. Off
- means the whole transcript is skipped at the scan, not dropped later.
- """
- monkeypatch.setenv("ONE_MEMORY_IMPORT_SUBAGENT_TRACE", "0")
- root = tmp_path / "claude"
- (root / "project").mkdir(parents=True)
- (root / "project" / "subagents").mkdir()
-
- def write(path, records):
- path.write_text(
- "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + "\n",
- encoding="utf-8",
- )
-
- write(
- root / "project" / "session.jsonl",
- [
- {
- "type": "user",
- "sessionId": "session",
- "uuid": "m1",
- "cwd": "/private/work",
- "timestamp": "2026-09-01T00:00:00Z",
- "message": {"content": [{"type": "text", "text": "本人问的"}]},
- },
- {
- "type": "user",
- "sessionId": "session",
- "uuid": "s1",
- "isSidechain": True,
- "message": {"content": [{"type": "text", "text": "子 agent 的"}]},
- },
- ],
- )
- write(
- root / "project" / "subagents" / "child.jsonl",
- [
- {
- "type": "user",
- "sessionId": "child",
- "uuid": "child-u1",
- "message": {"content": [{"type": "text", "text": "委派任务"}]},
- }
- ],
- )
-
- records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root)))))
- ids = {item.evidence.message_id for item in records}
-
- assert "m1" in ids, "the main transcript must still import"
- assert "s1" not in ids, "a sidechain record was imported with the switch off"
- assert "child-u1" not in ids, "a subagents/ transcript was imported with the switch off"
-
-
-def test_two_sub_agents_do_not_share_a_conversation(tmp_path) -> None:
- """Each delegation is its own thread.
-
- One main session can delegate more than once. A single shared suffix would
- splice every sub agent into one conversation and hand HY a transcript that
- never happened — which is worse than the old behaviour of dropping them.
- """
- root = tmp_path / "claude"
- (root / "project").mkdir(parents=True)
- transcript = root / "project" / "session.jsonl"
-
- def line(**overrides: object) -> dict[str, object]:
- record: dict[str, object] = {
- "type": "user",
- "sessionId": "session",
- "cwd": "/private/work",
- "timestamp": "2026-09-01T00:00:00Z",
- "message": {"content": [{"type": "text", "text": "一句话"}]},
- }
- record.update(overrides)
- return record
-
- transcript.write_text(
- "\n".join(
- json.dumps(record, ensure_ascii=False)
- for record in (
- line(uuid="m1"),
- line(uuid="a1-1", isSidechain=True, agentId="agent-a"),
- line(uuid="b1-1", isSidechain=True, agentId="agent-b"),
- line(uuid="a1-2", type="assistant", isSidechain=True, agentId="agent-a"),
- line(uuid="m2", type="assistant"),
- )
- )
- + "\n",
- encoding="utf-8",
- )
-
- records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root)))))
- by_message = {item.evidence.message_id: item.evidence for item in records}
-
- assert by_message["m1"].conversation_id == by_message["m2"].conversation_id
- main = by_message["m1"].conversation_id
- agent_a = {by_message["a1-1"].conversation_id, by_message["a1-2"].conversation_id}
- agent_b = {by_message["b1-1"].conversation_id}
-
- # One thread per delegation, and neither is the main one.
- assert len(agent_a) == 1
- assert agent_a != agent_b
- assert main not in agent_a | agent_b
-
-
-def test_sidechain_records_without_an_agent_share_one_conversation(tmp_path) -> None:
- """Known limit, pinned: no `agentId` means one `{session}:subagent` thread.
-
- Two delegations that both lack the field would be spliced together. No
- real transcript omits it, so this is not guessed at from `parentUuid`
- chains — it is left as the one conversation and recorded here, so a change
- in behaviour has to say why.
- """
- root = tmp_path / "claude"
- (root / "project").mkdir(parents=True)
- transcript = root / "project" / "session.jsonl"
-
- def line(**overrides: object) -> dict[str, object]:
- record: dict[str, object] = {
- "type": "user",
- "sessionId": "session",
- "cwd": "/private/work",
- "timestamp": "2026-09-01T00:00:00Z",
- "message": {"content": [{"type": "text", "text": "一句话"}]},
- }
- record.update(overrides)
- return record
-
- transcript.write_text(
- "\n".join(
- json.dumps(record, ensure_ascii=False)
- for record in (
- line(uuid="m1"),
- line(uuid="s1", isSidechain=True),
- line(uuid="s2", type="assistant", isSidechain=True),
- )
- )
- + "\n",
- encoding="utf-8",
- )
-
- records = evidence(asyncio.run(scan(create_claude_code_adapter(_grant("claude-code", root)))))
- by_message = {item.evidence.message_id: item.evidence for item in records}
-
- assert by_message["s1"].conversation_id == by_message["s2"].conversation_id
- assert by_message["s1"].conversation_id != by_message["m1"].conversation_id
diff --git a/memhub/apps/core/tests/test_subagent_turns.py b/memhub/apps/core/tests/test_subagent_turns.py
deleted file mode 100644
index 22890cf..0000000
--- a/memhub/apps/core/tests/test_subagent_turns.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""D98: a sub agent's turn is ordinary user-assistant data.
-
-The host parser marks a sub agent's question as written by the agent, and the
-turn assembly then kept only `user` + `PERSON` — so Claude Code sent just the
-first question of a transcript, and a turn holding only a sub agent's question
-was dropped whole.
-
-The `role` field cannot say who wrote a message, so filtering on the author
-threw away real turns. Author stays: it labels the display and decides owner.
-It no longer decides whether a message is sent.
-"""
-
-import asyncio
-
-from one_memory.imports.models import WrittenBy
-from tests.test_hy_turn_input import (
- FakeBridge,
- _runner,
- _setup,
- _task,
-)
-from tests.test_memory_processing import _put
-
-
-def test_a_sub_agent_question_reaches_hy_with_its_answer(tmp_path) -> None:
- """Acceptance case 1: the sub agent's Q and A are both sent."""
- stores = _setup(tmp_path)
- evidence, _profiles, tasks, _governance, _secrets, profile = stores
- bridge = FakeBridge()
-
- person = _put(
- evidence,
- message_id="u0",
- role="user",
- written_by=WrittenBy.PERSON,
- content="帮我看下这个仓库的测试为什么挂了",
- )
- sub_question = _put(
- evidence,
- message_id="u1",
- role="user",
- written_by=WrittenBy.AGENT,
- content="读取 tests/test_import.py 并找出失败原因",
- )
- sub_answer = _put(
- evidence,
- message_id="a1",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="失败原因是 fixture 没清理",
- )
-
- asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, person).task_id))
-
- request = bridge.requests[0]
- roles = [turn.role for turn in request.turns]
- contents = [turn.content for turn in request.turns]
- assert roles == ["user", "user", "assistant"], roles
- assert any("读取 tests/test_import.py" in content for content in contents)
- assert sub_question.evidence_id in request.evidence_ids
- assert sub_answer.evidence_id in request.evidence_ids
-
-
-def test_a_turn_of_only_a_sub_agent_question_is_not_dropped(tmp_path) -> None:
- """Acceptance case 2: other hosts lost the whole turn here."""
- stores = _setup(tmp_path)
- evidence, _profiles, tasks, _governance, _secrets, profile = stores
- bridge = FakeBridge()
-
- sub_question = _put(
- evidence,
- message_id="u0",
- role="user",
- written_by=WrittenBy.AGENT,
- content="这条是子 agent 的追问",
- )
- _put(
- evidence,
- message_id="a0",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="这条是它的回答",
- )
-
- asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, sub_question).task_id))
-
- assert bridge.requests, "a turn with no question by the person was dropped"
- assert [turn.role for turn in bridge.requests[0].turns] == ["user", "assistant"]
-
-
-def test_a_turn_by_the_person_is_unchanged(tmp_path) -> None:
- """Acceptance case 3: regression — the ordinary case sends the same."""
- stores = _setup(tmp_path)
- evidence, _profiles, tasks, _governance, _secrets, profile = stores
- bridge = FakeBridge()
-
- person = _put(
- evidence,
- message_id="u0",
- role="user",
- written_by=WrittenBy.PERSON,
- content="我以后都用中文记",
- )
- _put(
- evidence,
- message_id="a0",
- role="assistant",
- written_by=WrittenBy.AGENT,
- content="好的,记下了",
- )
-
- asyncio.run(_runner(tmp_path, stores, bridge).run(_task(tasks, profile, person).task_id))
-
- request = bridge.requests[0]
- assert [turn.role for turn in request.turns] == ["user", "assistant"]
- assert [turn.content for turn in request.turns] == ["我以后都用中文记", "好的,记下了"]
diff --git a/memhub/apps/core/tests/test_tool_evidence_hy_turns.py b/memhub/apps/core/tests/test_tool_evidence_hy_turns.py
deleted file mode 100644
index 2a165d8..0000000
--- a/memhub/apps/core/tests/test_tool_evidence_hy_turns.py
+++ /dev/null
@@ -1,586 +0,0 @@
-"""Every adapter's tool records reach HY inside the turn they happened in.
-
-Each case starts from the source's own on-disk shape, imports it through
-`ImportRunner`, and reads back what the processing path hands HY. Adapter
-tests stop at the evidence row and turn tests start from hand-built rows;
-this is the seam between them: a call filed under the wrong conversation,
-dropped from a mixed block, or ordered by something other than when it
-happened would pass both and still reach HY in the wrong turn.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import sqlite3
-from pathlib import Path
-
-from one_memory.imports.access import AuthorizedSourceRoot
-from one_memory.imports.adapters import SourceAdapter
-from one_memory.imports.jobs import ImportJobStore, ImportStatus
-from one_memory.imports.runner import ImportRunner
-from one_memory.imports.sources.claude_code import create_claude_code_adapter
-from one_memory.imports.sources.cursor_unified import CursorUnifiedSourceAdapter
-from one_memory.imports.sources.deepseek_harness import create_deepseek_harness_adapter
-from one_memory.imports.sources.hermes import create_hermes_adapter
-from one_memory.imports.sources.openclaw import (
- OPENCLAW_DESCRIPTOR,
- OpenClawSourceAdapter,
-)
-from one_memory.imports.sources.opencode import create_opencode_adapter
-from one_memory.imports.sources.pi import create_pi_adapter
-from one_memory.imports.sources.qwen_code import create_qwen_code_adapter
-from one_memory.memory_processing import MemoryTaskRunner, _conversation_chunks
-from tests.test_hy_turn_input import _supervisor
-from tests.test_memory_processing import FakeBridge, _runner, _setup
-
-SECRET = "sk-abcdefghijklmnopqrstu"
-
-#: (role, a marker the content must contain), in the order HY must see them.
-Expected = list[tuple[str, str]]
-
-FIRST_TURN: Expected = [
- ("user", "第一个问题"),
- ("tool", "pytest --token"),
- ("tool", "3 failed"),
- ("assistant", "第一个结论"),
-]
-SECOND_TURN: Expected = [
- ("user", "第二个问题"),
- ("tool", "git status"),
- ("tool", "working tree clean"),
- ("assistant", "第二个结论"),
-]
-FIRST_ONLY = ("pytest --token", "3 failed", "第一个")
-SECOND_ONLY = ("git status", "working tree clean", "第二个")
-
-
-def _grant(source_id: str, root: Path) -> AuthorizedSourceRoot:
- return AuthorizedSourceRoot.grant(
- source_id=source_id, root=root, user_confirmed=True, home=root.parent / "other-home"
- )
-
-
-def _write_jsonl(path: Path, records: list[dict[str, object]]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text("".join(f"{json.dumps(r, ensure_ascii=False)}\n" for r in records), "utf-8")
-
-
-def _import(tmp_path: Path, adapter: SourceAdapter, source_id: str):
- stores = _setup(tmp_path)
- jobs = ImportJobStore(tmp_path / "one-memory.sqlite3")
- job = jobs.create(user_id="local", source_id=source_id)
- done = asyncio.run(
- ImportRunner(evidence_store=stores[0], job_store=jobs).run(
- adapter=adapter, job_id=job.job_id
- )
- )
- assert done.status is ImportStatus.COMPLETED
- return stores
-
-
-def _requests_to_hy(tmp_path: Path, adapter: SourceAdapter, source_id: str) -> list[Expected]:
- """What the processing path sends HY for every imported conversation, call by call."""
- stores = _import(tmp_path, adapter, source_id)
- evidence = stores[0]
- supervisor = _supervisor(tmp_path, stores)
- bridge = FakeBridge(outputs=())
- runner: MemoryTaskRunner = _runner(tmp_path, stores, bridge)
- for seed_id in evidence.list_conversation_seed_ids():
- seed = evidence.get(seed_id)
- assert seed is not None
- for task in supervisor.prepare_conversation(seed):
- asyncio.run(runner.run(task.task_id))
- return [[(turn.role, turn.content) for turn in request.turns] for request in bridge.requests]
-
-
-def _assert_turns(sent: list[Expected], *expected: Expected) -> None:
- assert len(sent) == len(expected), sent
- for turns, wanted in zip(sent, expected, strict=True):
- assert [role for role, _ in turns] == [role for role, _ in wanted], turns
- for (_, content), (_, marker) in zip(turns, wanted, strict=True):
- assert marker in content, (marker, turns)
- assert all(SECRET not in content for _, content in turns)
-
-
-def _assert_two_turns(sent: list[Expected]) -> None:
- _assert_turns(sent, FIRST_TURN, SECOND_TURN)
- first, second = ("\n".join(content for _, content in turns) for turns in sent)
- assert not any(marker in first for marker in SECOND_ONLY)
- assert not any(marker in second for marker in FIRST_ONLY)
-
-
-def test_claude_code_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None:
- root = tmp_path / "claude"
- at = iter(f"2026-09-20T10:00:{second:02d}.000Z" for second in range(60))
-
- def line(uuid: str, kind: str, content: object) -> dict[str, object]:
- return {
- "type": kind,
- "uuid": uuid,
- "sessionId": "s",
- "cwd": "/private/work/acme",
- "timestamp": next(at),
- "message": {"role": kind, "content": content},
- }
-
- def call(uuid: str, command: str, *, saying: str | None = None) -> dict[str, object]:
- block = {"type": "tool_use", "id": uuid, "name": "Bash", "input": {"command": command}}
- said = [{"type": "text", "text": saying}] if saying else []
- return line(uuid, "assistant", [*said, block])
-
- def result(uuid: str, text: str) -> dict[str, object]:
- block = {"type": "tool_result", "tool_use_id": uuid, "content": text}
- return line(uuid, "user", [block])
-
- _write_jsonl(
- root / "project" / "s.jsonl",
- [
- line("u1", "user", "第一个问题:跑测试"),
- call("c1", f"pytest --token {SECRET}"),
- result("r1", "3 failed"),
- line("a1", "assistant", [{"type": "text", "text": "第一个结论"}]),
- line("u2", "user", "第二个问题:看状态"),
- # Said something and called a tool in one record.
- call("c2", "git status", saying="先看一下。"),
- result("r2", "working tree clean"),
- line("a2", "assistant", [{"type": "text", "text": "第二个结论"}]),
- ],
- )
-
- sent = _requests_to_hy(
- tmp_path, create_claude_code_adapter(_grant("claude-code", root)), "claude-code"
- )
-
- second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]]
- _assert_turns(sent, FIRST_TURN, second)
- assert sent[0][1][1].startswith("[Bash] ")
- first, later = ("\n".join(content for _, content in turns) for turns in sent)
- assert not any(marker in first for marker in SECOND_ONLY)
- assert not any(marker in later for marker in FIRST_ONLY)
-
-
-def test_cursor_transcript_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None:
- """Cursor transcripts carry no timestamps; file order is the only order there is.
-
- An assistant message routinely says something and calls a tool in the same
- content list; the call must not be lost because the text was kept.
- """
- root = tmp_path / ".cursor"
-
- def user(text: str) -> dict[str, object]:
- block = {"type": "text", "text": f"{text} "}
- return {"role": "user", "message": {"content": [block]}}
-
- def assistant(*blocks: dict[str, object]) -> dict[str, object]:
- return {"role": "assistant", "message": {"content": list(blocks)}}
-
- def call(command: str) -> dict[str, object]:
- return {"type": "tool_use", "name": "Shell", "input": {"command": command}}
-
- def result(text: str) -> dict[str, object]:
- block = {"type": "tool_result", "tool_use_id": "t", "content": text}
- return {"role": "user", "message": {"content": [block]}}
-
- _write_jsonl(
- root / "projects" / "workspace" / "agent-transcripts" / "chat" / "chat.jsonl",
- [
- user("第一个问题:跑测试"),
- assistant(call(f"pytest --token {SECRET}")),
- result("3 failed"),
- assistant({"type": "text", "text": "第一个结论"}),
- user("第二个问题:看状态"),
- assistant({"type": "text", "text": "先看一下。"}, call("git status")),
- result("working tree clean"),
- assistant({"type": "text", "text": "第二个结论"}),
- ],
- )
-
- sent = _requests_to_hy(
- tmp_path, CursorUnifiedSourceAdapter(grant=_grant("cursor", root)), "cursor"
- )
-
- second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]]
- _assert_turns(sent, FIRST_TURN, second)
-
-
-def test_pi_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None:
- root = tmp_path / "pi"
- root.mkdir()
- at = iter(f"2026-09-20T10:00:{second:02d}.000Z" for second in range(60))
-
- def message(entry_id: str, role: str, content: object, **extra: object) -> dict[str, object]:
- body = {"role": role, "content": content, **extra}
- return {"type": "message", "id": entry_id, "timestamp": next(at), "message": body}
-
- def call(call_id: str, command: str) -> dict[str, object]:
- return {
- "type": "toolCall",
- "id": call_id,
- "name": "bash",
- "arguments": {"command": command},
- }
-
- def result(entry_id: str, call_id: str, text: str) -> dict[str, object]:
- return message(
- entry_id,
- "toolResult",
- [{"type": "text", "text": text}],
- toolCallId=call_id,
- toolName="bash",
- )
-
- _write_jsonl(
- root / "session-t.jsonl",
- [
- {"type": "session", "id": "session-t", "cwd": "/Users/example/work/atlas"},
- message("u1", "user", [{"type": "text", "text": "第一个问题:跑测试"}]),
- message("c1", "assistant", [call("k1", f"pytest --token {SECRET}")]),
- result("r1", "k1", "3 failed"),
- message("a1", "assistant", [{"type": "text", "text": "第一个结论"}]),
- message("u2", "user", [{"type": "text", "text": "第二个问题:看状态"}]),
- message(
- "c2",
- "assistant",
- [{"type": "text", "text": "先看一下。"}, call("k2", "git status")],
- ),
- result("r2", "k2", "working tree clean"),
- message("a2", "assistant", [{"type": "text", "text": "第二个结论"}]),
- ],
- )
-
- sent = _requests_to_hy(tmp_path, create_pi_adapter(_grant("pi", root)), "pi")
-
- second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]]
- _assert_turns(sent, FIRST_TURN, second)
-
-
-def test_qwen_code_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None:
- root = tmp_path / "qwen-code"
- session = "a1b2c3d4-0000-4000-8000-000000000003"
- at = iter(f"2026-09-20T10:00:{second:02d}.000Z" for second in range(60))
- base = {"sessionId": session, "cwd": "/Users/example/work/atlas"}
-
- def user(uuid: str, text: str) -> dict[str, object]:
- return {**base, "uuid": uuid, "timestamp": next(at), "type": "user", "text": text}
-
- def model(uuid: str, *parts: dict[str, object]) -> dict[str, object]:
- return {
- **base,
- "uuid": uuid,
- "timestamp": next(at),
- "type": "assistant",
- "provenance": "assistant_output",
- "message": {"role": "model", "parts": list(parts)},
- }
-
- def call(command: str) -> dict[str, object]:
- return {"functionCall": {"name": "run_shell_command", "args": {"command": command}}}
-
- def result(uuid: str, text: str) -> dict[str, object]:
- response = {"name": "run_shell_command", "response": {"output": text}}
- return {
- **base,
- "uuid": uuid,
- "timestamp": next(at),
- "type": "tool_result",
- "provenance": "tool_result",
- "message": {"role": "user", "parts": [{"functionResponse": response}]},
- }
-
- _write_jsonl(
- root / "-Users-example-work-atlas" / "chats" / f"{session}.jsonl",
- [
- user("u1", "第一个问题:跑测试"),
- model("c1", call(f"pytest --token {SECRET}")),
- result("r1", "3 failed"),
- model("a1", {"text": "第一个结论"}),
- user("u2", "第二个问题:看状态"),
- model("c2", {"text": "先看一下。"}, call("git status")),
- result("r2", "working tree clean"),
- model("a2", {"text": "第二个结论"}),
- ],
- )
-
- sent = _requests_to_hy(
- tmp_path, create_qwen_code_adapter(_grant("qwen-code", root)), "qwen-code"
- )
-
- second = [("user", "第二个问题"), ("assistant", "先看一下"), *SECOND_TURN[1:]]
- _assert_turns(sent, FIRST_TURN, second)
-
-
-def test_deepseek_harness_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None:
- root = tmp_path / "dsh"
- seq = iter(range(1, 100))
-
- def event(kind: str, data: dict[str, object]) -> dict[str, object]:
- number = next(seq)
- return {
- "type": kind,
- "seq": number,
- "time": f"2026-09-20T10:00:{number:02d}Z",
- "data": data,
- }
-
- def user(message_id: str, text: str) -> dict[str, object]:
- content = [{"type": "text", "text": text}]
- body = {"id": message_id, "role": "user", "source": {"kind": "user"}, "content": content}
- return event("user/message", body)
-
- def assistant(message_id: str, *blocks: dict[str, object]) -> dict[str, object]:
- body = {"id": message_id, "role": "assistant", "content": list(blocks)}
- return event("assistant/message", {"message": body})
-
- def call(call_id: str, command: str) -> list[dict[str, object]]:
- arguments = json.dumps({"command": command})
- block = {"type": "tool-call", "id": call_id, "name": "shell", "arguments": arguments}
- # Upstream stores the call twice: inside the assistant message and as its own event.
- return [
- assistant(f"m-{call_id}", block),
- event("tool/call", {"callId": call_id, "name": "shell", "arguments": arguments}),
- ]
-
- def result(call_id: str, text: str) -> dict[str, object]:
- block = {
- "type": "tool-result",
- "toolCallId": call_id,
- "content": [{"type": "text", "text": text}],
- "isError": False,
- }
- body = {
- "id": f"result-{call_id}",
- "role": "user",
- "source": {"kind": "tool", "callId": call_id},
- "content": [block],
- }
- return event("tool/result", {"message": body})
-
- _write_jsonl(
- root / "run-t" / "session.jsonl",
- [
- {"type": "session", "id": "run-t", "cwd": "/private/project"},
- user("u1", "第一个问题:跑测试"),
- *call("k1", f"pytest --token {SECRET}"),
- result("k1", "3 failed"),
- assistant("a1", {"type": "text", "text": "第一个结论"}),
- user("u2", "第二个问题:看状态"),
- *call("k2", "git status"),
- result("k2", "working tree clean"),
- assistant("a2", {"type": "text", "text": "第二个结论"}),
- ],
- )
-
- sent = _requests_to_hy(
- tmp_path,
- create_deepseek_harness_adapter(_grant("deepseek-harness", root)),
- "deepseek-harness",
- )
-
- _assert_two_turns(sent)
-
-
-def test_hermes_tool_calls_reach_hy_inside_their_turn(tmp_path: Path) -> None:
- """Hermes keeps the call on the assistant row (`tool_calls`) and the result as a `tool` row.
-
- `timestamp` is whole seconds here, so rows written in the same second tie;
- the row id is the order Hermes wrote them in.
- """
- root = tmp_path / "hermes"
- root.mkdir()
- connection = sqlite3.connect(root / "state.db")
- connection.executescript(
- """
- CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, cwd TEXT);
- CREATE TABLE messages (
- id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
- tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, reasoning TEXT,
- timestamp INTEGER, active INTEGER
- );
- INSERT INTO sessions VALUES ('s', 'cli', '/private/project');
- """
- )
-
- def calls(command: str) -> str:
- arguments = json.dumps({"command": command})
- return json.dumps([{"id": "c", "function": {"name": "terminal", "arguments": arguments}}])
-
- connection.executemany(
- "INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- [
- (1, "s", "user", "第一个问题:跑测试", None, None, None, None, 1000, 1),
- (2, "s", "assistant", "", None, calls(f"pytest --token {SECRET}"), None, None, 1001, 1),
- (3, "s", "tool", "3 failed", "c", None, "terminal", None, 1001, 1),
- (4, "s", "assistant", "第一个结论", None, None, None, None, 1001, 1),
- (5, "s", "user", "第二个问题:看状态", None, None, None, None, 1002, 1),
- (6, "s", "assistant", "", None, calls("git status"), None, None, 1003, 1),
- (7, "s", "tool", "working tree clean", "c", None, "terminal", None, 1003, 1),
- (8, "s", "assistant", "第二个结论", None, None, None, None, 1003, 1),
- ],
- )
- connection.commit()
- connection.close()
-
- sent = _requests_to_hy(tmp_path, create_hermes_adapter(_grant("hermes", root)), "hermes")
-
- _assert_two_turns(sent)
- assert sent[0][1][1].startswith("[terminal] ")
-
-
-def _assembled_for_hy(tmp_path: Path, adapter: SourceAdapter, source_id: str) -> list[Expected]:
- """The turns HY would get for each imported conversation, assembled the processing way.
-
- For sources whose `user` rows are not attributed to the person, the
- processing path does not schedule the conversation at all, so the
- assembly is read directly.
- """
- evidence = _import(tmp_path, adapter, source_id)[0]
- with sqlite3.connect(tmp_path / "one-memory.sqlite3") as db:
- members = [
- row[0]
- for row in db.execute("SELECT MIN(evidence_id) FROM evidence GROUP BY conversation_id")
- ]
- requests: list[Expected] = []
- for member in members:
- for chunk in _conversation_chunks(evidence.list_conversation(member)):
- request = MemoryTaskRunner._request(_FakeTask, chunk)
- requests.append([(turn.role, turn.content) for turn in request.turns])
- return requests
-
-
-class _FakeTask:
- request_id = "assembly-only"
-
-
-def _assert_in_source_order(sent: list[Expected], expected: Expected) -> None:
- assert len(sent) == 1, sent
- _assert_turns(sent, expected)
-
-
-def test_openclaw_tool_rows_reach_hy_in_the_order_they_happened(tmp_path: Path) -> None:
- """`created_at` is whole seconds; a call and its result share one.
-
- Row order is what breaks the tie, not the hash of an id.
- """
- root = tmp_path / "openclaw"
- root.mkdir()
- connection = sqlite3.connect(root / "conversations.db")
- connection.executescript(
- """
- CREATE TABLE conversations (id TEXT PRIMARY KEY, workspace_path TEXT, git_root TEXT);
- CREATE TABLE messages (
- id TEXT PRIMARY KEY, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT
- );
- INSERT INTO conversations VALUES ('c', '/private/project', NULL);
- """
- )
- connection.executemany(
- "INSERT INTO messages VALUES (?, ?, ?, ?, ?)",
- [
- ("m1", "c", "user", "第一个问题:跑测试", "2026-09-20T10:00:00Z"),
- ("m2", "c", "tool", f"pytest --token {SECRET}", "2026-09-20T10:00:01Z"),
- ("m3", "c", "tool", "3 failed", "2026-09-20T10:00:01Z"),
- ("m4", "c", "assistant", "第一个结论", "2026-09-20T10:00:01Z"),
- ("m5", "c", "user", "第二个问题:看状态", "2026-09-20T10:00:02Z"),
- ("m6", "c", "tool", "git status", "2026-09-20T10:00:03Z"),
- ("m7", "c", "tool", "working tree clean", "2026-09-20T10:00:03Z"),
- ("m8", "c", "assistant", "第二个结论", "2026-09-20T10:00:03Z"),
- ],
- )
- connection.commit()
- connection.close()
-
- # Reader-shape coverage only. The public factory is paused; the negative
- # control lives in test_c1_openclaw_paused.py.
- adapter = OpenClawSourceAdapter(OPENCLAW_DESCRIPTOR, _grant("openclaw", root))
- sent = _assembled_for_hy(tmp_path, adapter, "openclaw")
-
- # D98: a `user` row reaches HY whoever the parser says wrote it, so the
- # questions are here now — what must hold is that everything is still in
- # the order it happened.
- _assert_in_source_order(sent, FIRST_TURN + SECOND_TURN)
-
-
-def test_opencode_tool_parts_reach_hy_in_the_order_they_happened(tmp_path: Path) -> None:
- """A message's tool parts carry the message's own time, so they tie with its text.
-
- OpenCode keeps one assistant message across several steps: text, a tool
- call, more text. The call must stay between the two.
- """
- root = tmp_path / "opencode"
- root.mkdir()
- connection = sqlite3.connect(root / "opencode.db")
- connection.executescript(
- """
- CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT);
- CREATE TABLE message (
- id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT
- );
- CREATE TABLE part (
- id TEXT PRIMARY KEY, message_id TEXT, time_created INTEGER, data TEXT
- );
- INSERT INTO session VALUES ('s', '/private/project');
- """
- )
- at = iter(range(1_758_000_000_000, 1_758_000_100_000, 1000))
-
- def message(message_id: str, role: str, *parts: dict[str, object]) -> None:
- connection.execute(
- "INSERT INTO message VALUES (?, 's', ?, ?)",
- (message_id, next(at), json.dumps({"role": role})),
- )
- for index, data in enumerate(parts):
- connection.execute(
- "INSERT INTO part VALUES (?, ?, ?, ?)",
- (f"{message_id}-p{index}", message_id, next(at), json.dumps(data)),
- )
-
- def tool(command: str, output: str) -> dict[str, object]:
- state = {"status": "completed", "input": {"command": command}, "output": output}
- return {"type": "tool", "tool": "bash", "callID": command, "state": state}
-
- message("u1", "user", {"type": "text", "text": "第一个问题:跑测试"})
- message(
- "a1",
- "assistant",
- tool(f"pytest --token {SECRET}", "3 failed"),
- {"type": "text", "text": "第一个结论"},
- )
- message("u2", "user", {"type": "text", "text": "第二个问题:看状态"})
- message(
- "a2",
- "assistant",
- {"type": "text", "text": "先看一下。"},
- tool("git status", "working tree clean"),
- {"type": "text", "text": "第二个结论"},
- )
- connection.commit()
- connection.close()
-
- sent = _assembled_for_hy(
- tmp_path, create_opencode_adapter(_grant("opencode", root)), "opencode"
- )
-
- # One tool part is one row: OpenCode keeps a call and its output together.
- # D98: the questions are here now too — a `user` row reaches HY whoever
- # the parser says wrote it — and everything stays in the order it happened.
- _assert_in_source_order(
- sent,
- [
- ("user", "第一个问题:跑测试"),
- ("tool", "[bash] 3 failed"),
- ("assistant", "第一个结论"),
- ("user", "第二个问题:看状态"),
- ("assistant", "先看一下。"),
- ("tool", "[bash] working tree clean"),
- ("assistant", "第二个结论"),
- ],
- )
-
-
-def test_source_positions_compare_as_numbers_and_leave_the_file_key_alone() -> None:
- from one_memory.imports.sources.common import sortable_ordinal
-
- assert sortable_ordinal("f0a9:byte:900") < sortable_ordinal("f0a9:byte:1000")
- assert sortable_ordinal("f0a9:row:12:1") < sortable_ordinal("f0a9:row:12:10")
- assert sortable_ordinal("f0a9:byte:900").startswith("f0a9:byte:")
diff --git a/memhub/apps/web/src/App.import-navigation.test.tsx b/memhub/apps/web/src/App.import-navigation.test.tsx
index 21db28a..3f4d120 100644
--- a/memhub/apps/web/src/App.import-navigation.test.tsx
+++ b/memhub/apps/web/src/App.import-navigation.test.tsx
@@ -117,7 +117,6 @@ vi.mock("./MatterWorkspace", () => ({ MatterWorkspace: () => null }));
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
vi.mock("./FirstUseGuide", () => ({ FirstUseGuide: () => null }));
vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null }));
-vi.mock("./ModelContentConsent", () => ({ ModelContentConsent: () => null }));
beforeEach(() => {
vi.clearAllMocks();
diff --git a/memhub/apps/web/src/App.r6.test.tsx b/memhub/apps/web/src/App.r6.test.tsx
index 65435c4..cc33510 100644
--- a/memhub/apps/web/src/App.r6.test.tsx
+++ b/memhub/apps/web/src/App.r6.test.tsx
@@ -42,7 +42,6 @@ vi.mock("./MatterWorkspace", () => ({ MatterWorkspace: () => null }));
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
vi.mock("./FirstUseGuide", () => ({ FirstUseGuide: () => null }));
vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null }));
-vi.mock("./ModelContentConsent", () => ({ ModelContentConsent: () => null }));
beforeEach(() => {
window.history.replaceState({}, "", "/");
diff --git a/memhub/apps/web/src/App.setup.test.tsx b/memhub/apps/web/src/App.setup.test.tsx
index 82b5810..9c6f1c8 100644
--- a/memhub/apps/web/src/App.setup.test.tsx
+++ b/memhub/apps/web/src/App.setup.test.tsx
@@ -31,14 +31,12 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
modelProps: vi.fn(),
mutations: {
activateModelProfile: vi.fn(),
- setModelContentPermission: vi.fn(),
},
}));
vi.mock("./api", () => ({
getOnboarding,
getModelProfiles: vi.fn().mockResolvedValue([]),
- getModelContentPermission: vi.fn().mockResolvedValue({ allowed: false }),
getSources: vi.fn().mockResolvedValue(sources),
...mutations,
}));
@@ -46,17 +44,8 @@ vi.mock("./use-import-sources", () => ({
useImportSources: () => ({ sources, jobs: [], loading: false, health: null }),
}));
vi.mock("./MemorySpace", () => ({
- MemorySpace: ({
- onReviewProcessing,
- onConfigureModel,
- }: {
- onReviewProcessing?(): void;
- onConfigureModel(): void;
- }) => (
+ MemorySpace: ({ onConfigureModel }: { onConfigureModel(): void }) => (
-
- 打开模型内容权限
-
设置记忆整理
@@ -79,24 +68,6 @@ vi.mock("./ModelSettings", () => ({
return ;
},
}));
-vi.mock("./ModelContentConsent", () => ({
- ModelContentConsent: ({
- onClose,
- onConfigureModel,
- }: {
- onClose(): void;
- onConfigureModel?(): void;
- }) => (
-
- ),
-}));
vi.mock("./OnboardingFlow", () => ({
OnboardingFlow: ({ onFinish }: { onFinish(target: string): void }) => (
@@ -127,27 +98,11 @@ beforeEach(() => {
});
afterEach(cleanup);
-function noPermissionWrites() {
+function noModelWrites() {
for (const mutation of Object.values(mutations))
expect(mutation).not.toHaveBeenCalled();
}
-it("opens model-content permission over the memory workbench", async () => {
- window.history.replaceState({}, "", "/?view=memory");
- render(
);
- await userEvent.click(
- await screen.findByRole("button", { name: "打开模型内容权限" }),
- );
- expect(screen.getByRole("dialog", { name: "模型内容权限" })).toBeTruthy();
- expect(window.location.search).toBe("?view=memory");
- expect(screen.queryByText("应用列表")).toBeNull();
- await userEvent.click(screen.getByRole("button", { name: "关闭" }));
- expect(screen.queryByRole("dialog", { name: "模型内容权限" })).toBeNull();
- expect(getOnboarding).toHaveBeenCalledTimes(1);
- expect(screen.getByRole("region", { name: "测试工作台" })).toBeTruthy();
- noPermissionWrites();
-});
-
it("enters D47 settings without a tour or mandatory second scope-confirmation flow", async () => {
getOnboarding.mockResolvedValue({ status: "new" });
render(
);
@@ -164,13 +119,12 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
expect(
screen.queryByRole("button", { name: "继续:确认整理范围" }),
).toBeNull();
- expect(screen.queryByRole("dialog", { name: "模型内容权限" })).toBeNull();
- noPermissionWrites();
+ noModelWrites();
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
expect(screen.getByRole("button", { name: "模型" })).toBeTruthy();
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull();
- noPermissionWrites();
+ noModelWrites();
});
it("enters the workbench without an automatic tour or removed help section", async () => {
@@ -187,7 +141,7 @@ it("enters the workbench without an automatic tour or removed help section", asy
await userEvent.click(within(nav).getByRole("button", { name: "设置" }));
expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull();
expect(screen.queryByRole("dialog", { name: "功能教学" })).toBeNull();
- noPermissionWrites();
+ noModelWrites();
});
it("does not resume a pending tour over a settings deep link", async () => {
@@ -202,14 +156,13 @@ it("does not resume a pending tour over a settings deep link", async () => {
await waitFor(() => expect(modelProps).toHaveBeenCalled());
expect(modelProps.mock.calls.at(-1)?.[0].compactSingle).toBe(true);
expect(screen.queryByRole("dialog", { name: "功能教学" })).toBeNull();
- expect(screen.queryByRole("dialog", { name: "模型内容权限" })).toBeNull();
expect(window.location.search).toBe("?view=settings");
// Still pending: nothing read or rewrote it.
expect(
JSON.parse(localStorage.getItem("one-memory.learning.s1-3") ?? "null")
?.state,
).toBe("pending");
- noPermissionWrites();
+ noModelWrites();
});
it("keeps the applications default while explicit model configuration remains reachable", async () => {
@@ -224,7 +177,7 @@ it("keeps the applications default while explicit model configuration remains re
expect(window.location.search).toBe("?view=settings");
expect(modelProps.mock.calls.at(-1)?.[0].compactSingle).toBe(true);
expect(screen.queryByRole("dialog")).toBeNull();
- noPermissionWrites();
+ noModelWrites();
});
it("keeps an open model draft while switching settings groups", async () => {
@@ -243,5 +196,5 @@ it("keeps an open model draft while switching settings groups", async () => {
"value",
"unsaved draft",
);
- noPermissionWrites();
+ noModelWrites();
});
diff --git a/memhub/apps/web/src/App.startup-recovery.test.tsx b/memhub/apps/web/src/App.startup-recovery.test.tsx
index aecd529..ea99197 100644
--- a/memhub/apps/web/src/App.startup-recovery.test.tsx
+++ b/memhub/apps/web/src/App.startup-recovery.test.tsx
@@ -48,7 +48,6 @@ vi.mock("./ImportProgressStrip", () => ({ ImportProgressStrip: () => null }));
vi.mock("./MatterWorkspace", () => ({ MatterWorkspace: () => null }));
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
vi.mock("./FirstUseGuide", () => ({ FirstUseGuide: () => null }));
-vi.mock("./ModelContentConsent", () => ({ ModelContentConsent: () => null }));
function deferred() {
let resolve!: (state: { status: string }) => void;
diff --git a/memhub/apps/web/src/App.tsx b/memhub/apps/web/src/App.tsx
index 05df5c1..fd53128 100644
--- a/memhub/apps/web/src/App.tsx
+++ b/memhub/apps/web/src/App.tsx
@@ -30,7 +30,6 @@ import { HealthPanel } from "./HealthPanel";
import { ImportProgressStrip } from "./ImportProgressStrip";
import { MatterWorkspace } from "./MatterWorkspace";
import { MemorySpace } from "./MemorySpace";
-import { ModelContentConsent } from "./ModelContentConsent";
import type { MemoryArrival } from "./memory-arrival-state";
import { OnboardingFlow } from "./OnboardingFlow";
import { R6Button, R6LocalStorage } from "./R6Controls";
@@ -72,7 +71,6 @@ function AppContent() {
const startupFlight = useRef
| null>(null);
const startupEpoch = useRef(0);
const startupAlive = useRef(false);
- const [processingScopeOpen, setProcessingScopeOpen] = useState(false);
const [detailSourceId, setDetailSourceId] = useState(null);
const [memoryArrival, setMemoryArrival] = useState<
MemoryArrival | undefined
@@ -212,11 +210,6 @@ function AppContent() {
setView(next);
};
- const openProcessingScope = () => {
- if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
- setProcessingScopeOpen(true);
- };
-
if (!onboarding || imports.loading) {
return (
@@ -351,7 +344,6 @@ function AppContent() {
onRefresh={imports.refresh}
arrival={memoryArrival}
onConfigureModel={() => navigate("settings")}
- onReviewProcessing={openProcessingScope}
onJobAction={imports.changeJob}
showSourceLink={displayedView !== "settings"}
showProgressDetails={displayedView !== "settings"}
@@ -378,7 +370,6 @@ function AppContent() {
onFocusDialogueConsumed={() => setFocusDialogue(false)}
onConnect={() => navigate("sources")}
onConfigureModel={() => navigate("settings")}
- onReviewProcessing={openProcessingScope}
onArrivalChange={setMemoryArrival}
/>
@@ -419,7 +410,6 @@ function AppContent() {
navigate("memory");
}}
onCalls={() => navigate("calls")}
- onSettings={() => navigate("settings")}
/>
)}
@@ -469,15 +459,6 @@ function AppContent() {
- {processingScopeOpen && (
- setProcessingScopeOpen(false)}
- onConfigureModel={() => {
- setProcessingScopeOpen(false);
- navigate("settings");
- }}
- />
- )}
);
}
diff --git a/memhub/apps/web/src/ApplicationsHome.test.tsx b/memhub/apps/web/src/ApplicationsHome.test.tsx
index fb0efc0..9b630be 100644
--- a/memhub/apps/web/src/ApplicationsHome.test.tsx
+++ b/memhub/apps/web/src/ApplicationsHome.test.tsx
@@ -19,21 +19,14 @@ import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { queryClient } from "./query-client";
-const {
- summary,
- processing,
- permission,
- history,
- liveSync,
- recentConversations,
-} = vi.hoisted(() => ({
- summary: vi.fn(),
- processing: vi.fn(),
- permission: vi.fn(),
- history: vi.fn(),
- liveSync: vi.fn(),
- recentConversations: vi.fn(),
-}));
+const { summary, processing, history, liveSync, recentConversations } =
+ vi.hoisted(() => ({
+ summary: vi.fn(),
+ processing: vi.fn(),
+ history: vi.fn(),
+ liveSync: vi.fn(),
+ recentConversations: vi.fn(),
+ }));
vi.mock("./call-history-client", async (original) => ({
...(await original()),
getCallHistory: history,
@@ -42,7 +35,6 @@ vi.mock("./api", () => ({
getHomeSummary: summary,
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
- getModelContentPermission: permission,
getRecentConversations: recentConversations,
getSources: vi.fn().mockResolvedValue([]),
}));
@@ -82,7 +74,6 @@ beforeEach(() => {
observed_at: "2026-09-19T00:00:00Z",
});
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
- permission.mockReset().mockResolvedValue({ allowed: true });
liveSync.mockReset().mockResolvedValue([]);
recentConversations.mockReset().mockResolvedValue({
items: [],
@@ -545,26 +536,6 @@ it.each([
},
);
-it("shows disabled model-content permission and opens settings", async () => {
- permission.mockResolvedValue({ allowed: false });
- const onSettings = vi.fn();
-
- render(
- {}}
- onMemories={() => {}}
- onSettings={onSettings}
- />,
- );
-
- expect((await screen.findAllByText("模型读取已关闭")).length).toBeGreaterThan(
- 0,
- );
- expect(screen.getByText("新的对话不会再整理成记忆")).toBeTruthy();
- fireEvent.click(screen.getByRole("button", { name: "打开模型内容权限" }));
- expect(onSettings).toHaveBeenCalledOnce();
-});
afterEach(() => {
cleanup();
resetConnectionReconcile();
diff --git a/memhub/apps/web/src/ApplicationsHome.tsx b/memhub/apps/web/src/ApplicationsHome.tsx
index 3042aa2..aa892b5 100644
--- a/memhub/apps/web/src/ApplicationsHome.tsx
+++ b/memhub/apps/web/src/ApplicationsHome.tsx
@@ -12,7 +12,6 @@ import {
getHomeSummary,
getLiveSyncHealth,
getMemoryProcessing,
- getModelContentPermission,
getRecentConversations,
} from "./api";
import { AppMark } from "./app-mark";
@@ -169,7 +168,6 @@ export function ApplicationsHome({
imports,
onManage,
onMemories,
- onSettings,
onCalls,
active = true,
}: {
@@ -178,7 +176,7 @@ export function ApplicationsHome({
onMemories(memoryId?: string): void;
/** 保留兼容旧调用方;“查看会话”入口已按产品要求下线。 */
onConversations?(): void;
- onSettings(): void;
+ onSettings?(): void;
onCalls?(): void;
active?: boolean;
}) {
@@ -198,9 +196,6 @@ export function ApplicationsHome({
const [processing, setProcessing] = useState(
null,
);
- const [modelContentAllowed, setModelContentAllowed] = useState<
- boolean | null
- >(null);
const [syncHealth, setSyncHealth] = useState([]);
const [failed, setFailed] = useState(false);
const [recentCall, setRecentCall] = useState(null);
@@ -554,10 +549,9 @@ export function ApplicationsHome({
async function refresh() {
if (busy || document.hidden) return;
busy = true;
- const [home, state, permission, sync, recent] = await Promise.allSettled([
+ const [home, state, sync, recent] = await Promise.allSettled([
getHomeSummary(),
getMemoryProcessing(),
- getModelContentPermission(),
getLiveSyncHealth(),
getRecentConversations({ limit: 1, recentActivity: true }),
]);
@@ -572,9 +566,6 @@ export function ApplicationsHome({
setFailed(true);
}
if (state.status === "fulfilled") setProcessing(state.value);
- if (permission.status === "fulfilled")
- setModelContentAllowed(permission.value.allowed);
- else setModelContentAllowed(null);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
if (recent.status === "fulfilled") {
setLatestDialogue(recent.value.items[0] ?? null);
@@ -660,22 +651,19 @@ export function ApplicationsHome({
const apps = imports.sources.filter(
(s) => s.authorized || s.detected === true || s.location_state === "found",
);
- const stateLabel =
- modelContentAllowed === false
- ? "模型读取已关闭"
- : processing?.running
- ? "整理中"
- : processing?.blocked_stages?.some((stage) => stage.count > 0)
- ? "排队处理中"
- : processing?.waiting_for_model
- ? "等待模型"
- : processing?.failed
- ? "需要处理"
- : processing?.pending
- ? "准备中"
- : processing
- ? "一切就绪"
- : "待核对";
+ const stateLabel = processing?.running
+ ? "整理中"
+ : processing?.blocked_stages?.some((stage) => stage.count > 0)
+ ? "排队处理中"
+ : processing?.waiting_for_model
+ ? "等待模型"
+ : processing?.failed
+ ? "需要处理"
+ : processing?.pending
+ ? "准备中"
+ : processing
+ ? "一切就绪"
+ : "待核对";
const latestClock = latest ? clockParts(latest.updated_at) : null;
const time = latestClock?.clock ?? (latest ? "时间未知" : null);
const checked = visible ? clockParts(visible.observed_at) : null;
@@ -848,19 +836,12 @@ export function ApplicationsHome({
沉淀状态
{stateLabel}
- {modelContentAllowed === false
- ? "新的对话不会再整理成记忆"
- : processing?.running
- ? "新的对话正在整理"
- : processing?.pending
- ? "等待队列开始处理"
- : "以当前处理状态为准"}
+ {processing?.running
+ ? "新的对话正在整理"
+ : processing?.pending
+ ? "等待队列开始处理"
+ : "以当前处理状态为准"}
- {modelContentAllowed === false ? (
- onSettings()}>
- 打开模型内容权限
-
- ) : null}
最近更新
diff --git a/memhub/apps/web/src/ConversationSearch.test.tsx b/memhub/apps/web/src/ConversationSearch.test.tsx
index fad82b8..fc6cfed 100644
--- a/memhub/apps/web/src/ConversationSearch.test.tsx
+++ b/memhub/apps/web/src/ConversationSearch.test.tsx
@@ -393,11 +393,9 @@ it("点开一条结果就地展开它的原文", async () => {
expect(getConversation).toHaveBeenCalledWith("evidence-1");
});
-it("explains both model configuration and content consent before generating memory", () => {
+it("explains model configuration before generating memory", () => {
render(
);
- expect(
- screen.getByText(/设置模型并打开模型内容权限后,才会开始生成记忆/),
- ).toBeTruthy();
+ expect(screen.getByText(/设置模型后,才会开始生成记忆/)).toBeTruthy();
expect(screen.queryByText(/配好生成模型之后/)).toBeNull();
});
diff --git a/memhub/apps/web/src/ConversationSearch.tsx b/memhub/apps/web/src/ConversationSearch.tsx
index 991b350..b70c426 100644
--- a/memhub/apps/web/src/ConversationSearch.tsx
+++ b/memhub/apps/web/src/ConversationSearch.tsx
@@ -299,7 +299,7 @@ export function ConversationSearch({
- 这里搜的是你导入的原始对话,不是记忆。设置模型并打开模型内容权限后,才会开始生成记忆。
+ 这里搜的是你导入的原始对话,不是记忆。设置模型后,才会开始生成记忆。
diff --git a/memhub/apps/web/src/FirstImportReview.r6.test.tsx b/memhub/apps/web/src/FirstImportReview.r6.test.tsx
index fa09af6..11cc78c 100644
--- a/memhub/apps/web/src/FirstImportReview.r6.test.tsx
+++ b/memhub/apps/web/src/FirstImportReview.r6.test.tsx
@@ -384,9 +384,7 @@ it("uses user-facing connection confirmation copy without implementation narrati
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
- expect(
- screen.getByText(/配置模型并打开全局模型内容权限后,才会开始生成记忆/),
- ).toBeTruthy();
+ expect(screen.getByText(/配置模型后,才会开始生成记忆/)).toBeTruthy();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
diff --git a/memhub/apps/web/src/FirstImportReview.tsx b/memhub/apps/web/src/FirstImportReview.tsx
index 1bb2139..a5cfd16 100644
--- a/memhub/apps/web/src/FirstImportReview.tsx
+++ b/memhub/apps/web/src/FirstImportReview.tsx
@@ -377,7 +377,7 @@ export function FirstImportReview({
: "对话先保存在本机。你可以同时设置后续连接。"}
- 导入不等于允许模型读取内容,也不会自动开放应用读取权限。配置模型并打开全局模型内容权限后,才会开始生成记忆。
+ 导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。
{historyWindowLimited && (
diff --git a/memhub/apps/web/src/HealthPanel.test.tsx b/memhub/apps/web/src/HealthPanel.test.tsx
index d36145c..311b469 100644
--- a/memhub/apps/web/src/HealthPanel.test.tsx
+++ b/memhub/apps/web/src/HealthPanel.test.tsx
@@ -139,7 +139,7 @@ describe("HealthPanel", () => {
unknown: 1,
failed: 2,
cancelled: 3,
- cancelled_reasons: { model_content_permission_revoked: 3 },
+ cancelled_reasons: { source_permission_changed: 3 },
governed_memories: 80,
workbuddy: { injected: 20, timed_out: 1, unavailable: 0 },
},
diff --git a/memhub/apps/web/src/ImportProgressStrip.test.tsx b/memhub/apps/web/src/ImportProgressStrip.test.tsx
index f6775cf..b978951 100644
--- a/memhub/apps/web/src/ImportProgressStrip.test.tsx
+++ b/memhub/apps/web/src/ImportProgressStrip.test.tsx
@@ -254,7 +254,7 @@ describe("ImportProgressStrip", () => {
).not.toContain("异常");
});
- it.each(["model-required", "consent-required", "failed"] as const)(
+ it.each(["model-required", "failed"] as const)(
"does not override critical %s with running import/backfill",
(phase) => {
const { container } = render(
@@ -270,7 +270,6 @@ describe("ImportProgressStrip", () => {
phase,
}}
onConfigureModel={vi.fn()}
- onReviewProcessing={vi.fn()}
/>,
);
expect(
@@ -405,15 +404,13 @@ describe("ImportProgressStrip", () => {
);
it("opens all supplied exact issues and task receipts without triggering processing", async () => {
- const open = vi.fn(),
- review = vi.fn();
+ const open = vi.fn();
const { container } = render(
{
expect(details?.textContent).toContain("已完成");
expect(details?.textContent).not.toContain("completed");
expect(open).not.toHaveBeenCalled();
- expect(review).not.toHaveBeenCalled();
});
it("makes authentication failure actionable when no generation task is running", async () => {
diff --git a/memhub/apps/web/src/ImportProgressStrip.tsx b/memhub/apps/web/src/ImportProgressStrip.tsx
index a5f7926..68560c6 100644
--- a/memhub/apps/web/src/ImportProgressStrip.tsx
+++ b/memhub/apps/web/src/ImportProgressStrip.tsx
@@ -3,14 +3,7 @@ import type {
ImportJob,
SourceResponse,
} from "@one-memory/contracts";
-import {
- LoaderCircle,
- Pause,
- Play,
- RefreshCw,
- Settings2,
- ShieldCheck,
-} from "lucide-react";
+import { LoaderCircle, Pause, Play, RefreshCw, Settings2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
type MemoryArrival,
@@ -67,7 +60,6 @@ export interface ImportProgressStripProps {
onRefresh?(): Promise;
arrival?: MemoryArrival;
onConfigureModel?(): void;
- onReviewProcessing?(): void;
/** Pauses or continues one job and resolves with its receipt. */
onJobAction?: SourceJobActionsProps["onAction"];
issues?: readonly ImportIssue[];
@@ -87,7 +79,6 @@ export function ImportProgressStrip({
onRefresh,
arrival,
onConfigureModel,
- onReviewProcessing,
onJobAction,
issues,
showSourceLink = true,
@@ -225,7 +216,6 @@ export function ImportProgressStrip({
currentArrival &&
[
"model-required",
- "consent-required",
"failed",
"stopped",
"idle",
@@ -246,13 +236,9 @@ export function ImportProgressStrip({
const firstImport = !stale && runningHistory.length > 0;
const actionable =
currentArrival !== null &&
- [
- "model-required",
- "consent-required",
- "failed",
- "stopped",
- "unavailable",
- ].includes(currentArrival.phase);
+ ["model-required", "failed", "stopped", "unavailable"].includes(
+ currentArrival.phase,
+ );
if (
!firstImport &&
!actionable &&
@@ -269,9 +255,7 @@ export function ImportProgressStrip({
? `正在导入你的历史对话,已导入 ${imported.toLocaleString("zh-CN")} 个会话。记忆会陆续生成,你可以先正常使用。`
: currentArrival?.phase === "model-required"
? "模型还没有配置好,新的对话暂时不会整理成记忆。"
- : currentArrival?.phase === "consent-required"
- ? "模型授权已撤回,新的对话不会再整理成记忆。"
- : "有些内容没有处理完成,请前往设置查看。";
+ : "有些内容没有处理完成,请前往设置查看。";
return (
@@ -670,16 +652,10 @@ export function ImportProgressStrip({
)}
{!stale &&
currentArrival &&
- ["consent-required", "failed", "stopped"].includes(
- currentArrival.phase,
- ) &&
- (onReviewProcessing || onConfigureModel) && (
-
-
+ ["failed", "stopped"].includes(currentArrival.phase) &&
+ onConfigureModel && (
+
+
继续整理
)}
diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx
index 27cfd9e..e99b6a4 100644
--- a/memhub/apps/web/src/MemoryMap.test.tsx
+++ b/memhub/apps/web/src/MemoryMap.test.tsx
@@ -272,7 +272,6 @@ function Host({
loaded = true,
deferMemoryId,
onViewHistory,
- onReviewProcessing,
onConfigureModel = () => {},
libraryView,
}: {
@@ -288,7 +287,6 @@ function Host({
loaded?: boolean;
deferMemoryId?: string;
onViewHistory?(): void;
- onReviewProcessing?(): void;
onConfigureModel?(): void;
libraryView?: Parameters[0]["libraryView"];
} = {}) {
@@ -304,7 +302,6 @@ function Host({
sources={sources}
arrival={arrival}
onViewHistory={onViewHistory}
- onReviewProcessing={onReviewProcessing}
loaded={loaded}
processing={processing}
detail={
@@ -600,31 +597,6 @@ it("explains missing setup during ongoing raw import without pretending to gener
expect(history).toHaveBeenCalledOnce();
});
-it("uses a separate consent action and never auto-confirms or processes", async () => {
- const review = vi.fn(),
- configure = vi.fn();
- render(
- ,
- );
- expect(review).not.toHaveBeenCalled();
- expect(configure).not.toHaveBeenCalled();
- await userEvent.click(
- screen.getByRole("button", { name: "打开模型内容权限" }),
- );
- expect(review).toHaveBeenCalledOnce();
- expect(configure).not.toHaveBeenCalled();
-});
-
it("adds real memories without replacing graph, current detail, or question draft", async () => {
const view = render( );
const canvas = screen.getByTestId("canvas");
diff --git a/memhub/apps/web/src/MemoryMap.tsx b/memhub/apps/web/src/MemoryMap.tsx
index e6d8ad1..32e773c 100644
--- a/memhub/apps/web/src/MemoryMap.tsx
+++ b/memhub/apps/web/src/MemoryMap.tsx
@@ -51,7 +51,6 @@ type Props = Parameters[0] & {
id?: string;
arrival?: MemoryArrival;
onViewHistory?(): void;
- onReviewProcessing?(): void;
onMutationComplete?(id: string): void;
focusDialogue?: boolean;
onFocusDialogueConsumed?(): void;
@@ -1052,7 +1051,6 @@ export function MemoryMap(props: Props) {
hasMemories={active.length > 0}
suspended={overlaysSuspended}
onViewHistory={props.onViewHistory}
- onReviewProcessing={props.onReviewProcessing}
onConfigureModel={props.onConfigureModel}
onConnect={props.onConnect}
/>
diff --git a/memhub/apps/web/src/MemoryNoteComposer.test.tsx b/memhub/apps/web/src/MemoryNoteComposer.test.tsx
index 1ac6a8b..1fbea50 100644
--- a/memhub/apps/web/src/MemoryNoteComposer.test.tsx
+++ b/memhub/apps/web/src/MemoryNoteComposer.test.tsx
@@ -14,7 +14,6 @@ const api = vi.hoisted(() => ({
saveMemoryNote: vi.fn(),
apiErrorCode: vi.fn(),
getSources: vi.fn(),
- getModelContentPermission: vi.fn(),
}));
vi.mock("./api", () => api);
diff --git a/memhub/apps/web/src/MemorySpace.library.test.tsx b/memhub/apps/web/src/MemorySpace.library.test.tsx
index 2c0441d..0669ca0 100644
--- a/memhub/apps/web/src/MemorySpace.library.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.library.test.tsx
@@ -45,7 +45,6 @@ vi.mock("./api", () => ({
}),
getMemoryProcessing: async () => null,
getModelProfiles: async () => [],
- getModelContentPermission: async () => ({ allowed: false }),
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
diff --git a/memhub/apps/web/src/MemorySpace.map-search.test.tsx b/memhub/apps/web/src/MemorySpace.map-search.test.tsx
index f501842..4760695 100644
--- a/memhub/apps/web/src/MemorySpace.map-search.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.map-search.test.tsx
@@ -24,7 +24,6 @@ vi.mock("./api", () => ({
getMemoryGraph: vi.fn(),
getMemoryProcessing: vi.fn(),
getModelProfiles: vi.fn(),
- getModelContentPermission: vi.fn(),
getMemory: vi.fn(),
getConversation: vi.fn(),
correctMemory: vi.fn(),
@@ -155,9 +154,6 @@ beforeEach(() => {
vi.mocked(api.getMemoryGraph).mockResolvedValue(graph(corpus.slice(0, 100)));
vi.mocked(api.getMemoryProcessing).mockResolvedValue(null as never);
vi.mocked(api.getModelProfiles).mockResolvedValue([]);
- vi.mocked(api.getModelContentPermission).mockResolvedValue({
- allowed: false,
- });
});
afterEach(() => {
cleanup();
diff --git a/memhub/apps/web/src/MemorySpace.r6.test.tsx b/memhub/apps/web/src/MemorySpace.r6.test.tsx
index 753f36c..5964d15 100644
--- a/memhub/apps/web/src/MemorySpace.r6.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.r6.test.tsx
@@ -17,7 +17,6 @@ const { reads } = vi.hoisted(() => ({
graph: vi.fn(),
processing: vi.fn(),
profiles: vi.fn(),
- permission: vi.fn(),
library: vi.fn(),
mapLibrary: vi.fn(),
recent: vi.fn(),
@@ -30,7 +29,6 @@ vi.mock("./api", async (importOriginal) => ({
getMemoryGraph: reads.graph,
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
- getModelContentPermission: reads.permission,
getMemoryLibrary: (query: { active_only?: boolean }, signal?: AbortSignal) =>
query.active_only === true
? reads.mapLibrary(query, signal)
@@ -106,7 +104,6 @@ function setup() {
completed: 1,
});
reads.profiles.mockResolvedValue([]);
- reads.permission.mockResolvedValue({ allowed: false });
reads.library.mockResolvedValue({
items: [],
total: 0,
diff --git a/memhub/apps/web/src/MemorySpace.supersede.test.tsx b/memhub/apps/web/src/MemorySpace.supersede.test.tsx
index 3cbbd29..be3428c 100644
--- a/memhub/apps/web/src/MemorySpace.supersede.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.supersede.test.tsx
@@ -42,7 +42,6 @@ vi.mock("./api", () => ({
}),
getMemoryProcessing: async () => null,
getModelProfiles: async () => [],
- getModelContentPermission: async () => ({ allowed: false }),
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
diff --git a/memhub/apps/web/src/MemorySpace.test.tsx b/memhub/apps/web/src/MemorySpace.test.tsx
index 700dd7a..15e1906 100644
--- a/memhub/apps/web/src/MemorySpace.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.test.tsx
@@ -85,7 +85,6 @@ const {
getGraphWidth,
decideGraphWidth,
getModelProfiles,
- getModelContentPermission,
createMemory,
digestMemoryGraph,
searchConversations,
@@ -112,7 +111,6 @@ const {
getGraphWidth: vi.fn(),
decideGraphWidth: vi.fn(),
getModelProfiles: vi.fn(),
- getModelContentPermission: vi.fn(),
createMemory: vi.fn(),
digestMemoryGraph: vi.fn(),
searchConversations: vi.fn(),
@@ -143,7 +141,6 @@ vi.mock("./api", () => ({
getGraphWidth,
decideGraphWidth,
getModelProfiles,
- getModelContentPermission,
createMemory,
digestMemoryGraph,
searchConversations,
@@ -241,7 +238,6 @@ describe("MemorySpace", () => {
getModelProfiles.mockResolvedValue([
{ profile_id: "profile-1", is_active: true },
]);
- getModelContentPermission.mockResolvedValue({ allowed: false });
getMemoryNotes.mockResolvedValue([]);
searchConversations.mockResolvedValue({
hits: [
@@ -706,7 +702,7 @@ describe("MemorySpace", () => {
// The arrival status belongs to the workbench surface, not the ask tab.
await userEvent.click(screen.getByRole("tab", { name: "工作台" }));
expect(screen.getByRole("region", { name: "记忆整理状态" })).toBeTruthy();
- expect(screen.getByText("等待允许模型读取本地会话内容")).toBeTruthy();
+ expect(screen.getByText("还没有保存的对话")).toBeTruthy();
expect(screen.queryByTestId("memory-graph")).toBeNull();
expectNoTeachingWrites();
});
diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx
index 1e07296..9bbc688 100644
--- a/memhub/apps/web/src/MemorySpace.tsx
+++ b/memhub/apps/web/src/MemorySpace.tsx
@@ -16,7 +16,6 @@ import {
MessageCircle,
Network,
TextSearch,
- X,
} from "lucide-react";
import {
type FormEvent,
@@ -36,7 +35,6 @@ import {
getMemory,
getMemoryGraph,
getMemoryProcessing,
- getModelContentPermission,
getModelProfiles,
manualExtract,
mutateMemory,
@@ -95,7 +93,6 @@ export function MemorySpace({
sourceSnapshotStale = false,
onConnect = () => undefined,
onConfigureModel = () => undefined,
- onReviewProcessing,
onArrivalChange,
active = true,
preload = false,
@@ -112,7 +109,6 @@ export function MemorySpace({
sourceSnapshotStale?: boolean;
onConnect?(): void;
onConfigureModel?(): void;
- onReviewProcessing?(): void;
onArrivalChange?(arrival: MemoryArrival): void;
active?: boolean;
preload?: boolean;
@@ -284,7 +280,7 @@ export function MemorySpace({
null,
);
const [arrivalSetup, setArrivalSetup] = useState<
- "checking" | "model-required" | "consent-required" | "ready" | "unavailable"
+ "checking" | "model-required" | "ready" | "unavailable"
>("checking");
const [checkingSetup, setCheckingSetup] = useState(false);
const liveLoading = useRef(false);
@@ -301,27 +297,18 @@ export function MemorySpace({
}
let stopped = false;
let request = 0;
- let controller: AbortController | null = null;
setArrivalSetup("checking");
async function inspectSetup() {
const version = ++request;
- controller?.abort();
- controller = new AbortController();
setCheckingSetup(true);
- const [profiles, permission] = await Promise.allSettled([
- getModelProfiles(),
- getModelContentPermission(controller.signal),
- ]);
+ const profiles = await Promise.allSettled([getModelProfiles()]);
if (stopped || version !== request) return;
setCheckingSetup(false);
- if (
- profiles.status !== "fulfilled" ||
- permission.status !== "fulfilled"
- ) {
+ if (profiles[0].status !== "fulfilled") {
setArrivalSetup("unavailable");
return;
}
- const activeProfiles = profiles.value.filter((item) => item.is_active);
+ const activeProfiles = profiles[0].value.filter((item) => item.is_active);
const profile = activeProfiles.length === 1 ? activeProfiles[0] : null;
if (activeProfiles.length > 1) {
setArrivalSetup("unavailable");
@@ -331,18 +318,17 @@ export function MemorySpace({
setArrivalSetup("model-required");
return;
}
- setArrivalSetup(permission.value.allowed ? "ready" : "consent-required");
+ setArrivalSetup("ready");
}
void inspectSetup();
const stop = subscribePoll({
- key: "model-consent",
+ key: "model-profiles",
intervalMs: 12_000,
run: inspectSetup,
});
return () => {
stopped = true;
request++;
- controller?.abort();
stop();
};
}, [active, onArrivalChange, simplified, sourceKey, sourceSnapshotStale]);
@@ -1781,7 +1767,6 @@ export function MemorySpace({
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
- onReviewProcessing={onReviewProcessing ?? onConnect}
loaded={simplified ? mapLibrary.page !== null : loaded}
detail={detail}
detailLoad={detailLoad}
diff --git a/memhub/apps/web/src/ModelContentConsent.test.tsx b/memhub/apps/web/src/ModelContentConsent.test.tsx
deleted file mode 100644
index d20cc7b..0000000
--- a/memhub/apps/web/src/ModelContentConsent.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-// @vitest-environment jsdom
-
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import * as api from "./api";
-import { ModelContentConsent } from "./ModelContentConsent";
-
-vi.mock("./api", () => ({
- getModelContentPermission: vi.fn(),
- setModelContentPermission: vi.fn(),
-}));
-vi.mock("./ui-trace", () => ({
- traceUiAction: () => ({ traceId: "trace-id", done: vi.fn() }),
-}));
-
-beforeEach(() => {
- vi.mocked(api.getModelContentPermission).mockResolvedValue({
- allowed: false,
- });
- vi.mocked(api.setModelContentPermission).mockImplementation(
- async (allowed) => ({
- allowed,
- }),
- );
-});
-
-afterEach(() => {
- cleanup();
- vi.clearAllMocks();
-});
-
-it("shows one global model-content switch", async () => {
- render( );
-
- const toggle = await screen.findByRole("switch", {
- name: "允许当前配置模型读取本地会话内容",
- });
- expect(toggle.getAttribute("aria-checked")).toBe("false");
- expect(screen.queryByText(/选择来源|授权范围|后续新增/)).toBeNull();
-});
-
-it("writes the desired boolean and updates the switch", async () => {
- const user = userEvent.setup();
- render( );
- const toggle = await screen.findByRole("switch", {
- name: "允许当前配置模型读取本地会话内容",
- });
-
- await user.click(toggle);
-
- await waitFor(() =>
- expect(api.setModelContentPermission).toHaveBeenCalledWith(
- true,
- "trace-id",
- ),
- );
- expect(toggle.getAttribute("aria-checked")).toBe("true");
-});
-
-it("states that generated-memory search is independent", async () => {
- render( );
- expect(
- await screen.findByText(/已生成记忆的搜索和读取不受影响/),
- ).toBeTruthy();
-});
diff --git a/memhub/apps/web/src/ModelContentConsent.tsx b/memhub/apps/web/src/ModelContentConsent.tsx
deleted file mode 100644
index bddfa4d..0000000
--- a/memhub/apps/web/src/ModelContentConsent.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { RefreshCw, X } from "lucide-react";
-import { useEffect, useId, useRef } from "react";
-import { R6Button, R6Switch } from "./R6Controls";
-import { useModelContentPermission } from "./use-model-content-permission";
-import "./model-content-consent.css";
-
-export type ModelContentConsentProps = {
- onClose(): void;
- onConfigureModel?(): void;
-};
-
-export function ModelContentConsent({
- onClose,
- onConfigureModel,
-}: ModelContentConsentProps) {
- const id = useId();
- const panel = useRef(null);
- const permission = useModelContentPermission();
-
- useEffect(() => {
- const before = document.activeElement;
- panel.current?.querySelector("button:not(:disabled)")?.focus();
- return () => {
- if (before instanceof HTMLElement && before.isConnected) before.focus();
- };
- }, []);
-
- return (
-
-
{
- if (event.key === "Escape" && !permission.writing) onClose();
- }}
- >
-
-
-
模型内容权限
-
允许模型读取本地会话内容
-
- void permission.refresh()}
- >
-
-
-
-
-
-
-
-
- 开启后,当前配置的模型可以读取已导入和之后同步到本机的会话内容,用于生成记忆。
-
-
- 来源同步决定哪些会话进入本机;此开关只控制模型是否可以读取这些内容。
- 已生成记忆的搜索和读取不受影响。
-
-
- 允许当前配置模型读取本地会话内容
- void permission.update(allowed)}
- />
-
- {permission.reading &&
正在读取当前设置…
}
- {permission.writing &&
正在保存设置…
}
- {permission.error && (
-
- {permission.error}
-
- )}
- {permission.allowed === false && onConfigureModel && (
-
- 可先配置并测试模型,再决定是否允许读取会话内容。
-
- )}
-
-
- {onConfigureModel && (
-
- 配置模型
-
- )}
-
- 完成
-
-
-
-
- );
-}
diff --git a/memhub/apps/web/src/OnboardingModelController.tsx b/memhub/apps/web/src/OnboardingModelController.tsx
index 48e8e73..9b3f2e7 100644
--- a/memhub/apps/web/src/OnboardingModelController.tsx
+++ b/memhub/apps/web/src/OnboardingModelController.tsx
@@ -4,7 +4,7 @@ import type {
ModelProfileInput,
} from "@one-memory/contracts";
import { useRef, useState } from "react";
-import { getModelProfiles, setModelContentPermission } from "./api";
+import { getModelProfiles } from "./api";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -232,12 +232,11 @@ export function OnboardingModelController({
try {
const active = await models.activate(saved.profile_id);
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
- await setModelContentPermission(true, trace.traceId);
trace.done("succeeded");
onEnabled?.();
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
- setError("模型或内容权限未能确认启用,请在设置中核对。");
+ setError("模型未能确认启用,请在设置中核对。");
} finally {
flight.current = false;
setPhase("idle");
diff --git a/memhub/apps/web/src/OnboardingModelStep.test.tsx b/memhub/apps/web/src/OnboardingModelStep.test.tsx
index a087730..220917b 100644
--- a/memhub/apps/web/src/OnboardingModelStep.test.tsx
+++ b/memhub/apps/web/src/OnboardingModelStep.test.tsx
@@ -251,7 +251,7 @@ it("discloses that testing does not send historical conversations", () => {
expect(screen.getByText(/测试连接只发送测试内容/)).toBeTruthy();
expect(screen.getByText(/已导入 37 条对话/)).toBeTruthy();
- expect(screen.getByText(/启用时会打开全局模型内容权限/)).toBeTruthy();
+ expect(screen.getByText(/启用后会使用此模型整理/)).toBeTruthy();
});
it("does not expose an extra backlog selector in the first-run model page", () => {
diff --git a/memhub/apps/web/src/OnboardingModelStep.tsx b/memhub/apps/web/src/OnboardingModelStep.tsx
index 2c2909b..55fc0bd 100644
--- a/memhub/apps/web/src/OnboardingModelStep.tsx
+++ b/memhub/apps/web/src/OnboardingModelStep.tsx
@@ -594,7 +594,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
相关对话会发送给所选服务商。
- 测试连接只发送测试内容。启用时会打开全局模型内容权限;之后可在设置中随时关闭。
+ 测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。
{!props.capability.saveAndEnable && (
diff --git a/memhub/apps/web/src/R6Settings.test.tsx b/memhub/apps/web/src/R6Settings.test.tsx
index 2a86620..839f81d 100644
--- a/memhub/apps/web/src/R6Settings.test.tsx
+++ b/memhub/apps/web/src/R6Settings.test.tsx
@@ -5,10 +5,6 @@ import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { R6Settings } from "./R6Settings";
-const permission = vi.hoisted(() => ({
- update: vi.fn(),
-}));
-
vi.mock("./api", () => ({
getModelProfiles: vi.fn().mockResolvedValue([
{
@@ -34,36 +30,11 @@ vi.mock("./runtime-state", () => ({
vi.mock("./diagnostics-export", () => ({
exportDiagnosticsFile: vi.fn(),
}));
-vi.mock("./use-model-content-permission", () => ({
- useModelContentPermission: () => ({
- allowed: false,
- reading: false,
- writing: false,
- error: null,
- refresh: vi.fn(),
- update: permission.update,
- }),
-}));
-
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
-it("renders one global model-content permission switch", async () => {
- const user = userEvent.setup();
- render( );
-
- const toggle = await screen.findByRole("switch", {
- name: "允许当前配置模型读取本地会话内容",
- });
- expect(toggle.getAttribute("aria-checked")).toBe("false");
-
- await user.click(toggle);
- expect(permission.update).toHaveBeenCalledWith(true);
- expect(screen.queryByText(/模型处理范围|后续新增/)).toBeNull();
-});
-
it("hides diagnostics by default and exposes it only when configured", async () => {
const user = userEvent.setup();
const view = render( );
diff --git a/memhub/apps/web/src/R6Settings.tsx b/memhub/apps/web/src/R6Settings.tsx
index b19bbfc..68c5530 100644
--- a/memhub/apps/web/src/R6Settings.tsx
+++ b/memhub/apps/web/src/R6Settings.tsx
@@ -18,7 +18,6 @@ import {
setLoginPreference,
useLoginPreference,
} from "./runtime-state";
-import { useModelContentPermission } from "./use-model-content-permission";
import "./r6-settings.css";
type Group = "model" | "connections" | "help";
@@ -69,9 +68,6 @@ export function R6Settings({
const quitPanel = useRef(null);
const quitOpener = useRef(null);
const currentModel = profiles?.find((profile) => profile.is_active) ?? null;
- const contentPermission = useModelContentPermission(
- active && group === "model",
- );
useLayoutEffect(() => {
if (!active) return;
@@ -279,32 +275,6 @@ export function R6Settings({
请等待当前操作结束后重试;现有配置和原文不会被改动。
)}
- {currentModel && (
-
-
模型内容权限
-
- 允许当前配置模型读取已导入和之后同步到本机的会话内容。
- 已生成记忆的搜索不受此开关影响。
-
-
- void contentPermission.update(allowed)
- }
- />
- {contentPermission.error && (
-
- {contentPermission.error}
-
- )}
-
- )}
{
]);
});
- it("discloses that ask may use model processing, and lets it be left out", async () => {
+ it("discloses that ask may use the configured model, and lets it be left out", async () => {
const api = setup();
await open();
- expect(screen.getByText(/可能使用另行获准的模型处理/)).toBeTruthy();
+ expect(screen.getByText(/可能使用已配置的模型处理/)).toBeTruthy();
await userEvent.click(check("记忆问答(可选)"));
await confirm();
expect(api.set.mock.calls[0][1].allowedTools).toEqual([
diff --git a/memhub/apps/web/src/RecallPermissionControl.tsx b/memhub/apps/web/src/RecallPermissionControl.tsx
index 2bc1734..f25e8e0 100644
--- a/memhub/apps/web/src/RecallPermissionControl.tsx
+++ b/memhub/apps/web/src/RecallPermissionControl.tsx
@@ -892,7 +892,7 @@ function PermissionDialog({
{toolNames[tool]}
{tool === "om_ask" && (
- 可能使用另行获准的模型处理。本次不会授予模型内容权限。
+ 可能使用已配置的模型处理。本次只授予此应用的记忆读取范围。
)}
@@ -1068,7 +1068,8 @@ function PermissionDialog({
)}
{snapshot.action === "grant" && (
- 本次不会安装连接、导入历史、开启持续同步或授权模型处理。授权不代表应用已实际调用或采用记忆。
+ 本次不会安装连接、导入历史或开启持续同步,也不改变内置 SDK
+ 的本地记忆处理。授权不代表应用已实际调用或采用记忆。
)}
{pending && (
diff --git a/memhub/apps/web/src/SourcesView.test.tsx b/memhub/apps/web/src/SourcesView.test.tsx
index a0f0b51..e657bfe 100644
--- a/memhub/apps/web/src/SourcesView.test.tsx
+++ b/memhub/apps/web/src/SourcesView.test.tsx
@@ -4,7 +4,6 @@ import type { ConnectSourcesResponse, ImportJob } from "@one-memory/contracts";
import {
act,
cleanup,
- fireEvent,
render,
screen,
waitFor,
@@ -13,22 +12,11 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { ModelContentConsent } from "./ModelContentConsent";
import { SourcesView } from "./SourcesView";
import type { useImportSources } from "./use-import-sources";
afterEach(cleanup);
-vi.mock("./ModelContentConsent", () => ({
- ModelContentConsent: vi.fn(({ onClose }: { onClose(): void }) => (
-
-
- 关闭模型授权
-
-
- )),
-}));
-
describe("SourcesView", () => {
afterEach(() => {
cleanup();
@@ -1467,43 +1455,6 @@ it("Thaw and Freeze use the one Harness processing switch", async () => {
);
});
-it("opens the global model-content permission only on demand", async () => {
- const imports = syncImports();
- const view = render( );
- expect(screen.queryByRole("dialog", { name: "模型整理测试" })).toBeNull();
- await userEvent.click(screen.getByRole("button", { name: "管理模型权限" }));
- expect(screen.getByRole("dialog", { name: "模型整理测试" })).toBeTruthy();
- expect(
- screen.getByRole("button", { name: "管理模型权限" })
- .disabled,
- ).toBe(false);
- expect(ModelContentConsent).toHaveBeenCalled();
- expect(
- screen.getByRole("checkbox", { name: "处理 WorkBuddy" })
- .disabled,
- ).toBe(false);
- const next = {
- ...imports,
- sources: imports.sources.map((source) => ({
- ...source,
- imported_messages: 8,
- })),
- };
- view.rerender( );
- await userEvent.click(screen.getByRole("button", { name: "关闭模型授权" }));
- expect(screen.queryByRole("dialog")).toBeNull();
- expect(imports.connectSelected).not.toHaveBeenCalled();
- expect(imports.setSync).not.toHaveBeenCalled();
-});
-
-it("COLD-14..16: model consent does not nest inside history import", async () => {
- render( );
- await userEvent.click(screen.getByRole("button", { name: "导入历史包" }));
- fireEvent.click(screen.getByRole("button", { name: "管理模型权限" }));
- expect(screen.getAllByRole("dialog")).toHaveLength(1);
- expect(screen.getByRole("dialog", { name: "导入历史包" })).toBeTruthy();
-});
-
it("S1.3: an incomplete permission receipt cannot activate global sync", () => {
const imports = syncImports();
render(
diff --git a/memhub/apps/web/src/SourcesView.tsx b/memhub/apps/web/src/SourcesView.tsx
index 499fb07..33e1c01 100644
--- a/memhub/apps/web/src/SourcesView.tsx
+++ b/memhub/apps/web/src/SourcesView.tsx
@@ -24,7 +24,6 @@ import {
uninstallHost,
useRecallHosts,
} from "./host-state";
-import { ModelContentConsent } from "./ModelContentConsent";
import { RecallPermissionControl } from "./RecallPermissionControl";
import { fullGrantSelection, selectionIsValid } from "./recall-grant";
import { LINK_WORDING, SKILL_WORDING } from "./recall-host-wording";
@@ -87,7 +86,6 @@ export function SourcesView({ imports }: { imports: ImportState }) {
string | null
>(null);
const [directoryBusy, setDirectoryBusy] = useState(null);
- const [modelConsentOpen, setModelConsentOpen] = useState(false);
const [consentPhase, setConsentPhase] = useState<
"general" | "environment_override" | null
>(null);
@@ -474,9 +472,6 @@ export function SourcesView({ imports }: { imports: ImportState }) {
return (
- {modelConsentOpen && (
- setModelConsentOpen(false)} />
- )}
应用
@@ -509,32 +504,6 @@ export function SourcesView({ imports }: { imports: ImportState }) {
onRefresh={imports.refresh}
/>
-
-
-
模型内容权限
-
用一个全局开关控制当前模型能否读取本地会话内容。
-
- {
- if (!document.querySelector('[role="dialog"][aria-modal="true"]')) {
- setModelConsentOpen(true);
- }
- }}
- >
-
- 管理模型权限
-
-
-
{
- it("accepts a valid model profile list and permission", async () => {
- fake.get
- .mockResolvedValueOnce({ data: [modelProfile] })
- .mockResolvedValueOnce({ data: { allowed: false } });
+ it("accepts a valid model profile list", async () => {
+ fake.get.mockResolvedValueOnce({ data: [modelProfile] });
await expect(getModelProfiles()).resolves.toEqual([modelProfile]);
- await expect(getModelContentPermission()).resolves.toEqual({
- allowed: false,
- });
});
it("rejects a malformed profile before settings renders it", async () => {
@@ -94,27 +87,6 @@ describe("model read transport validation", () => {
});
await expect(getModelProfiles()).rejects.toThrow("model_profiles_invalid");
});
-
- it("rejects a malformed model-content permission", async () => {
- fake.get.mockResolvedValue({
- data: { allowed: "yes" },
- });
- await expect(getModelContentPermission()).rejects.toThrow(
- "model_content_permission_invalid",
- );
- });
-
- it("posts only the desired global permission", async () => {
- fake.post.mockResolvedValue({ data: { allowed: true } });
- await expect(setModelContentPermission(true)).resolves.toEqual({
- allowed: true,
- });
- expect(fake.post).toHaveBeenCalledWith(
- "/api/v1/model-content-permission",
- { allowed: true },
- undefined,
- );
- });
});
describe("live sync health transport validation", () => {
diff --git a/memhub/apps/web/src/api.ts b/memhub/apps/web/src/api.ts
index 3f8d53f..5d85eed 100644
--- a/memhub/apps/web/src/api.ts
+++ b/memhub/apps/web/src/api.ts
@@ -36,7 +36,6 @@ import {
type MemoryNoteReceipt,
type MemoryOrigin,
type MemoryProcessingSummary,
- type ModelContentPermission,
type ModelCreationOperation,
type ModelProbeOperationReceipt,
type ModelProbeResult,
@@ -703,44 +702,6 @@ export async function getModelProfiles(): Promise {
return profiles as ModelProfile[];
}
-export async function getModelContentPermission(
- signal?: AbortSignal,
-): Promise {
- const response = await client.get(
- "/api/v1/model-content-permission",
- { signal },
- );
- const permission = parseModelContentPermission(response.data);
- if (permission === null) throw new Error("model_content_permission_invalid");
- return permission;
-}
-
-export async function setModelContentPermission(
- allowed: boolean,
- traceId?: string,
-): Promise {
- const response = await client.post(
- "/api/v1/model-content-permission",
- { allowed },
- traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
- );
- const permission = parseModelContentPermission(response.data);
- if (permission === null || permission.allowed !== allowed)
- throw new Error("model_content_permission_invalid");
- return permission;
-}
-
-export function parseModelContentPermission(
- value: unknown,
-): ModelContentPermission | null {
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
- const permission = value as Record;
- return Object.keys(permission).length === 1 &&
- typeof permission.allowed === "boolean"
- ? { allowed: permission.allowed }
- : null;
-}
-
export async function getModelCreationOperation(
operationId: string,
): Promise {
diff --git a/memhub/apps/web/src/host-state.integration.test.tsx b/memhub/apps/web/src/host-state.integration.test.tsx
index f57b0b4..8efce7f 100644
--- a/memhub/apps/web/src/host-state.integration.test.tsx
+++ b/memhub/apps/web/src/host-state.integration.test.tsx
@@ -34,8 +34,6 @@ vi.mock("./api", () => ({
getRecentConversations: recent,
getSources: vi.fn().mockResolvedValue([]),
getModelProfiles: vi.fn().mockResolvedValue([]),
- getModelContentPermission: vi.fn().mockResolvedValue({ allowed: false }),
- setModelContentPermission: vi.fn(),
setSourceSync: vi.fn(),
}));
vi.mock("./ModelSettings", () => ({ ModelSettings: () => null }));
diff --git a/memhub/apps/web/src/index-sync-notice.ts b/memhub/apps/web/src/index-sync-notice.ts
index 1a996ca..f1bea4c 100644
--- a/memhub/apps/web/src/index-sync-notice.ts
+++ b/memhub/apps/web/src/index-sync-notice.ts
@@ -43,12 +43,11 @@ export function indexSyncPresentation(
if (
reason === "context_changed" ||
reason === "memory_revision_conflict" ||
- reason === "model_content_permission_changed" ||
reason === "model_content_profile_changed"
) {
return {
attemptedLine,
- explanation: "这条记忆或授权版本已经变化,先刷新核对再决定下一步。",
+ explanation: "这条记忆或模型版本已经变化,先刷新核对再决定下一步。",
action: "refresh",
};
}
diff --git a/memhub/apps/web/src/memory-arrival-state.test.ts b/memhub/apps/web/src/memory-arrival-state.test.ts
index 1485cfb..401bfba 100644
--- a/memhub/apps/web/src/memory-arrival-state.test.ts
+++ b/memhub/apps/web/src/memory-arrival-state.test.ts
@@ -69,33 +69,7 @@ it.each([
},
"model-required",
],
- [
- "consent block",
- {
- blocked_stages: [
- {
- kind: "extract",
- reason: "model_content_permission_required",
- count: 1,
- },
- ],
- },
- "consent-required",
- ],
["failure", { failed: 1 }, "failed"],
- [
- "revoked consent block",
- {
- blocked_stages: [
- {
- kind: "extract",
- reason: "model_content_permission_revoked",
- count: 210,
- },
- ],
- },
- "consent-required",
- ],
[
"unknown block",
{ blocked_stages: [{ kind: "extract", reason: "unknown", count: 1 }] },
@@ -152,7 +126,6 @@ it("does not promise continued processing for stale or queued-only state", () =>
it.each([
["model_profile_not_active", "model-required"],
- ["model_content_permission_required", "consent-required"],
["hy_memory_model_authentication_failed", "model-required"],
["hy_memory_model_payment_required", "model-required"],
] as const)(
@@ -279,13 +252,7 @@ it.each([
).toBe("processing");
});
-it.each([
- "checking",
- "unavailable",
- "stopped",
- "model-required",
- "consent-required",
-] as const)(
+it.each(["checking", "unavailable", "stopped", "model-required"] as const)(
"explicit parent %s remains authoritative over a running count",
(phase) => {
expect(
@@ -316,28 +283,3 @@ it("raw import alone has an import title, without a generated-memory promise", (
expect(result.phase).toBe("importing");
expect(memoryArrivalTitle(result)).toBe("正在导入对话");
});
-
-it("reports the global model-content permission block", () => {
- const result = resolveMemoryArrival({
- ...base,
- arrival: {
- importing: false,
- importedMessages: 12,
- importedConversations: 3,
- phase: "idle",
- },
- processing: {
- ...processing,
- blocked_stages: [
- {
- kind: "extract",
- reason: "model_content_permission_required",
- count: 2,
- },
- ],
- },
- });
- expect(result.phase).toBe("consent-required");
- expect(memoryArrivalTitle(result)).toBe("等待允许模型读取本地会话内容");
- expect(result.blockedStages).toHaveLength(1);
-});
diff --git a/memhub/apps/web/src/memory-arrival-state.ts b/memhub/apps/web/src/memory-arrival-state.ts
index 68f42eb..082d492 100644
--- a/memhub/apps/web/src/memory-arrival-state.ts
+++ b/memhub/apps/web/src/memory-arrival-state.ts
@@ -9,7 +9,6 @@ export type MemoryArrivalPhase =
| "checking"
| "importing"
| "model-required"
- | "consent-required"
| "processing"
| "failed"
| "idle"
@@ -31,11 +30,6 @@ export interface MemoryArrival {
memoryCount?: number;
}
-const permissionReasons = new Set([
- "model_content_permission_required",
- "model_content_permission_revoked",
-]);
-
export function resolveMemoryArrival({
arrival,
processing,
@@ -65,13 +59,9 @@ export function resolveMemoryArrival({
let phase: MemoryArrivalPhase = arrival?.phase ?? "idle";
if (
arrival &&
- [
- "checking",
- "unavailable",
- "stopped",
- "model-required",
- "consent-required",
- ].includes(arrival.phase)
+ ["checking", "unavailable", "stopped", "model-required"].includes(
+ arrival.phase,
+ )
) {
return blocks.length ? { ...arrival, blockedStages: blocks } : arrival;
}
@@ -86,8 +76,6 @@ export function resolveMemoryArrival({
phase = "processing";
} else if ((processing?.waiting_for_model ?? 0) > 0 || modelBlock(blocks)) {
phase = "model-required";
- } else if (blocks.some((block) => permissionReasons.has(block.reason))) {
- phase = "consent-required";
} else if ((processing?.failed ?? 0) > 0 && !onlyRetries(blocks)) {
// "Failed" is a prompt to go and look. Work the pipeline will pick up on
// its own pass is not that, and asking the reader to check a failure
@@ -135,8 +123,6 @@ export function memoryArrivalTitle(arrival: MemoryArrival): string {
return modelBlock(arrival.blockedStages ?? [])
? "待继续"
: "尚未设置整理模型";
- case "consent-required":
- return "等待允许模型读取本地会话内容";
case "failed":
case "stopped":
return "待继续";
diff --git a/memhub/apps/web/src/memory-arrival.test.tsx b/memhub/apps/web/src/memory-arrival.test.tsx
index e0560b4..dd968d6 100644
--- a/memhub/apps/web/src/memory-arrival.test.tsx
+++ b/memhub/apps/web/src/memory-arrival.test.tsx
@@ -64,7 +64,6 @@ it.each([
["checking", "进度待更新", false],
["importing", "正在导入对话", true],
["model-required", "尚未设置整理模型", false],
- ["consent-required", "等待允许模型读取本地会话内容", false],
["processing", "正在沉淀记忆", true],
["failed", "待继续", false],
["idle", "待继续", false],
@@ -87,22 +86,6 @@ it.each([
},
);
-it("uses existing settings navigation for consent when no separate callback is supplied", async () => {
- const settings = vi.fn();
- render(
- ,
- );
- expect(settings).not.toHaveBeenCalled();
- await userEvent.click(
- screen.getByRole("button", { name: "打开模型内容权限" }),
- );
- expect(settings).toHaveBeenCalledOnce();
-});
-
it("offers explicit connection only for the truly empty idle state", async () => {
const connect = vi.fn();
const view = render(
diff --git a/memhub/apps/web/src/memory-arrival.tsx b/memhub/apps/web/src/memory-arrival.tsx
index b381476..e0a4497 100644
--- a/memhub/apps/web/src/memory-arrival.tsx
+++ b/memhub/apps/web/src/memory-arrival.tsx
@@ -1,10 +1,4 @@
-import {
- FileText,
- Link2,
- LoaderCircle,
- Settings2,
- ShieldCheck,
-} from "lucide-react";
+import { FileText, Link2, LoaderCircle, Settings2 } from "lucide-react";
import {
type MemoryArrival,
memoryArrivalTitle,
@@ -22,7 +16,6 @@ export function MemoryArrivalPanel({
hasMemories = false,
suspended = false,
onViewHistory,
- onReviewProcessing,
onConfigureModel,
onConnect,
showDetails = true,
@@ -31,7 +24,6 @@ export function MemoryArrivalPanel({
hasMemories?: boolean;
suspended?: boolean;
onViewHistory?(): void;
- onReviewProcessing?(): void;
onConfigureModel(): void;
onConnect(): void;
showDetails?: boolean;
@@ -115,20 +107,11 @@ export function MemoryArrivalPanel({
设置记忆整理
)}
- {arrival.phase === "consent-required" && (
-
-
- 打开模型内容权限
-
- )}
{["failed", "unavailable", "stopped"].includes(arrival.phase) && (
查看整理状态
@@ -157,8 +140,6 @@ function arrivalDescription(arrival: MemoryArrival): string {
switch (arrival.phase) {
case "model-required":
return `${retained}请核对整理模型后继续。`;
- case "consent-required":
- return `${retained}确认来源和内容范围后才能开始整理。`;
case "processing":
return "先整理近期对话,更多记忆会陆续出现";
case "importing":
diff --git a/memhub/apps/web/src/memory-processing-labels.ts b/memhub/apps/web/src/memory-processing-labels.ts
index 52daf08..9673c20 100644
--- a/memhub/apps/web/src/memory-processing-labels.ts
+++ b/memhub/apps/web/src/memory-processing-labels.ts
@@ -63,12 +63,8 @@ const BLOCK_REASON_LABELS: Record = {
hy_memory_model_request_failed: "模型请求未成功",
hy_memory_unavailable: "HY Memory 不可用",
hy_memory_init_failed: "HY Memory 启动失败",
- model_content_permission_required: "模型内容权限已关闭",
- model_content_permission_revoked: "模型内容权限已关闭",
- model_content_permission_changed: "模型内容权限已变化",
model_content_profile_changed: "当前模型已变化",
model_content_content_scope_changed: "本地会话内容已变化",
- model_content_evidence_not_permitted: "模型内容权限未开启",
// Named rather than summed up as a retry: Core never picks this one up
// again (`_guard_task`), so calling it "正在重试" would be a lie.
hy_memory_input_exceeds_window: "这段对话含过长原文,暂未整理;原文已保留",
@@ -104,12 +100,8 @@ const NAMED_BLOCK_REASONS = new Set([
"hy_memory_model_request_failed",
"hy_memory_unavailable",
"hy_memory_init_failed",
- "model_content_permission_required",
- "model_content_permission_revoked",
- "model_content_permission_changed",
"model_content_profile_changed",
"model_content_content_scope_changed",
- "model_content_evidence_not_permitted",
// Never retried, and the one stall whose answer is "nothing to do, the
// original is still there" — worth saying out loud.
"hy_memory_input_exceeds_window",
diff --git a/memhub/apps/web/src/model-content-consent.css b/memhub/apps/web/src/model-content-consent.css
deleted file mode 100644
index 620a3c4..0000000
--- a/memhub/apps/web/src/model-content-consent.css
+++ /dev/null
@@ -1,393 +0,0 @@
-.model-content-entry {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- gap: 12px 20px;
- padding: 16px 0;
- border-bottom: 1px solid var(--line);
-}
-
-.model-content-entry h2 {
- margin: 0 0 4px;
- font-size: 16px;
-}
-
-.model-content-entry p {
- margin: 0;
- color: var(--muted);
- font-size: 13px;
-}
-
-.model-content-backdrop .model-content-consent {
- display: flex;
- flex-direction: column;
- width: min(680px, 100%);
- max-height: min(820px, calc(100dvh - 48px));
- overflow: hidden;
- padding: 0;
- background: #fff;
- backdrop-filter: none;
- -webkit-backdrop-filter: none;
- letter-spacing: 0;
- overflow-wrap: anywhere;
-}
-
-.model-content-consent .model-content-heading {
- display: flex;
- flex: 0 0 auto;
- align-items: center;
- gap: 8px;
- padding: 22px 24px 16px;
- border-bottom: 1px solid var(--line);
-}
-
-.model-content-heading > div {
- flex: 1;
- min-width: 0;
-}
-
-.model-content-heading .eyebrow {
- margin: 0 0 6px;
- letter-spacing: 0;
-}
-
-.model-content-consent .model-content-heading h2 {
- margin: 0;
- font-size: 20px;
-}
-
-.model-content-consent .model-content-icon {
- display: inline-flex;
- flex: 0 0 36px;
- align-items: center;
- justify-content: center;
- width: 36px;
- height: 36px;
- min-height: 36px;
- padding: 0;
-}
-
-.model-content-body {
- min-height: 0;
- padding: 16px 24px;
- overflow-y: auto;
- overscroll-behavior: contain;
- color: var(--text, #262a2e);
- font-size: 14px;
- line-height: 1.65;
-}
-
-.model-content-read-status {
- flex: 0 0 auto;
- max-height: 112px;
- overflow-y: auto;
- padding: 12px 24px;
- border-bottom: 1px solid var(--line);
- background: #f4f6f8;
- color: var(--text, #262a2e);
- font-size: 13px;
- line-height: 1.55;
-}
-
-.model-content-read-status p {
- margin: 0;
-}
-
-.model-content-read-status [role="alert"] {
- color: var(--danger, #9e4137);
-}
-
-.model-content-body > p:first-child {
- margin-top: 0;
-}
-
-.model-content-next-step {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 10px 16px;
- padding: 0 0 16px;
- margin-bottom: 16px;
- border-bottom: 1px solid var(--line);
- background: #fff;
-}
-
-.model-content-next-step p {
- flex: 1 1 240px;
- min-width: 0;
- margin: 0;
- color: var(--tx2, #5a6169);
-}
-
-.model-content-next-step button {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- max-width: 100%;
- white-space: normal;
-}
-
-.model-content-consent .model-content-accepted {
- color: var(--ac, #4e6478);
- background: var(--panel, #f4f6f9);
-}
-
-.model-content-consent .model-content-sources {
- min-width: 0;
- margin: 20px 0 0;
- padding: 0;
- border: 0;
-}
-
-.model-content-sources legend {
- padding: 0 0 8px;
- font-size: 14px;
- font-weight: 600;
-}
-
-/* 需求 6:范围默认全选,改为只读清单,不再逐条勾选。 */
-.model-content-scope {
- padding: 12px 14px;
- border: 1px solid var(--line, #dde6ed);
- border-radius: 10px;
- background: var(--panel, #f4f6f9);
-}
-
-.model-content-scope h3 {
- margin: 0 0 8px;
- font-size: 14px;
- font-weight: 600;
-}
-
-.model-content-scope-list {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.model-content-scope-list li {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 8px 0;
- border-bottom: 1px solid var(--line, #dde6ed);
-}
-
-.model-content-scope-list li:last-child {
- border-bottom: 0;
-}
-
-.model-content-scope-list small {
- color: var(--muted, #5a6169);
- font-size: 12px;
-}
-
-.model-content-scope .model-content-meta {
- margin: 8px 0 0;
-}
-
-.model-content-consent .model-content-choice {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- margin: 0;
- padding: 12px 0;
- border-bottom: 1px solid var(--line);
- cursor: pointer;
-}
-
-.model-content-consent .model-content-choice input[type="checkbox"] {
- flex: 0 0 16px;
- width: 16px;
- height: 16px;
- min-height: 16px;
- margin: 4px 0 0;
- padding: 0;
- accent-color: var(--accent);
-}
-
-.model-content-choice > span {
- min-width: 0;
-}
-
-.model-content-choice small {
- display: block;
- color: var(--muted);
- font-size: 12px;
-}
-
-.model-content-consent .model-content-future {
- margin-top: 8px;
- border: 0;
-}
-
-.model-content-consent .model-content-final {
- border: 0;
- border-top: 1px solid var(--line);
-}
-
-.model-content-meta {
- color: var(--muted, #5a6169);
- font-size: 12px;
-}
-
-.model-content-count {
- font-size: 16px;
- font-weight: 600;
-}
-
-.model-content-evidence {
- min-width: 0;
- margin: 16px 0;
- padding: 16px 0;
- border-top: 1px solid var(--line);
- border-bottom: 1px solid var(--line);
- background: #fff;
-}
-
-.model-content-evidence h3 {
- margin: 0 0 8px;
- font-size: 14px;
-}
-
-.model-content-evidence .model-content-meta {
- margin: 4px 0;
-}
-
-.model-content-consent .model-content-verbatim {
- max-width: 100%;
- margin: 12px 0 0;
- padding: 0;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- color: var(--text, #262a2e);
- background: #fff;
- font: inherit;
-}
-
-.model-content-recipients {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 20px;
- padding: 16px 0;
- border-top: 1px solid var(--line);
- border-bottom: 1px solid var(--line);
-}
-
-.model-content-recipient h3 {
- margin: 0;
- font-size: 13px;
-}
-
-.model-content-recipient p {
- margin: 4px 0 8px;
- color: var(--muted);
- font-size: 12px;
-}
-
-.model-content-recipient strong,
-.model-content-recipient code,
-.model-content-recipient small {
- display: block;
- max-width: 100%;
- overflow-wrap: anywhere;
-}
-
-.model-content-recipient code {
- margin-top: 4px;
- white-space: normal;
- font-size: 12px;
-}
-
-.model-content-consent .model-content-snapshot-sources {
- margin: 16px 0;
- border-top: 0;
- background: #fff;
-}
-
-.model-content-error,
-.model-content-notice {
- margin: 16px 0 0;
- padding: 12px;
- border-radius: 6px;
- background: #eef2f5;
- color: #394e5e;
-}
-
-.model-content-error {
- background: #f8eeec;
- color: #96453e;
-}
-
-.model-content-process-status {
- margin: 16px 0 0;
- padding: 12px;
- border: 1px solid var(--line);
- border-radius: 6px;
- background: #f4f6f8;
- color: #394e5e;
-}
-
-.model-content-process-status strong {
- font-size: 14px;
- font-weight: 600;
-}
-
-.model-content-process-status p {
- margin: 6px 0 0;
- font-size: 13px;
-}
-
-.model-content-consent .model-content-actions {
- flex: 0 0 auto;
- flex-wrap: wrap;
- margin: 0;
- padding: 16px 24px;
- border-top: 1px solid var(--line);
- background: #fff;
-}
-
-.model-content-actions button,
-.model-content-entry button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- max-width: 100%;
- white-space: normal;
-}
-
-.model-content-consent button svg,
-.model-content-entry button svg {
- flex-shrink: 0;
-}
-
-@media (max-width: 600px) {
- .model-content-backdrop {
- padding: 12px;
- }
-
- .model-content-backdrop .model-content-consent {
- max-height: calc(100dvh - 24px);
- }
-
- .model-content-consent .model-content-heading,
- .model-content-body,
- .model-content-consent .model-content-actions {
- padding: 16px;
- }
-
- .model-content-recipients {
- grid-template-columns: minmax(0, 1fr);
- gap: 16px;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .model-content-backdrop .model-content-consent,
- .model-content-consent * {
- animation: none;
- transition: none;
- }
-}
diff --git a/memhub/apps/web/src/poll-scheduler.ts b/memhub/apps/web/src/poll-scheduler.ts
index 61bb158..1c33919 100644
--- a/memhub/apps/web/src/poll-scheduler.ts
+++ b/memhub/apps/web/src/poll-scheduler.ts
@@ -27,8 +27,7 @@ export type PollKey =
| "conversations"
| "applications"
| "calls"
- | "model-consent"
- | "model-consent-request"
+ | "model-profiles"
| "note-evidence"
| "note-processing"
| "health";
diff --git a/memhub/apps/web/src/r6-settings.css b/memhub/apps/web/src/r6-settings.css
index 3602cce..8e14600 100644
--- a/memhub/apps/web/src/r6-settings.css
+++ b/memhub/apps/web/src/r6-settings.css
@@ -262,23 +262,6 @@
justify-content: flex-end;
gap: 12px;
}
-.r6-settings .r6-model-permission {
- padding: 24px 0 40px;
- border-bottom: 1px solid #d8e2ea;
-}
-.r6-settings .r6-model-permission h3 {
- font:
- 400 18px / 28px "Noto Sans SC",
- "PingFang SC",
- sans-serif;
- margin: 0 0 14px;
-}
-.r6-settings .r6-model-permission p {
- font-size: 15px;
- line-height: 28px;
- color: #63707b;
- margin: 0 0 19px;
-}
.r6-settings .r6-model-maintenance {
margin-top: 24px;
}
diff --git a/memhub/apps/web/src/ui-trace-coverage.test.ts b/memhub/apps/web/src/ui-trace-coverage.test.ts
index febbfb2..f1d3dd9 100644
--- a/memhub/apps/web/src/ui-trace-coverage.test.ts
+++ b/memhub/apps/web/src/ui-trace-coverage.test.ts
@@ -54,19 +54,6 @@ describe("ui trace write coverage", () => {
);
});
- it("traces the global model-content permission write", () => {
- const hook = source("use-model-content-permission.ts");
- expect(hook).toMatch(/setModelContentPermission\(next, trace\.traceId\)/);
- const api = source("api.ts");
- const start = api.indexOf(
- "export async function setModelContentPermission(",
- );
- expect(start, "setModelContentPermission not found").toBeGreaterThan(-1);
- const body = api.slice(start, start + 650);
- expect(body).toContain("traceId?: string");
- expect(body).toContain("X-One-Memory-Trace-Id");
- });
-
it("traces the history-import and graph-width writes", () => {
const history = source("HistoryImport.tsx");
expect(history).toMatch(
@@ -329,13 +316,6 @@ describe("ui trace write coverage", () => {
expect(api).toContain("X-One-Memory-Trace-Id");
});
- it("traces the global model-content permission toggle", () => {
- const hook = source("use-model-content-permission.ts");
- expect(hook).toMatch(/traceUiAction\("settings", "toggle"\)/);
- expect(hook).toMatch(/setModelContentPermission\(next, trace\.traceId\)/);
- expect(hook).toContain('trace.done("succeeded")');
- });
-
it("traces the onboarding completion write", () => {
// Finishing first-run setup records durable onboarding state. It is a
// non-GET and had no trace.
@@ -423,9 +403,8 @@ describe("ui trace write coverage", () => {
// A poll key is a *data plane* name. When two call sites name the same key
// but lead to different Core endpoints, one is misnamed — the scheduler
// runs every subscriber, so the key stops meaning "this surface". That is
- // how `getMemoryProcessing` once shared `model-consent` with
- // `getModelContentPermission`. Walk each `subscribePoll({...})` body and
- // require the reachable endpoints to agree per key.
+ // Walk each `subscribePoll({...})` body and require the reachable
+ // endpoints to agree per key.
const files = readdirSync(path.join(ROOT, "src"))
.filter((name) => /\.(ts|tsx)$/.test(name) && !/\.test\./.test(name))
.map((name) => path.join(ROOT, "src", name));
diff --git a/memhub/apps/web/src/use-model-content-permission.ts b/memhub/apps/web/src/use-model-content-permission.ts
deleted file mode 100644
index f5de5ad..0000000
--- a/memhub/apps/web/src/use-model-content-permission.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { getModelContentPermission, setModelContentPermission } from "./api";
-import { traceUiAction } from "./ui-trace";
-import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
-
-export function useModelContentPermission(enabled = true) {
- const [allowed, setAllowed] = useState(null);
- const [reading, setReading] = useState(enabled);
- const [writing, setWriting] = useState(false);
- const [error, setError] = useState(null);
- const alive = useRef(false);
- const version = useRef(0);
- const flight = useRef(false);
-
- const refresh = useCallback(async () => {
- if (!enabled) return;
- const readVersion = ++version.current;
- setReading(true);
- try {
- const permission = await getModelContentPermission();
- if (!alive.current || readVersion !== version.current) return;
- setAllowed(permission.allowed);
- setError(null);
- } catch {
- if (!alive.current || readVersion !== version.current) return;
- setError("暂时无法读取模型内容权限,请重试。");
- } finally {
- if (alive.current && readVersion === version.current) setReading(false);
- }
- }, [enabled]);
-
- useEffect(() => {
- alive.current = true;
- if (enabled) void refresh();
- return () => {
- alive.current = false;
- version.current += 1;
- };
- }, [enabled, refresh]);
-
- const update = useCallback(
- async (next: boolean) => {
- if (!enabled || flight.current || allowed === null || next === allowed)
- return false;
- flight.current = true;
- version.current += 1;
- setWriting(true);
- setError(null);
- const trace = traceUiAction("settings", "toggle");
- try {
- const permission = await setModelContentPermission(next, trace.traceId);
- trace.done("succeeded");
- if (alive.current) setAllowed(permission.allowed);
- return true;
- } catch (reason) {
- trace.done(writeTraceStatus(reason), {
- reason: isDefiniteRefusal(reason)
- ? "model_content_permission_refused"
- : "model_content_permission_unknown",
- });
- if (alive.current) {
- setError(
- isDefiniteRefusal(reason)
- ? "模型内容权限未能修改,请重试。"
- : "修改结果尚未确认,正在重新读取当前设置。",
- );
- await refresh();
- }
- return false;
- } finally {
- flight.current = false;
- if (alive.current) setWriting(false);
- }
- },
- [allowed, enabled, refresh],
- );
-
- return { allowed, reading, writing, error, refresh, update };
-}
diff --git a/memhub/docs/ARCHITECTURE.md b/memhub/docs/ARCHITECTURE.md
index 00298da..2277408 100644
--- a/memhub/docs/ARCHITECTURE.md
+++ b/memhub/docs/ARCHITECTURE.md
@@ -69,7 +69,9 @@ Source root authorization and Harness synchronization are distinct:
- SQLite `source_grants` records the authorized root, its identity, grant and
revocation state. A grant does not by itself turn synchronization on.
- Core-owned `/config.json` is the durable desired state for
- `sync_enabled` per source and the global `model_content_allowed` setting.
+ `sync_enabled` per source. The embedded Hy Memory SDK is part of MemHub and
+ may use the complete local memory store; there is no second model-content
+ permission switch.
- Core is the only writer of `config.json`. It validates a strict schema and
persists updates through a private temporary file, `fsync`, atomic replace,
and directory `fsync`.
diff --git a/tests/test_http_transport_factory.py b/tests/test_http_transport_factory.py
new file mode 100644
index 0000000..16114a3
--- /dev/null
+++ b/tests/test_http_transport_factory.py
@@ -0,0 +1,84 @@
+import asyncio
+from types import SimpleNamespace
+
+import httpx
+import openai
+
+from hy_memory.agent.llm_provider import LLMProvider
+from hy_memory.config import MemoryConfig
+from hy_memory.core.embed_service import EmbedService
+
+
+class _Transport(httpx.AsyncBaseTransport):
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
+ raise AssertionError(f"unexpected request: {request.url}")
+
+
+def test_embed_service_uses_runtime_transport_factory(monkeypatch) -> None:
+ config = MemoryConfig()
+ transports = []
+ config.http_transport_factory = lambda: transports.append(_Transport()) or transports[-1]
+ captured = {}
+
+ monkeypatch.setattr(
+ openai,
+ "AsyncOpenAI",
+ lambda **kwargs: captured.update(kwargs) or SimpleNamespace(),
+ )
+
+ EmbedService(config)._get_client("https://example.test/v1", "key", 30)
+
+ assert len(transports) == 1
+ assert isinstance(captured["http_client"], httpx.AsyncClient)
+ asyncio.run(captured["http_client"].aclose())
+
+
+def test_llm_provider_uses_runtime_transport_factory(monkeypatch) -> None:
+ config = MemoryConfig()
+ config.llm.base_url = "https://example.test/v1"
+ config.llm.api_key = "key"
+ config.llm.model = "model"
+ transports = []
+ config.http_transport_factory = lambda: transports.append(_Transport()) or transports[-1]
+ captured = {}
+
+ class Completions:
+ async def create(self, **kwargs):
+ return SimpleNamespace(
+ id="response",
+ model="model",
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(content="ok", tool_calls=None),
+ finish_reason="stop",
+ )
+ ],
+ usage=SimpleNamespace(
+ total_tokens=1,
+ prompt_tokens=1,
+ completion_tokens=0,
+ completion_tokens_details=None,
+ ),
+ )
+
+ class Client:
+ chat = SimpleNamespace(completions=Completions())
+
+ async def close(self):
+ await captured["http_client"].aclose()
+
+ monkeypatch.setattr(
+ openai,
+ "AsyncOpenAI",
+ lambda **kwargs: captured.update(kwargs) or Client(),
+ )
+
+ provider = LLMProvider(config)
+ response = asyncio.run(
+ provider._chat_completions([{"role": "user", "content": "hello"}], max_tokens=8)
+ )
+
+ assert response.content == "ok"
+ assert len(transports) == 1
+ assert isinstance(captured["http_client"], httpx.AsyncClient)
+ asyncio.run(provider.close())