diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index 8e36436a0e..475a28aa4b 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -325,6 +325,32 @@ jobs: compose=(docker compose -f e2e/bub/compose.yaml -f e2e/bub/compose.oceanbase.yaml) "${compose[@]}" up --detach --wait --wait-timeout 660 oceanbase container_id=$("${compose[@]}" ps -q oceanbase) + # Repeated schema creation needs more than the slim image's 1 GB datafile. + docker exec "$container_id" obclient -h127.0.0.1 -P2881 -uroot@sys \ + -e "ALTER SYSTEM SET datafile_maxsize='8G'; ALTER SYSTEM SET datafile_next='512M'; ALTER SYSTEM SET datafile_size='4G';" + datafile_ready=false + for config_attempt in $(seq 1 30); do + docker exec "$container_id" obclient -h127.0.0.1 -P2881 -uroot@sys --batch --skip-column-names \ + -e "SHOW PARAMETERS WHERE name IN ('datafile_size', 'datafile_maxsize', 'datafile_next');" \ + > "$RUNNER_TEMP/acceptance-oceanbase-datafile.tsv" + if awk ' + $5 == "datafile_size" && $7 == "4G" { size = 1 } + $5 == "datafile_maxsize" && $7 == "8G" { maximum = 1 } + $5 == "datafile_next" && $7 == "512M" { increment = 1 } + END { exit !(size && maximum && increment) } + ' "$RUNNER_TEMP/acceptance-oceanbase-datafile.tsv"; then + datafile_ready=true + break + fi + sleep 2 + done + cat "$RUNNER_TEMP/acceptance-oceanbase-datafile.tsv" + if [ "$datafile_ready" != true ]; then + echo 'OceanBase datafile configuration did not become effective within one minute.' >&2 + exit 1 + fi + docker exec "$container_id" obclient -h127.0.0.1 -P2881 -uroot@test -ppowercontext-e2e \ + -Dpowercontext -e "SET GLOBAL recyclebin=OFF; SHOW VARIABLES LIKE 'recyclebin';" oceanbase_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_id") test -n "$oceanbase_ip" export POWERCONTEXT_TEST_OCEANBASE_URL="mysql+aoceanbase://root%40test:powercontext-e2e@${oceanbase_ip}:2881/powercontext?charset=utf8mb4" diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b796694802..3f7c8ac566 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "c4571e4bd59833451e3091ee604e836d185643b0b0d9fa08dceb8eea43060cbf", + "contractSha256": "df721a9a4eff875f761ce59f9394bc4a7a83a79b8507b7f8d629d4f4388b812b", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 5be80d4d27..f31324ba00 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -16,6 +16,26 @@ // Generated from openapi/powercontext.yaml. Do not edit. export interface paths { + "/v1/atomic-memory/directory/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Query compact current Atomic Memory references + * @description Returns current metadata and exact revisions without loading Memory text or history. Any directory or tag change invalidates continuation; restart from the first page on invalid_cursor or cursor_expired. Requires verified directory migration and writer composition, and a supported local authorization configuration. Production enablement is unavailable until the unified migration bundle manages the complete target. Unavailable codes include artifact_owner_pending, atomic_memory_directory_authorization_unavailable and atomic_memory_directory_unavailable; do not treat failure as an empty page. + */ + post: operations["query_atomic_memory_directory"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/atomic-memory/list": { parameters: { query?: never; @@ -4920,6 +4940,35 @@ export interface components { items: components["schemas"]["AccessAuditEvent"][]; next_cursor: string | null; }; + QueryAtomicMemoryDirectoryRequest: { + scope_id: string; + /** + * @default [ + * "active" + * ] + */ + states: components["schemas"]["AtomicMemoryState"][]; + kind?: string | null; + /** @default [] */ + tags: string[]; + /** @default all */ + tag_match: components["schemas"]["TagMatch"]; + /** @default 50 */ + limit: number; + cursor?: string | null; + }; + AtomicMemoryDirectoryItem: { + artifact: components["schemas"]["ArtifactReference"]; + kind: string; + state: components["schemas"]["AtomicMemoryState"]; + state_version: number; + merged_into_id: string | null; + }; + QueryAtomicMemoryDirectoryResponse: { + scope_id: string; + items: components["schemas"]["AtomicMemoryDirectoryItem"][]; + next_cursor: string | null; + }; }; responses: { /** @description The request query or pagination cursor is invalid. */ @@ -5060,6 +5109,63 @@ export interface components { } export type $defs = Record; export interface operations { + query_atomic_memory_directory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QueryAtomicMemoryDirectoryRequest"]; + }; + }; + responses: { + /** @description Compact current directory page. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QueryAtomicMemoryDirectoryResponse"]; + }; + }; + /** @description The cursor is invalid or its query, identity, policy or directory generation changed. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description The cursor expired; restart from the first page. */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A compact item exceeds the response budget; no partial page is returned. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; list_atomic_memories: { parameters: { query?: never; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index b2d1479877..c6710e2b1c 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "c4571e4bd59833451e3091ee604e836d185643b0b0d9fa08dceb8eea43060cbf"; +export const contractSha256 = "df721a9a4eff875f761ce59f9394bc4a7a83a79b8507b7f8d629d4f4388b812b"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/workflows/atomic-memory.md b/docs/en/docs/workflows/atomic-memory.md index 8f8e998b68..e6deae9be2 100644 --- a/docs/en/docs/workflows/atomic-memory.md +++ b/docs/en/docs/workflows/atomic-memory.md @@ -111,6 +111,54 @@ both accept `kind`, `tags` and `tag_match: "all" | "any"`, with limits of 1–10 [Artifact tag API](manage-artifact-tags.md) with `{type: "artifact", family: "atomic-memory", artifact_id: "M"}` for new targets. +## Compact directory pagination + +To browse memory identities and states, call `POST /v1/atomic-memory/directory/query`: + +```json +{"scope_id": "S", "states": ["active"], "kind": "decision", "limit": 50} +``` + +The response contains `scope_id`, `items` and `next_cursor`. Each item contains only the exact `artifact` reference, +`kind`, `state`, `state_version` and `merged_into_id`; it loads no content, sources or history. Results are ordered by +`artifact.artifact_id` ascending. Read the returned exact revision when content is needed, so a later replacement +does not change the meaning of this page. + +Keep the Scope, principal, filters and limit unchanged and copy `next_cursor` into the next request's `cursor`. +Creation, replacement, lifecycle changes, merge, restore or effective tag changes invalidate older cursors. +On `400 invalid_cursor` or `410 cursor_expired`, discard the continuation and restart at page one. This is a current +directory; it does not guarantee that a traversal completes during continuous writes. The default limit is 50 and +maximum is 100. `tags` accepts at most 16 labels of at most 64 characters each, with `tag_match: "all" | "any"`. +Every page rechecks access. Revocation prevents continuation; sharing one Artifact does not grant directory access. + +The HTTP SDK and MCP operation is `query_atomic_memory_directory`; the Python scoped method is `query_directory`. +Unmanaged deployments return `503 atomic_memory_directory_unavailable`. The optional registered target supports +persistent SQLite, without an embedding extension, and the default business layout created by the current program. +Stop all writers and use the same deployment environment file: + +This target uses the default evidence directory next to the database; a custom `--evidence-dir` is not supported. + +```bash +powercontext server db-migrate plan --target atomic-memory-directory --env-file deployment.env +powercontext server db-migrate apply --target atomic-memory-directory --env-file deployment.env +powercontext server db-migrate verify --target atomic-memory-directory --env-file deployment.env +``` + +The default migration target remains the released five-table Artifact schema at `p0004`. The optional directory target +adopts the current business baseline at `p0005`, including Artifact handles and bootstrap receipts, then applies `p0006`. +The explicit migration adds directory metadata and backfills only exact current Atomic content. Restart the matching +program after successful verification; normal startup does not add or backfill directory fields. Other modules retain +their existing initialization behavior. Completing legacy Memory migration alone does not establish this target's +readiness: unregistered archive objects, historical layouts and vector configurations are rejected. Inspection and +verification do not repair data. Interrupted maintenance retains its original recovery point and blocks business restart. + +Directory access supports trusted local reads or native Builtin/Casbin Scope-read configurations on the same SQLite +database. Formal ownership is still checked across the entire Scope and is never guessed during migration. Unsupported access configurations +return `503 atomic_memory_directory_authorization_unavailable`. An item exceeding the response budget returns +`413 atomic_memory_directory_item_too_large`, without a partially successful page. +Restore only under stopped writes, with matching program/configuration and preserved recovery evidence. Rotate the +existing cursor signing secret before resuming service so cursors from before database restoration are rejected. + ## Merge and restore `POST /v1/atomic-memory/merges` requires at least two exact active input references and state versions, plus result content: diff --git a/docs/zh/docs/workflows/atomic-memory.md b/docs/zh/docs/workflows/atomic-memory.md index e6ec5f50c2..cf7e27f89d 100644 --- a/docs/zh/docs/workflows/atomic-memory.md +++ b/docs/zh/docs/workflows/atomic-memory.md @@ -107,6 +107,49 @@ Atomic Memory 将每条事实、偏好或决策保存为独立的 `atomic-memory `tag_match: "all" | "any"`,条数范围为 1–100。标签使用 [Artifact 标签接口](manage-artifact-tags.md),新目标为 `{type: "artifact", family: "atomic-memory", artifact_id: "M"}`。 +## 紧凑目录分页 + +只需浏览记忆身份和状态时,调用 `POST /v1/atomic-memory/directory/query`: + +```json +{"scope_id": "S", "states": ["active"], "kind": "decision", "limit": 50} +``` + +响应包含 `scope_id`、`items` 和 `next_cursor`。每项只有精确 `artifact` 引用、`kind`、`state`、 +`state_version` 与 `merged_into_id`,不加载正文、来源或历史。按 `artifact.artifact_id` 升序返回; +需要正文时,用返回引用调用精确 revision 读取接口,避免将后来修订的正文当成本页结果。 + +保持 Scope、主体、筛选和 limit 相同,将 `next_cursor` 原样放入下一次请求的 `cursor`。 +创建、修订、生命周期、合并、恢复或有效标签变化会使旧 cursor 失效;收到 `400 invalid_cursor` +或 `410 cursor_expired` 时丢弃续页位置,从第一页重新读取。这是当前目录,不保证持续写入期间完成一次遍历。 +默认每页 50 项,最大 100;`tags` 最多 16 个、每个最多 64 字符,支持 `tag_match: "all" | "any"`。 +每页重新检查权限,撤权后不能继续读取;单条 Artifact 分享不授予目录浏览权。 + +HTTP SDK 和 MCP 均使用 `query_atomic_memory_directory`;Python scoped 接口为 `query_directory`。 +未纳管目录的部署返回 `503 atomic_memory_directory_unavailable`。首版可选目标支持持久 SQLite、无 embedding +向量扩展,以及当前程序创建的默认业务结构。停止所有写入者后,使用同一部署环境文件: + +这个目标使用数据库旁的默认证据目录,首版不支持自定义 `--evidence-dir`。 + +```bash +powercontext server db-migrate plan --target atomic-memory-directory --env-file deployment.env +powercontext server db-migrate apply --target atomic-memory-directory --env-file deployment.env +powercontext server db-migrate verify --target atomic-memory-directory --env-file deployment.env +``` + +默认迁移目标仍为已发布的五表 Artifact 结构 `p0004`。可选目录目标将含 Artifact handle 和 bootstrap receipt 的 +当前业务结构登记为 `p0005` 基线,再应用 `p0006`。 +显式迁移增加目录字段,只回填当前 Atomic 精确正文中的类型。验证成功后启动匹配的新程序;普通启动不补目录 +字段、不回填目录。其他模块保留原有初始化行为。旧 Memory 迁移成功不能单独证明这个目标就绪:未登记的归档 +对象、历史结构和向量配置仍会被拒绝。检查和验证不修复数据;中断维护保留原恢复点,并阻止业务重新启动。 + +目录支持可信本地读取或同一 SQLite 数据库的原生 Builtin/Casbin Scope-read 配置。整个 Scope 的正式 owner +仍按共享规则检查,迁移不猜测或补造 owner。不支持的权限配置返回 +`503 atomic_memory_directory_authorization_unavailable`。单项超出响应预算返回 +`413 atomic_memory_directory_item_too_large`,不会返回部分成功页。 +恢复数据库时保持停写,使用匹配的程序/配置并保留恢复证据;恢复后先轮换既有 cursor 签名密钥,再恢复服务, +让数据库恢复前的旧 cursor 全部失效。 + ## 合并与恢复 `POST /v1/atomic-memory/merges` 至少需要两个 active 输入的精确引用和状态版本,以及结果正文: diff --git a/evaluation/skills/skill-up/README.md b/evaluation/skills/skill-up/README.md index b87a114b49..e8b4014a26 100644 --- a/evaluation/skills/skill-up/README.md +++ b/evaluation/skills/skill-up/README.md @@ -101,7 +101,7 @@ All relative config paths start at this project root, not at `evals/`. Both arms receive the same `evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md`: required/optional top-level argument names, types and defaults derived from OpenAPI and its MCP specialization, including content CAS headers. -The mock catalog contains the Server's complete declared MCP tool subset (54 operations), verified against +The mock catalog contains the Server's complete declared MCP tool subset (55 operations), verified against `src/powercontext/server/mcp.py` and the generated operation IDs. The successful save reply contains Atomic `records`; empty search returns the current `text` mode and an empty `hits` list. This fills the generic mock schemas' parameter information gap diff --git a/evaluation/skills/skill-up/evals/cases/empty-search.yaml b/evaluation/skills/skill-up/evals/cases/empty-search.yaml index d7c6753bd4..71bd8ff442 100644 --- a/evaluation/skills/skill-up/evals/cases/empty-search.yaml +++ b/evaluation/skills/skill-up/evals/cases/empty-search.yaml @@ -69,6 +69,7 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_memory_entries} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_scopes} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__publish_artifact} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_atomic_memory_directory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_code} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__record_task_outcome} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__reject_artifact_candidate} @@ -99,6 +100,7 @@ judge: - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__list_memory_entries} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__list_scopes} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__publish_artifact} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__query_atomic_memory_directory} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__query_code} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__record_task_outcome} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__reject_artifact_candidate} diff --git a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml index 8e9cf90aab..ee37a43916 100644 --- a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml @@ -62,6 +62,7 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_memory_entries} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_scopes} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__publish_artifact} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_atomic_memory_directory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_code} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__record_task_outcome} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__reject_artifact_candidate} diff --git a/evaluation/skills/skill-up/evals/cases/failed-save.yaml b/evaluation/skills/skill-up/evals/cases/failed-save.yaml index c6a121d9db..3b50ae7917 100644 --- a/evaluation/skills/skill-up/evals/cases/failed-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/failed-save.yaml @@ -78,6 +78,7 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_memory_entries} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_scopes} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__publish_artifact} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_atomic_memory_directory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_code} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__record_task_outcome} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__reject_artifact_candidate} diff --git a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml index 5b1e0d2fca..d2a0b45e5f 100644 --- a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml +++ b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml @@ -64,6 +64,7 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_memory_entries} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_scopes} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__publish_artifact} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_atomic_memory_directory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_code} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__record_task_outcome} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__reject_artifact_candidate} diff --git a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml index 26b8ca4f1a..7f516a4bb2 100644 --- a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml +++ b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml @@ -54,6 +54,7 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_memory_entries} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__list_scopes} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__publish_artifact} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_atomic_memory_directory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__query_code} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__record_task_outcome} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__reject_artifact_candidate} diff --git a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json index a2695ac5b3..1f2abbf867 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json @@ -172,6 +172,9 @@ "publish_artifact": { "error": "unexpected" }, + "query_atomic_memory_directory": { + "error": "unexpected" + }, "query_code": { "error": "unexpected" }, diff --git a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json index b5cbbc182b..261336f447 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json @@ -172,6 +172,9 @@ "publish_artifact": { "error": "unexpected" }, + "query_atomic_memory_directory": { + "error": "unexpected" + }, "query_code": { "error": "unexpected" }, diff --git a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md index f6574f1b91..6e019d4a3e 100644 --- a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md +++ b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md @@ -210,6 +210,11 @@ not replace those schemas or enforce their constraints. It contains no scenario - Required: `idempotency_key: string`, `source: ArtifactAddress`, `target_scope_id: string`. - Optional: (none). +## mcp__powercontext__query_atomic_memory_directory + +- Required: `scope_id: string`. +- Optional: `cursor: string | null`, `kind: string | null`, `limit: integer = 50`, `states: array<"active" | "forgotten" | "merged" | "retired"> = ["active"]`, `tag_match: "all" | "any" = "all"`, `tags: array = []`. + ## mcp__powercontext__query_code - Required: `operation: CodeStatusOperation | CodeChangesOperation | CodeMapOperation | CodeSearchOperation | CodeRelationOperation | CodeTestsOperation | CodeReadOperation`, `scope_id: string`. diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index ff71c0b069..0f0e810805 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -70,6 +70,9 @@ capabilities = ["memory_read"] id = "list_atomic_memories" capabilities = ["memory_read"] [[toolsets.tools]] +id = "query_atomic_memory_directory" +non_profile_reason = "Optional compact directory browsing requires the registered persistent SQLite directory migration and qualified Scope access; it is not a mandatory host profile operation." +[[toolsets.tools]] id = "search_atomic_memory" capabilities = ["memory_read"] [[toolsets.tools]] diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index 91dd7ff22a..8a2ed510b1 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -69,6 +69,7 @@ "import_external_skill", "list_artifact_candidates", "list_atomic_memories", + "query_atomic_memory_directory", "list_external_skills", "list_managed_skills", "list_memory_entries", diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index d4409ed73b..577f94e59f 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -159,6 +159,17 @@ function writeFailureConfirmation(error) { //#endregion //#region src/operations.generated.ts const OPERATIONS$1 = { + query_atomic_memory_directory: { + method: "POST", + path: "/v1/atomic-memory/directory/query", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_atomic_memories: { method: "POST", path: "/v1/atomic-memory/list", diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 104f93c7ed..75fb30a8f8 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -17,6 +17,7 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { + query_atomic_memory_directory: { method: 'POST', path: '/v1/atomic-memory/directory/query', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/merges', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 104f93c7ed..75fb30a8f8 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -17,6 +17,7 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { + query_atomic_memory_directory: { method: 'POST', path: '/v1/atomic-memory/directory/query', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/merges', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 104f93c7ed..75fb30a8f8 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -17,6 +17,7 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { + query_atomic_memory_directory: { method: 'POST', path: '/v1/atomic-memory/directory/query', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/merges', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index dcb2378621..673ebb69fb 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -25,6 +25,65 @@ security: - BearerAuth: [] - {} paths: + /v1/atomic-memory/directory/query: + post: + tags: + - atomic-memory + summary: Query compact current Atomic Memory references + description: >- + Returns current metadata and exact revisions without loading Memory text or history. + Any directory or tag change invalidates continuation; restart from the first page on + invalid_cursor or cursor_expired. Requires verified directory migration and writer + composition, and a supported local authorization configuration. Production enablement + is unavailable until the unified migration bundle manages the complete target. + Unavailable codes include artifact_owner_pending, atomic_memory_directory_authorization_unavailable + and atomic_memory_directory_unavailable; do not treat failure as an empty page. + operationId: query_atomic_memory_directory + x-powercontext-scope-mode: current + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + responses: + '200': + description: Compact current directory page. + content: + application/json: + schema: + $ref: '#/components/schemas/QueryAtomicMemoryDirectoryResponse' + '400': + description: The cursor is invalid or its query, identity, policy or directory generation changed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + description: The cursor expired; restart from the first page. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '413': + description: A compact item exceeds the response budget; no partial page is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + $ref: '#/components/responses/InvalidRequest' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QueryAtomicMemoryDirectoryRequest' /v1/atomic-memory/list: post: tags: @@ -12021,3 +12080,98 @@ components: items: $ref: "#/components/schemas/AccessAuditEvent" next_cursor: {type: string, maxLength: 2048, nullable: true} + QueryAtomicMemoryDirectoryRequest: + type: object + additionalProperties: false + required: + - scope_id + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: .*\S.* + states: + type: array + minItems: 1 + maxItems: 4 + uniqueItems: true + default: [active] + items: + $ref: '#/components/schemas/AtomicMemoryState' + kind: + type: string + minLength: 1 + maxLength: 128 + pattern: .*\S.* + nullable: true + tags: + type: array + maxItems: 16 + default: [] + items: + type: string + minLength: 1 + maxLength: 64 + tag_match: + $ref: '#/components/schemas/TagMatch' + default: all + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + AtomicMemoryDirectoryItem: + type: object + additionalProperties: false + required: + - artifact + - kind + - state + - state_version + - merged_into_id + properties: + artifact: + $ref: '#/components/schemas/ArtifactReference' + kind: + type: string + minLength: 1 + maxLength: 128 + pattern: .*\S.* + state: + $ref: '#/components/schemas/AtomicMemoryState' + state_version: + type: integer + minimum: 0 + merged_into_id: + type: string + minLength: 1 + maxLength: 128 + nullable: true + QueryAtomicMemoryDirectoryResponse: + type: object + additionalProperties: false + required: + - scope_id + - items + - next_cursor + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + items: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/AtomicMemoryDirectoryItem' + next_cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true diff --git a/src/powercontext/builtin/artifacts/merge.py b/src/powercontext/builtin/artifacts/merge.py index 5fdf8cc13a..4f0d136a07 100644 --- a/src/powercontext/builtin/artifacts/merge.py +++ b/src/powercontext/builtin/artifacts/merge.py @@ -122,7 +122,7 @@ def __init__( sources: SourceRepository | None = None, ) -> None: self.artifacts = artifacts - self.states = ArtifactGovernanceRepository() + self.states = ArtifactGovernanceRepository(atomic_memory_directory=artifacts.atomic_memory_directory) self.adapter = adapter self.family = adapter.family self.errors = errors or ArtifactMergeErrors() diff --git a/src/powercontext/builtin/persistence/artifact_governance.py b/src/powercontext/builtin/persistence/artifact_governance.py index 63659b9627..b8ee0aa482 100644 --- a/src/powercontext/builtin/persistence/artifact_governance.py +++ b/src/powercontext/builtin/persistence/artifact_governance.py @@ -17,6 +17,7 @@ from __future__ import annotations from enum import StrEnum +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field, model_validator from sqlalchemy import select, update @@ -27,6 +28,9 @@ from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE from powercontext.errors import PowerContextError +if TYPE_CHECKING: + from powercontext.builtin.persistence.atomic_memory_directory import AtomicMemoryDirectoryRepository + class InvalidArtifactLifecycleError(PowerContextError, ValueError): """Raised when an explicit governance transition is not valid.""" @@ -67,6 +71,9 @@ def validate_replacement(self) -> ArtifactGovernance: class ArtifactGovernanceRepository: """Read and transition governance state without changing the head Revision.""" + def __init__(self, *, atomic_memory_directory: AtomicMemoryDirectoryRepository | None = None) -> None: + self.atomic_memory_directory = atomic_memory_directory + async def get( self, connection: AsyncConnection, @@ -77,6 +84,8 @@ async def get( *, for_update: bool = False, ) -> ArtifactGovernance: + if for_update and family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) statement = select(ARTIFACT_HEADS_TABLE).where( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == family, @@ -116,6 +125,8 @@ async def transition_merge( """Apply a service-validated group transition while retaining unchanged state generations.""" _validate_transition(expected.lifecycle_state, lifecycle_state) + if family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) current = await self.require_summary(connection, scope_id, family, artifact_id, expected) if ( expected.lifecycle_state == lifecycle_state @@ -147,6 +158,8 @@ async def transition_merge( ) if result.rowcount != 1: raise StoredPayloadConflictError("artifact-governance", (scope_id, family, artifact_id)) + if family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.record_mutation(connection, scope_id) return requested async def transition( @@ -160,6 +173,8 @@ async def transition( replacement_artifact_id: str | None, /, ) -> ArtifactGovernance: + if family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) current = await self.get(connection, scope_id, family, artifact_id) if current.merged_into_id is not None: raise InvalidArtifactLifecycleError("frozen merge inputs require whole-group restoration") # noqa: TRY003 @@ -206,6 +221,8 @@ async def transition( raise StoredPayloadConflictError( "artifact-governance", (scope_id, family, artifact_id, expected_generation) ) + if family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.record_mutation(connection, scope_id) return requested diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 8e16a20afa..0544e597d1 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -17,7 +17,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence -from typing import Any +from typing import TYPE_CHECKING, Any, cast from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError from sqlalchemy import insert, select, true, tuple_, update @@ -55,6 +55,10 @@ from powercontext.limits import MAX_SCOPE_ID_LENGTH from powercontext.sources import SourceRef +if TYPE_CHECKING: + from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemory + from powercontext.builtin.persistence.atomic_memory_directory import AtomicMemoryDirectoryRepository + class RepositoryArtifactDraft(BaseModel): """A family-validated draft accepted by the shared Artifact repository.""" @@ -76,6 +80,7 @@ def __init__( /, *, sources: Any | None = None, + atomic_memory_directory: AtomicMemoryDirectoryRepository | None = None, ) -> None: self._by_family = {artifact_type.family: artifact_type for artifact_type in artifact_types} self._content_types: dict[str, type[BaseModel]] = {} @@ -85,6 +90,8 @@ def __init__( raise TypeError(f"{artifact_type.__name__}.content must be a BaseModel") # noqa: TRY003 self._content_types[family] = content_type self._sources = sources + # Compose only after the unified schema/data capability is ready. + self.atomic_memory_directory = atomic_memory_directory @property def families(self) -> frozenset[str]: @@ -112,6 +119,8 @@ async def create( artifact_type = self._artifact_type(draft.family) self._require_content(draft.family, draft.content) ref = ArtifactRef(family=draft.family, artifact_id=artifact_id, revision=1) + if ref.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) await self._validate_lineage_sources(connection, scope_id, ref, draft.sources) conflict = await self._head_conflict(connection, scope_id, ref, draft) if conflict is not None: @@ -141,6 +150,8 @@ async def create( if conflict is None: raise raise conflict from None + if ref.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.record_publication(connection, scope_id, cast("AtomicMemory", artifact)) return artifact async def create_merge_result( @@ -292,6 +303,8 @@ async def _revise( artifact_id=artifact.artifact_id, revision=artifact.revision + 1, ) + if artifact.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) await self._validate_lineage_sources(connection, scope_id, target, draft.sources) locked = await connection.execute( @@ -355,6 +368,8 @@ async def _revise( ) if advanced.rowcount != 1: raise RevisionConflictError(artifact, revised) + if artifact.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.record_publication(connection, scope_id, cast("AtomicMemory", revised)) return revised async def get( @@ -369,6 +384,8 @@ async def get( """Load one exact revision with ordered direct lineage.""" _require_scope(scope_id) + if for_update and ref.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) statement = select(ARTIFACTS_TABLE).where( ARTIFACTS_TABLE.c.scope_id == scope_id, ARTIFACTS_TABLE.c.family == ref.family, @@ -468,6 +485,8 @@ async def lock_heads( """ _require_scope(scope_id) + if self.atomic_memory_directory is not None and any(ref.family == "atomic-memory" for ref in refs): + await self.atomic_memory_directory.lock_scope(connection, scope_id) for family, artifact_id in sorted({(ref.family, ref.artifact_id) for ref in refs}): locked = await connection.execute( update(ARTIFACT_HEADS_TABLE) @@ -541,6 +560,8 @@ async def copy_exact( _require_scope(target_scope_id) artifact_type = self._artifact_type(source.family) ref = ArtifactRef(family=source.family, artifact_id=target_artifact_id, revision=1) + if ref.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, target_scope_id) copied = await self._insert_revision( connection, target_scope_id, @@ -560,6 +581,10 @@ async def copy_exact( revision=ref.revision, ) ) + if ref.family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.record_publication( + connection, target_scope_id, cast("AtomicMemory", copied) + ) return copied async def _insert_revision( @@ -865,6 +890,8 @@ async def _find_head( *, for_update: bool = False, ) -> int | None: + if for_update and family == "atomic-memory" and self.atomic_memory_directory is not None: + await self.atomic_memory_directory.lock_scope(connection, scope_id) statement = select(ARTIFACT_HEADS_TABLE.c.revision).where( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == family, diff --git a/src/powercontext/builtin/persistence/atomic_memory.py b/src/powercontext/builtin/persistence/atomic_memory.py index d871100818..4a8c70d80a 100644 --- a/src/powercontext/builtin/persistence/atomic_memory.py +++ b/src/powercontext/builtin/persistence/atomic_memory.py @@ -14,6 +14,10 @@ """Atomic Memory state adapter over authoritative shared Artifact heads.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from sqlalchemy import case from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError, AtomicMemoryRelationError @@ -27,12 +31,15 @@ from powercontext.builtin.persistence.errors import StoredPayloadConflictError from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE +if TYPE_CHECKING: + from powercontext.builtin.persistence.atomic_memory_directory import AtomicMemoryDirectoryRepository + class AtomicMemoryStateRepository: """Translate Atomic vocabulary through the shared governance CAS boundary.""" - def __init__(self) -> None: - self.governance = ArtifactGovernanceRepository() + def __init__(self, *, atomic_memory_directory: AtomicMemoryDirectoryRepository | None = None) -> None: + self.governance = ArtifactGovernanceRepository(atomic_memory_directory=atomic_memory_directory) async def get(self, connection, scope_id, artifact_id, /, *, for_update=False) -> AtomicMemoryState: current = await self.governance.get(connection, scope_id, "atomic-memory", artifact_id, for_update=for_update) diff --git a/src/powercontext/builtin/persistence/atomic_memory_directory.py b/src/powercontext/builtin/persistence/atomic_memory_directory.py new file mode 100644 index 0000000000..4f18219fbb --- /dev/null +++ b/src/powercontext/builtin/persistence/atomic_memory_directory.py @@ -0,0 +1,299 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Internal compact directory storage over expanded shared Scope/Artifact tables. + +The caller owns authorization, readiness and a consistent connection. This +repository neither opens transactions nor enables an application operation. +Production schema/readiness and writer composition must be integrated before publication. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from hashlib import sha256 +from typing import Any, Literal, cast + +import rfc8785 +from pydantic import BaseModel, ConfigDict, Field, StrictInt, ValidationError, field_validator +from sqlalchemy import BigInteger, ColumnElement, column, func, literal_column, select, table, update +from sqlalchemy.engine import RowMapping +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemory, AtomicMemoryContent, AtomicMemoryState +from powercontext.builtin.persistence.atomic_memory import atomic_memory_state_expression +from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec +from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, identity_string +from powercontext.builtin.persistence.tags import tag_predicate +from powercontext.builtin.records import BaseAccessError +from powercontext.builtin.sources import validate_scope_id +from powercontext.builtin.tags import TagFilter + +DIRECTORY_ITEM_BYTES = 4 * 1024 * 1024 +DIRECTORY_ENVELOPE_BYTES = 8192 +DIRECTORY_CURSOR_LENGTH = 4096 +_MAX_GENERATION = (1 << 63) - 1 + +# Expression views only: they do not mutate startup metadata or execute DDL. +# The production schema belongs to the unified frozen migration resources. +_SCOPES = table( + "pc_scopes", + column("scope_id", identity_string(256)), + column("atomic_memory_directory_generation", BigInteger()), +) +_HEADS = table( + "pc_artifact_heads", + column("scope_id", identity_string(256)), + column("family", identity_string(128)), + column("artifact_id", identity_string(128)), + column("revision"), + column("content_kind", identity_string(128)), +) +_KIND = literal_column("pc_artifact_heads.content_kind", type_=identity_string(128)) + + +class AtomicMemoryDirectoryUnavailableError(BaseAccessError): + """Required directory data does not satisfy the declared storage contract.""" + + +class AtomicMemoryDirectoryAuthorizationUnavailableError(BaseAccessError): + """The configured access authorities cannot safely qualify this directory.""" + + +class AtomicMemoryDirectoryItemTooLargeError(BaseAccessError): + """A single compact item cannot fit the item-payload allowance.""" + + +class AtomicMemoryDirectoryQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + scope_id: str + states: tuple[Literal["active", "forgotten", "merged", "retired"], ...] = Field( + default=("active",), min_length=1, max_length=4 + ) + kind: str | None = None + tag_filter: TagFilter | None = None + limit: StrictInt = Field(default=50, ge=1, le=100) + cursor: str | None = Field(default=None, min_length=1, max_length=DIRECTORY_CURSOR_LENGTH) + + @field_validator("scope_id") + @classmethod + def valid_scope(cls, value: str) -> str: + return validate_scope_id(value) + + @field_validator("states") + @classmethod + def distinct_states(cls, values): + if len(set(values)) != len(values): + raise ValueError("states must be distinct") # noqa: TRY003 + return values + + @field_validator("kind") + @classmethod + def valid_kind(cls, value: str | None) -> str | None: + return None if value is None else AtomicMemoryContent(kind=value, text="validation").kind + + +class AtomicMemoryDirectoryItem(AtomicMemoryState): + artifact: ArtifactRef + kind: str = Field(min_length=1, max_length=128) + + @field_validator("kind") + @classmethod + def valid_kind(cls, value: str) -> str: + return AtomicMemoryContent(kind=value, text="validation").kind + + +class AtomicMemoryDirectoryPage(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + items: tuple[AtomicMemoryDirectoryItem, ...] + next_cursor: str | None = None + + +class AtomicMemoryDirectoryRepository: + """Select page metadata using a caller-supplied, authorized SQL eligibility. + + Eligibility and policy_context must come from the shared authorization + authority. Neither arbitrary SQL nor a string context proves authorization. + An application adapter must enforce that boundary before exposing query(). + """ + + def __init__(self, cursor: SignedCursorCodec, *, available: bool = True) -> None: + self.cursor = cursor + self.available = available + + async def lock_scope(self, connection: AsyncConnection, scope_id: str) -> None: + """Serialize directory-enabled writers before they acquire any head locks. + + A no-op UPDATE also takes SQLite's write lock. It does not invalidate + cursors; only effective publications and mutations advance generation. + """ + + result = await connection.execute( + update(_SCOPES) + .where( + _SCOPES.c.scope_id == scope_id, + func.typeof(_SCOPES.c.atomic_memory_directory_generation) == "integer", + _SCOPES.c.atomic_memory_directory_generation >= 0, + _SCOPES.c.atomic_memory_directory_generation <= _MAX_GENERATION, + ) + .values(atomic_memory_directory_generation=_SCOPES.c.atomic_memory_directory_generation) + ) + if result.rowcount != 1: + raise AtomicMemoryDirectoryUnavailableError + + async def query( + self, + connection: AsyncConnection, + query: AtomicMemoryDirectoryQuery, + *, + subject: str, + eligibility: ColumnElement[bool], + policy_context: str, + ) -> AtomicMemoryDirectoryPage: + if not self.available: + raise AtomicMemoryDirectoryUnavailableError + generation = await connection.scalar( + select(_SCOPES.c.atomic_memory_directory_generation).where(_SCOPES.c.scope_id == query.scope_id) + ) + if type(generation) is not int or not 0 <= generation <= _MAX_GENERATION: + raise AtomicMemoryDirectoryUnavailableError + filters = { + "states": sorted(query.states), + "kind": query.kind, + "tags": None if query.tag_filter is None else list(query.tag_filter.keys), + "tag_match": None if query.tag_filter is None else query.tag_filter.match, + "limit": query.limit, + } + bound = { + "endpoint": "atomic_memory_directory", + "version": 1, + "order": "artifact_id_asc_v1", + "context": _digest({"scope_id": query.scope_id, "subject": subject, "policy": policy_context}), + "query": _digest(filters), + "generation": generation, + } + after = self.cursor.after_text(query.cursor, bound) + heads = ARTIFACT_HEADS_TABLE + statement = ( + select( + heads.c.artifact_id, + heads.c.revision, + _KIND.label("kind"), + atomic_memory_state_expression().label("state"), + heads.c.governance_generation.label("state_version"), + heads.c.merged_into_id, + heads.c.replacement_artifact_id, + ) + .where( + heads.c.scope_id == query.scope_id, + heads.c.family == "atomic-memory", + heads.c.artifact_id > after, + atomic_memory_state_expression().in_(query.states), + eligibility, + ) + .order_by(heads.c.artifact_id) + .limit(query.limit + 1) + ) + if query.kind is not None: + statement = statement.where(query.kind == _KIND) + if query.tag_filter is not None: + statement = statement.where( + tag_predicate( + query.scope_id, + "atomic-memory", + heads.c.artifact_id, + "artifact", + heads.c.artifact_id, + query.tag_filter, + ) + ) + rows = (await connection.execute(statement)).mappings().all() + items: list[AtomicMemoryDirectoryItem] = [] + # Account for the array brackets as well as each comma-separated item. + item_bytes = 2 + for row in rows[: query.limit]: + item = _directory_item(row) + encoded_size = len(item.model_dump_json().encode("utf-8")) + if encoded_size + 2 > DIRECTORY_ITEM_BYTES: + raise AtomicMemoryDirectoryItemTooLargeError + next_size = item_bytes + encoded_size + bool(items) + if next_size > DIRECTORY_ITEM_BYTES: + break + items.append(item) + item_bytes = next_size + has_more = len(rows) > len(items) + next_cursor = self.cursor.encode(bound, items[-1].artifact.artifact_id) if has_more else None + if next_cursor is not None and len(next_cursor) > DIRECTORY_CURSOR_LENGTH: + raise AtomicMemoryDirectoryUnavailableError + page = AtomicMemoryDirectoryPage(scope_id=query.scope_id, items=tuple(items), next_cursor=next_cursor) + envelope = page.model_copy(update={"items": ()}) + if len(envelope.model_dump_json().encode("utf-8")) - 2 > DIRECTORY_ENVELOPE_BYTES: + raise AtomicMemoryDirectoryUnavailableError + return page + + async def record_publication(self, connection: AsyncConnection, scope_id: str, artifact: AtomicMemory) -> None: + """Maintain metadata after an effective publish, inside its business transaction.""" + + result = await connection.execute( + update(_HEADS) + .where( + _HEADS.c.scope_id == scope_id, + _HEADS.c.family == artifact.family, + _HEADS.c.artifact_id == artifact.artifact_id, + _HEADS.c.revision == artifact.revision, + ) + .values(content_kind=artifact.content.kind) + ) + if result.rowcount != 1: + raise AtomicMemoryDirectoryUnavailableError + await self.record_mutation(connection, scope_id) + + async def record_mutation(self, connection: AsyncConnection, scope_id: str) -> None: + """Invalidate cursors after an effective state/tag/repair mutation; never commit.""" + + result = await connection.execute( + update(_SCOPES) + .where( + _SCOPES.c.scope_id == scope_id, + func.typeof(_SCOPES.c.atomic_memory_directory_generation) == "integer", + _SCOPES.c.atomic_memory_directory_generation >= 0, + _SCOPES.c.atomic_memory_directory_generation < _MAX_GENERATION, + ) + .values(atomic_memory_directory_generation=_SCOPES.c.atomic_memory_directory_generation + 1) + ) + if result.rowcount != 1: + raise AtomicMemoryDirectoryUnavailableError + + +def _digest(value: Mapping[str, Any]) -> str: + return sha256(rfc8785.dumps(cast(Any, value))).hexdigest() + + +def _directory_item(row: RowMapping | Mapping[str, Any]) -> AtomicMemoryDirectoryItem: + if row["replacement_artifact_id"] is not None: + raise AtomicMemoryDirectoryUnavailableError + try: + return AtomicMemoryDirectoryItem( + artifact=ArtifactRef(family="atomic-memory", artifact_id=row["artifact_id"], revision=row["revision"]), + kind=row["kind"], + state=row["state"], + state_version=row["state_version"], + merged_into_id=row["merged_into_id"], + ) + except (ValidationError, ValueError) as error: + raise AtomicMemoryDirectoryUnavailableError from error diff --git a/src/powercontext/builtin/persistence/migrations/bundle.py b/src/powercontext/builtin/persistence/migrations/bundle.py index c38bc2671e..a840aa15ea 100644 --- a/src/powercontext/builtin/persistence/migrations/bundle.py +++ b/src/powercontext/builtin/persistence/migrations/bundle.py @@ -18,6 +18,7 @@ import hashlib import json +import runpy import threading from pathlib import Path from tempfile import TemporaryDirectory @@ -40,7 +41,7 @@ class MigrationBundle: partially applied DDL cannot be mistaken for a supported baseline. """ - def __init__(self, directory: Path) -> None: + def __init__(self, directory: Path, *, target: str | None = None) -> None: # noqa: C901 self.directory = directory.resolve() # Execute exactly the reviewed bytes, even if the installed resources # change during a long backup. Alembic reloads scripts on every command. @@ -52,11 +53,22 @@ def __init__(self, directory: Path) -> None: self._snapshot = TemporaryDirectory(prefix="pc-migration-bundle-") self.snapshot_directory = Path(self._snapshot.name).resolve() for name, content in resources.items(): - target = self.snapshot_directory / name - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(content) + resource_target = self.snapshot_directory / name + resource_target.parent.mkdir(parents=True, exist_ok=True) + resource_target.write_bytes(content) + manifest = json.loads(resources["manifest.json"]) + selected = manifest.get("targets", {}).get(target) if target is not None else None + if target is not None and selected is None: + raise MigrationError("unsupported_target", "The release does not register this migration target.") + head = selected["revision"] if selected is not None else manifest.get("default_revision", "heads") self.script = ScriptDirectory(str(self.snapshot_directory)) - revisions = list(reversed(list(self.script.walk_revisions()))) + known_revisions = {item.revision for item in self.script.walk_revisions()} + if ( + set(manifest["sqlite_fingerprints"]) != known_revisions + or not set(manifest["sqlite_baselines"].values()) <= known_revisions + ): + raise MigrationError("invalid_bundle", "Schema inventory must cover the registered history and baselines.") + revisions = list(reversed(list(self.script.walk_revisions(head=head)))) previous = None for item in revisions: if item.down_revision != previous or item.dependencies or len(item.revision) > 32: @@ -66,8 +78,7 @@ def __init__(self, directory: Path) -> None: raise MigrationError("invalid_bundle", "Migration history is empty.") self.revisions = tuple(item.revision for item in revisions) self.head = self.revisions[-1] - manifest = json.loads(resources["manifest.json"]) - self.affected_objects = tuple(manifest.get("affected_objects", ())) + self.affected_objects = tuple((selected or manifest).get("affected_objects", ())) self.retained_objects = tuple(manifest.get("retained_objects", ())) self.retained_fingerprints = manifest.get("retained_fingerprints", {}) self.retained_revisions = manifest.get("retained_revisions", {}) @@ -75,10 +86,17 @@ def __init__(self, directory: Path) -> None: self.retained_revisions.values() ) <= set(self.revisions): raise MigrationError("invalid_bundle", "Retained objects must declare their introducing revision.") - self.fingerprints: dict[str, list[str]] = manifest["sqlite_fingerprints"] - self.baselines: dict[str, str] = manifest["sqlite_baselines"] - if set(self.fingerprints) != set(self.revisions) or not set(self.baselines.values()) <= set(self.revisions): + if not set(self.revisions) <= set(manifest["sqlite_fingerprints"]): raise MigrationError("invalid_bundle", "Schema inventory does not cover the revision chain.") + self.fingerprints: dict[str, list[str]] = { + revision: manifest["sqlite_fingerprints"][revision] for revision in self.revisions + } + self.baselines: dict[str, str] = { + fingerprint: revision + for fingerprint, revision in manifest["sqlite_baselines"].items() + if revision in self.revisions and (selected is None or revision in selected["baseline_revisions"]) + } + self.minimum_source_revision = None if selected is None else selected["minimum_source_revision"] if any(fingerprint not in self.fingerprints[revision] for fingerprint, revision in self.baselines.items()): raise MigrationError("invalid_bundle", "A baseline does not match its declared revision.") self.resources = {name: hashlib.sha256(content).hexdigest() for name, content in resources.items()} @@ -90,7 +108,32 @@ def __init__(self, directory: Path) -> None: Path(item.path).read_bytes() ).hexdigest() self.checksums[item.revision] = digest(inputs) - self.checksum = digest(self.resources) + self.checksum = digest({"resources": self.resources, "target_revision": self.head, "target": selected}) + self.data_tasks = tuple(task for task in manifest.get("data_tasks", ()) if task["revision"] in self.revisions) + for task in self.data_tasks: + if task["resource"] not in manifest["revision_resources"][task["revision"]]: + raise MigrationError("invalid_bundle", "A data task must be bound to its revision resources.") + + def task_functions(self, task: dict[str, str]) -> dict[str, Any]: + """Load only the immutable resource snapshot used by the reviewed plan.""" + + self.assert_unchanged() + return runpy.run_path(str(self.snapshot_directory / task["resource"])) + + def pending_data_tasks(self, connection: Connection, revision: str | None) -> tuple[str, ...]: + """Inspect current data, independently of the schema revision or old completion evidence.""" + + completed = set(self.revisions) - set(self.pending(revision)) + pending = [] + for task in self.data_tasks: + try: + if task["revision"] not in completed or not self.task_functions(task)["verify"](connection): + pending.append(task["name"]) + except ValueError as error: + raise MigrationError( + "verification_failed", "Registered data does not satisfy the frozen domain format." + ) from error + return tuple(pending) def assert_unchanged(self) -> None: resources = { diff --git a/src/powercontext/builtin/persistence/migrations/deployment.py b/src/powercontext/builtin/persistence/migrations/deployment.py index 34bbd9156b..a944fcf88b 100644 --- a/src/powercontext/builtin/persistence/migrations/deployment.py +++ b/src/powercontext/builtin/persistence/migrations/deployment.py @@ -17,6 +17,8 @@ from __future__ import annotations import os +import sqlite3 +from contextlib import closing from pathlib import Path from typing import TYPE_CHECKING, Literal @@ -25,6 +27,7 @@ from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.seekdb import SeekDBConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.sqlite.profile import _readonly_sqlite_url from .bundle import MigrationBundle from .connections import MaintenanceConnections @@ -36,23 +39,100 @@ from powercontext.server.settings import ServerSettings -def production_bundle() -> MigrationBundle: +def production_bundle(*, target: Literal["atomic-memory-directory"] | None = None) -> MigrationBundle: """Load installed resources; readiness is scoped to this acceptance bundle.""" - return MigrationBundle(Path(__file__).parent / "resources") + return MigrationBundle(Path(__file__).parent / "resources", target=target) + + +def _directory_runner(database: SQLiteConfig) -> tuple[SQLiteMigrationRunner, str | None] | None: + if not database.is_persistent: + return None + url = make_url(database.url) + if not url.database or url.host or url.username or url.password or url.port: + return None + readonly_url = _readonly_sqlite_url(database.url).set(drivername="sqlite") + arguments, _ = readonly_url.get_dialect()().create_connect_args(readonly_url) + try: + inspection = sqlite3.connect(str(arguments[0]), uri=True, timeout=database.busy_timeout_ms / 1000) + except sqlite3.OperationalError as error: + if getattr(error, "sqlite_errorcode", None) == sqlite3.SQLITE_CANTOPEN: + return None + raise + with closing(inspection) as connection: + path = Path(next(row[2] for row in connection.execute("PRAGMA database_list") if row[1] == "main")).resolve() + exists = connection.execute("SELECT 1 FROM sqlite_schema WHERE name='pc_schema_revision'").fetchone() + if exists is None and not path.with_name(path.name + ".pc-migration-state").exists(): + return None + revisions = ( + [] if exists is None else connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() + ) + if len(revisions) > 1: + raise MigrationError("incompatible_schema", "Multiple schema revisions are not supported.") + revision = None if not revisions else str(revisions[0][0]) + runner = SQLiteMigrationRunner( + path, + production_bundle(target="atomic-memory-directory"), + configuration=database.model_dump(exclude={"url", "echo"}), + ) + return runner, revision + + +def assert_directory_maintenance_complete(database: SQLiteConfig) -> None: + """Check only maintenance evidence; do not scan domain data for each component open.""" + + inspected = _directory_runner(database) + if inspected is not None: + inspected[0].assert_business_startup_allowed() + + +def directory_readiness(database: SQLiteConfig) -> bool | None: + """Inspect the optional directory without initialization or repair. + + False retains writer hooks but disables querying. None is an unmanaged + directory. The shared executor owns data and maintenance classification. + """ + + inspected = _directory_runner(database) + if inspected is None: + return None + runner, revision = inspected + runner.assert_business_startup_allowed() + if revision is None or revision in {"p0001", "p0002", "p0003", "p0004"}: + return None + if revision not in {"p0005", "p0006"}: + raise MigrationError("incompatible_schema", "The schema revision is not supported by this program.") + try: + plan = runner.plan() + except MigrationError as error: + if error.code == "verification_failed": + return False + raise + if plan.state == "recovery_required": + raise MigrationError("recovery_required", "Complete explicit directory maintenance before business startup.") + return plan.state == "ready" and not make_url(database.url).query if revision == "p0006" else None MigrationRunner = SQLiteMigrationRunner | MySQLMigrationRunner -def deployment_runner( +def deployment_runner( # noqa: C901 - one authority for target/backend/coordination eligibility settings: ServerSettings, *, evidence_dir: Path | None = None, lock_coordination: Literal["single-host"] | None = None, acknowledge_previous_run: str | None = None, + target: Literal["atomic-memory-directory"] | None = None, ) -> MigrationRunner: """Use the real configured identity without opening a business profile.""" database = settings.database + if target is not None and evidence_dir is not None: + raise MigrationError( + "unsupported_option", "The directory target uses the database's default evidence directory." + ) + if target is not None and not isinstance(database, SQLiteConfig): + raise MigrationError("unsupported_target", "The directory target is registered only for persistent SQLite.") + if target is not None and settings.inference.embedding_model is not None: + raise MigrationError("unsupported_target", "The registered directory target has no embedding extension.") if acknowledge_previous_run is not None and not isinstance(database, OceanBaseConfig): raise MigrationError("unsupported_option", "--acknowledge-previous-run is supported only for OceanBase.") if isinstance(database, (SeekDBConfig, OceanBaseConfig)): @@ -62,8 +142,8 @@ def deployment_runner( "evidence_required", "OceanBase maintenance requires --evidence-dir on one fixed host, shared by all migration Jobs.", ) - target = database.path.expanduser().resolve() - evidence_dir = target.with_name(target.name + ".pc-migration-state") + engine_directory = database.path.expanduser().resolve() + evidence_dir = engine_directory.with_name(engine_directory.name + ".pc-migration-state") if isinstance(database, OceanBaseConfig) and lock_coordination != "single-host": raise MigrationError( "coordination_required", @@ -88,10 +168,10 @@ def deployment_runner( "unsupported_target", "Use a regular SQLite file URL without credentials, host, port or URI options." ) # Match SQLite's URL normalization before the runner expands standalone Path inputs. - target = Path(os.path.abspath(url.database)) + database_path = Path(os.path.abspath(url.database)) return SQLiteMigrationRunner( - target, - production_bundle(), + database_path, + production_bundle(target=target), configuration=database.model_dump(exclude={"url", "echo"}), evidence_directory=evidence_dir, ) diff --git a/src/powercontext/builtin/persistence/migrations/models.py b/src/powercontext/builtin/persistence/migrations/models.py index abbbd7af57..d0ccfc965f 100644 --- a/src/powercontext/builtin/persistence/migrations/models.py +++ b/src/powercontext/builtin/persistence/migrations/models.py @@ -48,6 +48,7 @@ class MigrationPlan(BaseModel): configuration_digest: str state: Literal["uninitialized", "migration_required", "ready", "recovery_required"] revisions: tuple[str, ...] + data_tasks: tuple[str, ...] = () adopt_baseline: bool = False backup_policy: Literal["auto", "manual", "skip"] = "auto" backup_available: bool = True diff --git a/src/powercontext/builtin/persistence/migrations/mysql.py b/src/powercontext/builtin/persistence/migrations/mysql.py index abfccff3a6..be05598e9f 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql.py +++ b/src/powercontext/builtin/persistence/migrations/mysql.py @@ -148,6 +148,8 @@ def __init__( configuration: Mapping[str, Any] | None = None, backup_factory: BackupFactory | None = None, ) -> None: + if bundle.data_tasks: + raise MigrationError("unsupported_target", "This backend has no registered data-task execution adapter.") self.connections = connections self.bundle = bundle self.schema = MySQLSchema(bundle) diff --git a/src/powercontext/builtin/persistence/migrations/resources/manifest.json b/src/powercontext/builtin/persistence/migrations/resources/manifest.json index 16364daa4f..eee297b4b0 100644 --- a/src/powercontext/builtin/persistence/migrations/resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/resources/manifest.json @@ -5,12 +5,20 @@ ], "p0002": [], "p0003": [], - "p0004": [] + "p0004": [], + "p0005": [ + "schemas/atomic_directory_sqlite_v1.json" + ], + "p0006": [ + "schemas/atomic_directory_sqlite_v1.json", + "tasks/atomic_directory_v1.py" + ] }, "sqlite_baselines": { "e1cae089a8a565291a7a1f782cea1f6c6e1a6cdd9eab75ef0c4ca61dfda453cb": "p0001", "fcee685ed307ee6d02fe5e950ed4e80be75f38d19de902ccacf443f21ee8c73d": "p0003", - "8cb2d41e53cde751c8d0ef4023c344ec5ec78d9e579366c1c93d17de30aa5ea4": "p0004" + "8cb2d41e53cde751c8d0ef4023c344ec5ec78d9e579366c1c93d17de30aa5ea4": "p0004", + "d93e21ab21b78a07de3ad5f15f1aba498332decfc86fd14974d7abfb9d237e84": "p0005" }, "sqlite_fingerprints": { "p0001": [ @@ -26,6 +34,12 @@ "p0004": [ "f65244be963a382a62565118ff874cd775ab1ea33cf51a49881b40391e47c051", "8cb2d41e53cde751c8d0ef4023c344ec5ec78d9e579366c1c93d17de30aa5ea4" + ], + "p0005": [ + "d93e21ab21b78a07de3ad5f15f1aba498332decfc86fd14974d7abfb9d237e84" + ], + "p0006": [ + "5d13c7ecb46b45851807316363868b44135faa6d86c43522c86752008149e801" ] }, "affected_objects": [ @@ -45,5 +59,87 @@ }, "retained_revisions": { "pc_retained_p0002_artifact_tags": "p0003" - } + }, + "default_revision": "p0004", + "targets": { + "atomic-memory-directory": { + "revision": "p0006", + "affected_objects": [ + "pc_scopes", + "pc_artifact_heads", + "pc_access_audit", + "pc_access_idempotency", + "pc_access_owners", + "pc_access_relationship_heads", + "pc_access_relationships", + "pc_agent_skill_targets", + "pc_artifact_candidate_heads", + "pc_artifact_candidate_versions", + "pc_artifact_fts", + "pc_artifact_handles", + "pc_artifact_lineage_artifacts", + "pc_artifact_lineage_sources", + "pc_artifact_processing_auto_wave_targets", + "pc_artifact_processing_binding_states", + "pc_artifact_processing_intents", + "pc_artifact_processing_leases", + "pc_artifact_processing_migration_receipts", + "pc_artifact_processing_pending", + "pc_artifact_processing_schema", + "pc_artifact_processing_sequences", + "pc_artifact_publications", + "pc_artifact_tags", + "pc_artifacts", + "pc_atomic_memory_current", + "pc_atomic_memory_current_fts", + "pc_connector_checkpoints", + "pc_context_bootstrap_receipts", + "pc_decision_observations", + "pc_dream_runs", + "pc_external_skill_registrations", + "pc_memory_entry_heads", + "pc_memory_entry_versions", + "pc_memory_source_windows", + "pc_model_usage_daily", + "pc_profile_policies", + "pc_recall_effort_daily", + "pc_recall_token_daily", + "pc_receipt_migration_review", + "pc_recurrence_match", + "pc_recurrence_observation", + "pc_scope_bindings", + "pc_scope_context_references", + "pc_scope_creation_requests", + "pc_scope_external_references", + "pc_scope_settings", + "pc_server_identity", + "pc_skill_packages", + "pc_skill_publications", + "pc_source_cursors", + "pc_source_definition_manifests", + "pc_source_journal_heads", + "pc_sources", + "pc_topic_memory_active_chunks", + "pc_topic_memory_active_topics", + "pc_topic_memory_chunk_fts", + "pc_topic_memory_fts_index", + "pc_topic_memory_processing_targets", + "pc_topic_memory_retrieval_shape", + "pc_topic_memory_revision_publications", + "pc_topic_memory_topic_fts", + "pc_topic_memory_work_budgets" + ], + "baseline_revisions": [ + "p0005" + ], + "minimum_source_revision": "p0005" + } + }, + "data_tasks": [ + { + "name": "atomic-memory-directory-v1", + "revision": "p0006", + "resource": "tasks/atomic_directory_v1.py" + } + ] } diff --git a/src/powercontext/builtin/persistence/migrations/resources/schemas/atomic_directory_sqlite_v1.json b/src/powercontext/builtin/persistence/migrations/resources/schemas/atomic_directory_sqlite_v1.json new file mode 100644 index 0000000000..1ff3c82229 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/resources/schemas/atomic_directory_sqlite_v1.json @@ -0,0 +1,158 @@ +{ + "provenance": { + "commit": "b5bcfe24bcce9c574ee85175d623bb9963970a41", + "configuration": "persistent SQLite, default Server/Runtime, no embedding extension", + "scope": "current business structural baseline including Artifact handles and bootstrap receipts; legacy data conversions remain outside this target", + "upstream_commit": "0867144ceed7e78910d1745df66ce72ca1a05ce0" + }, + "sqlite_init": [ + "CREATE TABLE pc_access_audit (\n\tcursor INTEGER NOT NULL, \n\tevent_id VARCHAR(64) NOT NULL, \n\toccurred_at VARCHAR(32) NOT NULL, \n\trequest_id VARCHAR(128), \n\ttransport VARCHAR(16) NOT NULL, \n\toperation VARCHAR(128) NOT NULL, \n\tprincipal_type VARCHAR(16) NOT NULL, \n\tprincipal_id VARCHAR(255) NOT NULL, \n\tprincipal_description TEXT, \n\tactor_type VARCHAR(16), \n\tactor_id VARCHAR(255), \n\tactor_description TEXT, \n\taction VARCHAR(64) NOT NULL, \n\tresource_type VARCHAR(16) NOT NULL, \n\tdeployment_id VARCHAR(128), \n\tscope_id VARCHAR(256), \n\tfamily VARCHAR(128), \n\tartifact_id VARCHAR(128), \n\tselector_type VARCHAR(32), \n\tselector_entry_id VARCHAR(128), \n\tallowed BOOLEAN NOT NULL, \n\treason_code VARCHAR(64) NOT NULL, \n\tpolicy_revision VARCHAR(64), \n\tmatched_subject_type VARCHAR(16), \n\tmatched_subject_id VARCHAR(255), \n\tmatched_subject_description TEXT, \n\tbinding_id VARCHAR(64), \n\ttarget_type VARCHAR(16), \n\ttarget_id VARCHAR(255), \n\ttarget_description TEXT, \n\trole VARCHAR(32), \n\texpected_version INTEGER, \n\tresult_version INTEGER, \n\tPRIMARY KEY (cursor), \n\tUNIQUE (event_id)\n)", + "CREATE TABLE pc_access_idempotency (\n\tactor_id VARCHAR(255) NOT NULL, \n\tidempotency_key_hash VARCHAR(64) NOT NULL, \n\toperation VARCHAR(64) NOT NULL, \n\tpayload_hash VARCHAR(64) NOT NULL, \n\tresult_binding_id VARCHAR(64), \n\tsecondary_binding_id VARCHAR(64), \n\tPRIMARY KEY (actor_id, idempotency_key_hash), \n\tCONSTRAINT uq_pc_access_idempotency_actor_key UNIQUE (actor_id, idempotency_key_hash)\n)", + "CREATE TABLE pc_access_owners (\n\towner_kind VARCHAR(16) NOT NULL, \n\tobject_key_hash VARCHAR(64) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128), \n\tcandidate_id VARCHAR(128), \n\ttarget_artifact_id VARCHAR(128), \n\tselector_type VARCHAR(32), \n\tselector_entry_id VARCHAR(128), \n\towner_type VARCHAR(16) NOT NULL, \n\towner_id VARCHAR(255) NOT NULL, \n\towner_description TEXT, \n\testablished_at VARCHAR(32), \n\tpolicy_revision VARCHAR(64), \n\tidempotency_key VARCHAR(255) NOT NULL, \n\tPRIMARY KEY (owner_kind, object_key_hash), \n\tCONSTRAINT ck_pc_access_owners_kind CHECK ((owner_kind = 'artifact' AND artifact_id IS NOT NULL AND candidate_id IS NULL AND target_artifact_id IS NULL AND established_at IS NOT NULL AND policy_revision IS NOT NULL) OR (owner_kind = 'candidate' AND candidate_id IS NOT NULL AND artifact_id IS NULL AND selector_type IS NULL AND selector_entry_id IS NULL AND established_at IS NULL AND policy_revision IS NULL))\n)", + "CREATE TABLE pc_access_relationship_heads (\n\tname VARCHAR(32) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tPRIMARY KEY (name), \n\tCONSTRAINT ck_pc_access_relationship_heads_revision_nonnegative CHECK (revision >= 0)\n)", + "CREATE TABLE pc_access_relationships (\n\tbinding_id VARCHAR(64) NOT NULL, \n\tsubject_type VARCHAR(16) NOT NULL, \n\tsubject_id VARCHAR(255) NOT NULL, \n\tsubject_description TEXT, \n\tresource_key_hash VARCHAR(64) NOT NULL, \n\tresource_type VARCHAR(16) NOT NULL, \n\tdeployment_id VARCHAR(128), \n\tscope_id VARCHAR(256), \n\tfamily VARCHAR(128), \n\tartifact_id VARCHAR(128), \n\tselector_type VARCHAR(32), \n\tselector_entry_id VARCHAR(128), \n\trole VARCHAR(32) NOT NULL, \n\tsingleton_key VARCHAR(64), \n\tgranted_by_type VARCHAR(16) NOT NULL, \n\tgranted_by_id VARCHAR(255) NOT NULL, \n\tgranted_by_description TEXT, \n\treason TEXT, \n\tcreated_at VARCHAR(32) NOT NULL, \n\texpires_at VARCHAR(32), \n\tstate VARCHAR(16) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tpolicy_revision VARCHAR(64) NOT NULL, \n\tidempotency_key VARCHAR(255) NOT NULL, \n\trevoked_at VARCHAR(32), \n\trevoked_by_type VARCHAR(16), \n\trevoked_by_id VARCHAR(255), \n\trevoked_by_description TEXT, \n\tPRIMARY KEY (binding_id), \n\tCONSTRAINT ck_pc_access_relationships_version_positive CHECK (version > 0), \n\tUNIQUE (singleton_key)\n)", + "CREATE TABLE pc_agent_skill_targets (\n\tscope_id VARCHAR(256) NOT NULL, \n\ttarget_id VARCHAR(64) NOT NULL, \n\tdisplay_name VARCHAR(128) NOT NULL, \n\tagent_kind VARCHAR(32) NOT NULL, \n\tinstallation_scope VARCHAR(16) NOT NULL, \n\tdelivery_mode VARCHAR(16) NOT NULL, \n\tinstallation_id VARCHAR(128), \n\tstate VARCHAR(16) NOT NULL, \n\tenrollment_token_digest VARCHAR(64), \n\tenrollment_expires_at DATETIME, \n\tcredential_subject VARCHAR(128), \n\tcredential_verifier VARCHAR(64), \n\treceiver_version VARCHAR(64), \n\tenvironment_fingerprint VARCHAR(64), \n\tmachine_hostname VARCHAR(255), \n\tworkspace_name VARCHAR(128), \n\tlast_seen_at DATETIME, \n\tgeneration BIGINT NOT NULL, \n\tcreated_at DATETIME NOT NULL, \n\tupdated_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, target_id), \n\tCONSTRAINT uq_pc_agent_skill_targets_installation UNIQUE (scope_id, agent_kind, installation_scope, installation_id), \n\tCONSTRAINT uq_pc_agent_skill_targets_enrollment_token UNIQUE (enrollment_token_digest), \n\tCONSTRAINT uq_pc_agent_skill_targets_credential_subject UNIQUE (credential_subject), \n\tCONSTRAINT uq_pc_agent_skill_targets_credential_verifier UNIQUE (credential_verifier), \n\tCONSTRAINT ck_pc_agent_skill_targets_agent_kind CHECK (agent_kind IN ('codex', 'claude_code')), \n\tCONSTRAINT ck_pc_agent_skill_targets_installation_scope CHECK (installation_scope IN ('project')), \n\tCONSTRAINT ck_pc_agent_skill_targets_delivery_mode CHECK (delivery_mode = 'agent_pull'), \n\tCONSTRAINT ck_pc_agent_skill_targets_state CHECK (state IN ('pending', 'active', 'revoked')), \n\tCONSTRAINT ck_pc_agent_skill_targets_state_payload CHECK ((state = 'pending' AND enrollment_token_digest IS NOT NULL AND enrollment_expires_at IS NOT NULL AND installation_id IS NULL AND credential_subject IS NULL AND credential_verifier IS NULL) OR (state = 'active' AND enrollment_token_digest IS NULL AND enrollment_expires_at IS NULL AND installation_id IS NOT NULL AND credential_subject IS NOT NULL AND credential_verifier IS NOT NULL) OR (state = 'revoked' AND enrollment_token_digest IS NULL AND enrollment_expires_at IS NULL AND credential_verifier IS NULL)), \n\tCONSTRAINT ck_pc_agent_skill_targets_generation_nonnegative CHECK (generation >= 0)\n)", + "CREATE TABLE pc_artifact_candidate_heads (\n\tscope_id VARCHAR(256) NOT NULL, \n\tcandidate_id VARCHAR(128) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tstatus VARCHAR(16) NOT NULL, \n\tresult_family VARCHAR(128), \n\tresult_artifact_id VARCHAR(128), \n\tresult_revision INTEGER, \n\tdecision_reason TEXT, \n\tPRIMARY KEY (scope_id, candidate_id), \n\tFOREIGN KEY(scope_id, candidate_id, version) REFERENCES pc_artifact_candidate_versions (scope_id, candidate_id, version) ON DELETE RESTRICT, \n\tFOREIGN KEY(scope_id, result_family, result_artifact_id, result_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_candidate_heads_status CHECK (status IN ('pending', 'approved', 'rejected')), \n\tCONSTRAINT ck_pc_artifact_candidate_heads_terminal_result CHECK ((status = 'approved' AND result_family IS NOT NULL AND result_artifact_id IS NOT NULL AND result_revision > 0 AND decision_reason IS NULL) OR (status = 'rejected' AND result_family IS NULL AND result_artifact_id IS NULL AND result_revision IS NULL AND decision_reason IS NOT NULL) OR (status = 'pending' AND result_family IS NULL AND result_artifact_id IS NULL AND result_revision IS NULL AND decision_reason IS NULL))\n)", + "CREATE TABLE pc_artifact_candidate_versions (\n\tscope_id VARCHAR(256) NOT NULL, \n\tcandidate_id VARCHAR(128) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tproposal BLOB NOT NULL, \n\tsource_refs BLOB NOT NULL, \n\tartifact_refs BLOB NOT NULL, \n\ttarget_family VARCHAR(128), \n\ttarget_artifact_id VARCHAR(128), \n\ttarget_revision INTEGER, \n\treason TEXT, \n\tPRIMARY KEY (scope_id, candidate_id, version), \n\tFOREIGN KEY(scope_id, target_family, target_artifact_id, target_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_candidate_versions_version_positive CHECK (version > 0), \n\tCONSTRAINT ck_pc_artifact_candidate_versions_target_complete CHECK ((target_family IS NULL AND target_artifact_id IS NULL AND target_revision IS NULL) OR (target_family IS NOT NULL AND target_artifact_id IS NOT NULL AND target_revision > 0))\n)", + "CREATE VIRTUAL TABLE pc_artifact_fts USING fts5(\n scope_id UNINDEXED,\n family UNINDEXED,\n artifact_id UNINDEXED,\n revision UNINDEXED,\n searchable_text,\n tokenize='unicode61'\n)", + "CREATE TABLE pc_artifact_handles (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\thandle_key VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tPRIMARY KEY (scope_id, family, handle_key), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_handles_revision_positive CHECK (revision > 0)\n)", + "CREATE TABLE pc_artifact_heads (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tsearchable_text TEXT, \n\tlifecycle_state VARCHAR(16) DEFAULT 'active' NOT NULL, \n\treplacement_artifact_id VARCHAR(128), \n\tmerged_into_id VARCHAR(128), \n\tgovernance_generation BIGINT DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_heads_revision_positive CHECK (revision > 0), \n\tCONSTRAINT ck_pc_artifact_heads_lifecycle_state CHECK (lifecycle_state IN ('active', 'deprecated', 'retired')), \n\tCONSTRAINT ck_pc_artifact_heads_governance_generation_nonnegative CHECK (governance_generation >= 0), \n\tCONSTRAINT ck_pc_artifact_heads_replacement_deprecated CHECK (replacement_artifact_id IS NULL OR lifecycle_state = 'deprecated'), \n\tCONSTRAINT ck_pc_artifact_heads_merge_target CHECK (merged_into_id IS NULL OR (lifecycle_state = 'deprecated' AND merged_into_id <> artifact_id AND replacement_artifact_id IS NULL))\n)", + "CREATE TABLE pc_artifact_lineage_artifacts (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tupstream_family VARCHAR(128) NOT NULL, \n\tupstream_artifact_id VARCHAR(128) NOT NULL, \n\tupstream_revision INTEGER NOT NULL, \n\tis_merge_input BOOLEAN DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision, ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE CASCADE, \n\tFOREIGN KEY(scope_id, upstream_family, upstream_artifact_id, upstream_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT\n)", + "CREATE TABLE pc_artifact_lineage_sources (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tsource_type VARCHAR(128) NOT NULL, \n\tsource_id VARCHAR(256) NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision, ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE CASCADE, \n\tFOREIGN KEY(scope_id, source_type, source_id) REFERENCES pc_sources (scope_id, source_type, source_id) ON DELETE RESTRICT\n)", + "CREATE TABLE pc_artifact_processing_auto_wave_targets (\n\twave_id VARCHAR(36) NOT NULL, \n\tbinding_name VARCHAR(128) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tsource_through BIGINT NOT NULL, \n\tcompleted BOOLEAN DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (wave_id, binding_name, scope_id), \n\tCONSTRAINT ck_pc_artifact_processing_auto_wave_target_source_positive CHECK (source_through >= 1)\n)", + "CREATE TABLE pc_artifact_processing_binding_states (\n\tbinding_name VARCHAR(128) NOT NULL, \n\tlast_auto_wave_completed_at DATETIME, \n\tlast_schedule_checkpoint_at DATETIME, \n\tscan_generation BIGINT DEFAULT '0' NOT NULL, \n\tscan_in_progress BOOLEAN DEFAULT '0' NOT NULL, \n\tscan_upper_pending_sequence BIGINT, \n\tPRIMARY KEY (binding_name), \n\tCONSTRAINT ck_pc_processing_scan_generation CHECK (scan_generation >= 0)\n)", + "CREATE TABLE pc_artifact_processing_intents (\n\tbinding_name VARCHAR(128) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tpending_sequence BIGINT NOT NULL, \n\tdirty_generation BIGINT DEFAULT '0' NOT NULL, \n\tclean_generation BIGINT DEFAULT '0' NOT NULL, \n\trequested_generation BIGINT DEFAULT '0' NOT NULL, \n\thandled_generation BIGINT DEFAULT '0' NOT NULL, \n\tlast_auto_scan_generation BIGINT DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (binding_name, scope_id), \n\tCONSTRAINT ck_pc_processing_intent_sequence CHECK (pending_sequence > 0), \n\tCONSTRAINT ck_pc_processing_intent_dirty CHECK (clean_generation >= 0 AND clean_generation <= dirty_generation), \n\tCONSTRAINT ck_pc_processing_intent_request CHECK (handled_generation >= 0 AND handled_generation <= requested_generation), \n\tCONSTRAINT ck_pc_processing_intent_scan CHECK (last_auto_scan_generation >= 0), \n\tUNIQUE (pending_sequence)\n)", + "CREATE TABLE pc_artifact_processing_leases (\n\tsupervisor_group VARCHAR(64) NOT NULL, \n\tholder_id VARCHAR(36) NOT NULL, \n\tsupervisor_generation BIGINT NOT NULL, \n\tlease_expires_at DATETIME, \n\tPRIMARY KEY (supervisor_group), \n\tCONSTRAINT ck_pc_artifact_processing_leases_generation_positive CHECK (supervisor_generation > 0)\n)", + "CREATE TABLE pc_artifact_processing_migration_receipts (\n\tmigration_id VARCHAR(64) NOT NULL, \n\tbinding_name VARCHAR(128) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tsource_snapshot BLOB NOT NULL, \n\trequested_generation BIGINT NOT NULL, \n\tdirty_generation BIGINT NOT NULL, \n\tsource_through BIGINT NOT NULL, \n\tPRIMARY KEY (migration_id, binding_name, scope_id)\n)", + "CREATE TABLE pc_artifact_processing_pending (\n\tbinding_name VARCHAR(128) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tsource_through BIGINT NOT NULL, \n\tflush_generation BIGINT DEFAULT '0' NOT NULL, \n\thandled_flush_generation BIGINT DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (binding_name, scope_id), \n\tCONSTRAINT ck_pc_artifact_processing_pending_source_positive CHECK (source_through >= 1), \n\tCONSTRAINT ck_pc_artifact_processing_pending_flush_nonnegative CHECK (flush_generation >= 0), \n\tCONSTRAINT ck_pc_artifact_processing_pending_handled_nonnegative CHECK (handled_flush_generation >= 0), \n\tCONSTRAINT ck_pc_artifact_processing_pending_handled_not_ahead CHECK (handled_flush_generation <= flush_generation)\n)", + "CREATE TABLE pc_artifact_processing_schema (\n\tsingleton INTEGER NOT NULL, \n\tschema_version INTEGER NOT NULL, \n\tsource_version INTEGER DEFAULT '1490' NOT NULL, \n\tphase VARCHAR(32) NOT NULL, \n\tmigration_id VARCHAR(64) NOT NULL, \n\tconfig_manifest TEXT NOT NULL, \n\tPRIMARY KEY (singleton), \n\tCONSTRAINT ck_pc_processing_schema_singleton CHECK (singleton = 1)\n)", + "CREATE TABLE pc_artifact_processing_sequences (\n\tsingleton INTEGER NOT NULL, \n\tsequence BIGINT NOT NULL, \n\tPRIMARY KEY (singleton), \n\tCONSTRAINT ck_pc_processing_sequence CHECK (singleton = 1 AND sequence >= 0)\n)", + "CREATE TABLE pc_artifact_publications (\n\ttarget_scope_id VARCHAR(256) NOT NULL, \n\ttarget_family VARCHAR(128) NOT NULL, \n\ttarget_artifact_id VARCHAR(128) NOT NULL, \n\ttarget_revision INTEGER NOT NULL, \n\tsource_scope_id VARCHAR(256) NOT NULL, \n\tsource_family VARCHAR(128) NOT NULL, \n\tsource_artifact_id VARCHAR(128) NOT NULL, \n\tsource_revision INTEGER NOT NULL, \n\tcontent_digest VARCHAR(64) NOT NULL, \n\tidempotency_key VARCHAR(256) NOT NULL, \n\tPRIMARY KEY (target_scope_id, target_family, target_artifact_id, target_revision), \n\tFOREIGN KEY(target_scope_id, target_family, target_artifact_id, target_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tFOREIGN KEY(source_scope_id, source_family, source_artifact_id, source_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT uq_pc_artifact_publications_request UNIQUE (target_scope_id, idempotency_key)\n)", + "CREATE TABLE pc_artifact_tags (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\ttarget_type VARCHAR(12) NOT NULL, \n\ttarget_id VARCHAR(128) NOT NULL, \n\ttag_key_hash BLOB NOT NULL, \n\ttag_key VARCHAR(128) NOT NULL, \n\ttag VARCHAR(64) NOT NULL, \n\tassigned_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, target_type, target_id, tag_key_hash), \n\tFOREIGN KEY(scope_id, family, artifact_id) REFERENCES pc_artifact_heads (scope_id, family, artifact_id) ON DELETE CASCADE, \n\tCONSTRAINT ck_pc_artifact_tags_target CHECK ((target_type = 'artifact' AND target_id = artifact_id) OR (target_type = 'memory_entry' AND family = 'memory'))\n)", + "CREATE TABLE pc_artifacts (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tcontent BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision)\n)", + "CREATE TABLE pc_atomic_memory_current (\n\tscope_id VARCHAR(256) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision BIGINT NOT NULL, \n\tstate_version BIGINT NOT NULL, \n\tcontent_hash VARCHAR(64) NOT NULL, \n\tprojection_format VARCHAR(64) NOT NULL, \n\tkind TEXT NOT NULL, \n\ttext TEXT NOT NULL, \n\tsearchable_text TEXT NOT NULL, \n\ttag_keys TEXT NOT NULL, \n\tembedding BLOB, \n\tprofile_fingerprint VARCHAR(64), \n\tembedding_input_hash VARCHAR(64), \n\tPRIMARY KEY (scope_id, artifact_id), \n\tCONSTRAINT ck_pc_atomic_memory_current_revision CHECK (revision > 0), \n\tCONSTRAINT ck_pc_atomic_memory_current_state_version CHECK (state_version >= 0), \n\tCONSTRAINT ck_pc_atomic_memory_current_embedding_metadata CHECK ((embedding IS NULL AND profile_fingerprint IS NULL AND embedding_input_hash IS NULL) OR (embedding IS NOT NULL AND profile_fingerprint IS NOT NULL AND embedding_input_hash IS NOT NULL))\n)", + "CREATE VIRTUAL TABLE pc_atomic_memory_current_fts USING fts5(scope_id UNINDEXED, artifact_id UNINDEXED, revision UNINDEXED, state_version UNINDEXED, content_hash UNINDEXED, projection_format UNINDEXED, kind UNINDEXED, text UNINDEXED, searchable_text, tag_keys UNINDEXED, identity_token, tokenize='unicode61')", + "CREATE TABLE pc_connector_checkpoints (\n\tscope_id VARCHAR(256) NOT NULL, \n\tbinding_id VARCHAR(256) NOT NULL, \n\tconnector_name VARCHAR(128) NOT NULL, \n\tconnector_version VARCHAR(128) NOT NULL, \n\tcheckpoint BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, binding_id)\n)", + "CREATE TABLE pc_context_bootstrap_receipts (\n\treceipt_id VARCHAR(64) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tevent_key VARCHAR(64), \n\trequest_digest VARCHAR(71) NOT NULL, \n\tintegration VARCHAR(64) NOT NULL, \n\tlifecycle VARCHAR(16) NOT NULL, \n\tprofile VARCHAR(64) NOT NULL, \n\tstate VARCHAR(16) NOT NULL, \n\treason VARCHAR(32), \n\tmax_bytes INTEGER NOT NULL, \n\tpackage_digest VARCHAR(71), \n\tcontent_bytes INTEGER NOT NULL, \n\ttruncated BOOLEAN NOT NULL, \n\titems BLOB NOT NULL, \n\tcreated_at DATETIME NOT NULL, \n\tPRIMARY KEY (receipt_id), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tCONSTRAINT uq_pc_context_bootstrap_receipts_event UNIQUE (event_key), \n\tCONSTRAINT ck_pc_context_bootstrap_receipts_state CHECK (state IN ('pending', 'injected', 'skipped', 'failed')), \n\tCONSTRAINT ck_pc_context_bootstrap_receipts_budget CHECK (max_bytes >= 512 AND max_bytes <= 8192), \n\tCONSTRAINT ck_pc_context_bootstrap_receipts_content_bytes CHECK (content_bytes >= 0)\n)", + "CREATE TABLE pc_decision_observations (\n\tscope_id VARCHAR(256) NOT NULL, \n\tobservation_id VARCHAR(128) NOT NULL, \n\toperation_id VARCHAR(256) NOT NULL, \n\tcreated_at DATETIME NOT NULL, \n\tconsumer VARCHAR(128) NOT NULL, \n\tpolicy_id VARCHAR(256) NOT NULL, \n\tpolicy_version VARCHAR(64) NOT NULL, \n\tmode VARCHAR(16) NOT NULL, \n\tprivacy_boundary VARCHAR(32) NOT NULL, \n\tcoverage VARCHAR(16) NOT NULL, \n\tverdict VARCHAR(16) NOT NULL, \n\tassessment_source VARCHAR(32) NOT NULL, \n\tfinal_action VARCHAR(128) NOT NULL, \n\tused_fallback BOOLEAN NOT NULL, \n\tfallback_reason VARCHAR(128), \n\tprovider_id VARCHAR(128), \n\tbackend_model_id VARCHAR(256), \n\tmodel_policy_id VARCHAR(256), \n\trequests BIGINT NOT NULL, \n\tinput_tokens BIGINT, \n\toutput_tokens BIGINT, \n\tlatency_ms FLOAT, \n\tpayload BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, observation_id), \n\tCONSTRAINT ck_pc_decision_observations_requests_nonnegative CHECK (requests >= 0), \n\tCONSTRAINT ck_pc_decision_observations_input_tokens_nonnegative CHECK (input_tokens IS NULL OR input_tokens >= 0), \n\tCONSTRAINT ck_pc_decision_observations_output_tokens_nonnegative CHECK (output_tokens IS NULL OR output_tokens >= 0), \n\tCONSTRAINT ck_pc_decision_observations_latency_nonnegative CHECK (latency_ms IS NULL OR latency_ms >= 0)\n)", + "CREATE TABLE pc_dream_runs (\n\tscope_id VARCHAR(256) NOT NULL, \n\trun_id VARCHAR(64) NOT NULL, \n\tprincipal_key VARCHAR(64) NOT NULL, \n\tidempotency_key VARCHAR(128) NOT NULL, \n\trequest_digest VARCHAR(71) NOT NULL, \n\toperation VARCHAR(32) NOT NULL, \n\tstatus VARCHAR(16) NOT NULL, \n\taccepted_at BIGINT NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\trequest_generation BIGINT NOT NULL, \n\tpayload BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, run_id), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tCONSTRAINT uq_pc_dream_idempotency UNIQUE (scope_id, principal_key, idempotency_key), \n\tCONSTRAINT ck_pc_dream_generation CHECK (generation >= 0)\n)", + "CREATE TABLE pc_external_skill_registrations (\n\tscope_id VARCHAR(256) NOT NULL, \n\texternal_skill_id VARCHAR(128) NOT NULL, \n\tprovider VARCHAR(128) NOT NULL, \n\tagent_kind VARCHAR(128) NOT NULL, \n\thost_id VARCHAR(128) NOT NULL, \n\tinstallation_scope VARCHAR(16) NOT NULL, \n\tlocator VARCHAR(2000) NOT NULL, \n\tlocator_hash VARCHAR(64) NOT NULL, \n\tfingerprint VARCHAR(64) NOT NULL, \n\tname VARCHAR(128) NOT NULL, \n\tdescription VARCHAR(2000) NOT NULL, \n\tPRIMARY KEY (scope_id, external_skill_id), \n\tCONSTRAINT uq_pc_external_skill_registrations_binding UNIQUE (scope_id, provider, host_id, installation_scope, locator_hash), \n\tCONSTRAINT ck_pc_external_skill_registrations_scope CHECK (installation_scope IN ('user', 'project', 'plugin'))\n)", + "CREATE TABLE pc_memory_entry_heads (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tmemory_artifact_id VARCHAR(128) NOT NULL, \n\thead_revision INTEGER NOT NULL, \n\tentry_id VARCHAR(128) NOT NULL, \n\tentry_version_id VARCHAR(128) NOT NULL, \n\tentry_content_hash VARCHAR(64) NOT NULL, \n\tsearchable_text TEXT NOT NULL, \n\tPRIMARY KEY (scope_id, memory_artifact_id, entry_id), \n\tFOREIGN KEY(scope_id, memory_artifact_id, entry_id, entry_version_id) REFERENCES pc_memory_entry_versions (scope_id, memory_artifact_id, entry_id, entry_version_id) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_memory_entry_heads_revision_positive CHECK (head_revision > 0)\n)", + "CREATE TABLE pc_memory_entry_versions (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tmemory_artifact_id VARCHAR(128) NOT NULL, \n\tentry_id VARCHAR(128) NOT NULL, \n\tentry_version_id VARCHAR(128) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tprevious_version_id VARCHAR(128), \n\tkind VARCHAR(128) NOT NULL, \n\ttext TEXT NOT NULL, \n\tsource_refs BLOB NOT NULL, \n\tartifact_refs BLOB NOT NULL, \n\tentry_content_hash VARCHAR(64) NOT NULL, \n\tcreated_in_revision INTEGER NOT NULL, \n\tPRIMARY KEY (scope_id, memory_artifact_id, entry_version_id), \n\tCONSTRAINT uq_pc_memory_entry_versions_logical_version UNIQUE (scope_id, memory_artifact_id, entry_id, version), \n\tCONSTRAINT uq_pc_memory_entry_versions_identity UNIQUE (scope_id, memory_artifact_id, entry_id, entry_version_id), \n\tCONSTRAINT ck_pc_memory_entry_versions_version_positive CHECK (version > 0), \n\tCONSTRAINT ck_pc_memory_entry_versions_revision_positive CHECK (created_in_revision > 0)\n)", + "CREATE TABLE pc_memory_source_windows (\n\tscope_id VARCHAR(256) NOT NULL, \n\tsource_through BIGINT NOT NULL, \n\twindow_limit BIGINT NOT NULL, \n\tPRIMARY KEY (scope_id), \n\tCONSTRAINT ck_pc_memory_source_window_positive CHECK (source_through > 0 AND window_limit > 0)\n)", + "CREATE TABLE pc_model_usage_daily (\n\tscope_id VARCHAR(256) NOT NULL, \n\tusage_date DATE NOT NULL, \n\tpurpose VARCHAR(64) NOT NULL, \n\toperation VARCHAR(16) NOT NULL, \n\trequests BIGINT NOT NULL, \n\tinput_tokens BIGINT NOT NULL, \n\toutput_tokens BIGINT NOT NULL, \n\tinput_complete BOOLEAN NOT NULL, \n\toutput_complete BOOLEAN NOT NULL, \n\tPRIMARY KEY (scope_id, usage_date, purpose, operation), \n\tCONSTRAINT ck_pc_model_usage_daily_operation CHECK (operation IN ('generation', 'embedding')), \n\tCONSTRAINT ck_pc_model_usage_daily_requests_nonnegative CHECK (requests >= 0), \n\tCONSTRAINT ck_pc_model_usage_daily_input_nonnegative CHECK (input_tokens >= 0), \n\tCONSTRAINT ck_pc_model_usage_daily_output_nonnegative CHECK (output_tokens >= 0)\n)", + "CREATE TABLE pc_profile_policies (\n\tscope_id VARCHAR(256) NOT NULL, \n\tgeneration_enabled BOOLEAN NOT NULL, \n\tactivation_mode VARCHAR(32) NOT NULL, \n\tpending_candidate_id VARCHAR(128), \n\tversion BIGINT NOT NULL, \n\tupdated_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tFOREIGN KEY(scope_id, pending_candidate_id) REFERENCES pc_artifact_candidate_heads (scope_id, candidate_id) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_profile_policies_activation_mode CHECK (activation_mode IN ('automatic', 'review_required')), \n\tCONSTRAINT ck_pc_profile_policies_version_positive CHECK (version > 0)\n)", + "CREATE TABLE pc_recall_effort_daily (\n\tscope_id VARCHAR(256) NOT NULL, \n\tusage_date DATE NOT NULL, \n\tpolicy_id VARCHAR(128) NOT NULL, \n\tassessment VARCHAR(64) NOT NULL, \n\tpreparations BIGINT NOT NULL CONSTRAINT ck_recall_effort_preparations_nonnegative CHECK (preparations >= 0), \n\trounds BIGINT NOT NULL CONSTRAINT ck_recall_effort_rounds_nonnegative CHECK (rounds >= 0), \n\texpanded_preparations BIGINT NOT NULL CONSTRAINT ck_recall_effort_expanded_preparations_nonnegative CHECK (expanded_preparations >= 0), \n\tadmission_expansions BIGINT NOT NULL CONSTRAINT ck_recall_effort_admission_expansions_nonnegative CHECK (admission_expansions >= 0), \n\tpolicy_floor_expansions BIGINT NOT NULL CONSTRAINT ck_recall_effort_policy_floor_expansions_nonnegative CHECK (policy_floor_expansions >= 0), \n\tcandidate_round_samples BIGINT NOT NULL CONSTRAINT ck_recall_effort_candidate_round_samples_nonnegative CHECK (candidate_round_samples >= 0), \n\tcandidates_assessed BIGINT NOT NULL CONSTRAINT ck_recall_effort_candidates_assessed_nonnegative CHECK (candidates_assessed >= 0), \n\tfinal_candidate_pool BIGINT NOT NULL CONSTRAINT ck_recall_effort_final_candidate_pool_nonnegative CHECK (final_candidate_pool >= 0), \n\tadded_embeddings BIGINT NOT NULL CONSTRAINT ck_recall_effort_added_embeddings_nonnegative CHECK (added_embeddings >= 0), \n\tadded_generation_calls BIGINT NOT NULL CONSTRAINT ck_recall_effort_added_generation_calls_nonnegative CHECK (added_generation_calls >= 0), \n\ttruncated_items BIGINT NOT NULL CONSTRAINT ck_recall_effort_truncated_items_nonnegative CHECK (truncated_items >= 0), \n\tdropped_items BIGINT NOT NULL CONSTRAINT ck_recall_effort_dropped_items_nonnegative CHECK (dropped_items >= 0), \n\tdropped_below_min_bytes BIGINT NOT NULL CONSTRAINT ck_recall_effort_dropped_below_min_bytes_nonnegative CHECK (dropped_below_min_bytes >= 0), \n\tdropped_no_fitting_truncation BIGINT NOT NULL CONSTRAINT ck_recall_effort_dropped_no_fitting_truncation_nonnegative CHECK (dropped_no_fitting_truncation >= 0), \n\tPRIMARY KEY (scope_id, usage_date, policy_id, assessment), \n\tCONSTRAINT ck_pc_recall_effort_daily_drop_total CHECK (dropped_items = dropped_below_min_bytes + dropped_no_fitting_truncation)\n)", + "CREATE TABLE pc_recall_token_daily (\n\tscope_id VARCHAR(256) NOT NULL, \n\tusage_date DATE NOT NULL, \n\testimator_id VARCHAR(128) NOT NULL, \n\testimator_version VARCHAR(64) NOT NULL, \n\tpreparations BIGINT NOT NULL, \n\tready_preparations BIGINT NOT NULL, \n\tcomparable_preparations BIGINT NOT NULL, \n\tbaseline_tokens BIGINT NOT NULL, \n\trecalled_tokens BIGINT NOT NULL, \n\tPRIMARY KEY (scope_id, usage_date, estimator_id, estimator_version), \n\tCONSTRAINT ck_pc_recall_token_daily_preparations_nonnegative CHECK (preparations >= 0), \n\tCONSTRAINT ck_pc_recall_token_daily_ready_nonnegative CHECK (ready_preparations >= 0), \n\tCONSTRAINT ck_pc_recall_token_daily_comparable_nonnegative CHECK (comparable_preparations >= 0), \n\tCONSTRAINT ck_pc_recall_token_daily_comparable_ready CHECK (comparable_preparations <= ready_preparations), \n\tCONSTRAINT ck_pc_recall_token_daily_ready_total CHECK (ready_preparations <= preparations), \n\tCONSTRAINT ck_pc_recall_token_daily_baseline_nonnegative CHECK (baseline_tokens >= 0), \n\tCONSTRAINT ck_pc_recall_token_daily_recalled_nonnegative CHECK (recalled_tokens >= 0)\n)", + "CREATE TABLE pc_receipt_migration_review (\n\tscope_id VARCHAR(256) NOT NULL, \n\tsource_id VARCHAR(256) NOT NULL, \n\treason VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (scope_id, source_id)\n)", + "CREATE TABLE pc_recurrence_match (\n\tscope_id VARCHAR(256) NOT NULL, \n\tmatch_key VARCHAR(71) NOT NULL, \n\ttask_outcome_source_type VARCHAR(128) NOT NULL, \n\ttask_outcome_source_id VARCHAR(256) NOT NULL, \n\ttask_outcome_position BIGINT NOT NULL, \n\tfailure_item_kind VARCHAR(16) NOT NULL, \n\tfailure_item_index INTEGER NOT NULL, \n\tfailure_item_digest VARCHAR(71) NOT NULL, \n\tcandidate_set_mode VARCHAR(32) NOT NULL, \n\tcandidate_set_digest VARCHAR(71) NOT NULL, \n\tresult VARCHAR(16) NOT NULL, \n\ttarget_family VARCHAR(128), \n\ttarget_artifact_id VARCHAR(128), \n\ttarget_revision INTEGER, \n\tsignature_key TEXT, \n\tpayload BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, match_key), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tCONSTRAINT uq_pc_recurrence_match_identity UNIQUE (scope_id, task_outcome_source_type, task_outcome_source_id, failure_item_kind, failure_item_index), \n\tCONSTRAINT ck_pc_recurrence_match_result CHECK (result IN ('matched', 'unmatched', 'ambiguous')), \n\tCONSTRAINT ck_pc_recurrence_match_candidate_set_mode CHECK (candidate_set_mode IN ('handoff_citations', 'scope_heads')), \n\tCONSTRAINT ck_pc_recurrence_match_failure_item_kind CHECK (failure_item_kind IN ('observation', 'check')), \n\tCONSTRAINT ck_pc_recurrence_match_position_positive CHECK (task_outcome_position > 0), \n\tCONSTRAINT ck_pc_recurrence_match_item_index_nonnegative CHECK (failure_item_index >= 0)\n)", + "CREATE TABLE pc_recurrence_observation (\n\tscope_id VARCHAR(256) NOT NULL, \n\tobservation_id VARCHAR(71) NOT NULL, \n\tselection_key VARCHAR(71) NOT NULL, \n\tverdict_key VARCHAR(71) NOT NULL, \n\tevent VARCHAR(16) NOT NULL, \n\tmatch_basis VARCHAR(8) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tsignature_key TEXT NOT NULL, \n\tsignature_key_hash BLOB NOT NULL, \n\ttask_outcome_source_type VARCHAR(128) NOT NULL, \n\ttask_outcome_source_id VARCHAR(256) NOT NULL, \n\ttask_outcome_position BIGINT NOT NULL, \n\tpayload BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, observation_id), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tCONSTRAINT uq_pc_recurrence_observation_selection UNIQUE (scope_id, selection_key), \n\tCONSTRAINT uq_pc_recurrence_observation_verdict UNIQUE (scope_id, verdict_key), \n\tCONSTRAINT ck_pc_recurrence_observation_event CHECK (event IN ('selected', 'recurred', 'avoided')), \n\tCONSTRAINT ck_pc_recurrence_observation_revision_positive CHECK (revision > 0), \n\tCONSTRAINT ck_pc_recurrence_observation_position_positive CHECK (task_outcome_position > 0)\n)", + "CREATE TABLE pc_scope_bindings (\n\tintegration VARCHAR(128) NOT NULL, \n\tkind VARCHAR(64) NOT NULL, \n\texternal_id VARCHAR(256) NOT NULL, \n\texternal_id_search TEXT NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tPRIMARY KEY (integration, kind, external_id), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE RESTRICT\n)", + "CREATE TABLE pc_scope_context_references (\n\tscope_id VARCHAR(256) NOT NULL, \n\treferenced_scope_id VARCHAR(256) NOT NULL, \n\tPRIMARY KEY (scope_id, referenced_scope_id), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tFOREIGN KEY(referenced_scope_id) REFERENCES pc_scopes (scope_id) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_scope_context_references_not_self CHECK (scope_id <> referenced_scope_id)\n)", + "CREATE TABLE pc_scope_creation_requests (\n\tidempotency_key VARCHAR(256) NOT NULL, \n\trequest_digest VARCHAR(64) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tPRIMARY KEY (idempotency_key), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE RESTRICT\n)", + "CREATE TABLE pc_scope_external_references (\n\tscope_id VARCHAR(256) NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tkind VARCHAR(128) NOT NULL, \n\tvalue VARCHAR(2000) NOT NULL, \n\tvalue_search TEXT NOT NULL, \n\tvalue_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (scope_id, ordinal), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE CASCADE, \n\tCONSTRAINT uq_pc_scope_external_references_value UNIQUE (scope_id, kind, value_digest), \n\tCONSTRAINT ck_pc_scope_external_references_ordinal_nonnegative CHECK (ordinal >= 0)\n)", + "CREATE TABLE pc_scope_settings (\n\tname VARCHAR(64) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tPRIMARY KEY (name), \n\tFOREIGN KEY(scope_id) REFERENCES pc_scopes (scope_id) ON DELETE RESTRICT\n)", + "CREATE TABLE pc_scopes (\n\tscope_id VARCHAR(256) NOT NULL, \n\ttitle VARCHAR(256) NOT NULL, \n\tsummary VARCHAR(2000) NOT NULL, \n\tscope_id_search TEXT NOT NULL, \n\ttitle_search TEXT NOT NULL, \n\tsummary_search TEXT NOT NULL, \n\tparent_scope_id VARCHAR(256), \n\tversion INTEGER NOT NULL, \n\tPRIMARY KEY (scope_id), \n\tFOREIGN KEY(parent_scope_id) REFERENCES pc_scopes (scope_id) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_scopes_version_positive CHECK (version > 0)\n)", + "CREATE TABLE pc_server_identity (\n\tsingleton_key INTEGER NOT NULL, \n\tserver_id VARCHAR(36) NOT NULL, \n\tPRIMARY KEY (singleton_key), \n\tCONSTRAINT ck_pc_server_identity_singleton CHECK (singleton_key = 1), \n\tUNIQUE (server_id)\n)", + "CREATE TABLE pc_skill_packages (\n\tscope_id VARCHAR(256) NOT NULL, \n\ttree_digest VARCHAR(64) NOT NULL, \n\tarchive_digest VARCHAR(64) NOT NULL, \n\tarchive_bytes BLOB NOT NULL, \n\tmanifest BLOB NOT NULL, \n\tfile_count INTEGER NOT NULL, \n\tuncompressed_size BIGINT NOT NULL, \n\tarchive_size BIGINT NOT NULL, \n\tcreated_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, tree_digest), \n\tCONSTRAINT ck_pc_skill_packages_file_count CHECK (file_count > 0 AND file_count <= 256), \n\tCONSTRAINT ck_pc_skill_packages_uncompressed_size CHECK (uncompressed_size > 0 AND uncompressed_size <= 4194304), \n\tCONSTRAINT ck_pc_skill_packages_archive_size CHECK (archive_size > 0 AND archive_size <= 5242880)\n)", + "CREATE TABLE pc_skill_publications (\n\tscope_id VARCHAR(256) NOT NULL, \n\ttarget_id VARCHAR(64) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\tdesired_state VARCHAR(16) DEFAULT 'published' NOT NULL, \n\tdesired_revision INTEGER NOT NULL, \n\tdesired_tree_digest VARCHAR(64) NOT NULL, \n\tobserved_revision INTEGER, \n\tobserved_tree_digest VARCHAR(64), \n\tobserved_generation BIGINT, \n\tdestination TEXT, \n\tstate VARCHAR(32) NOT NULL, \n\tselected_runtime_variant VARCHAR(128), \n\tenvironment_fingerprint VARCHAR(64), \n\tlast_error_code VARCHAR(128), \n\tobserved_at DATETIME, \n\tgeneration BIGINT NOT NULL, \n\tupdated_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, target_id, artifact_id), \n\tCONSTRAINT ck_pc_skill_publications_desired_revision_positive CHECK (desired_revision > 0), \n\tCONSTRAINT ck_pc_skill_publications_observed_revision_positive CHECK (observed_revision IS NULL OR observed_revision > 0), \n\tCONSTRAINT ck_pc_skill_publications_desired_state CHECK (desired_state IN ('published', 'unpublished')), \n\tCONSTRAINT ck_pc_skill_publications_state CHECK (state IN ('unpublished', 'pending', 'current', 'update_available', 'delivery_failed', 'conflict', 'drifted', 'incompatible')), \n\tCONSTRAINT ck_pc_skill_publications_observed_generation_nonnegative CHECK (observed_generation IS NULL OR observed_generation >= 0), \n\tCONSTRAINT ck_pc_skill_publications_generation_nonnegative CHECK (generation >= 0)\n)", + "CREATE TABLE pc_source_cursors (\n\tscope_id VARCHAR(256) NOT NULL, \n\tbinding_name VARCHAR(128) NOT NULL, \n\tcursor BLOB NOT NULL, \n\tgeneration BIGINT NOT NULL, \n\tPRIMARY KEY (scope_id, binding_name), \n\tCONSTRAINT ck_pc_source_cursors_generation_nonnegative CHECK (generation >= 0)\n)", + "CREATE TABLE pc_source_definition_manifests (\n\tdefinition_name VARCHAR(128) NOT NULL, \n\tdefinition_version VARCHAR(128) NOT NULL, \n\tfingerprint VARCHAR(71) NOT NULL, \n\tmanifest BLOB NOT NULL, \n\tPRIMARY KEY (definition_name, definition_version), \n\tCONSTRAINT uq_pc_source_definition_manifest_fingerprint UNIQUE (definition_name, fingerprint)\n)", + "CREATE TABLE pc_source_journal_heads (\n\tscope_id VARCHAR(256) NOT NULL, \n\tposition BIGINT NOT NULL, \n\tPRIMARY KEY (scope_id), \n\tCONSTRAINT ck_pc_source_journal_heads_position_nonnegative CHECK (position >= 0)\n)", + "CREATE TABLE pc_sources (\n\tscope_id VARCHAR(256) NOT NULL, \n\tsource_type VARCHAR(128) NOT NULL, \n\tsource_id VARCHAR(256) NOT NULL, \n\tpayload BLOB NOT NULL, \n\tjournal_position BIGINT NOT NULL, \n\tPRIMARY KEY (scope_id, source_type, source_id), \n\tCONSTRAINT uq_pc_sources_scope_journal_position UNIQUE (scope_id, journal_position)\n)", + "CREATE TABLE pc_topic_memory_active_chunks (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tchunk_ordinal INTEGER NOT NULL, \n\tstart_offset INTEGER NOT NULL, \n\tend_offset INTEGER NOT NULL, \n\tchunk_text TEXT NOT NULL, \n\tsearchable_text TEXT NOT NULL, \n\tpolicy_version VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (scope_id, artifact_id, chunk_ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_topic_memory_revision_publications (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_topic_memory_active_chunks_family CHECK (family = 'topic-memory'), \n\tCONSTRAINT ck_pc_topic_memory_active_chunks_revision_positive CHECK (revision > 0), \n\tCONSTRAINT ck_pc_topic_memory_active_chunks_ordinal_nonnegative CHECK (chunk_ordinal >= 0), \n\tCONSTRAINT ck_pc_topic_memory_active_chunks_start_nonnegative CHECK (start_offset >= 0), \n\tCONSTRAINT ck_pc_topic_memory_active_chunks_offsets CHECK (end_offset > start_offset)\n)", + "CREATE TABLE pc_topic_memory_active_topics (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\ttitle TEXT NOT NULL, \n\tsummary TEXT NOT NULL, \n\tsearchable_text TEXT NOT NULL, \n\tsource_count INTEGER NOT NULL, \n\tPRIMARY KEY (scope_id, artifact_id), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_topic_memory_revision_publications (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_topic_memory_active_topics_family CHECK (family = 'topic-memory'), \n\tCONSTRAINT ck_pc_topic_memory_active_topics_revision_positive CHECK (revision > 0), \n\tCONSTRAINT ck_pc_topic_memory_active_topics_sources_nonnegative CHECK (source_count >= 0)\n)", + "CREATE VIRTUAL TABLE pc_topic_memory_chunk_fts USING fts5(\n scope_id UNINDEXED, artifact_id UNINDEXED, revision UNINDEXED,\n chunk_ordinal UNINDEXED, start_offset UNINDEXED,\n title UNINDEXED, summary UNINDEXED, chunk_text UNINDEXED,\n searchable_text, tokenize='unicode61'\n)", + "CREATE TABLE pc_topic_memory_fts_index (\n\tsingleton INTEGER NOT NULL, \n\tschema_version INTEGER NOT NULL, \n\tPRIMARY KEY (singleton), \n\tCONSTRAINT ck_pc_topic_memory_fts_index_singleton CHECK (singleton = 1)\n)", + "CREATE TABLE pc_topic_memory_processing_targets (\n\tbinding_name VARCHAR(128) NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\ttarget_request_generation BIGINT NOT NULL, \n\tsource_through BIGINT NOT NULL, \n\tcaptured_flush_generation BIGINT NOT NULL, \n\tobserved_dirty_generation BIGINT NOT NULL, \n\tPRIMARY KEY (binding_name, scope_id), \n\tCONSTRAINT ck_pc_topic_processing_target CHECK (target_request_generation > 0 AND source_through >= 0 AND captured_flush_generation >= 0 AND observed_dirty_generation >= 0)\n)", + "CREATE TABLE pc_topic_memory_retrieval_shape (\n\tsingleton INTEGER NOT NULL, \n\tshape VARCHAR(16) NOT NULL, \n\tprofile_fingerprint VARCHAR(64), \n\tPRIMARY KEY (singleton), \n\tCONSTRAINT ck_pc_topic_memory_retrieval_shape_singleton CHECK (singleton = 1), \n\tCONSTRAINT ck_pc_topic_memory_retrieval_shape_value CHECK (shape IN ('fts', 'hybrid')), \n\tCONSTRAINT ck_pc_topic_memory_retrieval_shape_profile CHECK ((shape = 'fts' AND profile_fingerprint IS NULL) OR (shape = 'hybrid' AND profile_fingerprint IS NOT NULL))\n)", + "CREATE TABLE pc_topic_memory_revision_publications (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tpublished_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_topic_memory_publications_family CHECK (family = 'topic-memory'), \n\tCONSTRAINT ck_pc_topic_memory_publications_revision_positive CHECK (revision > 0)\n)", + "CREATE VIRTUAL TABLE pc_topic_memory_topic_fts USING fts5(\n scope_id UNINDEXED, artifact_id UNINDEXED, revision UNINDEXED,\n title UNINDEXED, summary UNINDEXED, searchable_text, tokenize='unicode61'\n)", + "CREATE TABLE pc_topic_memory_work_budgets (\n\tscope_id VARCHAR(256) NOT NULL, \n\tbinding_name VARCHAR(128) NOT NULL, \n\tsource_after BIGINT NOT NULL, \n\tsource_through BIGINT NOT NULL, \n\tattempt_id VARCHAR(36) NOT NULL, \n\tattempts INTEGER NOT NULL, \n\trequests BIGINT NOT NULL, \n\ttokens BIGINT NOT NULL, \n\tfailure_code VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (scope_id, binding_name, source_after), \n\tCONSTRAINT ck_pc_topic_budget_window CHECK (source_after >= 0 AND source_through > source_after), \n\tCONSTRAINT ck_pc_topic_budget_usage CHECK (attempts > 0 AND requests >= 0 AND tokens >= 0)\n)", + "CREATE INDEX ix_pc_artifact_tags_family_key ON pc_artifact_tags (scope_id, family, tag_key_hash, target_type, artifact_id, target_id)", + "CREATE INDEX ix_pc_artifact_tags_key ON pc_artifact_tags (scope_id, tag_key_hash, family, target_type, artifact_id, target_id)", + "CREATE INDEX ix_pc_context_bootstrap_receipts_created_at ON pc_context_bootstrap_receipts (created_at)", + "CREATE INDEX ix_pc_decision_observations_operation ON pc_decision_observations (scope_id, operation_id, created_at)", + "CREATE INDEX ix_pc_decision_observations_scope_created ON pc_decision_observations (scope_id, created_at, observation_id)", + "CREATE INDEX ix_pc_dream_dispatch ON pc_dream_runs (scope_id, operation, status, request_generation)", + "CREATE INDEX ix_pc_dream_list ON pc_dream_runs (scope_id, accepted_at, run_id)", + "CREATE INDEX ix_pc_processing_intent_binding_sequence ON pc_artifact_processing_intents (binding_name, pending_sequence)", + "CREATE INDEX ix_pc_recurrence_observation_key ON pc_recurrence_observation (scope_id, signature_key_hash)", + "CREATE INDEX ix_pc_recurrence_observation_outcome ON pc_recurrence_observation (scope_id, task_outcome_source_type, task_outcome_source_id, task_outcome_position)", + "CREATE INDEX ix_pc_recurrence_observation_revision ON pc_recurrence_observation (scope_id, family, artifact_id, revision)", + "CREATE TRIGGER pc_atomic_memory_fts_delete AFTER DELETE ON pc_atomic_memory_current BEGIN DELETE FROM pc_atomic_memory_current_fts WHERE identity_token MATCH ('i' || lower(hex(old.scope_id)) || 'x' || lower(hex(old.artifact_id))); END", + "CREATE TRIGGER pc_atomic_memory_fts_insert AFTER INSERT ON pc_atomic_memory_current BEGIN INSERT INTO pc_atomic_memory_current_fts (scope_id, artifact_id, revision, state_version, content_hash, projection_format, kind, text, searchable_text, tag_keys, identity_token) VALUES (new.scope_id, new.artifact_id, new.revision, new.state_version, new.content_hash, new.projection_format, new.kind, new.text, new.searchable_text, new.tag_keys, 'i' || lower(hex(new.scope_id)) || 'x' || lower(hex(new.artifact_id))); END", + "CREATE TRIGGER pc_atomic_memory_fts_update AFTER UPDATE ON pc_atomic_memory_current BEGIN DELETE FROM pc_atomic_memory_current_fts WHERE identity_token MATCH ('i' || lower(hex(old.scope_id)) || 'x' || lower(hex(old.artifact_id))); INSERT INTO pc_atomic_memory_current_fts (scope_id, artifact_id, revision, state_version, content_hash, projection_format, kind, text, searchable_text, tag_keys, identity_token) VALUES (new.scope_id, new.artifact_id, new.revision, new.state_version, new.content_hash, new.projection_format, new.kind, new.text, new.searchable_text, new.tag_keys, 'i' || lower(hex(new.scope_id)) || 'x' || lower(hex(new.artifact_id))); END" + ], + "sqlite_expand": [ + "ALTER TABLE pc_scopes ADD COLUMN atomic_memory_directory_generation BIGINT NOT NULL DEFAULT 0 CONSTRAINT ck_pc_scopes_atomic_directory_generation CHECK (atomic_memory_directory_generation >= 0)", + "ALTER TABLE pc_artifact_heads ADD COLUMN content_kind VARCHAR(128) COLLATE BINARY" + ], + "content_schema": { + "$defs": { + "AtomicMemoryCreation": { + "additionalProperties": false, + "description": "Select merge inputs from the exact lineage of revision one.", + "properties": { + "type": { + "const": "merge", + "default": "merge", + "title": "Type", + "type": "string" + }, + "input_artifact_ids": { + "items": { + "type": "string" + }, + "minItems": 2, + "title": "Input Artifact Ids", + "type": "array" + } + }, + "required": [ + "input_artifact_ids" + ], + "title": "AtomicMemoryCreation", + "type": "object" + } + }, + "additionalProperties": false, + "description": "One durable fact, preference or other atomic memory.", + "properties": { + "schema": { + "const": "powercontext.atomic-memory.v1", + "default": "powercontext.atomic-memory.v1", + "title": "Schema", + "type": "string" + }, + "kind": { + "maxLength": 128, + "minLength": 1, + "title": "Kind", + "type": "string" + }, + "text": { + "minLength": 1, + "title": "Text", + "type": "string" + }, + "creation": { + "anyOf": [ + { + "$ref": "#/$defs/AtomicMemoryCreation" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "kind", + "text" + ], + "title": "AtomicMemoryContent", + "type": "object" + } +} diff --git a/src/powercontext/builtin/persistence/migrations/resources/tasks/atomic_directory_v1.py b/src/powercontext/builtin/persistence/migrations/resources/tasks/atomic_directory_v1.py new file mode 100644 index 0000000000..f8d9134e2b --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/resources/tasks/atomic_directory_v1.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Immutable current-head directory task; the shared executor owns commits and recovery. + +This resource imports no current Repository, Runtime or domain decoder. Its v1 +content schema and supplementary validation travel with the reviewed bundle. +""" + +import json +import unicodedata +from pathlib import Path + +from jsonschema import Draft202012Validator, ValidationError + +_MAX_GENERATION = (1 << 63) - 1 +_BATCH_SIZE = 200 +_RESOURCE = json.loads((Path(__file__).parents[1] / "schemas/atomic_directory_sqlite_v1.json").read_text()) +_CONTENT = Draft202012Validator(_RESOURCE["content_schema"]) + + +def _valid_identity(value, maximum): + return isinstance(value, str) and 0 < len(value) <= maximum and bool(value.strip()) + + +def _check_authority(connection): + orphan = connection.exec_driver_sql( + "SELECT 1 FROM pc_artifact_heads h LEFT JOIN pc_scopes s ON s.scope_id=h.scope_id " + "WHERE h.family='atomic-memory' AND s.scope_id IS NULL LIMIT 1" + ).first() + legacy = connection.exec_driver_sql("SELECT 1 FROM pc_artifacts WHERE family='memory' LIMIT 1").first() + if orphan is not None or legacy is not None: + raise ValueError("atomic_directory_authority_invalid") + after = None + while True: + statement = ( + "SELECT scope_id, atomic_memory_directory_generation FROM pc_scopes ORDER BY scope_id LIMIT 200" + if after is None + else "SELECT scope_id, atomic_memory_directory_generation FROM pc_scopes " + "WHERE scope_id>? ORDER BY scope_id LIMIT 200" + ) + rows = connection.exec_driver_sql( + statement, + () if after is None else (after,), + ).all() + if not rows: + return + for scope_id, generation in rows: + if ( + not _valid_identity(scope_id, 256) + or type(generation) is not int + or not 0 <= generation <= _MAX_GENERATION + ): + raise ValueError("atomic_directory_generation_invalid") + after = rows[-1][0] + + +def _batches(connection): + after = None + while True: + clause = "" if after is None else " AND (h.scope_id,h.artifact_id)>(?,?)" + rows = connection.exec_driver_sql( + "SELECT h.scope_id,h.artifact_id,h.revision,h.content_kind,h.lifecycle_state," + "h.governance_generation,h.merged_into_id,h.replacement_artifact_id,a.content " + "FROM pc_artifact_heads h LEFT JOIN pc_artifacts a " + "ON a.scope_id=h.scope_id AND a.family=h.family AND a.artifact_id=h.artifact_id AND a.revision=h.revision " + "WHERE h.family='atomic-memory'" + clause + " ORDER BY h.scope_id,h.artifact_id LIMIT 201", + () if after is None else after, + ).all() + if not rows: + return + yield rows[:_BATCH_SIZE] + if len(rows) <= _BATCH_SIZE: + return + after = (rows[_BATCH_SIZE - 1][0], rows[_BATCH_SIZE - 1][1]) + + +def _kind(row): + scope_id, identity, revision, _kind_value, lifecycle, generation, merged, replacement, body = row + try: + content = json.loads(body) + _CONTENT.validate(content) + kind = content["kind"] + text = content["text"] + if ( + not kind.strip() + or not text.strip() + or min(len(text.encode("utf-8")), len(unicodedata.normalize("NFC", text).encode("utf-8"))) > 8192 + or not _valid_identity(scope_id, 256) + or not _valid_identity(identity, 128) + or type(revision) is not int + or revision < 1 + or type(generation) is not int + or generation < 0 + or lifecycle not in {"active", "deprecated", "retired"} + or replacement is not None + or ( + merged is not None + and (not _valid_identity(merged, 128) or lifecycle != "deprecated" or merged == identity) + ) + ): + raise ValueError("invalid current Atomic metadata") # noqa: TRY003, TRY301 + creation = content.get("creation") + if creation is not None: + inputs = creation["input_artifact_ids"] + if len(set(inputs)) != len(inputs) or any(not _valid_identity(item, 128) for item in inputs): + raise ValueError("invalid Atomic creation inputs") # noqa: TRY003, TRY301 + except (TypeError, ValueError, ValidationError) as error: + # Expose only a stable classification, never private content or identities. + raise ValueError("atomic_directory_data_invalid") from error + return kind + + +def verify(connection): + _check_authority(connection) + for rows in _batches(connection): + for row in rows: + if row[3] != _kind(row): + return False + return True + + +def apply_batches(connection): + _check_authority(connection) + for rows in _batches(connection): + # Validate this entire batch before issuing any updates. + changes = [(row, _kind(row)) for row in rows] + changes = [(row, kind) for row, kind in changes if row[3] != kind] + scopes = sorted({row[0] for row, _ in changes}) + for scope_id in scopes: + result = connection.exec_driver_sql( + "UPDATE pc_scopes SET atomic_memory_directory_generation=atomic_memory_directory_generation+1 " + "WHERE scope_id=? AND atomic_memory_directory_generation>=0 AND atomic_memory_directory_generation None: + connection = op.get_bind() + if connection.dialect.name != "sqlite": + raise ValueError("This registered target supports only SQLite") # noqa: TRY003 + # The earlier five-table acceptance history is usable for empty initialization + # only. Existing real business layouts are explicitly adopted at p0005. + tables = ( + "pc_artifact_handles", + "pc_artifact_tags", + "pc_artifact_candidate_versions", + "pc_artifact_heads", + "pc_artifacts", + ) + for table in tables: + if connection.exec_driver_sql(f"SELECT 1 FROM {table} LIMIT 1").first() is not None: # noqa: S608 + raise ValueError("Nonempty acceptance layouts require a registered legacy adapter") # noqa: TRY003 + for table in tables: + connection.exec_driver_sql(f"DROP TABLE {table}") + resource = json.loads((Path(__file__).parents[1] / "schemas/atomic_directory_sqlite_v1.json").read_text()) + for statement in resource["sqlite_init"]: + connection.exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/resources/versions/p0006_atomic_directory.py b/src/powercontext/builtin/persistence/migrations/resources/versions/p0006_atomic_directory.py new file mode 100644 index 0000000000..88806e4daf --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/resources/versions/p0006_atomic_directory.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Expand directory metadata; explicit data tasks run under the same maintenance lock.""" + +import json +from pathlib import Path + +from alembic import op + +revision = "p0006" +down_revision = "p0005" + + +def upgrade() -> None: + connection = op.get_bind() + if connection.dialect.name != "sqlite": + raise ValueError("This registered target supports only SQLite") # noqa: TRY003 + resource = json.loads((Path(__file__).parents[1] / "schemas/atomic_directory_sqlite_v1.json").read_text()) + for statement in resource["sqlite_expand"]: + connection.exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/sqlite.py b/src/powercontext/builtin/persistence/migrations/sqlite.py index 2664f2f440..ddee2f531e 100644 --- a/src/powercontext/builtin/persistence/migrations/sqlite.py +++ b/src/powercontext/builtin/persistence/migrations/sqlite.py @@ -146,6 +146,13 @@ def verify(self) -> MigrationResult: raise MigrationError(plan.state, "Inspect and explicitly apply the registered bundle before verification.") return MigrationResult(revision=self.bundle.head, changed=False) + def assert_business_startup_allowed(self) -> None: + """Reject an unfinished recorded maintenance window before any initializer writes.""" + + evidence = self._evidence() + if evidence is not None and evidence["state"] != "complete": + raise MigrationError("recovery_required", "Complete explicit maintenance before business startup.") + def apply( self, *, @@ -191,7 +198,21 @@ def apply( connection.commit() self._upgrade(connection) self._begin(connection) + for task in self.bundle.data_tasks: + functions = self.bundle.task_functions(task) + try: + for _ in functions["apply_batches"](connection): + connection.commit() + self._begin(connection) + except ValueError as error: + raise MigrationError( + "verification_failed", "A frozen data task rejected the current data." + ) from error self._verify_schema(connection, self.bundle.head) + if self.bundle.pending_data_tasks(connection, self.bundle.head): + raise MigrationError( + "verification_failed", "Required data tasks did not satisfy their postconditions." + ) connection.commit() evidence["state"] = "complete" evidence["release_checksums"] = self.bundle.checksums @@ -262,7 +283,7 @@ def _connect(self, *, read_only: bool) -> Iterator[Connection]: finally: engine.dispose() - def _inspect(self, connection: Connection, **options: Any) -> MigrationPlan: + def _inspect(self, connection: Connection, **options: Any) -> MigrationPlan: # noqa: C901 inventory = sqlite_inventory(connection) controls = [row for row in inventory if row[1] in _CONTROL_NAMES or row[2] in _CONTROL_NAMES] initialized = bool(controls) @@ -277,6 +298,17 @@ def _inspect(self, connection: Connection, **options: Any) -> MigrationPlan: raise MigrationError("incompatible_schema", "Multiple revision heads are unsupported.") revision = revisions[0] if revisions else None self._verify_schema(connection, revision) + minimum = self.bundle.minimum_source_revision + if ( + minimum is not None + and revision is not None + and self.bundle.revisions.index(revision) < self.bundle.revisions.index(minimum) + ): + evidence = self._evidence() + if evidence is None or evidence["state"] == "complete" or evidence["source_revision"] is not None: + raise MigrationError( + "unsupported_target", "This target does not adopt a historical acceptance database." + ) if self.bundle.pending(revision) and self._evidence() is None: raise MigrationError( "recovery_required", @@ -290,9 +322,28 @@ def _inspect(self, connection: Connection, **options: Any) -> MigrationPlan: self._verify_integrity(connection) else: revision = None - return self._plan(revision, fingerprint, initialized=initialized, **options) + try: + data_tasks = self.bundle.pending_data_tasks(connection, revision) + except MigrationError as error: + evidence = self._evidence() + if error.code == "verification_failed" and evidence and evidence["state"] != "complete": + raise MigrationError( + "recovery_required", "Repair rejected data within the original maintenance window." + ) from error + raise + if initialized and data_tasks and self._evidence() is None: + raise MigrationError( + "recovery_required", "Original evidence is required before resuming pending data tasks." + ) + return self._plan( + revision, + fingerprint, + initialized=initialized, + data_tasks=data_tasks, + **options, + ) - def _plan( + def _plan( # noqa: C901 - one authority for schema/data/evidence plan classification self, revision: str | None, fingerprint: str, @@ -301,10 +352,13 @@ def _plan( backup_policy: BackupPolicy, service: dict[str, Any] | None, shared_database: bool, + data_tasks: tuple[str, ...] | None = None, ) -> MigrationPlan: pending = self.bundle.pending(revision) + if data_tasks is None: + data_tasks = tuple(task["name"] for task in self.bundle.data_tasks if task["revision"] in pending) adopt = not initialized and revision is not None - state = "migration_required" if pending or adopt else "ready" + state = "migration_required" if pending or adopt or data_tasks else "ready" if not initialized and revision is None: state = "uninitialized" evidence = self._evidence() @@ -340,6 +394,7 @@ def _plan( "configuration_digest": self.configuration_digest, "state": state, "revisions": pending, + "data_tasks": data_tasks, "adopt_baseline": adopt, "backup_policy": backup_policy, "backup_available": available, @@ -508,7 +563,12 @@ def _upgrade(self, connection: Connection) -> None: self._begin(connection) self._verify_schema(connection, current) with ALEMBIC_CONTEXT_LOCK: - command.upgrade(self.bundle.config(connection), revision) + try: + command.upgrade(self.bundle.config(connection), revision) + except ValueError as error: + raise MigrationError( + "verification_failed", "A frozen revision rejected its source layout." + ) from error self._verify_schema(connection, revision) connection.commit() current = revision diff --git a/src/powercontext/builtin/persistence/sqlite/profile.py b/src/powercontext/builtin/persistence/sqlite/profile.py index 6415d3e35c..9f80414aa6 100644 --- a/src/powercontext/builtin/persistence/sqlite/profile.py +++ b/src/powercontext/builtin/persistence/sqlite/profile.py @@ -97,6 +97,10 @@ async def open( ) -> AsyncIterator[SQLiteProfile]: """Create, initialize and exclusively own one SQLite engine.""" + if config.is_persistent: + from powercontext.builtin.persistence.migrations.deployment import assert_directory_maintenance_complete + + await asyncio.to_thread(assert_directory_maintenance_complete, config) _create_database_directory(config.url) engine_options: dict[str, object] = {"echo": config.echo, "hide_parameters": True} if not config.is_persistent: diff --git a/src/powercontext/builtin/persistence/tags.py b/src/powercontext/builtin/persistence/tags.py index 9353b900d0..b38e1eaeab 100644 --- a/src/powercontext/builtin/persistence/tags.py +++ b/src/powercontext/builtin/persistence/tags.py @@ -136,11 +136,14 @@ async def get(self, scope_id: str, target: TagTarget) -> ArtifactTagSet: current = await self._read(connection, scope_id, target) return tag_set(scope_id, target, current.tags) - async def replace( + async def replace( # noqa: C901 self, scope_id: str, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str, execution_context=None ) -> ArtifactTagSet: desired = normalize_tags(tags) async with self._database.transaction() as connection: + directory = self._artifacts.atomic_memory_directory if target.family == "atomic-memory" else None + if directory is not None: + await directory.lock_scope(connection, scope_id) # Acquire the database write lock before any reads. In particular, # SELECT FOR UPDATE alone cannot serialize empty-set writes on SQLite. locked = await connection.execute( @@ -186,6 +189,8 @@ async def replace( .where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key == key) .values(tag=label) ) + if directory is not None and previous != desired: + await directory.record_mutation(connection, scope_id) if target.family == "atomic-memory" and self._projection_hook is not None: await self._projection_hook(connection, scope_id, target.artifact_id, tuple(desired)) return tag_set(scope_id, target, tags) diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 75650cfd20..1725fecb24 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -3001,6 +3001,10 @@ async def list(self, **kwargs): async with self._runtime._scope_operation(self.scope_id): return await self._scoped.list(**kwargs) + async def query_directory(self, **kwargs): + async with self._runtime._scope_operation(self.scope_id): + return await self._scoped.query_directory(**kwargs) + async def search(self, query: str, *, _trace: bool = True, **kwargs): async with self._runtime._scoped_operation( self.scope_id, diff --git a/src/powercontext/builtin/runtime/atomic_memory.py b/src/powercontext/builtin/runtime/atomic_memory.py index f79cb78fcb..8f085068e2 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -24,7 +24,7 @@ from typing import cast from uuid import uuid4 -from sqlalchemy import select +from sqlalchemy import select, true from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts.search import ArtifactSearchExecutionContext @@ -58,6 +58,12 @@ ) from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.atomic_memory import AtomicMemoryStateRepository, atomic_memory_state_expression +from powercontext.builtin.persistence.atomic_memory_directory import ( + AtomicMemoryDirectoryAuthorizationUnavailableError, + AtomicMemoryDirectoryPage, + AtomicMemoryDirectoryQuery, + AtomicMemoryDirectoryUnavailableError, +) from powercontext.builtin.persistence.atomic_memory_index import ( ATOMIC_MEMORY_RRF_CONSTANT, AtomicMemoryIndex, @@ -91,6 +97,32 @@ async def filters( await self.authorize(connection, scope_id, context, "read") return AtomicMemoryIndexFilter(tag_filter=tags) + async def directory_context( + self, connection: AsyncConnection, scope_id: str, context: ArtifactSearchExecutionContext | None + ) -> str: + from powercontext.server.authz import AccessDeniedError, AccessUnavailableError + + # This additive API requires an explicit host-owned context, including + # local calls. Existing full-record operations retain their local policy. + if context is None: + raise AccessDeniedError + if context.access is None: + if not context.trusted_local: + raise AccessDeniedError + return "trusted-local-v1" + try: + return await context.access.require_scope_directory_read( + context.principal, scope_id, connection=connection, context=context.audit + ) + except AccessUnavailableError as error: + if error.code in { + "safe_resource_filtering_unavailable", + "transactional_relationships_unavailable", + "relationship_management_unavailable", + }: + raise AtomicMemoryDirectoryAuthorizationUnavailableError from error + raise + def deferred_decision_audit(context: ArtifactSearchExecutionContext | None): """Flush decision audit only after the business snapshot closes.""" @@ -189,7 +221,7 @@ def __init__( ) self.service = AtomicMemoryService( artifacts=artifacts, - states=AtomicMemoryStateRepository(), + states=AtomicMemoryStateRepository(atomic_memory_directory=artifacts.atomic_memory_directory), security=self.security, projections=self.publisher, merge_tags=self.merge_tags, @@ -208,6 +240,53 @@ def __init__(self, application: AtomicMemoryApplication, scope_id: str) -> None: self.application = application self.scope_id = scope_id + async def query_directory( + self, + *, + states: tuple[str, ...] = ("active",), + kind: str | None = None, + tag_filter: TagFilter | None = None, + limit: int = 50, + cursor: str | None = None, + context: ArtifactSearchExecutionContext | None = None, + connection: AsyncConnection | None = None, + ) -> AtomicMemoryDirectoryPage: + """Read compact current references after qualifying every page's access. + + Directory maintenance must be composed into Artifact writes only after + unified schema/data verification. An uncomposed application fails closed. + A supplied connection retains its caller's consistent transaction; the + caller also defers decision audit until that transaction closes. + """ + + query = AtomicMemoryDirectoryQuery.model_validate({ + "scope_id": self.scope_id, + "states": states, + "kind": kind, + "tag_filter": tag_filter, + "limit": limit, + "cursor": cursor, + }) + async with ( + deferred_decision_audit(context), + self.application.database.transaction(consistent_snapshot=True) + if connection is None + else self.application.database.connection(connection) as connection, + ): + policy_context = await self.application.security.directory_context(connection, self.scope_id, context) + directory = self.application.artifacts.atomic_memory_directory + if directory is None: + raise AtomicMemoryDirectoryUnavailableError + return await directory.query( + connection, + query, + subject=self.application.security.subject(context), + policy_context=policy_context, + # Qualified Scope read and explicit trusted-local both permit + # every row. Per-Artifact-only grants are not a supported mode. + eligibility=true(), + ) + async def get(self, artifact_id: str, *, revision: int | None = None, context=None) -> AtomicMemoryRecord: # Builtin authority shares the business snapshot. Configured external # providers keep their own decision boundary. Audit writes are flushed diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index b2c0cd1b99..4a47540fbd 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import json import logging import os @@ -95,13 +96,16 @@ UsageReportingEmbeddingModel, UsageReportingStructuredGenerator, ) +from powercontext.builtin.persistence.atomic_memory_directory import AtomicMemoryDirectoryRepository from powercontext.builtin.persistence.atomic_memory_index import AtomicMemoryIndexCapabilities from powercontext.builtin.persistence.atomic_memory_readiness import ( assert_atomic_memory_migration_ready, ensure_supported_atomic_memory_schema, ) from powercontext.builtin.persistence.bootstrap_receipts import BootstrapReceiptRepository +from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec from powercontext.builtin.persistence.experience_index import ensure_artifact_head_searchable_text +from powercontext.builtin.persistence.migrations.deployment import directory_readiness from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex from powercontext.builtin.persistence.oceanbase.experience_index import OceanBaseExperienceFTSIndex from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig, OceanBaseProfile @@ -994,6 +998,14 @@ async def open_builtin_contexts( database = config.database configured_token_estimator = character_token_estimator() if token_estimator is None else token_estimator if isinstance(database, SQLiteConfig): + directory_state = None if _topic_memory_worker else await asyncio.to_thread(directory_readiness, database) + directory = ( + None + if directory_state is None + else AtomicMemoryDirectoryRepository( + SignedCursorCodec(secret=cursor_secret), available=directory_state and embedding_model is None + ) + ) experience_index = SQLiteExperienceFTSIndex() topic_indexes: list[TopicMemoryIndex] = [SQLiteTopicMemoryFTSIndex()] if embedding_model is not None: @@ -1029,6 +1041,7 @@ async def open_builtin_contexts( database=profile.database, topic_memory_index=topic_index, atomic_memory_index=atomic_index, + atomic_memory_directory=directory, atomic_memory_preview_signing_secret=None if config.runtime.atomic_memory_preview_signing_secret is None else config.runtime.atomic_memory_preview_signing_secret.get_secret_value().encode("utf-8"), diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 73708495a6..70eb7597fc 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -130,6 +130,7 @@ ) from powercontext.builtin.persistence.artifact_readers import TopicMemoryArtifactListReader from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.atomic_memory_directory import AtomicMemoryDirectoryRepository from powercontext.builtin.persistence.atomic_memory_index import AtomicMemoryIndex from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository @@ -465,6 +466,7 @@ def __init__( database: AsyncDatabase, topic_memory_index: TopicMemoryIndex | None = None, atomic_memory_index: AtomicMemoryIndex | None = None, + atomic_memory_directory: AtomicMemoryDirectoryRepository | None = None, atomic_memory_preview_signing_secret: bytes | None = None, atomic_memory_preview_signing_key_id: str = "atomic-memory-v1", atomic_memory_preview_ttl_seconds: int = 300, @@ -508,6 +510,7 @@ def __init__( artifact_repository = ArtifactRepository( (Handoff, AtomicMemory, Experience, Skill, Profile, Prompt, TopicMemory), sources=source_repository, + atomic_memory_directory=atomic_memory_directory, ) if atomic_memory_index is None: if database.engine.dialect.name == "sqlite": @@ -556,7 +559,7 @@ def __init__( self.repositories = _Repositories( sources=source_repository, artifacts=artifact_repository, - governance=ArtifactGovernanceRepository(), + governance=ArtifactGovernanceRepository(atomic_memory_directory=atomic_memory_directory), candidates=CandidateRepository({ Experience.family: ExperienceContent, Skill.family: SkillContent, diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index e199783e0d..f9a996f38f 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -967,6 +967,12 @@ async def search_atomic_memory( ) -> atomic_models.SearchAtomicMemoryResponse: return await self._request(atomic_operations.SEARCH_ATOMIC_MEMORY, request) + async def query_atomic_memory_directory( + self, request: atomic_models.QueryAtomicMemoryDirectoryRequest + ) -> atomic_models.QueryAtomicMemoryDirectoryResponse: + """Read compact exact references; restart pagination when the directory changes.""" + return await self._request(atomic_operations.QUERY_ATOMIC_MEMORY_DIRECTORY, request) + async def merge_atomic_memories( self, request: atomic_models.MergeAtomicMemoryRequest ) -> atomic_models.AtomicMemoryMutationResponse: diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index b0198916e9..9bb47deb48 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -70,6 +70,7 @@ ArtifactTagSet, ArtifactTagTarget, AssignableSubjectType, + AtomicMemoryDirectoryItem, AtomicMemoryInput, AtomicMemoryLifecycleRequest, AtomicMemoryMutationResponse, @@ -315,6 +316,8 @@ PublishRemoteSkillRequest, PutProfilePolicyRequest, QueryArtifactTagsRequest, + QueryAtomicMemoryDirectoryRequest, + QueryAtomicMemoryDirectoryResponse, ReadinessResponse, ReadinessStatus, RecallTokenDay, @@ -514,6 +517,7 @@ "ArtifactTagSet", "ArtifactTagTarget", "AssignableSubjectType", + "AtomicMemoryDirectoryItem", "AtomicMemoryInput", "AtomicMemoryLifecycleRequest", "AtomicMemoryMutationResponse", @@ -759,6 +763,8 @@ "PublishRemoteSkillRequest", "PutProfilePolicyRequest", "QueryArtifactTagsRequest", + "QueryAtomicMemoryDirectoryRequest", + "QueryAtomicMemoryDirectoryResponse", "ReadinessResponse", "ReadinessStatus", "RecallTokenDay", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 7111a3050e..1a966df693 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3267,6 +3267,39 @@ class AccessAuditPage(BaseModel): next_cursor: Annotated[StrictStr | None, Field(max_length=2048)] +class QueryAtomicMemoryDirectoryRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + states: Annotated[list[AtomicMemoryState], Field(max_length=4, min_length=1)] = [AtomicMemoryState.ACTIVE] + kind: Annotated[StrictStr | None, Field(max_length=128, min_length=1, pattern=".*\\S.*")] = None + tags: Annotated[list[Tag2], Field(max_length=16, validate_default=True)] = [] + tag_match: TagMatch = TagMatch.ALL + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class AtomicMemoryDirectoryItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + kind: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + state: AtomicMemoryState + state_version: Annotated[StrictInt, Field(ge=0)] + merged_into_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + + +class QueryAtomicMemoryDirectoryResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + items: Annotated[list[AtomicMemoryDirectoryItem], Field(max_length=100)] + next_cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] + + class AtomicMemoryWriteContent(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index d7aeb4d78d..3232f14e23 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -138,6 +138,8 @@ PublishRemoteSkillRequest, PutProfilePolicyRequest, QueryArtifactTagsRequest, + QueryAtomicMemoryDirectoryRequest, + QueryAtomicMemoryDirectoryResponse, ReadinessResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, @@ -241,6 +243,33 @@ class AccessRequirement(BaseModel): resolver: str +QUERY_ATOMIC_MEMORY_DIRECTORY = Operation[QueryAtomicMemoryDirectoryRequest, QueryAtomicMemoryDirectoryResponse]( + method="POST", + path="/v1/atomic-memory/directory/query", + operation_id="query_atomic_memory_directory", + request_type=QueryAtomicMemoryDirectoryRequest, + request_location="body", + path_parameters=(), + response_type=QueryAtomicMemoryDirectoryResponse, + success_status=200, + summary="Query compact current Atomic Memory references", + tags=("atomic-memory",), + scope_mode="current", + responses={ + 200: {"description": "Compact current directory page."}, + 400: {"description": "The cursor is invalid or its query, identity, policy or directory generation changed."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 410: {"description": "The cursor expired; restart from the first page."}, + 413: {"description": "A compact item exceeds the response budget; no partial page is returned."}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + LIST_ATOMIC_MEMORIES = Operation[ListAtomicMemoryRequest, ListAtomicMemoryResponse]( method="POST", path="/v1/atomic-memory/list", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index bf86682eb0..4873a46981 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -10,6 +10,94 @@ "version": "1.3.0", }, "paths": { + "/v1/atomic-memory/directory/query": { + "post": { + "tags": ["atomic-memory"], + "summary": "Query compact current Atomic Memory references", + "description": "Returns current metadata " + "and exact revisions " + "without loading Memory " + "text or history. Any " + "directory or tag change " + "invalidates " + "continuation; restart " + "from the first page on " + "invalid_cursor or " + "cursor_expired. Requires " + "verified directory " + "migration and writer " + "composition, and a " + "supported local " + "authorization " + "configuration. " + "Production enablement is " + "unavailable until the " + "unified migration bundle " + "manages the complete " + "target. Unavailable " + "codes include " + "artifact_owner_pending, " + "atomic_memory_directory_authorization_unavailable " + "and " + "atomic_memory_directory_unavailable; " + "do not treat failure as " + "an empty page.", + "operationId": "query_atomic_memory_directory", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/QueryAtomicMemoryDirectoryRequest"} + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Compact current directory page.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/QueryAtomicMemoryDirectoryResponse"} + } + }, + }, + "400": { + "description": "The " + "cursor " + "is " + "invalid " + "or " + "its " + "query, " + "identity, " + "policy " + "or " + "directory " + "generation " + "changed.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "410": { + "description": "The cursor expired; restart from the first page.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, + "413": { + "description": "A compact item exceeds the response budget; no partial page is returned.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "current", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + } + }, "/v1/atomic-memory/list": { "post": { "tags": ["atomic-memory"], @@ -11594,6 +11682,64 @@ "type": "object", "required": ["items", "next_cursor"], }, + "QueryAtomicMemoryDirectoryRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "states": { + "items": {"$ref": "#/components/schemas/AtomicMemoryState"}, + "type": "array", + "maxItems": 4, + "minItems": 1, + "uniqueItems": True, + "default": ["active"], + }, + "kind": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 16, + "default": [], + }, + "tag_match": {"$ref": "#/components/schemas/TagMatch", "default": "all"}, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "AtomicMemoryDirectoryItem": { + "properties": { + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "kind": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "state": {"$ref": "#/components/schemas/AtomicMemoryState"}, + "state_version": {"type": "integer", "minimum": 0.0}, + "merged_into_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["artifact", "kind", "state", "state_version", "merged_into_id"], + }, + "QueryAtomicMemoryDirectoryResponse": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "items": { + "items": {"$ref": "#/components/schemas/AtomicMemoryDirectoryItem"}, + "type": "array", + "maxItems": 100, + }, + "next_cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "items", "next_cursor"], + }, }, "responses": { "BadRequest": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 5116b2519d..eeeeb132b5 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -153,6 +153,11 @@ ArtifactLifecycleState, InvalidArtifactLifecycleError, ) +from powercontext.builtin.persistence.atomic_memory_directory import ( + AtomicMemoryDirectoryAuthorizationUnavailableError, + AtomicMemoryDirectoryItemTooLargeError, + AtomicMemoryDirectoryUnavailableError, +) from powercontext.builtin.persistence.atomic_memory_identity import legacy_entry_artifact_id from powercontext.builtin.persistence.atomic_memory_index import AtomicMemoryIndexError from powercontext.builtin.persistence.errors import ( @@ -3326,6 +3331,8 @@ def _atomic_execution_context(request: Request, operation: str) -> ArtifactSearc if access is None: if getattr(application, "atomic_memory", None) is None: raise BaseOperationNotSupportedError("artifact_family", "atomic-memory", "runtime application") + if operation == "query_atomic_memory_directory": + return ArtifactSearchExecutionContext(trusted_local=True) return None return ArtifactSearchExecutionContext( principal=_require_principal(), access=access, audit=_access_audit_context(operation) @@ -5385,6 +5392,9 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: access_error = _map_access_error(error) if access_error is not None: return access_error + directory_error = _map_atomic_directory_error(error) + if directory_error is not None: + return directory_error if isinstance(error, (DreamError, EvidenceResolutionError)): statuses = { "idempotency_conflict": 409, @@ -5575,6 +5585,31 @@ def _map_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | return None +def _map_atomic_directory_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, AtomicMemoryDirectoryAuthorizationUnavailableError): + return ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "atomic_memory_directory_authorization_unavailable", + "The access configuration does not support safe directory queries.", + None, + ) + if isinstance(error, AtomicMemoryDirectoryUnavailableError): + return ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "atomic_memory_directory_unavailable", + "The Atomic Memory directory is not ready.", + None, + ) + if isinstance(error, AtomicMemoryDirectoryItemTooLargeError): + return ( + status.HTTP_413_CONTENT_TOO_LARGE, + "atomic_memory_directory_item_too_large", + "An Atomic Memory directory item exceeds the response budget.", + None, + ) + return None + + def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, SourceNotEligibleError): return ( diff --git a/src/powercontext/server/atomic_memory.py b/src/powercontext/server/atomic_memory.py index 6068a762a9..460dc1a3b0 100644 --- a/src/powercontext/server/atomic_memory.py +++ b/src/powercontext/server/atomic_memory.py @@ -42,7 +42,10 @@ def _tag_filter(request): if request.tag_match is not None: raise InvalidBaseAccessRequestError("tag_match", "requires tags") return None - return TagFilter(tags=tuple(request.tags), match="all" if request.tag_match is None else request.tag_match.value) + return TagFilter( + tags=tuple(tag.root for tag in request.tags), + match="all" if request.tag_match is None else request.tag_match.value, + ) def _scoped(application, scope_id): @@ -98,6 +101,21 @@ async def list_atomic_memories( items=[record_response(item) for item in result.items], next_cursor=result.next_cursor ) + async def query_atomic_memory_directory( + request: models.QueryAtomicMemoryDirectoryRequest, + http_request: Request, + application: Annotated[Any, Depends(application_dependency)], + ) -> models.QueryAtomicMemoryDirectoryResponse: + result = await _scoped(application, request.scope_id).query_directory( + states=tuple(state.value for state in request.states), + kind=request.kind, + tag_filter=None if not request.tags else _tag_filter(request), + limit=request.limit, + cursor=request.cursor, + context=execution_context(http_request, "query_atomic_memory_directory"), + ) + return models.QueryAtomicMemoryDirectoryResponse.model_validate(result.model_dump(mode="json")) + async def search_atomic_memory( request: models.SearchAtomicMemoryRequest, http_request: Request, @@ -224,6 +242,7 @@ async def restore_atomic_memory( for operation, endpoint in ( (operations.GET_ATOMIC_MEMORY_STATE, get_atomic_memory_state), (operations.LIST_ATOMIC_MEMORIES, list_atomic_memories), + (operations.QUERY_ATOMIC_MEMORY_DIRECTORY, query_atomic_memory_directory), (operations.SEARCH_ATOMIC_MEMORY, search_atomic_memory), (operations.MERGE_ATOMIC_MEMORIES, merge_atomic_memories), (operations.CHANGE_ATOMIC_MEMORY_LIFECYCLE, change_atomic_memory_lifecycle), diff --git a/src/powercontext/server/authz/casbin.py b/src/powercontext/server/authz/casbin.py index 9bc4b8f9f8..8b35cfb66f 100644 --- a/src/powercontext/server/authz/casbin.py +++ b/src/powercontext/server/authz/casbin.py @@ -93,6 +93,7 @@ async def check_batch(self, requests: Sequence[AccessRequest], /) -> tuple[Acces artifact_resources=tuple( request.resource for request in requests if request.resource.type is AccessResourceType.ARTIFACT ), + decision_resources=tuple(request.resource for request in requests), ) revision = contextual_policy_revision(state.policy_revision, requests[0].context.subject_groups) bindings = state.bindings diff --git a/src/powercontext/server/authz/repository.py b/src/powercontext/server/authz/repository.py index 85013c091b..20c945a296 100644 --- a/src/powercontext/server/authz/repository.py +++ b/src/powercontext/server/authz/repository.py @@ -32,7 +32,10 @@ Table, Text, UniqueConstraint, + and_, delete, + exists, + func, insert, or_, select, @@ -42,7 +45,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.database import AsyncDatabase -from powercontext.builtin.persistence.tables import identity_string +from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, identity_string from powercontext.limits import ( MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, @@ -611,6 +614,55 @@ def with_connection(self, connection: AsyncConnection) -> RelationalAccessReposi raise AccessUnavailableError("transactional_relationships_unavailable") return RelationalAccessRepository(self._database, connection=connection) + async def require_scope_content_ready(self, scope_id: str) -> None: + """Fail closed for any missing formal owner without returning the Scope catalog. + + Match the collection catalog's exclusions: legacy Memory is outside the + runtime, and Topic Memory uses Scope ownership. No lifecycle or page + filter may hide an unowned identity elsewhere in the Scope. + """ + + heads = ARTIFACT_HEADS_TABLE + owners = ACCESS_OWNERS_TABLE + formal_owner = exists( + select(1).where( + owners.c.owner_kind == "artifact", + owners.c.object_key_hash + == func.pc_access_artifact_key_hash(heads.c.scope_id, heads.c.family, heads.c.artifact_id), + owners.c.scope_id == heads.c.scope_id, + owners.c.family == heads.c.family, + owners.c.artifact_id == heads.c.artifact_id, + owners.c.selector_type.is_(None), + owners.c.selector_entry_id.is_(None), + ) + ) + async with self._database.connection(self._bound_connection) as connection: + if connection.dialect.name != "sqlite" or connection.dialect.driver != "aiosqlite": + raise AccessUnavailableError("safe_resource_filtering_unavailable") + # Hash the exact canonical ResourceRef through the existing owner + # key authority. SQLite has no native SHA-256. This connection-local + # function enables primary-key probes and does not write schema/data. + await connection.run_sync( + lambda sync: sync.connection.run_async( + lambda driver: driver.create_function( + "pc_access_artifact_key_hash", + 3, + lambda scope, family, identity: _digest( + ResourceRef.artifact(scope, family=family, artifact_id=identity).key + ), + deterministic=True, + ) + ) + ) + missing = await connection.scalar( + select(1) + .select_from(heads) + .where(heads.c.scope_id == scope_id, heads.c.family.not_in(("memory", "topic-memory")), ~formal_owner) + .limit(1) + ) + if missing is not None: + raise AccessUnavailableError("artifact_owner_pending") + async def decision_snapshot( self, subjects: Sequence[AccessSubjectRef], @@ -618,6 +670,21 @@ async def decision_snapshot( now: datetime, artifact_resources: Sequence[ResourceRef] = (), owned_by: PrincipalRef | None = None, + ) -> DecisionState: + """Preserve the public snapshot SPI, including repository overrides.""" + + return await self._decision_snapshot( + subjects, now=now, artifact_resources=artifact_resources, owned_by=owned_by + ) + + async def _decision_snapshot( + self, + subjects: Sequence[AccessSubjectRef], + *, + now: datetime, + artifact_resources: Sequence[ResourceRef] = (), + owned_by: PrincipalRef | None = None, + decision_resources: Sequence[ResourceRef] | None = None, ) -> DecisionState: """Read revision, active bindings and ownership from one snapshot. @@ -642,24 +709,32 @@ async def decision_snapshot( await self._pin_read_snapshot(connection) revision = await self._read_policy_revision(connection) if subjects: - binding_rows = ( - ( - await connection.execute( - select(ACCESS_BINDINGS_TABLE).where( - or_( - *( - (ACCESS_BINDINGS_TABLE.c.subject_type == subject.type) - & (ACCESS_BINDINGS_TABLE.c.subject_id == subject.id) - for subject in subjects - ) - ), - ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, - ) + statement = select(ACCESS_BINDINGS_TABLE).where( + or_( + *( + (ACCESS_BINDINGS_TABLE.c.subject_type == subject.type) + & (ACCESS_BINDINGS_TABLE.c.subject_id == subject.id) + for subject in subjects ) - ) - .mappings() - .all() + ), + ACCESS_BINDINGS_TABLE.c.state == AccessBindingState.ACTIVE.value, ) + if decision_resources is not None: + # A point check needs only exact resources and their Scope + # parents. Keep Server rows for existing inheritance rules. + resources = {resource.key: resource for resource in decision_resources} + resources.update({ + parent.key: parent + for resource in decision_resources + if (parent := resource.parent_scope) is not None + }) + statement = statement.where( + or_( + ACCESS_BINDINGS_TABLE.c.resource_type == "server", + *(and_(*_resource_predicates(ACCESS_BINDINGS_TABLE, item)) for item in resources.values()), + ) + ) + binding_rows = (await connection.execute(statement)).mappings().all() if artifact_resources: owner_rows = ( ( diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index c1078a0ec3..42698af4c7 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -374,13 +374,14 @@ async def decision_snapshot( _SNAPSHOT_RETRY_LIMIT = 3 -async def read_decision_state( +async def read_decision_state( # noqa: C901 repository: AccessRepository, subjects: Sequence[AccessSubjectRef], *, now: datetime, artifact_resources: Sequence[ResourceRef] = (), owned_by: PrincipalRef | None = None, + decision_resources: Sequence[ResourceRef] | None = None, ) -> DecisionState: """Read decision inputs from one snapshot when the repository offers one. @@ -399,6 +400,19 @@ async def read_decision_state( last_error: AccessUnavailableError | None = None for _ in range(_SNAPSHOT_RETRY_LIMIT): try: + if decision_resources is not None: + from powercontext.server.authz.repository import RelationalAccessRepository + + # Only the canonical adapter promises point-resource + # selection. External/subclass snapshot contracts are kept. + if type(repository) is RelationalAccessRepository: + return await repository._decision_snapshot( + subjects, + now=now, + artifact_resources=artifact_resources, + owned_by=owned_by, + decision_resources=decision_resources, + ) return await snapshot_reader( subjects, now=now, artifact_resources=artifact_resources, owned_by=owned_by ) @@ -505,6 +519,7 @@ async def check_batch( artifact_resources=tuple( request.resource for request in requests if request.resource.type is AccessResourceType.ARTIFACT ), + decision_resources=tuple(request.resource for request in requests), ) revision = contextual_policy_revision(state.policy_revision, requests[0].context.subject_groups) bindings = state.bindings @@ -660,6 +675,54 @@ async def require_scope_read(self, principal, scope_id: str, *, connection, cont access._deferred_decisions = self._deferred_decisions return await access.require(principal, AccessAction.SCOPE_READ, ResourceRef.scope(scope_id), context=context) + async def require_scope_directory_read( + self, principal, scope_id: str, *, connection, context: AccessAuditContext + ) -> str: + """Qualify a Scope-wide SQL directory without replacing custom policy or audit. + + A successful Scope read permits all catalog rows, so no per-object + authorization scan is necessary. Formal ownership remains a separate, + Scope-wide fail-closed precondition in the same database snapshot. + Return the policy/identity context digest owned by this authority. + """ + + decision = await self.require_scope_read(principal, scope_id, connection=connection, context=context) + from powercontext.server.authz.repository import RelationalAccessRepository + + provider = self.provider + canonical = type(provider) is BuiltinAuthorizationProvider + if not canonical and type(provider).__module__ == "powercontext.server.authz.casbin": + from powercontext.server.authz.casbin import CasbinAuthorizationProvider + + canonical = type(provider) is CasbinAuthorizationProvider + policy_repository = getattr(provider, "_repository", None) + relationships = self._relationship_reader() + if ( + not self.provider_capabilities.safe_resource_filtering + or not canonical + or type(policy_repository) is not RelationalAccessRepository + or type(relationships) is not RelationalAccessRepository + or decision.policy_revision is None + ): + raise AccessUnavailableError("safe_resource_filtering_unavailable") + # Verify both authorities belong to the caller's database. Different + # audit storage remains supported through the original access service. + policy_repository.with_connection(connection) + await relationships.with_connection(connection).require_scope_content_ready(scope_id) + return hashlib.sha256( + json.dumps( + { + "provider": f"{type(provider).__module__}.{type(provider).__qualname__}", + "deployment": self.deployment_id, + "revision": decision.policy_revision, + "groups": sorted({group.id for group in context.subject_groups}), + }, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ).encode() + ).hexdigest() + async def bootstrap_subject_scope(self, connection, principal, scope_id, *, context): """Grant only a newly created ordinary Scope, in the Source transaction.""" from powercontext.server.authz.repository import RelationalAccessRepository diff --git a/src/powercontext/server/database_migration.py b/src/powercontext/server/database_migration.py index d813cff2f9..22672f042a 100644 --- a/src/powercontext/server/database_migration.py +++ b/src/powercontext/server/database_migration.py @@ -37,6 +37,12 @@ from powercontext.service.model import ServiceError BackupPolicy = Literal["auto", "manual", "skip"] +MigrationTarget = Annotated[ + Literal["atomic-memory-directory"] | None, + typer.Option( + help="Select the registered current SQLite directory target; default is the four-table acceptance target." + ), +] EnvFile = Annotated[Path | None, typer.Option(help="Read the same environment file as the deployed Server.")] EvidenceDir = Annotated[ Path | None, @@ -72,18 +78,24 @@ def main() -> None: @app.command() def status( - env_file: EnvFile = None, evidence_dir: EvidenceDir = None, lock_coordination: LockCoordination = None + env_file: EnvFile = None, + evidence_dir: EvidenceDir = None, + lock_coordination: LockCoordination = None, + target: MigrationTarget = None, ) -> None: """Inspect the schema version without creating a database or control tables.""" - _inspect(env_file, evidence_dir=evidence_dir, lock_coordination=lock_coordination, verify=False) + _inspect(env_file, evidence_dir=evidence_dir, lock_coordination=lock_coordination, verify=False, target=target) @app.command() def verify( - env_file: EnvFile = None, evidence_dir: EvidenceDir = None, lock_coordination: LockCoordination = None + env_file: EnvFile = None, + evidence_dir: EvidenceDir = None, + lock_coordination: LockCoordination = None, + target: MigrationTarget = None, ) -> None: """Verify database readiness; this does not assert Server or cluster readiness.""" - _inspect(env_file, evidence_dir=evidence_dir, lock_coordination=lock_coordination, verify=True) + _inspect(env_file, evidence_dir=evidence_dir, lock_coordination=lock_coordination, verify=True, target=target) @app.command() @@ -94,10 +106,13 @@ def plan( backup: Annotated[BackupPolicy, typer.Option(help="Bind the selected backup policy to the plan.")] = "auto", manage_service: ManageService = False, shared_database: SharedDatabase = False, + target: MigrationTarget = None, ) -> None: """Preview the target, resource digests, backup policy, and maintenance scope.""" with _operator_errors(), server_settings_context(env_file=_environment_file(env_file)) as settings: - runner = deployment_runner(settings, evidence_dir=evidence_dir, lock_coordination=lock_coordination) + runner = deployment_runner( + settings, evidence_dir=evidence_dir, lock_coordination=lock_coordination, target=target + ) service = _service_scope(_environment_file(env_file)) if manage_service else None reviewed = runner.plan(backup_policy=backup, service=service, shared_database=shared_database) _write_payload(reviewed) @@ -137,6 +152,7 @@ def apply( backup_ref: Annotated[str | None, typer.Option(help="Optional reference for the operator's manual backup.")] = None, manage_service: ManageService = False, shared_database: SharedDatabase = False, + target: MigrationTarget = None, ) -> None: """Review once, then stop writes, back up, migrate, verify, and optionally switch a local service.""" with _operator_errors(), server_settings_context(env_file=_environment_file(env_file)) as settings: @@ -145,6 +161,7 @@ def apply( evidence_dir=evidence_dir, lock_coordination=lock_coordination, acknowledge_previous_run=acknowledge_previous_run, + target=target, ) policy: BackupPolicy = backup or "auto" initial = runner.plan(backup_policy=policy, shared_database=shared_database) @@ -199,9 +216,12 @@ def _inspect( evidence_dir: Path | None, lock_coordination: Literal["single-host"] | None, verify: bool, + target: Literal["atomic-memory-directory"] | None, ) -> None: with _operator_errors(), server_settings_context(env_file=_environment_file(env_file)) as settings: - runner = deployment_runner(settings, evidence_dir=evidence_dir, lock_coordination=lock_coordination) + runner = deployment_runner( + settings, evidence_dir=evidence_dir, lock_coordination=lock_coordination, target=target + ) result = runner.verify() if verify else runner.plan() _write_payload(result) diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 0cf9ca8955..e6dab8aa73 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -112,7 +112,10 @@ Ordinary coding needs no routine Memory calls. Use sufficient current context when continuing work. For an explicit memory search (search my memories / 搜索记忆), call search_memory with a focused query, mode auto, and at most eight hits. search_memory and list_memory_entries return current Atomic Memory records with real ArtifactRef and state_version. -Use list_atomic_memories for explicit state filters and pagination. get_memory_entry accepts only a legacy logical +Use list_atomic_memories when full records are needed. query_atomic_memory_directory browses compact exact references +and states without content. Keep its filters and limit unchanged on continuation; if a cursor is invalid or expired, +restart at page one. Read directory content with get_artifact_revision using the returned exact reference. +get_memory_entry accepts only a legacy logical target; it returns current Atomic Memory and does not follow merged_into_id automatically. Historical citations are unsupported. For new identities use get_artifact for the current content and get_artifact_revision for exact historical content. @@ -152,6 +155,7 @@ """ _MCP_OPERATION_IDS = frozenset({ "get_atomic_memory_state", + "query_atomic_memory_directory", "list_atomic_memories", "search_atomic_memory", "merge_atomic_memories", @@ -208,6 +212,7 @@ }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ "get_atomic_memory_state", + "query_atomic_memory_directory", "list_atomic_memories", "search_atomic_memory", "preview_atomic_memory_restoration", diff --git a/tests/builtin/persistence/test_atomic_memory_directory.py b/tests/builtin/persistence/test_atomic_memory_directory.py new file mode 100644 index 0000000000..f43092f3e0 --- /dev/null +++ b/tests/builtin/persistence/test_atomic_memory_directory.py @@ -0,0 +1,811 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compact current-directory storage, maintenance and shared authorization contracts.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from pathlib import Path + +import pytest +from pydantic import ValidationError +from sqlalchemy import BigInteger, Column, MetaData, event, insert, select, true, update + +from powercontext.artifacts import ArtifactAddress +from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemory, AtomicMemoryContent, AtomicMemoryDraft +from powercontext.builtin.artifacts.atomic_memory.service import AtomicMemoryMergeAdapter +from powercontext.builtin.artifacts.merge import ArtifactMergeService +from powercontext.builtin.persistence import atomic_memory_directory as directory +from powercontext.builtin.persistence.artifact_governance import ArtifactGovernanceRepository, ArtifactLifecycleState +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.atomic_memory import AtomicMemoryStateRepository +from powercontext.builtin.persistence.atomic_memory_directory import ( + AtomicMemoryDirectoryItemTooLargeError, + AtomicMemoryDirectoryQuery, + AtomicMemoryDirectoryRepository, + AtomicMemoryDirectoryUnavailableError, +) +from powercontext.builtin.persistence.atomic_memory_index import PreparedAtomicMemoryProjection +from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import ( + ARTIFACT_HEADS_TABLE, + ARTIFACT_TAGS_TABLE, + ARTIFACTS_TABLE, + SCOPE_TABLES, + SHARED_TABLES, + identity_string, +) +from powercontext.builtin.persistence.tags import RelationalTagService +from powercontext.builtin.records import CursorExpiredError, InvalidCursorError +from powercontext.builtin.runtime.artifact_merge import ArtifactMergeApplication, merge_artifact_tags +from powercontext.builtin.sources.content import CONTENT_SOURCE_ADAPTER +from powercontext.builtin.tags import ArtifactTagTarget, TagFilter + + +@asynccontextmanager +async def directory_profile(path: Path | None = None, *, extra_tables=()): + # A disposable target-schema fixture, not a startup/migration implementation. + metadata = MetaData() + for shared in (*SHARED_TABLES, *SCOPE_TABLES, ARTIFACT_TAGS_TABLE, *extra_tables): + shared.to_metadata(metadata) + scopes = metadata.tables["pc_scopes"] + scopes.append_column(Column("atomic_memory_directory_generation", BigInteger, nullable=False, server_default="0")) + heads = metadata.tables["pc_artifact_heads"] + heads.append_column(Column("content_kind", identity_string(128))) + config = SQLiteConfig() if path is None else SQLiteConfig(url=f"sqlite+aiosqlite:///{path}") + async with SQLiteProfile.open(config, tables=tuple(metadata.sorted_tables)) as profile: + async with profile.database.transaction() as connection: + await connection.execute( + insert(scopes).values( + scope_id="scope-a", + title="A", + summary="A", + scope_id_search="scope-a", + title_search="a", + summary_search="a", + version=1, + ) + ) + yield profile, scopes, heads + + +async def publish(connection, reader, identity="a", *, kind="rule", text="Keep this rule."): + return await ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader).create( + connection, "scope-a", identity, AtomicMemoryDraft(content=AtomicMemoryContent(kind=kind, text=text)) + ) + + +async def query(connection, reader, *, eligibility=None, subject="user:a", policy_context="fixture-local", **kwargs): + # true() is an explicit trusted storage fixture; it does not qualify authz support. + return await reader.query( + connection, + AtomicMemoryDirectoryQuery(scope_id="scope-a", **kwargs), + subject=subject, + policy_context=policy_context, + eligibility=true() if eligibility is None else eligibility, + ) + + +def test_exact_revision_directory_completes_and_content_change_requires_restart(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, _, _): + async with profile.database.transaction() as connection: + original = await publish(connection, reader, "a") + await publish(connection, reader, "b") + await publish(connection, reader, "c") + async with profile.database.transaction(consistent_snapshot=True) as connection: + first = await query(connection, reader, limit=2) + last = await query(connection, reader, limit=2, cursor=first.next_cursor) + assert [item.artifact.artifact_id for item in first.items + last.items] == ["a", "b", "c"] + assert first.items[0].artifact == original.as_ref() + assert first.next_cursor is not None and last.next_cursor is None + assert "text" not in first.items[0].model_dump() + async with profile.database.transaction() as connection: + revised = await ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader).revise( + connection, + "scope-a", + original, + AtomicMemoryDraft(content=AtomicMemoryContent(kind="preference", text="Use short answers.")), + ) + assert isinstance(revised, AtomicMemory) + async with profile.database.transaction(consistent_snapshot=True) as connection: + with pytest.raises(InvalidCursorError): + await query(connection, reader, limit=2, cursor=first.next_cursor) + current = await query(connection, reader, kind="preference") + assert current.items[0].artifact.revision == 2 + exact = await ArtifactRepository((AtomicMemory,)).get(connection, "scope-a", original.as_ref()) + assert exact.content.text == original.content.text + + asyncio.run(scenario()) + + +def test_locked_evidence_reads_and_direct_merge_transitions_take_scope_lock_first(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, scopes, _), profile.database.transaction() as connection: + artifact = await publish(connection, reader, "a") + repository = ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader) + governance = ArtifactGovernanceRepository(atomic_memory_directory=reader) + before = await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) + statements = [] + + def observe(_connection, _cursor, statement, _parameters, _context, _many): + statements.append(statement.lower().lstrip()) + + event.listen(profile.database.engine.sync_engine, "before_cursor_execute", observe) + try: + for read in ( + lambda: repository.latest(connection, "scope-a", "atomic-memory", "a", for_update=True), + lambda: repository.get(connection, "scope-a", artifact.as_ref(), for_update=True), + ): + statements.clear() + assert await read() == artifact + assert statements[0].startswith("update pc_scopes") + current = await governance.get(connection, "scope-a", "atomic-memory", "a") + statements.clear() + await governance.transition_merge( + connection, "scope-a", "atomic-memory", "a", current, ArtifactLifecycleState.ACTIVE + ) + assert statements[0].startswith("update pc_scopes") + finally: + event.remove(profile.database.engine.sync_engine, "before_cursor_execute", observe) + # Locking is not an effective directory mutation, including a no-op merge. + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == before + + asyncio.run(scenario()) + + +def test_lifecycle_kind_tags_and_supplied_eligibility_are_applied_before_limit(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, _, _), profile.database.transaction() as connection: + for identity, kind in (("a", "other"), ("b", "rule"), ("c", "rule"), ("d", "rule")): + await publish(connection, reader, identity, kind=kind) + governance = ArtifactGovernanceRepository(atomic_memory_directory=reader) + current = await governance.get(connection, "scope-a", "atomic-memory", "c") + await governance.transition_merge( + connection, "scope-a", "atomic-memory", "c", current, ArtifactLifecycleState.DEPRECATED, "d" + ) + for identity in ("b", "c"): + await connection.execute( + insert(ARTIFACT_TAGS_TABLE).values( + scope_id="scope-a", + family="atomic-memory", + artifact_id=identity, + target_type="artifact", + target_id=identity, + tag_key="rule", + tag_key_hash=sha256(b"rule").digest(), + tag="Rule", + assigned_at=datetime.now(UTC), + ) + ) + await reader.record_mutation(connection, "scope-a") + selected = await query( + connection, + reader, + states=("active", "merged"), + kind="rule", + limit=1, + tag_filter=TagFilter(tags=("RULE",)), + eligibility=ARTIFACT_HEADS_TABLE.c.artifact_id != "b", + ) + assert [item.artifact.artifact_id for item in selected.items] == ["c"] + assert selected.items[0].state == "merged" + assert selected.items[0].state_version == 1 + assert selected.items[0].merged_into_id == "d" + assert selected.next_cursor is None + + asyncio.run(scenario()) + + +def test_long_normalized_filters_continue_and_context_changes_reject_cursor(): + async def scenario(): + now = [datetime(2026, 10, 10, tzinfo=UTC)] + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture", clock=lambda: now[0])) + labels = tuple(chr(0x4E00 + index) * 64 for index in range(16)) + async with directory_profile() as (profile, _, _), profile.database.transaction() as connection: + for identity in ("a", "b"): + await publish(connection, reader, identity) + await connection.execute( + insert(ARTIFACT_TAGS_TABLE), + [ + { + "scope_id": "scope-a", + "family": "atomic-memory", + "artifact_id": identity, + "target_type": "artifact", + "target_id": identity, + "tag_key": label, + "tag_key_hash": sha256(label.encode()).digest(), + "tag": label, + "assigned_at": datetime.now(UTC), + } + for label in labels + ], + ) + first = await query(connection, reader, limit=1, tag_filter=TagFilter(tags=labels)) + assert first.next_cursor is not None and len(first.next_cursor) <= 4096 + tail = await query( + connection, + reader, + limit=1, + cursor=first.next_cursor, + tag_filter=TagFilter(tags=tuple(reversed(labels))), + ) + assert tail.items[0].artifact.artifact_id == "b" and tail.next_cursor is None + for changes in ({"subject": "user:b"}, {"policy_context": "revoked"}, {"limit": 2}, {"kind": "other"}): + with pytest.raises(InvalidCursorError): + await query( + connection, + reader, + cursor=first.next_cursor, + tag_filter=TagFilter(tags=labels), + **{"limit": 1, **changes}, + ) + with pytest.raises(InvalidCursorError): + await query( + connection, reader, limit=1, tag_filter=TagFilter(tags=labels), cursor="!" + first.next_cursor[1:] + ) + now[0] += timedelta(hours=1) + with pytest.raises(CursorExpiredError): + await query(connection, reader, limit=1, tag_filter=TagFilter(tags=labels), cursor=first.next_cursor) + + asyncio.run(scenario()) + + +def test_atomic_state_noop_preserves_cursor_but_forget_and_restore_invalidate_it(): + async def scenario(): + from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryStateValue + + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + states = AtomicMemoryStateRepository(atomic_memory_directory=reader) + async with directory_profile() as (profile, scopes, _), profile.database.transaction() as connection: + await publish(connection, reader, "a") + await publish(connection, reader, "b") + first = await query(connection, reader, limit=1) + generation = await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) + original = await states.get(connection, "scope-a", "a") + unchanged = await states.transition(connection, "scope-a", "a", original, AtomicMemoryStateValue.ACTIVE) + assert unchanged == original + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == generation + assert (await query(connection, reader, limit=1, cursor=first.next_cursor)).items[ + 0 + ].artifact.artifact_id == "b" + forgotten = await states.transition(connection, "scope-a", "a", original, AtomicMemoryStateValue.FORGOTTEN) + with pytest.raises(InvalidCursorError): + await query(connection, reader, limit=1, cursor=first.next_cursor) + page = await query(connection, reader, states=("active", "forgotten"), limit=1) + assert page.items[0].state == "forgotten" + await states.transition(connection, "scope-a", "a", forgotten, AtomicMemoryStateValue.ACTIVE) + with pytest.raises(InvalidCursorError): + await query(connection, reader, states=("active", "forgotten"), limit=1, cursor=page.next_cursor) + + asyncio.run(scenario()) + + +def test_tag_replace_noop_preserves_pages_and_display_change_invalidates_unfiltered_pages(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, scopes, _): + artifacts = ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader) + tags = RelationalTagService(profile.database, artifacts) + target = ArtifactTagTarget(family="atomic-memory", artifact_id="a") + async with profile.database.transaction() as connection: + await publish(connection, reader, "a") + await publish(connection, reader, "b") + for identity in ("a", "b"): + current_target = target.model_copy(update={"artifact_id": identity}) + empty = await tags.get("scope-a", current_target) + await tags.replace("scope-a", current_target, ("Rule", "中文"), expected_etag=empty.etag) + async with profile.database.transaction(consistent_snapshot=True) as connection: + plain = await query(connection, reader, limit=1) + filtered = await query(connection, reader, limit=1, tag_filter=TagFilter(tags=("rule",))) + generation = await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) + current = await tags.get("scope-a", target) + unchanged = await tags.replace("scope-a", target, ("中文", "Rule"), expected_etag=current.etag) + async with profile.database.transaction(consistent_snapshot=True) as connection: + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == generation + assert (await query(connection, reader, limit=1, cursor=plain.next_cursor)).items + # The normalized membership is identical, but the displayed tag changed. + await tags.replace("scope-a", target, ("RULE", "中文"), expected_etag=unchanged.etag) + async with profile.database.transaction(consistent_snapshot=True) as connection: + for page, filters in ((plain, {}), (filtered, {"tag_filter": TagFilter(tags=("rule",))})): + with pytest.raises(InvalidCursorError): + await query(connection, reader, limit=1, cursor=page.next_cursor, **filters) + assert "RULE" in (await tags.get("scope-a", target)).tags + + asyncio.run(scenario()) + + +class _TrustedStorageSecurity: + """An explicit storage fixture, without production ownership or policy claims.""" + + def subject(self, context): + return context + + async def authorize(self, connection, scope_id, context, action, ref=None): + pass + + async def authorize_sources(self, connection, scope_id, context, sources): + pass + + async def establish_owner(self, connection, scope_id, artifact_id, context): + pass + + +class _StorageProjections: + """Keep search outside this test and inject a failure after a business write.""" + + fail_publication = False + + async def prepare(self, content): + return PreparedAtomicMemoryProjection( + kind=content.kind, + text=content.text, + searchable_text=content.text, + content_hash=sha256(content.text.encode()).hexdigest(), + ) + + def validate_prepared(self, prepared): + pass + + async def publish(self, connection, scope_id, record, prepared): + if self.fail_publication: + raise RuntimeError("projection publication failed") # noqa: TRY003 + + async def remove(self, connection, scope_id, artifact_id): + pass + + +def test_cascade_merge_restore_and_failed_publication_keep_directory_atomic(tmp_path): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile(tmp_path / "merge.sqlite") as (profile, scopes, _): + sources = SourceRepository((CONTENT_SOURCE_ADAPTER,)) + artifacts = ArtifactRepository((AtomicMemory,), sources=sources, atomic_memory_directory=reader) + projections = _StorageProjections() + + async def merge_tags(connection, scope_id, result_id, inputs): + await merge_artifact_tags(connection, scope_id, result_id, inputs, family="atomic-memory") + + service = ArtifactMergeService( + artifacts=artifacts, + adapter=AtomicMemoryMergeAdapter(projections), + security=_TrustedStorageSecurity(), + merge_tags=merge_tags, + ) + memory = ArtifactMergeApplication(profile.database, (service,), default_context="fixture-owner").for_scope( + "scope-a", "atomic-memory" + ) + async with profile.database.transaction() as connection: + for identity in ("a", "b", "d"): + await publish(connection, reader, identity) + async with profile.database.transaction(consistent_snapshot=True) as connection: + initial = await query(connection, reader, limit=1) + first = await memory.merge( + tuple([(await memory.get(identity)).as_read() for identity in ("a", "b")]), + AtomicMemoryContent(kind="combined", text="Keep both rules."), + artifact_id="c", + ) + await memory.merge( + (first.primary.as_read(), (await memory.get("d")).as_read()), + AtomicMemoryContent(kind="summary", text="Keep all three rules."), + artifact_id="e", + ) + async with profile.database.transaction(consistent_snapshot=True) as connection: + with pytest.raises(InvalidCursorError): + await query(connection, reader, limit=1, cursor=initial.next_cursor) + active = await query(connection, reader) + assert [(item.artifact.artifact_id, item.kind) for item in active.items] == [("e", "summary")] + before = await query(connection, reader, states=("active", "merged"), limit=1) + generation = await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) + projections.fail_publication = True + with pytest.raises(RuntimeError, match="projection publication failed"): + await memory.restore("b") + async with profile.database.transaction(consistent_snapshot=True) as connection: + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == generation + assert (await query(connection, reader)).items == active.items + assert ( + await query(connection, reader, states=("active", "merged"), limit=1, cursor=before.next_cursor) + ).items + projections.fail_publication = False + restored = await memory.restore("b") + assert restored.undo_merge_results == ("e", "c") + async with profile.database.transaction(consistent_snapshot=True) as connection: + with pytest.raises(InvalidCursorError): + await query(connection, reader, states=("active", "merged"), limit=1, cursor=before.next_cursor) + final = await query(connection, reader, states=("active", "retired")) + assert {item.artifact.artifact_id: (item.state.value, item.kind) for item in final.items} == { + "a": ("active", "rule"), + "b": ("active", "rule"), + "c": ("retired", "combined"), + "d": ("active", "rule"), + "e": ("retired", "summary"), + } + assert all(item.artifact.revision == 2 for item in final.items) + generation = await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) + assert not (await memory.restore("a")).changed + async with profile.database.transaction(consistent_snapshot=True) as connection: + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == generation + + asyncio.run(scenario()) + + +def test_copy_exact_maintains_kind_and_overflow_rolls_back_business_publication(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + artifacts = ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader) + async with directory_profile() as (profile, scopes, _): + async with profile.database.transaction() as connection: + original = await publish(connection, reader) + copied = await artifacts.copy_exact( + connection, + "scope-a", + "copy", + ArtifactAddress(scope_id="scope-a", artifact=original.as_ref()), + original, + sha256(original.content.model_dump_json().encode()).hexdigest(), + ) + page = await query(connection, reader) + assert page.items[1].artifact == copied.as_ref() and page.items[1].kind == original.content.kind + await connection.execute(update(scopes).values(atomic_memory_directory_generation=(1 << 63) - 1)) + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + async with profile.database.transaction() as connection: + await publish(connection, reader, "never-committed") + async with profile.database.transaction(consistent_snapshot=True) as connection: + assert {item.artifact.artifact_id for item in (await query(connection, reader)).items} == {"a", "copy"} + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == (1 << 63) - 1 + + asyncio.run(scenario()) + + +def test_fractional_sqlite_generation_cannot_commit_business_writes_or_be_incremented(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, scopes, heads): + async with profile.database.transaction() as connection: + await publish(connection, reader) + # SQLite BIGINT affinity and a nonnegative CHECK still admit fractions. + await connection.execute(update(scopes).values(atomic_memory_directory_generation=0.5)) + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + async with profile.database.transaction() as connection: + await publish(connection, reader, "must-not-commit") + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + async with profile.database.transaction() as connection: + await reader.record_mutation(connection, "scope-a") + async with profile.database.transaction(consistent_snapshot=True) as connection: + assert (await connection.execute(select(heads.c.artifact_id))).scalars().all() == ["a"] + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == 0.5 + + asyncio.run(scenario()) + + +def test_other_scopes_and_families_do_not_invalidate_atomic_directory(): + async def scenario(): + from tests.builtin.persistence.contract import Handoff, HandoffContent, HandoffDraft + + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + artifacts = ArtifactRepository((AtomicMemory, Handoff), atomic_memory_directory=reader) + async with directory_profile() as (profile, scopes, _), profile.database.transaction() as connection: + for identity in ("a", "b"): + await publish(connection, reader, identity) + first = await query(connection, reader, limit=1) + await connection.execute( + insert(scopes).values( + scope_id="scope-b", + title="B", + summary="B", + scope_id_search="scope-b", + title_search="b", + summary_search="b", + version=1, + ) + ) + await artifacts.create( + connection, + "scope-b", + "other-memory", + AtomicMemoryDraft(content=AtomicMemoryContent(kind="rule", text="Another Scope.")), + ) + handoff = await artifacts.create( + connection, "scope-a", "handoff", HandoffDraft(content=HandoffContent(summary="A different Family.")) + ) + await artifacts.revise( + connection, "scope-a", handoff, HandoffDraft(content=HandoffContent(summary="A revised handoff.")) + ) + assert (await query(connection, reader, limit=1, cursor=first.next_cursor)).items[ + 0 + ].artifact.artifact_id == "b" + assert ( + await connection.scalar( + select(scopes.c.atomic_memory_directory_generation).where(scopes.c.scope_id == "scope-b") + ) + == 1 + ) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("size", (200, 1000, 5000)) +def test_scope_owner_ready_uses_one_bounded_result_and_exact_canonical_owner_keys(size, record_property, monkeypatch): + async def scenario(): + from time import perf_counter + + from powercontext.server.authz import AccessUnavailableError, ResourceRef + from powercontext.server.authz.repository import ACCESS_OWNERS_TABLE, ACCESS_TABLES, RelationalAccessRepository + + async with directory_profile(extra_tables=ACCESS_TABLES) as (profile, _, heads): + async with profile.database.transaction() as connection: + ids = tuple(f"记忆-{index:05d}" for index in range(size)) + await connection.execute( + insert(ARTIFACTS_TABLE), + [ + { + "scope_id": "scope-a", + "family": "atomic-memory", + "artifact_id": identity, + "revision": 1, + "content": b"not decoded", + } + for identity in ids + ], + ) + await connection.execute( + insert(heads), + [ + {"scope_id": "scope-a", "family": "atomic-memory", "artifact_id": identity, "revision": 1} + for identity in ids + ], + ) + await connection.execute( + insert(ACCESS_OWNERS_TABLE), + [ + { + "owner_kind": "artifact", + "object_key_hash": sha256( + ResourceRef.artifact( + "scope-a", family="atomic-memory", artifact_id=identity + ).key.encode() + ).hexdigest(), + "scope_id": "scope-a", + "family": "atomic-memory", + "artifact_id": identity, + "owner_type": "user", + "owner_id": "reader", + "established_at": datetime.now(UTC).isoformat(), + "policy_revision": "0", + "idempotency_key": identity, + } + for identity in ids + ], + ) + async with profile.database.transaction(consistent_snapshot=True) as connection: + execute = type(connection).execute + returned_rows = [] + + async def measured(current, statement, *args, **kwargs): + result = await execute(current, statement, *args, **kwargs) + if current is connection and result.returns_rows: + frozen = result.freeze() + returned_rows.append(len(frozen.data)) + return frozen() + return result + + monkeypatch.setattr(type(connection), "execute", measured) + started = perf_counter() + await RelationalAccessRepository(profile.database, connection=connection).require_scope_content_ready( + "scope-a" + ) + record_property("owner_ready_ms", round((perf_counter() - started) * 1000, 3)) + assert returned_rows == [0] + # A coordinate-matching row with the wrong canonical hash is not an owner. + async with profile.database.transaction() as connection: + await connection.execute( + update(ACCESS_OWNERS_TABLE) + .where(ACCESS_OWNERS_TABLE.c.artifact_id == ids[-1]) + .values(object_key_hash="0" * 64) + ) + with pytest.raises(AccessUnavailableError, match="owner"): + await RelationalAccessRepository( + profile.database, connection=connection + ).require_scope_content_ready("scope-a") + + asyncio.run(scenario()) + + +def test_custom_directory_policy_is_rejected_without_replacing_ordinary_scope_read(): + async def scenario(): + from powercontext.server.authz import ( + AccessAuditContext, + AccessBinding, + AccessBindingState, + AccessControlService, + AccessRole, + AccessUnavailableError, + BuiltinAuthorizationProvider, + PrincipalRef, + ResourceRef, + ) + from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository + + class CustomPolicy(BuiltinAuthorizationProvider): + pass + + principal = PrincipalRef(type="user", id="reader") + context = AccessAuditContext(transport="test", operation="custom-policy") + async with directory_profile(extra_tables=ACCESS_TABLES) as (profile, _, _): + repository = RelationalAccessRepository(profile.database) + await repository.create_binding( + AccessBinding( + binding_id="scope-reader", + subject=principal, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + granted_by=principal, + reason="fixture", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="fixture", + ) + ) + provider = CustomPolicy(repository) + access = AccessControlService(provider, relationships=repository, audit=repository) + async with ( + access.defer_decision_audit(), + profile.database.transaction(consistent_snapshot=True) as connection, + ): + assert ( + await access.require_scope_read(principal, "scope-a", connection=connection, context=context) + ).allowed + with pytest.raises(AccessUnavailableError) as unavailable: + await access.require_scope_directory_read( + principal, "scope-a", connection=connection, context=context + ) + assert unavailable.value.code == "safe_resource_filtering_unavailable" + assert access.provider is provider + + asyncio.run(scenario()) + + +def test_byte_boundary_preserves_the_next_eligible_item_and_oversized_item_fails(monkeypatch): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, _, _), profile.database.transaction() as connection: + await publish(connection, reader, "a") + await publish(connection, reader, "b") + initial = await query(connection, reader, limit=2) + first_size = len(initial.items[0].model_dump_json().encode()) + 2 + monkeypatch.setattr(directory, "DIRECTORY_ITEM_BYTES", first_size) + first = await query(connection, reader, limit=2) + assert len(first.items) == 1 and first.next_cursor is not None + last = await query(connection, reader, limit=2, cursor=first.next_cursor) + assert [item.artifact.artifact_id for item in first.items + last.items] == ["a", "b"] + assert last.next_cursor is None + monkeypatch.setattr(directory, "DIRECTORY_ITEM_BYTES", first_size - 1) + with pytest.raises(AtomicMemoryDirectoryItemTooLargeError): + await query(connection, reader, limit=2) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("size", (200, 1000, 5000)) +def test_large_sparse_kind_directory_reads_no_bodies_or_unbounded_application_candidates(size, monkeypatch): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, _, heads): + async with profile.database.transaction() as connection: + await connection.execute( + insert(ARTIFACTS_TABLE), + [ + { + "scope_id": "scope-a", + "family": "atomic-memory", + "artifact_id": f"m-{index:05d}", + "revision": 1, + "content": b"not a decodable body: directory must not read it", + } + for index in range(size) + ], + ) + await connection.execute( + insert(heads), + [ + { + "scope_id": "scope-a", + "family": "atomic-memory", + "artifact_id": f"m-{index:05d}", + "revision": 1, + "content_kind": "rare" if index % 10 == 0 else "common", + } + for index in range(size) + ], + ) + statements = [] + + def observed(_connection, _cursor, statement, _parameters, _context, _executemany): + statements.append(statement) + + event.listen(profile.database.engine.sync_engine, "before_cursor_execute", observed) + try: + async with profile.database.transaction(consistent_snapshot=True) as connection: + execute = type(connection).execute + returned_rows = [] + + async def measured(current, statement, *args, **kwargs): + result = await execute(current, statement, *args, **kwargs) + if current is connection and result.returns_rows: + frozen = result.freeze() + returned_rows.append(len(frozen.data)) + return frozen() + return result + + monkeypatch.setattr(type(connection), "execute", measured) + page = await query(connection, reader, limit=10, kind="rare") + assert [item.artifact.artifact_id for item in page.items] == [ + f"m-{index:05d}" for index in range(0, 100, 10) + ] + assert page.next_cursor is not None + # Measure actual result cardinality, not SQL text or private call order. + assert returned_rows and max(returned_rows) <= 11 + finally: + event.remove(profile.database.engine.sync_engine, "before_cursor_execute", observed) + # The storage budget prohibits body access regardless of body size/validity. + assert all("pc_artifacts" not in statement for statement in statements) + + asyncio.run(scenario()) + + +def test_missing_metadata_and_generation_overflow_fail_without_publishing_a_page(): + async def scenario(): + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, scopes, heads), profile.database.transaction() as connection: + await publish(connection, reader) + await connection.execute(update(heads).values(content_kind=None)) + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + await query(connection, reader) + for generation in (-1, (1 << 63) - 1): + await connection.execute(update(scopes).values(atomic_memory_directory_generation=generation)) + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + await reader.record_mutation(connection, "scope-a") + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == generation + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "values", + ( + {"limit": True}, + {"limit": 0}, + {"states": ()}, + {"states": ("active", "active")}, + {"kind": " "}, + {"kind": "x" * 129}, + {"cursor": "x" * 4097}, + ), +) +def test_query_validation_rejects_invalid_contract_inputs(values): + with pytest.raises(ValidationError): + AtomicMemoryDirectoryQuery(scope_id="scope-a", **values) diff --git a/tests/e2e/test_atomic_memory_directory.py b/tests/e2e/test_atomic_memory_directory.py new file mode 100644 index 0000000000..3952cded0d --- /dev/null +++ b/tests/e2e/test_atomic_memory_directory.py @@ -0,0 +1,508 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Directory application, caller transactions and HTTP/SDK/MCP contracts.""" + +from __future__ import annotations + +import asyncio +from contextlib import nullcontext +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import insert, select + +from powercontext.artifacts.search import ArtifactSearchExecutionContext +from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemory +from powercontext.builtin.persistence.artifact_governance import ArtifactGovernanceRepository, ArtifactLifecycleState +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.atomic_memory_directory import ( + AtomicMemoryDirectoryAuthorizationUnavailableError, + AtomicMemoryDirectoryRepository, + AtomicMemoryDirectoryUnavailableError, +) +from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.sqlite.atomic_memory_index import SQLiteAtomicMemoryIndex +from powercontext.builtin.records import InvalidCursorError +from powercontext.builtin.runtime.atomic_memory import AtomicMemoryApplication +from tests.builtin.persistence.test_atomic_memory_directory import directory_profile, publish, query + + +def test_file_sqlite_bound_transaction_sees_publication_and_rolls_back_directory_state(tmp_path): + async def scenario(): + from powercontext.server.authz import AccessDeniedError + + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile(tmp_path / "directory.sqlite") as (profile, scopes, _): + artifacts = ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader) + scoped = AtomicMemoryApplication(profile.database, artifacts, SQLiteAtomicMemoryIndex(None)).for_scope( + "scope-a" + ) + local = ArtifactSearchExecutionContext(trusted_local=True) + for context in (None, ArtifactSearchExecutionContext()): + with pytest.raises(AccessDeniedError): + await scoped.query_directory(context=context) + uncomposed = AtomicMemoryApplication( + profile.database, ArtifactRepository((AtomicMemory,)), SQLiteAtomicMemoryIndex(None) + ).for_scope("scope-a") + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + await uncomposed.query_directory(context=local) + async with profile.database.engine.connect() as connection: + transaction = await connection.begin() + await publish(connection, reader) + page = await scoped.query_directory(context=local, connection=connection) + assert page.items[0].artifact.artifact_id == "a" + assert connection.in_transaction() and not connection.closed + await transaction.rollback() + assert (await scoped.query_directory(context=local)).items == () + async with profile.database.transaction(consistent_snapshot=True) as connection: + assert (await query(connection, reader)).items == () + assert await connection.scalar(select(scopes.c.atomic_memory_directory_generation)) == 0 + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("provider_name", ("builtin", "casbin")) +@pytest.mark.parametrize("audit_location", ("business", "independent")) +def test_shared_directory_access_preserves_policy_audit_and_global_owner_failure( + provider_name, audit_location, tmp_path, monkeypatch +): + async def scenario(): + from powercontext.server.authz import ( + AccessAuditContext, + AccessBinding, + AccessBindingState, + AccessControlService, + AccessDeniedError, + AccessProviderCapabilities, + AccessRole, + AccessUnavailableError, + BuiltinAuthorizationProvider, + CasbinAuthorizationProvider, + GroupRef, + PrincipalRef, + ResourceRef, + ) + from powercontext.server.authz.models import ArtifactOwnerRelation + from powercontext.server.authz.repository import ( + ACCESS_BINDINGS_TABLE, + ACCESS_TABLES, + RelationalAccessRepository, + _binding_row, + ) + + now = [datetime.now(UTC)] + principal = PrincipalRef(type="user", id="reader") + audit_context = AccessAuditContext(transport="test", operation="directory-query") + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile(tmp_path / "policy.sqlite", extra_tables=ACCESS_TABLES) as (profile, _, _): + relationships = RelationalAccessRepository(profile.database) + provider_type = BuiltinAuthorizationProvider if provider_name == "builtin" else CasbinAuthorizationProvider + scope_binding = await relationships.create_binding( + AccessBinding( + binding_id="scope-reader", + subject=principal, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + granted_by=principal, + reason="fixture", + created_at=datetime.now(UTC), + expires_at=now[0] + timedelta(minutes=1), + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="fixture", + ) + ) + async with profile.database.transaction() as connection: + # Unrelated direct grants in this same Scope must not be loaded + # to decide Scope read. Seed via the shared encoding authority. + await connection.execute( + insert(ACCESS_BINDINGS_TABLE), + [ + _binding_row( + replace( + scope_binding, + binding_id=f"unrelated-{index}", + idempotency_key=f"unrelated-{index}", + role=AccessRole.ARTIFACT_VIEWER, + resource=ResourceRef.artifact( + "scope-a", family="atomic-memory", artifact_id=f"unrelated-{index}" + ), + ) + ) + for index in range(5000) + ], + ) + scoped = AtomicMemoryApplication( + profile.database, + ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader), + SQLiteAtomicMemoryIndex(None), + ).for_scope("scope-a") + # Preserve both default same-file audit and an independent audit store. + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) + if audit_location == "independent" + else nullcontext(profile) as audit_profile + ): + audit = RelationalAccessRepository(audit_profile.database) + provider = provider_type(relationships, clock=lambda: now[0]) + access = AccessControlService(provider, relationships=relationships, audit=audit) + context = ArtifactSearchExecutionContext(principal=principal, access=access, audit=audit_context) + async with profile.database.transaction() as connection: + for identity in ("a", "b", "z"): + await publish(connection, reader, identity) + governance = ArtifactGovernanceRepository(atomic_memory_directory=reader) + current = await governance.get(connection, "scope-a", "atomic-memory", "z") + await governance.transition_merge( + connection, "scope-a", "atomic-memory", "z", current, ArtifactLifecycleState.RETIRED + ) + for identity in ("a", "b"): + await relationships.establish_artifact_owner( + ArtifactOwnerRelation( + resource=ResourceRef.artifact("scope-a", family="atomic-memory", artifact_id=identity), + owner=principal, + established_at=datetime.now(UTC), + policy_revision="0", + idempotency_key=identity, + ) + ) + # Even the active first page fails for an unowned retired row elsewhere. + with pytest.raises(AccessUnavailableError, match="owner"): + await scoped.query_directory(limit=1, context=context) + with pytest.raises(AccessDeniedError): + await scoped.query_directory( + context=replace(context, principal=PrincipalRef(type="user", id="stranger")) + ) + await relationships.establish_artifact_owner( + ArtifactOwnerRelation( + resource=ResourceRef.artifact("scope-a", family="atomic-memory", artifact_id="z"), + owner=principal, + established_at=datetime.now(UTC), + policy_revision="0", + idempotency_key="z", + ) + ) + # Measure actual application row materialization, including + # authorization, instead of inferring cost from selector LIMIT. + async with profile.database.engine.connect() as observed: + execute = type(observed).execute + returned_rows = [] + + async def measured(current, statement, *args, **kwargs): + result = await execute(current, statement, *args, **kwargs) + if result.returns_rows: + frozen = result.freeze() + returned_rows.append(len(frozen.data)) + return frozen() + return result + + with monkeypatch.context() as patch: + patch.setattr(type(observed), "execute", measured) + first = await scoped.query_directory(limit=1, context=context) + assert returned_rows and max(returned_rows) <= 2 + assert [item.artifact.artifact_id for item in first.items] == ["a"] and first.next_cursor + async with ( + access.defer_decision_audit(), + profile.database.transaction(consistent_snapshot=True) as connection, + ): + tail = await scoped.query_directory( + limit=1, cursor=first.next_cursor, context=context, connection=connection + ) + assert connection.in_transaction() and not connection.closed + assert [item.artifact.artifact_id for item in tail.items] == ["b"] and tail.next_cursor is None + # Host-resolved groups and configured provider are cursor context, + # even when a direct Scope grant still permits both pages. + with pytest.raises(InvalidCursorError): + await scoped.query_directory( + limit=1, + cursor=first.next_cursor, + context=replace( + context, audit=replace(audit_context, subject_groups=(GroupRef("group", "g"),)) + ), + ) + alternate_type = ( + CasbinAuthorizationProvider if provider_name == "builtin" else BuiltinAuthorizationProvider + ) + alternate = AccessControlService( + alternate_type(relationships), relationships=relationships, audit=audit + ) + with pytest.raises(InvalidCursorError): + await scoped.query_directory( + limit=1, cursor=first.next_cursor, context=replace(context, access=alternate) + ) + disabled = AccessControlService( + provider, + relationships=relationships, + audit=audit, + provider_capabilities=AccessProviderCapabilities(False, True, True), + ) + with pytest.raises(AtomicMemoryDirectoryAuthorizationUnavailableError): + await scoped.query_directory(context=replace(context, access=disabled)) + assert access.audit is audit and access.provider is provider and provider._repository is relationships + assert await audit.list_audit(resource=ResourceRef.scope("scope-a"), limit=10) + now[0] += timedelta(minutes=2) + with pytest.raises(AccessDeniedError): + await scoped.query_directory(limit=1, cursor=first.next_cursor, context=context) + now[0] -= timedelta(minutes=2) + await relationships.create_binding( + AccessBinding( + binding_id="another-reader", + subject=PrincipalRef(type="user", id="other"), + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + granted_by=principal, + reason="fixture policy change", + created_at=now[0], + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="another-reader", + ) + ) + with pytest.raises(InvalidCursorError): + await scoped.query_directory(limit=1, cursor=first.next_cursor, context=context) + first = await scoped.query_directory(limit=1, context=context) + await relationships.revoke_binding( + "scope-reader", + revoked_by=principal, + expected_version=1, + idempotency_key="revoke", + revoked_at=datetime.now(UTC), + ) + # Revocation is checked before trusting a still-signed continuation. + with pytest.raises(AccessDeniedError): + await scoped.query_directory(limit=1, cursor=first.next_cursor, context=context) + + asyncio.run(scenario()) + + +def test_http_sdk_and_mcp_share_compact_pages_authentication_and_restart_semantics(monkeypatch): + async def scenario(): + from types import SimpleNamespace + + import httpx + from fastmcp import Client + from fastmcp.client.transports import StreamableHttpTransport + from starlette.middleware import Middleware + + from powercontext.builtin.persistence import atomic_memory_directory as directory + from powercontext.builtin.persistence.tags import RelationalTagService + from powercontext.builtin.tags import ArtifactTagTarget + from powercontext.client import PowerContextClient + from powercontext.client.errors import ServerResponseError + from powercontext.http import QueryAtomicMemoryDirectoryRequest + from powercontext.server.app import create_app + from powercontext.server.authentication import StaticBearerAuthenticationProvider + from powercontext.server.authz import ( + AccessBinding, + AccessBindingState, + AccessControlService, + AccessRole, + BuiltinAuthorizationProvider, + PrincipalRef, + ResourceRef, + ) + from powercontext.server.authz.models import ArtifactOwnerRelation + from powercontext.server.authz.repository import ACCESS_TABLES, RelationalAccessRepository + from powercontext.server.mcp import mount_mcp + from powercontext.server.middleware import AuthenticationMiddleware + + principal = PrincipalRef(type="user", id="reader") + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile(extra_tables=ACCESS_TABLES) as (profile, _, _): + artifacts = ArtifactRepository((AtomicMemory,), atomic_memory_directory=reader) + atomic = AtomicMemoryApplication(profile.database, artifacts, SQLiteAtomicMemoryIndex(None)) + relationships = RelationalAccessRepository(profile.database) + await relationships.create_binding( + AccessBinding( + binding_id="viewer", + subject=principal, + resource=ResourceRef.scope("scope-a"), + role=AccessRole.SCOPE_VIEWER, + granted_by=principal, + reason="fixture", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key="viewer", + ) + ) + tags = tuple(chr(0x4E00 + index) * 64 for index in range(16)) + tag_service = RelationalTagService(profile.database, artifacts=artifacts) + async with profile.database.transaction() as connection: + for identity in ("a", "b"): + await publish(connection, reader, identity, text="Private body must not enter the directory.") + for identity in ("a", "b"): + await relationships.establish_artifact_owner( + ArtifactOwnerRelation( + resource=ResourceRef.artifact("scope-a", family="atomic-memory", artifact_id=identity), + owner=principal, + established_at=datetime.now(UTC), + policy_revision="0", + idempotency_key=identity, + ) + ) + target = ArtifactTagTarget(family="atomic-memory", artifact_id=identity) + current = await tag_service.get("scope-a", target) + await tag_service.replace("scope-a", target, tags, expected_etag=current.etag) + access = AccessControlService( + BuiltinAuthorizationProvider(relationships), relationships=relationships, audit=relationships + ) + authentication = StaticBearerAuthenticationProvider("fixture-token", principal) + app = create_app( + application=SimpleNamespace(atomic_memory=atomic), + access_control=access, + authentication_provider=authentication, + middleware=(Middleware(AuthenticationMiddleware, provider=authentication),), + ) + mount_mcp(app) + url = "http://127.0.0.1" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url=url) as http: + credential = "fixture-token" + sdk = PowerContextClient(url, token=credential, http_client=http) + request = QueryAtomicMemoryDirectoryRequest.model_validate({ + "scope_id": "scope-a", + "limit": 1, + "tags": list(tags), + }) + first = await sdk.query_atomic_memory_directory(request) + assert first.next_cursor and len(first.next_cursor) <= 4096 + first_json = first.model_dump(mode="json") + assert set(first_json) == {"scope_id", "items", "next_cursor"} + assert set(first_json["items"][0]) == {"artifact", "kind", "state", "state_version", "merged_into_id"} + assert first_json["items"][0]["artifact"] == { + "family": "atomic-memory", + "artifact_id": "a", + "revision": 1, + } + assert "Private body" not in first.model_dump_json() + + def create_http_client(headers=None, timeout=None, auth=None, **_): + return httpx.AsyncClient( + transport=transport, + base_url=url, + headers={"Authorization": "Bearer fixture-token", **(headers or {})}, + timeout=timeout, + auth=auth, + follow_redirects=True, + ) + + mcp_transport = StreamableHttpTransport(f"{url}/mcp/", httpx_client_factory=create_http_client) + async with app.router.lifespan_context(app), Client(mcp_transport) as mcp: + tool = next(tool for tool in await mcp.list_tools() if tool.name == "query_atomic_memory_directory") + assert tool.annotations and tool.annotations.readOnlyHint + continuation = request.model_copy(update={"cursor": first.next_cursor}).model_dump(mode="json") + result = await mcp.call_tool("query_atomic_memory_directory", continuation) + assert not result.is_error + assert result.data["items"][0]["artifact"]["artifact_id"] == "b" + assert result.data["next_cursor"] is None + # An actual tag write invalidates both transports' old cursor. + target = ArtifactTagTarget(family="atomic-memory", artifact_id="a") + current = await tag_service.get("scope-a", target) + await tag_service.replace("scope-a", target, (), expected_etag=current.etag) + invalid = await mcp.call_tool("query_atomic_memory_directory", continuation, raise_on_error=False) + assert invalid.is_error and "invalid_cursor" in invalid.content[0].text + with pytest.raises(ServerResponseError) as invalid: + await sdk.query_atomic_memory_directory(request.model_copy(update={"cursor": first.next_cursor})) + assert invalid.value.status_code == 400 and invalid.value.code == "invalid_cursor" + # Single-item failure is mapped without returning a partial page. + with monkeypatch.context() as patch: + patch.setattr(directory, "DIRECTORY_ITEM_BYTES", 1) + with pytest.raises(ServerResponseError) as oversized: + await sdk.query_atomic_memory_directory(QueryAtomicMemoryDirectoryRequest(scope_id="scope-a")) + assert oversized.value.status_code == 413 + assert oversized.value.code == "atomic_memory_directory_item_too_large" + # Wire constraints are enforced as well as domain validation. + for changes in ({"limit": True}, {"states": []}, {"states": ["active", "active"]}, {"kind": " "}): + response = await http.post( + "/v1/atomic-memory/directory/query", + json={"scope_id": "scope-a", **changes}, + headers={"Authorization": "Bearer fixture-token"}, + ) + assert response.status_code == 422 and "items" not in response.json() + unauthenticated = await http.post("/v1/atomic-memory/directory/query", json={"scope_id": "scope-a"}) + assert unauthenticated.status_code == 401 + from powercontext.server.authz import AccessProviderCapabilities + + app.state.access_control = AccessControlService( + access.provider, + relationships=relationships, + audit=relationships, + provider_capabilities=AccessProviderCapabilities(False, True, True), + ) + try: + with pytest.raises(ServerResponseError) as unsupported: + await sdk.query_atomic_memory_directory(QueryAtomicMemoryDirectoryRequest(scope_id="scope-a")) + assert unsupported.value.status_code == 503 + assert unsupported.value.code == "atomic_memory_directory_authorization_unavailable" + finally: + app.state.access_control = access + await relationships.revoke_binding( + "viewer", + revoked_by=principal, + expected_version=1, + idempotency_key="revoke", + revoked_at=datetime.now(UTC), + ) + with pytest.raises(ServerResponseError) as revoked: + await sdk.query_atomic_memory_directory(QueryAtomicMemoryDirectoryRequest(scope_id="scope-a")) + assert revoked.value.status_code == 403 + audit = await relationships.list_audit(resource=ResourceRef.scope("scope-a"), limit=100) + decisions = [event for event in audit if event.operation == "query_atomic_memory_directory"] + assert {event.transport for event in decisions} >= {"http", "mcp"} + assert all(event.principal == principal for event in decisions) + + asyncio.run(scenario()) + + +def test_uncomposed_http_directory_fails_closed_and_full_record_tagged_list_still_works(): + async def scenario(): + from types import SimpleNamespace + + import httpx + + from powercontext.builtin.persistence.tags import RelationalTagService + from powercontext.builtin.tags import ArtifactTagTarget + from powercontext.server.app import create_app + + reader = AtomicMemoryDirectoryRepository(SignedCursorCodec(secret=b"fixture")) + async with directory_profile() as (profile, _, _): + async with profile.database.transaction() as connection: + await publish(connection, reader, text="Full record text remains available.") + artifacts = ArtifactRepository((AtomicMemory,)) + tag_service = RelationalTagService(profile.database, artifacts=artifacts) + target = ArtifactTagTarget(family="atomic-memory", artifact_id="a") + current = await tag_service.get("scope-a", target) + await tag_service.replace("scope-a", target, ("PRODUCTION",), expected_etag=current.etag) + atomic = AtomicMemoryApplication(profile.database, artifacts, SQLiteAtomicMemoryIndex(None)) + app = create_app(application=SimpleNamespace(atomic_memory=atomic), access_mode="disabled") + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1") as http: + response = await http.post("/v1/atomic-memory/directory/query", json={"scope_id": "scope-a"}) + assert response.status_code == 503 + assert response.json()["error"]["code"] == "atomic_memory_directory_unavailable" + response = await http.post( + "/v1/atomic-memory/list", json={"scope_id": "scope-a", "tags": ["production"]} + ) + assert response.status_code == 200 + assert response.json()["items"][0]["text"] == "Full record text remains available." + + asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_directory_migration.py b/tests/e2e/test_atomic_memory_directory_migration.py new file mode 100644 index 0000000000..c18a0eedc6 --- /dev/null +++ b/tests/e2e/test_atomic_memory_directory_migration.py @@ -0,0 +1,420 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Real business baselines and explicit unified directory migration/recovery.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from pathlib import Path + +import pytest +from pydantic import SecretStr +from sqlalchemy import Engine, event +from typer.testing import CliRunner + +from powercontext.artifacts.search import ArtifactSearchExecutionContext +from powercontext.builtin.persistence.atomic_memory_directory import AtomicMemoryDirectoryUnavailableError +from powercontext.builtin.persistence.migrations import MigrationError +from powercontext.builtin.persistence.migrations.deployment import deployment_runner, production_bundle +from powercontext.builtin.persistence.migrations.sqlite import SQLiteMigrationRunner +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import InvalidCursorError +from powercontext.builtin.runtime.composition import open_builtin_contexts +from powercontext.builtin.runtime.config import BuiltinConfig +from powercontext.cli.app import create_cli +from powercontext.server.cli import app as server_cli +from powercontext.server.factory import create_server_app +from powercontext.server.settings import ServerSettings + +_LOCAL = ArtifactSearchExecutionContext(trusted_local=True) + + +def _settings(path: Path) -> ServerSettings: + return ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{path}"), + cursor_signing_secret=SecretStr("directory-migration-test-secret-32-bytes"), + ) + + +def _runner(path: Path) -> SQLiteMigrationRunner: + runner = deployment_runner(_settings(path), target="atomic-memory-directory") + assert isinstance(runner, SQLiteMigrationRunner) + return runner + + +def _apply(runner: SQLiteMigrationRunner): + plan = runner.plan() + return runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) + + +async def _source(path: Path, count: int = 2) -> str: + application = create_server_app(settings=_settings(path), scheduler_path=path.with_suffix(".scheduler.json")) + async with application.router.lifespan_context(application): + runtime = application.state.application + scope_id = (await runtime.scopes.default_scope()).scope_id + await runtime._records().create_atomic_memories( + scope_id, tuple({"kind": "fact", "text": f"Fact {index}."} for index in range(count)) + ) + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + await runtime.atomic_memory.for_scope(scope_id).query_directory(context=_LOCAL) + return scope_id + + +def test_shared_cli_initializes_directory_target_and_inspections_do_not_create_it(tmp_path): + path = tmp_path / "fresh.sqlite" + environment = tmp_path / "deployment.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{path}\n" + ) + command = ["server", "db-migrate"] + options = ["--target", "atomic-memory-directory", "--env-file", str(environment)] + cli = CliRunner() + for action in ("status", "plan"): + result = cli.invoke(create_cli([server_cli]), [*command, action, *options]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["data_tasks"] == ["atomic-memory-directory-v1"] + assert not path.exists() + assert not path.with_name(path.name + ".pc-migration-state").exists() + plan = json.loads(result.output) + applied = cli.invoke( + create_cli([server_cli]), + [ + *command, + "apply", + *options, + "--backup", + "auto", + "--plan-id", + plan["plan_id"], + "--yes", + "--maintenance-confirmed", + ], + ) + assert applied.exit_code == 0, applied.output + assert json.loads(applied.output)["revision"] == "p0006" + verified = cli.invoke(create_cli([server_cli]), [*command, "verify", *options]) + assert verified.exit_code == 0, verified.output + assert json.loads(verified.output)["server_ready"] is False + + async def restart_frozen_database(): + application = create_server_app(settings=_settings(path), scheduler_path=path.with_suffix(".scheduler.json")) + async with application.router.lifespan_context(application): + runtime = application.state.application + scope_id = (await runtime.scopes.default_scope()).scope_id + page = await runtime.atomic_memory.for_scope(scope_id).query_directory(context=_LOCAL) + assert page.items == () + + asyncio.run(restart_frozen_database()) + # Native startup must not add missing upstream tables to a frozen initialized target. + assert _runner(path).verify().state == "ready" + + +def test_actual_server_baseline_upgrades_without_changing_exact_history_or_identity(tmp_path): + path = tmp_path / "existing.sqlite" + scope_id = asyncio.run(_source(path, 201)) + with sqlite3.connect(path) as connection: + originals = connection.execute("SELECT * FROM pc_artifacts ORDER BY artifact_id,revision").fetchall() + identity = connection.execute("SELECT server_id FROM pc_server_identity").fetchone() + family, artifact_id, revision = connection.execute( + "SELECT family,artifact_id,revision FROM pc_artifacts ORDER BY artifact_id LIMIT 1" + ).fetchone() + connection.execute( + "INSERT INTO pc_artifact_handles VALUES (?,?,?,?,?)", (scope_id, family, "saved", artifact_id, revision) + ) + connection.execute( + "INSERT INTO pc_context_bootstrap_receipts " + "(receipt_id,scope_id,request_digest,integration,lifecycle,profile,state,max_bytes," + "content_bytes,truncated,items,created_at) VALUES (?,?,?,'test','session_start','test'," + "'pending',512,0,0,X'5B5D','2026-09-28 12:00:00')", + ("receipt", scope_id, "digest"), + ) + handles = connection.execute("SELECT * FROM pc_artifact_handles").fetchall() + receipts = connection.execute("SELECT * FROM pc_context_bootstrap_receipts").fetchall() + runner = _runner(path) + plan = runner.plan() + assert plan.adopt_baseline and plan.source_revision == "p0005" + assert plan.data_tasks == ("atomic-memory-directory-v1",) + result = _apply(runner) + assert result.backup_state == "completed" + assert runner.verify().state == "ready" + with sqlite3.connect(path) as connection: + assert connection.execute("SELECT * FROM pc_artifacts ORDER BY artifact_id,revision").fetchall() == originals + assert connection.execute("SELECT server_id FROM pc_server_identity").fetchone() == identity + assert connection.execute("SELECT * FROM pc_artifact_handles").fetchall() == handles + assert connection.execute("SELECT * FROM pc_context_bootstrap_receipts").fetchall() == receipts + assert connection.execute("SELECT DISTINCT content_kind FROM pc_artifact_heads").fetchall() == [("fact",)] + generation = connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] + assert generation == 2 + assert _apply(runner).changed is False + with sqlite3.connect(path) as connection: + assert ( + connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] == generation + ) + + async def browse(): + application = create_server_app(settings=_settings(path), scheduler_path=path.with_suffix(".scheduler.json")) + async with application.router.lifespan_context(application): + runtime = application.state.application + scoped = runtime.atomic_memory.for_scope(scope_id) + first = await scoped.query_directory(limit=100, context=_LOCAL) + second = await scoped.query_directory(limit=100, cursor=first.next_cursor, context=_LOCAL) + last = await scoped.query_directory(limit=100, cursor=second.next_cursor, context=_LOCAL) + assert len(first.items + second.items + last.items) == 201 + assert last.next_cursor is None + item = first.items[0] + exact = await scoped.get(item.artifact.artifact_id, revision=item.artifact.revision, context=_LOCAL) + assert exact.ref == item.artifact + await runtime._records().create_atomic_memories(scope_id, ({"kind": "rule", "text": "A new rule."},)) + with pytest.raises(InvalidCursorError): + await scoped.query_directory(limit=100, cursor=first.next_cursor, context=_LOCAL) + + asyncio.run(browse()) + assert runner.verify().state == "ready" + + +def test_committed_first_batch_resumes_with_original_backup_and_no_extra_generation(tmp_path, monkeypatch): + path = tmp_path / "interrupted.sqlite" + asyncio.run(_source(path, 201)) + runner = _runner(path) + load = runner.bundle.task_functions + + def interrupted(task): + functions = load(task) + execute = functions["apply_batches"] + + def crash(connection): + for changed in execute(connection): + yield changed + raise RuntimeError("interrupted after committed data batch") # noqa: TRY003 + + functions["apply_batches"] = crash + return functions + + monkeypatch.setattr(runner.bundle, "task_functions", interrupted) + with pytest.raises(RuntimeError, match="committed data batch"): + _apply(runner) + evidence = json.loads(runner.evidence_path.read_text()) + assert evidence["state"] == "active" + + async def cannot_restart_during_maintenance(): + from powercontext.server.authz.composition import open_builtin_access_control + from powercontext.server.authz.models import PrincipalRef + + application = create_server_app(settings=_settings(path), scheduler_path=path.with_suffix(".scheduler.json")) + with pytest.raises(MigrationError, match="recovery_required"): + async with application.router.lifespan_context(application): + pytest.fail("Unfinished offline maintenance must not start business writers") + with pytest.raises(MigrationError, match="recovery_required"): + async with open_builtin_access_control( + _settings(path).database, bootstrap_administrators=(PrincipalRef(type="user", id="new-admin"),) + ): + pytest.fail("Authorization bootstrap must not write before the maintenance gate") + + asyncio.run(cannot_restart_during_maintenance()) + with sqlite3.connect(path) as connection: + assert ( + connection.execute("SELECT COUNT(*) FROM pc_artifact_heads WHERE content_kind IS NULL").fetchone()[0] == 1 + ) + assert connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] == 1 + resumed = _runner(path) + assert resumed.plan().state == "recovery_required" + assert resumed.plan().revisions == () + assert resumed.plan().data_tasks == ("atomic-memory-directory-v1",) + _apply(resumed) + completed = json.loads(resumed.evidence_path.read_text()) + assert completed["backup"] == evidence["backup"] + assert completed["maintenance_window_id"] == evidence["maintenance_window_id"] + with sqlite3.connect(path) as connection: + assert connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] == 2 + assert _apply(resumed).changed is False + + +def test_bad_later_batch_preserves_that_batch_and_explicit_repair_can_resume(tmp_path): + path = tmp_path / "bad-body.sqlite" + asyncio.run(_source(path, 201)) + with sqlite3.connect(path) as connection: + identity, body = connection.execute( + "SELECT artifact_id,content FROM pc_artifacts ORDER BY artifact_id DESC LIMIT 1" + ).fetchone() + connection.execute("UPDATE pc_artifacts SET content=? WHERE artifact_id=?", (b"not-json", identity)) + runner = _runner(path) + with pytest.raises(MigrationError, match="verification_failed"): + _apply(runner) + + async def cannot_restart_after_failed_task(): + application = create_server_app(settings=_settings(path), scheduler_path=path.with_suffix(".scheduler.json")) + with pytest.raises(MigrationError, match="recovery_required"): + async with application.router.lifespan_context(application): + pytest.fail("Failed offline maintenance must remain stopped") + + asyncio.run(cannot_restart_after_failed_task()) + with sqlite3.connect(path) as connection: + assert connection.execute( + "SELECT content_kind FROM pc_artifact_heads WHERE artifact_id=?", (identity,) + ).fetchone() == (None,) + assert ( + connection.execute("SELECT COUNT(*) FROM pc_artifact_heads WHERE content_kind='fact'").fetchone()[0] == 200 + ) + connection.execute("UPDATE pc_artifacts SET content=? WHERE artifact_id=?", (body, identity)) + _apply(_runner(path)) + assert _runner(path).verify().state == "ready" + + +def test_completed_schema_does_not_hide_data_damage_and_disabled_queries_keep_writer_maintenance(tmp_path): + path = tmp_path / "damaged-kind.sqlite" + scope_id = asyncio.run(_source(path)) + runner = _runner(path) + _apply(runner) + with sqlite3.connect(path) as connection: + connection.execute( + "UPDATE pc_artifact_heads SET content_kind='wrong' WHERE artifact_id=(SELECT MIN(artifact_id) FROM pc_artifact_heads)" + ) + plan = runner.plan() + assert plan.revisions == () and plan.state == "migration_required" + with pytest.raises(MigrationError, match="migration_required"): + runner.verify() + + async def write_while_directory_unavailable(): + application = create_server_app(settings=_settings(path), scheduler_path=path.with_suffix(".scheduler.json")) + async with application.router.lifespan_context(application): + runtime = application.state.application + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + await runtime.atomic_memory.for_scope(scope_id).query_directory(context=_LOCAL) + await runtime._records().create_atomic_memories( + scope_id, ({"kind": "rule", "text": "Still maintain new writes."},) + ) + + asyncio.run(write_while_directory_unavailable()) + with sqlite3.connect(path) as connection: + assert connection.execute("SELECT COUNT(*) FROM pc_artifact_heads WHERE content_kind='rule'").fetchone()[0] == 1 + before = connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] + _apply(runner) + with sqlite3.connect(path) as connection: + assert ( + connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] == before + 1 + ) + + +def test_unknown_business_objects_are_not_ignored_and_missing_recovery_evidence_blocks(tmp_path, monkeypatch): + path = tmp_path / "unknown.sqlite" + asyncio.run(_source(path)) + with sqlite3.connect(path) as connection: + connection.execute("CREATE TABLE unknown_extension (value TEXT)") + with pytest.raises(MigrationError, match="unknown_baseline"): + _runner(path).plan() + with sqlite3.connect(path) as connection: + connection.execute("DROP TABLE unknown_extension") + runner = _runner(path) + original = runner.bundle.task_functions + + def fail(task): + functions = original(task) + functions["apply_batches"] = lambda connection: (_ for _ in ()).throw(RuntimeError("interrupted")) + return functions + + monkeypatch.setattr(runner.bundle, "task_functions", fail) + with pytest.raises(RuntimeError, match="interrupted"): + _apply(runner) + runner.evidence_path.unlink() + with pytest.raises(MigrationError, match="recovery_required"): + _runner(path).plan() + + +def test_default_acceptance_bundle_keeps_released_artifact_handles_scope(): + bundle = production_bundle() + assert bundle.head == "p0004" + assert bundle.data_tasks == () + assert bundle.affected_objects == ( + "pc_artifacts", + "pc_artifact_heads", + "pc_artifact_candidate_versions", + "pc_artifact_tags", + "pc_artifact_handles", + ) + + +def test_unsupported_sqlite_connection_options_cannot_silently_skip_managed_writer_hooks(tmp_path): + path = tmp_path / "connection-options.sqlite" + scope_id = asyncio.run(_source(path)) + _apply(_runner(path)) + with sqlite3.connect(path) as connection: + before = connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] + + async def mutate_through_alias(): + alias = SQLiteConfig(url=f"sqlite+aiosqlite:///{path}?timeout=5") + async with open_builtin_contexts(BuiltinConfig(database=alias)) as contexts: + with pytest.raises(AtomicMemoryDirectoryUnavailableError): + await contexts.atomic_memory.for_scope(scope_id).query_directory(context=_LOCAL) + await contexts.records.create_atomic_memories(scope_id, ({"kind": "rule", "text": "Maintain aliases."},)) + + asyncio.run(mutate_through_alias()) + with sqlite3.connect(path) as connection: + assert ( + connection.execute("SELECT atomic_memory_directory_generation FROM pc_scopes").fetchone()[0] == before + 1 + ) + assert _runner(path).verify().state == "ready" + + +def test_directory_target_rejects_existing_acceptance_database_before_any_write(tmp_path): + path = tmp_path / "acceptance.sqlite" + acceptance = SQLiteMigrationRunner(path, production_bundle()) + _apply(acceptance) + before = path.read_bytes() + with pytest.raises(MigrationError, match="unsupported_target"): + _runner(path).plan() + assert path.read_bytes() == before + + +def test_registered_sqlite_task_target_rejects_other_backends_before_connecting(tmp_path): + from powercontext.builtin.persistence.seekdb import SeekDBConfig + + settings = ServerSettings(database=SeekDBConfig(path=tmp_path / "not-created")) + with pytest.raises(MigrationError, match="unsupported_target"): + deployment_runner(settings, target="atomic-memory-directory") + assert not (tmp_path / "not-created").exists() + + +def test_directory_target_requires_the_evidence_location_its_runtime_can_inspect(tmp_path): + path = tmp_path / "not-created.sqlite" + with pytest.raises(MigrationError, match="unsupported_option"): + deployment_runner(_settings(path), target="atomic-memory-directory", evidence_dir=tmp_path / "other-evidence") + assert not path.exists() + assert not (tmp_path / "other-evidence").exists() + + +def test_topic_child_does_not_scan_atomic_bodies_when_reusing_managed_parent_schema(tmp_path): + path = tmp_path / "topic-child.sqlite" + asyncio.run(_source(path)) + _apply(_runner(path)) + statements = [] + + def observe(_connection, _cursor, statement, _parameters, _context, _many): + normalized = statement.lower() + if "left join pc_artifacts" in normalized and "atomic-memory" in normalized: + statements.append(statement) + + async def open_child(): + async with open_builtin_contexts(BuiltinConfig(database=_settings(path).database), _topic_memory_worker=True): + pass + + event.listen(Engine, "before_cursor_execute", observe) + try: + asyncio.run(open_child()) + finally: + event.remove(Engine, "before_cursor_execute", observe) + assert statements == [], "Topic-only children must not verify the unrelated Atomic directory" diff --git a/tests/e2e/test_mcp_transport.py b/tests/e2e/test_mcp_transport.py index 31aaa22b79..6db485c46d 100644 --- a/tests/e2e/test_mcp_transport.py +++ b/tests/e2e/test_mcp_transport.py @@ -253,6 +253,7 @@ async def exercise_tools() -> set[str]: "list_artifact_candidates", "list_atomic_memories", "list_memory_entries", + "query_atomic_memory_directory", "list_scopes", "merge_atomic_memories", "change_atomic_memory_lifecycle", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index d21641ba0f..00a9025bba 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -109,6 +109,7 @@ async def inspect_components() -> tuple[list[str], int, int]: "get_artifact_revision", "replace_artifact", "get_atomic_memory_state", + "query_atomic_memory_directory", "list_atomic_memories", "search_atomic_memory", "merge_atomic_memories", @@ -158,6 +159,7 @@ async def inspect_annotations() -> dict[str, Any]: "get_artifact", "get_artifact_revision", "get_atomic_memory_state", + "query_atomic_memory_directory", "list_atomic_memories", "search_atomic_memory", "preview_atomic_memory_restoration",