From d8c1f438ea562a38437e3155c52d8f6d2d901523 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Thu, 29 Oct 2026 11:41:45 +0800 Subject: [PATCH 01/25] fix(access): support ownership migration after switching enforcement on --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 143 +++++++ desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/operate/deploy-server.md | 33 ++ docs/zh/docs/operate/deploy-server.md | 29 ++ .../powercontext/src/operations.generated.ts | 2 + .../powercontext/src/operations.generated.ts | 2 + .../powercontext/src/operations.generated.ts | 2 + openapi/powercontext.yaml | 165 ++++++++ release/upgrade-support.json | 4 +- src/powercontext/http/__init__.py | 10 + src/powercontext/http/_generated/models.py | 100 ++++- .../http/_generated/operations.py | 48 +++ src/powercontext/http/_generated/schema.py | 213 ++++++++++ src/powercontext/server/app.py | 125 +++++- src/powercontext/server/authz/errors.py | 1 + src/powercontext/server/cli.py | 85 +++- src/powercontext/server/ownership.py | 399 ++++++++++++++++++ tests/e2e/test_access_ownership_migration.py | 395 +++++++++++++++++ 19 files changed, 1751 insertions(+), 9 deletions(-) create mode 100644 src/powercontext/server/ownership.py create mode 100644 tests/e2e/test_access_ownership_migration.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b041e70422..da9f190823 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a", + "contractSha256": "8777c3453eab7b4c9d7aa30b04c07d94e8579ebb9ba0331d83874b6711090a6d", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index f2ef1ceadf..6f7a87e9e9 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -2283,6 +2283,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/access/ownership/plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Inventory ownership data missing after switching Access enforcement on + * @description Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet protect anything. + */ + post: operations["plan_access_ownership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/access/ownership/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Backfill missing Artifact owners and Candidate owner attestations + * @description Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when they disagree with the named Principal. Every item is one idempotent write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. + */ + post: operations["apply_access_ownership"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -4941,6 +4981,53 @@ export interface components { id: string; description?: string | null; }; + PlanAccessOwnershipRequest: { + /** @description Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. */ + scope_ids?: string[]; + /** @description Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each affected Scope is checked for its scope.contribute authority. */ + background_principal?: components["schemas"]["AccessPrincipal"] | null; + }; + /** @enum {string} */ + OwnershipGapKind: "artifact_owner" | "candidate_attestation"; + OwnershipGapItem: { + kind: components["schemas"]["OwnershipGapKind"]; + scope_id: string; + family: string; + artifact_id?: string | null; + entry_id?: string | null; + candidate_id?: string | null; + version?: number | null; + conflict_code?: string | null; + }; + AccessOwnershipScopePlan: { + scope_id: string; + artifact_gaps: components["schemas"]["OwnershipGapItem"][]; + candidate_gaps: components["schemas"]["OwnershipGapItem"][]; + }; + BackgroundPermissionGap: { + scope_id: string; + principal_id: string; + /** @enum {boolean} */ + allowed: false; + reason_code: string | null; + }; + AccessOwnershipPlan: { + mode: components["schemas"]["AccessControlMode"]; + scopes: components["schemas"]["AccessOwnershipScopePlan"][]; + background: components["schemas"]["BackgroundPermissionGap"][]; + }; + ApplyAccessOwnershipRequest: { + owner: components["schemas"]["AccessPrincipal"]; + /** @description Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments. */ + scope_ids?: string[]; + /** @default false */ + dry_run: boolean; + }; + AccessOwnershipApplyResult: { + dry_run: boolean; + applied: components["schemas"]["OwnershipGapItem"][]; + conflicts: components["schemas"]["OwnershipGapItem"][]; + }; AccessGroup: { /** * @description discriminator enum property added by openapi-typescript @@ -9341,4 +9428,60 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + plan_access_ownership: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanAccessOwnershipRequest"]; + }; + }; + responses: { + /** @description The missing-attribution inventory for the requested Scopes. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessOwnershipPlan"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + apply_access_ownership: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ApplyAccessOwnershipRequest"]; + }; + }; + responses: { + /** @description The applied, or, in dry-run, previewed ownership backfill. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessOwnershipApplyResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; } diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 06889f38b5..6fa119703e 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 = "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a"; +export const contractSha256 = "8777c3453eab7b4c9d7aa30b04c07d94e8579ebb9ba0331d83874b6711090a6d"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index f309cf4f5e..fb2548b0ab 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -185,6 +185,39 @@ A shared static token does not enforce human review or prevent an Agent from app submission and approval, use distinct authenticated Principals and grant review permission only to the reviewer. See [Separate Agent submission from review](../workflows/review-candidates.md#separate-agent-submission-from-review). +### Migrate data written before enforcement + +Data accepted while `ACCESS_MODE=disabled` has no Artifact owner and no Candidate owner attestation. After switching to +`enforced`, that data stays readable, but modifying it returns `403` and approving its pending Candidates returns +`503 artifact_owner_pending`, while data created under enforcement keeps working. The Server never derives ownership, +so an administrator migrates the missing attribution explicitly: + +1. Back up the database. The offline command below works while the Server is stopped, before or after the switch: +2. Inventory the affected data with `powercontext server access-ownership-migrate --action plan`. The same command + also runs before the switch, while `ACCESS_MODE=disabled`. Name the configured background Principal with + `POST /v1/access/ownership/plan` and `background_principal` (for example `ACCESS_BACKGROUND_PRINCIPAL_ID`) to + also list Scopes where scheduled processing would be denied for missing `scope.contribute` authority. +3. Backfill with an explicitly named owning Principal — for a single-user static-token deployment that is the + `server-token` service Principal: + + ```bash + powercontext server access-ownership-migrate \ + --action apply --owner service:server-token --maintenance-confirmed + ``` + + `apply` requires `--maintenance-confirmed` after stopping every Server process. Pass `--scope-id` to migrate + Scopes incrementally on large deployments. Every item is one idempotent write, so interrupted or repeated runs + refill only still-missing items. Existing owners and Candidate attestations are preserved and reported as + conflicts, never overwritten. Run `apply` again with another `--owner` when different identities must own + different Scopes; multi-user deployments keep this identity boundary explicit. + + Without a stopped-deployment window, the same flow is available over HTTP on a running Server: + `POST /v1/access/ownership/apply` with `{"owner": {"type": "service", "id": "server-token"}, "dry_run": true}` + previews first; both HTTP operations require `server.admin` and `apply` reports + `503 ownership_migration_unavailable` while `ACCESS_MODE=disabled`. +4. Verify: modify a previously immutable Artifact, approve a previously blocked Candidate, and confirm `plan` now + reports no gaps. Any identity without the migrated ownership or `server.admin` keeps receiving `403`. + ## Check the deployment Use liveness to determine whether the process can answer HTTP requests: diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 443a1347e4..bdfcc76e23 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -170,6 +170,35 @@ Cookie,最长八小时;HTTPS 下设置 Secure。反向代理应正确传递 共享静态 token 不会强制人工审核,也不会阻止 Agent 批准自己的 Candidate。需要分离提交和审批时,应使用不同的已认证 Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与审核](../workflows/review-candidates.md#分离-agent-提交与审核)。 +### 迁移认证开启前写入的数据 + +`ACCESS_MODE=disabled` 期间接受的数据没有 Artifact owner,也没有 Candidate owner attestation。切换到 `enforced` +之后,这些数据仍可读取,但修改会返回 `403`,审批其待审 Candidate 会返回 `503 artifact_owner_pending`;在认证模式下 +新建的数据不受影响。Server 不会推断归属,需要管理员显式迁移缺失的授权信息: + +1. 先备份数据库。下面的离线命令在 Server 停止时执行,切换前后都可以运行。 +2. 用 `powercontext server access-ownership-migrate --action plan` 盘点受影响的数据。`ACCESS_MODE=disabled` + 期间同样可以运行。把已配置的后台 Principal(例如 `ACCESS_BACKGROUND_PRINCIPAL_ID`)作为 + `background_principal` 传入 `POST /v1/access/ownership/plan`,还可以列出因缺少 `scope.contribute` 权限、 + 定时处理会被拒绝的 Scope。 +3. 用显式指定的归属 Principal 回补。单用户静态 token 部署就是 `server-token` 服务 Principal: + + ```bash + powercontext server access-ownership-migrate \ + --action apply --owner service:server-token --maintenance-confirmed + ``` + + `apply` 要求先用 `--maintenance-confirmed` 确认所有 Server 进程已停止。大型部署可以重复传 `--scope-id` + 分批迁移。每一项都是一次幂等写入,中断或重复执行只会补齐仍缺失的条目;已有的 owner 和 Candidate + attestation 会被保留,冲突时明确报告,不会被覆盖。不同身份需要拥有不同 Scope 时,用另一个 `--owner` + 再次执行;多用户部署由此保持明确的身份边界。 + + 如果没有停机窗口,也可以对运行中的 Server 走 HTTP:`POST /v1/access/ownership/apply`(先用 + `dry_run: true` 预览)。两个 HTTP 操作都要求 `server.admin`;`ACCESS_MODE=disabled` 时 `apply` 返回 + `503 ownership_migration_unavailable`。 +4. 验证:修改一个此前不可变的 Artifact,审批一个此前被阻塞的 Candidate,并确认 `plan` 不再报告缺口。没有 + 迁移归属或 `server.admin` 权限的身份仍会收到 `403`。 + ## 检查部署 使用 liveness 判断进程能否响应 HTTP 请求: diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..1dbb00c87b 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -143,6 +143,8 @@ export const OPERATIONS = { revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..1dbb00c87b 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -143,6 +143,8 @@ export const OPERATIONS = { revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..1dbb00c87b 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -143,6 +143,8 @@ export const OPERATIONS = { revoke_access_binding: { method: 'POST', path: '/v1/access/bindings/revoke', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, replace_access_binding: { method: 'POST', path: '/v1/access/bindings/replace', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_access_audit: { method: 'POST', path: '/v1/access/audit/list', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index f08902602e..d9f6d59c10 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5064,6 +5064,73 @@ paths: $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" + /v1/access/ownership/plan: + post: + tags: [access] + summary: Inventory ownership data missing after switching Access enforcement on + operationId: plan_access_ownership + description: >- + Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending + Review Candidates without an owner attestation, and, when named, the background Principal's + per-Scope write authority. Produces no change. In enforced mode completes as a deployment + administrator; in disabled mode completes without authentication because the Server cannot yet + protect anything. + x-powercontext-access: {action: access.self, resource: {type: server}} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlanAccessOwnershipRequest" + responses: + "200": + description: The missing-attribution inventory for the requested Scopes. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessOwnershipPlan" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/ownership/apply: + post: + tags: [access] + summary: Backfill missing Artifact owners and Candidate owner attestations + operationId: apply_access_ownership + description: >- + Grant the missing ownership data to the explicitly named Principal; the Server never derives it. + Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when + they disagree with the named Principal. Every item is one idempotent write, so interrupted or + repeated runs refill only still-missing items. Set dry_run first to preview the exact items. + Requires a deployment administrator; while Access is disabled the Server has no ownership store + and reports 503 ownership_migration_unavailable. + x-powercontext-access: {action: access.self, resource: {type: server}} + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApplyAccessOwnershipRequest" + responses: + "200": + description: The applied, or, in dry-run, previewed ownership backfill. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessOwnershipApplyResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" components: securitySchemes: BearerAuth: @@ -12086,6 +12153,104 @@ components: type: {type: string, enum: [user, service]} id: {type: string, minLength: 1, maxLength: 255} description: {type: string, minLength: 1, maxLength: 255, nullable: true} + PlanAccessOwnershipRequest: + type: object + additionalProperties: false + properties: + scope_ids: + type: array + minItems: 1 + items: {type: string, minLength: 1, maxLength: 256} + description: Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. + background_principal: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + description: >- + Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each + affected Scope is checked for its scope.contribute authority. + OwnershipGapKind: + type: string + enum: [artifact_owner, candidate_attestation] + OwnershipGapItem: + type: object + additionalProperties: false + required: [kind, scope_id, family] + properties: + kind: + $ref: "#/components/schemas/OwnershipGapKind" + scope_id: {type: string, minLength: 1, maxLength: 256} + family: {type: string, minLength: 1, maxLength: 128} + artifact_id: {type: string, maxLength: 256, nullable: true} + entry_id: {type: string, maxLength: 256, nullable: true} + candidate_id: {type: string, maxLength: 128, nullable: true} + version: {type: integer, minimum: 1, nullable: true} + conflict_code: {type: string, maxLength: 64, nullable: true} + AccessOwnershipScopePlan: + type: object + additionalProperties: false + required: [scope_id, artifact_gaps, candidate_gaps] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256} + artifact_gaps: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" + candidate_gaps: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" + BackgroundPermissionGap: + type: object + additionalProperties: false + required: [scope_id, principal_id, allowed, reason_code] + properties: + scope_id: {type: string, minLength: 1, maxLength: 256} + principal_id: {type: string, minLength: 1, maxLength: 255} + allowed: {type: boolean, enum: [false]} + reason_code: {type: string, maxLength: 64, nullable: true} + AccessOwnershipPlan: + type: object + additionalProperties: false + required: [mode, scopes, background] + properties: + mode: + $ref: "#/components/schemas/AccessControlMode" + scopes: + type: array + items: + $ref: "#/components/schemas/AccessOwnershipScopePlan" + background: + type: array + items: + $ref: "#/components/schemas/BackgroundPermissionGap" + ApplyAccessOwnershipRequest: + type: object + additionalProperties: false + required: [owner] + properties: + owner: + $ref: "#/components/schemas/AccessPrincipal" + scope_ids: + type: array + minItems: 1 + items: {type: string, minLength: 1, maxLength: 256} + description: Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments. + dry_run: {type: boolean, default: false} + AccessOwnershipApplyResult: + type: object + additionalProperties: false + required: [dry_run, applied, conflicts] + properties: + dry_run: {type: boolean} + applied: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" + conflicts: + type: array + items: + $ref: "#/components/schemas/OwnershipGapItem" AccessGroup: type: object additionalProperties: false diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 52a02d606e..e1be5cae42 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "99d448c2b9f3a3ef23a5e348a747d9e277c4cca13ad3bee475b26d10faabac98", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above." + "persistence_sha256": "e997fe65496f4c90c3c680b1f7ba9affdce912cd0c392dc798dee9509653c768", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index dcb6b7c740..ab3da1e485 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -31,6 +31,9 @@ AccessDecision, AccessGroup, AccessMeResponse, + AccessOwnershipApplyResult, + AccessOwnershipPlan, + AccessOwnershipScopePlan, AccessPrincipal, AccessProviderCapabilities, AccessRequirementMatch, @@ -46,6 +49,7 @@ ActivateHandoffRequest, AgentKind, AllScopeSelection, + ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactAccessResource, ArtifactAddress, @@ -296,6 +300,7 @@ ModelUsageValue, Omission, OpenQuestion, + PlanAccessOwnershipRequest, PrepareContextRequest, PreparedContext, PreparedContextSchema, @@ -494,6 +499,9 @@ "AccessDecision", "AccessGroup", "AccessMeResponse", + "AccessOwnershipApplyResult", + "AccessOwnershipPlan", + "AccessOwnershipScopePlan", "AccessPrincipal", "AccessProviderCapabilities", "AccessRequirementMatch", @@ -509,6 +517,7 @@ "ActivateHandoffRequest", "AgentKind", "AllScopeSelection", + "ApplyAccessOwnershipRequest", "ApproveCandidateRequest", "ArtifactAccessResource", "ArtifactAddress", @@ -759,6 +768,7 @@ "ModelUsageValue", "Omission", "OpenQuestion", + "PlanAccessOwnershipRequest", "PrepareContextRequest", "PrepareHandoffHintRequest", "PrepareHandoffRequest", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f97992dcb7..fb42ece5b8 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import date as date_aliased -from enum import IntEnum, StrEnum +from enum import Enum, IntEnum, StrEnum from typing import Annotated, Any, Literal from pydantic import ( @@ -3108,6 +3108,95 @@ class AccessPrincipal(BaseModel): description: Annotated[StrictStr | None, Field(max_length=255, min_length=1)] = None +class ScopeId1(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1)] + + +class PlanAccessOwnershipRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_ids: Annotated[ + list[ScopeId1] | None, + Field( + description="Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments.", + min_length=1, + ), + ] = None + background_principal: Annotated[ + AccessPrincipal | None, + Field( + description="Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each affected Scope is checked for its scope.contribute authority." + ), + ] = None + + +class OwnershipGapKind(StrEnum): + ARTIFACT_OWNER = "artifact_owner" + CANDIDATE_ATTESTATION = "candidate_attestation" + + +class OwnershipGapItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: OwnershipGapKind + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + family: Annotated[StrictStr, Field(max_length=128, min_length=1)] + artifact_id: Annotated[StrictStr | None, Field(max_length=256)] = None + entry_id: Annotated[StrictStr | None, Field(max_length=256)] = None + candidate_id: Annotated[StrictStr | None, Field(max_length=128)] = None + version: Annotated[StrictInt | None, Field(ge=1)] = None + conflict_code: Annotated[StrictStr | None, Field(max_length=64)] = None + + +class AccessOwnershipScopePlan(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + artifact_gaps: list[OwnershipGapItem] + candidate_gaps: list[OwnershipGapItem] + + +class Allowed(Enum): + BOOLEAN_FALSE = False + + +class BackgroundPermissionGap(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + principal_id: Annotated[StrictStr, Field(max_length=255, min_length=1)] + allowed: Allowed + reason_code: Annotated[StrictStr | None, Field(max_length=64)] + + +class ApplyAccessOwnershipRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + owner: AccessPrincipal + scope_ids: Annotated[ + list[ScopeId1] | None, + Field( + description="Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments.", + min_length=1, + ), + ] = None + dry_run: StrictBool = False + + +class AccessOwnershipApplyResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + dry_run: StrictBool + applied: list[OwnershipGapItem] + conflicts: list[OwnershipGapItem] + + class Type3(StrEnum): GROUP = "group" @@ -4679,6 +4768,15 @@ class SourcePage(BaseModel): next_cursor: Annotated[StrictStr | None, Field(...)] +class AccessOwnershipPlan(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: AccessControlMode + scopes: list[AccessOwnershipScopePlan] + background: list[BackgroundPermissionGap] + + class ArtifactFamilyAccessCapability(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index b96333617a..2d861f2a5f 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -14,10 +14,13 @@ AccessCheckRequest, AccessCheckResponse, AccessMeResponse, + AccessOwnershipApplyResult, + AccessOwnershipPlan, AccessResourcePage, AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, + ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactCreated, ArtifactHandleBinding, @@ -125,6 +128,7 @@ MemoryCapacity, MemoryMutationResponse, MergeAtomicMemoryRequest, + PlanAccessOwnershipRequest, PrepareContextRequest, PreparedContext, PreparedHandoff, @@ -3621,3 +3625,47 @@ class AccessRequirement(BaseModel): }, access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) + +PLAN_ACCESS_OWNERSHIP = Operation[PlanAccessOwnershipRequest, AccessOwnershipPlan]( + method="POST", + path="/v1/access/ownership/plan", + operation_id="plan_access_ownership", + request_type=PlanAccessOwnershipRequest, + request_location="body", + path_parameters=(), + response_type=AccessOwnershipPlan, + success_status=200, + summary="Inventory ownership data missing after switching Access enforcement on", + tags=("access",), + scope_mode="none", + responses={ + 200: {"description": "The missing-attribution inventory for the requested Scopes."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +APPLY_ACCESS_OWNERSHIP = Operation[ApplyAccessOwnershipRequest, AccessOwnershipApplyResult]( + method="POST", + path="/v1/access/ownership/apply", + operation_id="apply_access_ownership", + request_type=ApplyAccessOwnershipRequest, + request_location="body", + path_parameters=(), + response_type=AccessOwnershipApplyResult, + success_status=200, + summary="Backfill missing Artifact owners and Candidate owner attestations", + tags=("access",), + scope_mode="none", + responses={ + 200: {"description": "The applied, or, in dry-run, previewed ownership backfill."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3f4c08e56b..0dd6a9e6ac 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5494,6 +5494,91 @@ "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, + "/v1/access/ownership/plan": { + "post": { + "tags": ["access"], + "summary": "Inventory ownership data missing after switching Access enforcement on", + "description": "Inspect the Scopes that were " + "written while Access was " + "disabled: missing Artifact " + "owners, pending Review " + "Candidates without an owner " + "attestation, and, when named, " + "the background Principal's " + "per-Scope write authority. " + "Produces no change. In enforced " + "mode completes as a deployment " + "administrator; in disabled mode " + "completes without authentication " + "because the Server cannot yet " + "protect anything.", + "operationId": "plan_access_ownership", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PlanAccessOwnershipRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The missing-attribution inventory for the requested Scopes.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessOwnershipPlan"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + } + }, + "/v1/access/ownership/apply": { + "post": { + "tags": ["access"], + "summary": "Backfill missing Artifact owners and Candidate owner attestations", + "description": "Grant the missing ownership " + "data to the explicitly named " + "Principal; the Server never " + "derives it. Existing Artifact " + "owners and Candidate " + "attestations are preserved and " + "reported as conflicts when they " + "disagree with the named " + "Principal. Every item is one " + "idempotent write, so " + "interrupted or repeated runs " + "refill only still-missing " + "items. Set dry_run first to " + "preview the exact items. " + "Requires a deployment " + "administrator; while Access is " + "disabled the Server has no " + "ownership store and reports 503 " + "ownership_migration_unavailable.", + "operationId": "apply_access_ownership", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ApplyAccessOwnershipRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The applied, or, in dry-run, previewed ownership backfill.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/AccessOwnershipApplyResult"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + } + }, }, "components": { "schemas": { @@ -12000,6 +12085,134 @@ "type": "object", "required": ["type", "id"], }, + "PlanAccessOwnershipRequest": { + "properties": { + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1}, + "type": "array", + "minItems": 1, + "description": "Scopes " + "to " + "inspect; " + "every " + "Scope " + "when " + "omitted. " + "Pass " + "Scopes " + "incrementally " + "for " + "large " + "deployments.", + }, + "background_principal": { + "allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], + "description": "Optional " + "background " + "processing " + "Principal, " + "such " + "as " + "ACCESS_BACKGROUND_PRINCIPAL_ID; " + "each " + "affected " + "Scope " + "is " + "checked " + "for " + "its " + "scope.contribute " + "authority.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + }, + "OwnershipGapKind": {"type": "string", "enum": ["artifact_owner", "candidate_attestation"]}, + "OwnershipGapItem": { + "properties": { + "kind": {"$ref": "#/components/schemas/OwnershipGapKind"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "family": {"type": "string", "maxLength": 128, "minLength": 1}, + "artifact_id": {"type": "string", "maxLength": 256, "nullable": True}, + "entry_id": {"type": "string", "maxLength": 256, "nullable": True}, + "candidate_id": {"type": "string", "maxLength": 128, "nullable": True}, + "version": {"type": "integer", "minimum": 1.0, "nullable": True}, + "conflict_code": {"type": "string", "maxLength": 64, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "scope_id", "family"], + }, + "AccessOwnershipScopePlan": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "artifact_gaps": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + "candidate_gaps": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "artifact_gaps", "candidate_gaps"], + }, + "BackgroundPermissionGap": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "principal_id": {"type": "string", "maxLength": 255, "minLength": 1}, + "allowed": {"type": "boolean", "enum": [False]}, + "reason_code": {"type": "string", "maxLength": 64, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "principal_id", "allowed", "reason_code"], + }, + "AccessOwnershipPlan": { + "properties": { + "mode": {"$ref": "#/components/schemas/AccessControlMode"}, + "scopes": {"items": {"$ref": "#/components/schemas/AccessOwnershipScopePlan"}, "type": "array"}, + "background": {"items": {"$ref": "#/components/schemas/BackgroundPermissionGap"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["mode", "scopes", "background"], + }, + "ApplyAccessOwnershipRequest": { + "properties": { + "owner": {"$ref": "#/components/schemas/AccessPrincipal"}, + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1}, + "type": "array", + "minItems": 1, + "description": "Scopes " + "to " + "migrate; " + "every " + "Scope " + "when " + "omitted. " + "Pass " + "Scopes " + "incrementally " + "for " + "large " + "deployments.", + }, + "dry_run": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "type": "object", + "required": ["owner"], + }, + "AccessOwnershipApplyResult": { + "properties": { + "dry_run": {"type": "boolean"}, + "applied": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + "conflicts": {"items": {"$ref": "#/components/schemas/OwnershipGapItem"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["dry_run", "applied", "conflicts"], + }, "AccessGroup": { "properties": { "type": {"type": "string", "enum": ["group"]}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 980349239f..5b6ad56046 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -409,6 +409,7 @@ AccessRolePage, AcknowledgeHandoffRequest, ActivateHandoffRequest, + ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactAccessResource, ArtifactCollectionItem, @@ -511,6 +512,7 @@ MemoryCapacity, MemoryEntryAccessSelector, MemoryMutationResponse, + PlanAccessOwnershipRequest, PrepareContextRequest, PreparedContext, PreparedWorkHandoff, @@ -653,12 +655,14 @@ AccessControlMode as TransportAccessControlMode, ) from powercontext.http._generated.models import ( - ArtifactFamily as TransportArtifactFamily, -) -from powercontext.http._generated.models import ( + AccessOwnershipApplyResult, + AccessOwnershipPlan, + AccessOwnershipScopePlan, ArtifactTagPage, ArtifactTagSet, + BackgroundPermissionGap, GetTopicMemoryProcessingBlockRequest, + OwnershipGapItem, QueryArtifactTagsRequest, ReplaceArtifactTagsRequest, RetryTopicMemoryProcessingRequest, @@ -670,6 +674,12 @@ TopicMemoryProcessingStatus, TopicMemoryProcessingStatusRequest, ) +from powercontext.http._generated.models import Allowed as TransportOwnershipAllowed +from powercontext.http._generated.models import ( + ArtifactFamily as TransportArtifactFamily, +) +from powercontext.http._generated.models import OwnershipGapKind as TransportOwnershipGapKind +from powercontext.http._generated.models import ScopeId1 as TransportScopeId from powercontext.http._generated.models import ( ShareUnit as TransportShareUnit, ) @@ -680,6 +690,7 @@ API_DESCRIPTION, API_TITLE, API_VERSION, + APPLY_ACCESS_OWNERSHIP, APPROVE_CANDIDATE, BIND_ARTIFACT_HANDLE, CAPTURE_CONTENT_SOURCE, @@ -753,6 +764,7 @@ LIST_SCOPES, LIST_SOURCES, OPENAPI_VERSION, + PLAN_ACCESS_OWNERSHIP, PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, @@ -864,6 +876,12 @@ negotiate_dream_contract, require_extended_dream, ) +from powercontext.server.ownership import ( + ArtifactOwnershipGap, + CandidateOwnershipGap, + apply_ownership_migration, + plan_ownership_migration, +) from powercontext.server.request_id import REQUEST_ID_HEADER from powercontext.server.tracing import request_id_from_span from powercontext.sources import ConnectorBinding as RuntimeConnectorBinding @@ -1485,6 +1503,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, REVOKE_ACCESS_BINDING, revoke_access_binding) _add_route(app, REPLACE_ACCESS_BINDING, replace_access_binding) _add_route(app, LIST_ACCESS_AUDIT, list_access_audit) + _add_route(app, PLAN_ACCESS_OWNERSHIP, plan_access_ownership) + _add_route(app, APPLY_ACCESS_OWNERSHIP, apply_access_ownership) if handoff_report_enabled: _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) _add_route(app, LIST_SOURCES, list_sources) @@ -2020,6 +2040,105 @@ async def list_access_audit(payload: ListAccessAuditRequest, request: Request) - ) +async def plan_access_ownership(payload: PlanAccessOwnershipRequest, request: Request) -> AccessOwnershipPlan: + application = _require_application(request) + scope_ids = await _migration_scope_ids(payload.scope_ids, request) + if request.app.state.access_mode == "enforced": + access = _require_access_control(request) + await access.require( + _require_principal(), + *_binding_administrative_check(None, deployment_id=access.deployment_id), + context=_access_audit_context(PLAN_ACCESS_OWNERSHIP.operation_id), + ) + background = None if payload.background_principal is None else _access_principal(payload.background_principal) + plan = await plan_ownership_migration(application, access, scope_ids, background_principal=background) + else: + plan = await plan_ownership_migration(application, None, scope_ids) + return AccessOwnershipPlan( + mode=TransportAccessControlMode(request.app.state.access_mode), + scopes=[ + AccessOwnershipScopePlan( + scope_id=scope.scope_id, + artifact_gaps=[_ownership_gap_item(gap) for gap in scope.artifact_gaps], + candidate_gaps=[_ownership_gap_item(gap) for gap in scope.candidate_gaps], + ) + for scope in plan.scopes + ], + background=[ + BackgroundPermissionGap( + scope_id=gap.scope_id, + principal_id=gap.principal_id, + allowed=TransportOwnershipAllowed.BOOLEAN_FALSE, + reason_code=gap.reason_code, + ) + for gap in plan.background + ], + ) + + +async def apply_access_ownership(payload: ApplyAccessOwnershipRequest, request: Request) -> AccessOwnershipApplyResult: + application = _require_application(request) + if request.app.state.access_mode != "enforced": + raise AccessUnavailableError("ownership_migration_unavailable") + access = _require_access_control(request) + scope_ids = await _migration_scope_ids(payload.scope_ids, request) + await access.require( + _require_principal(), + *_binding_administrative_check(None, deployment_id=access.deployment_id), + context=_access_audit_context(APPLY_ACCESS_OWNERSHIP.operation_id), + ) + result = await apply_ownership_migration( + application, + access, + scope_ids, + owner=_access_principal(payload.owner), + dry_run=payload.dry_run, + ) + return AccessOwnershipApplyResult( + dry_run=payload.dry_run, + applied=[_ownership_gap_item(item.gap, item.detail) for item in result.applied], + conflicts=[_ownership_gap_item(item.gap, item.detail) for item in result.conflicts], + ) + + +async def _migration_scope_ids( + requested: Sequence[str | TransportScopeId] | None, + request: Request, +) -> list[str]: + if requested is None: + return [scope.scope_id for scope in await _require_scope_application(request).list()] + unique: list[str] = [] + for value in requested: + scope_id = value.root if isinstance(value, TransportScopeId) else str(value) + if scope_id not in unique: + unique.append(scope_id) + return unique + + +def _ownership_gap_item( + gap: ArtifactOwnershipGap | CandidateOwnershipGap, + conflict_code: str | None = None, +) -> OwnershipGapItem: + if isinstance(gap, ArtifactOwnershipGap): + return OwnershipGapItem( + kind=TransportOwnershipGapKind.ARTIFACT_OWNER, + scope_id=gap.scope_id, + family=gap.family, + artifact_id=gap.artifact_id, + entry_id=gap.entry_id, + conflict_code=conflict_code, + ) + return OwnershipGapItem( + kind=TransportOwnershipGapKind.CANDIDATE_ATTESTATION, + scope_id=gap.scope_id, + family=gap.family, + candidate_id=gap.candidate_id, + version=gap.version, + artifact_id=gap.target_artifact_id, + conflict_code=conflict_code, + ) + + def _list_scopes_query( http_request: Request, query: Annotated[str | None, Query(max_length=256)] = None, diff --git a/src/powercontext/server/authz/errors.py b/src/powercontext/server/authz/errors.py index 9f209a810c..e87bf81e31 100644 --- a/src/powercontext/server/authz/errors.py +++ b/src/powercontext/server/authz/errors.py @@ -54,6 +54,7 @@ def __init__(self, code: str = "access_unavailable") -> None: "relationship_management_unavailable": "Access relationship management is unavailable", "resource_filter_limit_exceeded": "the complete authorized resource filter exceeds its configured limit", "safe_resource_filtering_unavailable": "safe Access resource filtering is unavailable", + "ownership_migration_unavailable": "ownership migration requires enforced Access mode", } super().__init__(messages.get(code, messages["access_unavailable"])) diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index 8de06de50c..af2427db4a 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -19,10 +19,11 @@ import asyncio import json import signal +from collections.abc import Sequence from contextlib import AsyncExitStack, nullcontext from pathlib import Path from sqlite3 import SQLITE_CANTOPEN -from typing import TYPE_CHECKING, Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal, cast import typer from pydantic import ValidationError @@ -61,6 +62,11 @@ from powercontext.server.identity import open_server_identity_repository from powercontext.server.logging import configure_server_logging, shutdown_server_logging, worker_logging from powercontext.server.metrics import ServerMetrics +from powercontext.server.ownership import ( + apply_ownership_migration, + ownership_report, + plan_ownership_migration, +) from powercontext.server.processing_security import build_worker_security from powercontext.server.settings import ( MissingAuthenticationProviderError, @@ -505,6 +511,83 @@ def atomic_memory_rebuild_projection( raise typer.Exit(code=1) +@app.command("access-ownership-migrate") +def access_ownership_migrate( + action: Annotated[Literal["plan", "apply"], typer.Option(help="Ownership migration action.")] = "plan", + env_file: Annotated[Path | None, typer.Option(help="Load deployment settings from this environment file.")] = None, + owner: Annotated[ + str | None, + typer.Option(help="Owning Principal as TYPE:ID, e.g. service:server-token. Required for apply."), + ] = None, + scope_id: Annotated[ + list[str] | None, + typer.Option(help="Scope to migrate; repeat the option for several Scopes. Every Scope when omitted."), + ] = None, + maintenance_confirmed: Annotated[ + bool, + typer.Option(help="Confirm every Server process using the deployment database is stopped."), + ] = False, +) -> None: + """Inventory or backfill ownership data written before Access enforcement. + + Works while ACCESS_MODE is disabled, before switching to enforced mode, and against a database whose + Server ran either mode. Apply names one owning Principal explicitly; the Server never derives ownership. + """ + + if action == "apply": + if owner is None or ":" not in owner: + raise typer.BadParameter("apply requires --owner TYPE:ID, e.g. --owner service:server-token") # noqa: TRY003 + if not maintenance_confirmed: + raise typer.BadParameter( # noqa: TRY003 + "apply requires --maintenance-confirmed after stopping every Server process" + ) + env_file = resolve_server_environment_file(env_file, discover=True) + with server_settings_context(env_file=env_file) as settings: + ready = asyncio.run(_access_ownership_maintenance(settings, action, owner=owner, scope_ids=scope_id or ())) + if not ready: + raise typer.Exit(code=1) + + +async def _access_ownership_maintenance( + settings: ServerSettings, + action: Literal["plan", "apply"], + *, + owner: str | None, + scope_ids: Sequence[str], +) -> bool: + config = BuiltinConfig( + code=settings.code, + runtime=settings.runtime, + database=settings.database, + handoff_report=settings.handoff_report, + inference=settings.inference, + external_skills=settings.external_skills, + ) + async with AsyncExitStack() as resources: + access = await resources.enter_async_context( + open_builtin_access_control(settings.database, deployment_id=settings.access.deployment_id) + ) + runtime = await resources.enter_async_context(open_builtin_runtime(config)) + if runtime.scopes is None: + raise typer.BadParameter("ownership migration requires a runtime with Scope support") # noqa: TRY003 + scopes = list(scope_ids) or [scope.scope_id for scope in await runtime.scopes.list()] + if action == "plan": + plan = await plan_ownership_migration(runtime, access, scopes, transport="background") + typer.echo(json.dumps(ownership_report(plan))) + return True + owner_type, _, owner_id = cast(str, owner).partition(":") + result = await apply_ownership_migration( + runtime, + access, + scopes, + owner=PrincipalRef(type=owner_type, id=owner_id), + transport="background", + ) + report = ownership_report(result) + typer.echo(json.dumps(report)) + return not report["conflicts"] + + async def _sqlite_snapshot_budget_preflight(database: SQLiteConfig, snapshot_budget_bytes: int) -> bool: from powercontext.builtin.persistence.migrations.atomic_memory_v1 import ( AtomicMemoryMigrationReport, diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py new file mode 100644 index 0000000000..0dd7969bf2 --- /dev/null +++ b/src/powercontext/server/ownership.py @@ -0,0 +1,399 @@ +# 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. + +"""Supported ownership migration after switching Access enforcement on. + +Data accepted while Access was disabled has no Artifact owner and no Review +Candidate owner attestation, so switching ``ACCESS_MODE`` to ``enforced`` +leaves that data read-only for every Principal and blocks Candidate approval. +Migration never invents identity: an administrator names the Principal for one +explicit call, existing relations stay untouched, and every item write is one +idempotent Access-store transaction that can be safely repeated. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from hashlib import sha256 +from typing import TYPE_CHECKING + +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessConflictError, + AccessInvalidRequestError, + PrincipalRef, + ResourceRef, +) +from powercontext.server.authz.models import MemoryEntrySelector + +if TYPE_CHECKING: + from powercontext.builtin.runtime import BuiltinRuntime + from powercontext.server.app import ServerApplication + from powercontext.server.authz.service import AccessControlService + + Application = ServerApplication | BuiltinRuntime + +OWNED_ARTIFACT_FAMILIES = ("atomic-memory", "experience", "skill", "profile") +RESOURCE_PAGE_SIZE = 100 +CANDIDATE_PAGE_SIZE = 100 + +PLAN_OPERATION = "access.ownership.plan" +MIGRATION_OPERATION = "access.ownership.apply" + + +@dataclass(frozen=True, slots=True) +class ArtifactOwnershipGap: + """One logical Artifact identity whose owner relation is absent.""" + + scope_id: str + family: str + artifact_id: str + entry_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class CandidateOwnershipGap: + """One pending artifact-kind Candidate whose owner attestation is absent.""" + + scope_id: str + candidate_id: str + family: str + version: int + entry_ids: tuple[str, ...] = () + target_family: str | None = None + target_artifact_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeOwnershipPlan: + scope_id: str + artifact_gaps: tuple[ArtifactOwnershipGap, ...] + candidate_gaps: tuple[CandidateOwnershipGap, ...] + + +@dataclass(frozen=True, slots=True) +class BackgroundPermissionGap: + """One affected Scope where the named background Principal lacks write authority.""" + + scope_id: str + principal_id: str + reason_code: str | None = None + + +@dataclass(frozen=True, slots=True) +class OwnershipPlan: + """The complete missing-attribution inventory for the requested Scopes.""" + + scopes: tuple[ScopeOwnershipPlan, ...] + background: tuple[BackgroundPermissionGap, ...] + + +@dataclass(frozen=True, slots=True) +class OwnershipDisposition: + """One migration item with its per-item result.""" + + gap: ArtifactOwnershipGap | CandidateOwnershipGap + outcome: str # "applied" or "conflict" + detail: str | None = None + + +@dataclass(frozen=True, slots=True) +class OwnershipMigrationResult: + applied: tuple[OwnershipDisposition, ...] + conflicts: tuple[OwnershipDisposition, ...] + + +def migration_context(operation: str, *, transport: str = "http") -> AccessAuditContext: + return AccessAuditContext(transport=transport, operation=operation) + + +def ownership_report(value: OwnershipPlan | OwnershipMigrationResult) -> dict[str, object]: + """Serialize a plan or migration result for operator-facing JSON output.""" + + from dataclasses import asdict + + return asdict(value) + + +async def plan_ownership_migration( + application: Application, + access: AccessControlService | None, + scope_ids: Sequence[str], + *, + background_principal: PrincipalRef | None = None, + transport: str = "http", +) -> OwnershipPlan: + """Inventory missing Artifact owners and Candidate attestations for Scopes. + + Without an active Access store (pre-switch ``disabled`` mode) every owned + Artifact Family and every pending Candidate is a gap, which is the check an + administrator runs before enabling enforcement. + """ + + scopes: list[ScopeOwnershipPlan] = [] + for scope_id in scope_ids: + scopes.append( + ScopeOwnershipPlan( + scope_id=scope_id, + artifact_gaps=await _artifact_gaps(application, access, scope_id), + candidate_gaps=await _candidate_gaps(application, access, scope_id), + ) + ) + background: list[BackgroundPermissionGap] = [] + if background_principal is not None and access is not None: + context = migration_context(PLAN_OPERATION, transport=transport) + affected = sorted({scope.scope_id for scope in scopes if scope.artifact_gaps or scope.candidate_gaps}) + for scope_id in affected: + decision = await access.check( + background_principal, + AccessAction.SCOPE_CONTRIBUTE, + ResourceRef.scope(scope_id), + context=context, + ) + if not decision.allowed: + background.append( + BackgroundPermissionGap( + scope_id=scope_id, + principal_id=background_principal.id, + reason_code=decision.reason_code, + ) + ) + return OwnershipPlan(scopes=tuple(scopes), background=tuple(background)) + + +async def apply_ownership_migration( + application: Application, + access: AccessControlService, + scope_ids: Sequence[str], + *, + owner: PrincipalRef, + dry_run: bool = False, + transport: str = "http", +) -> OwnershipMigrationResult: + """Backfill the relations missing per plan, attributed to ``owner``. + + The inventory is recomputed at apply time, so interrupted or repeated runs + refill only still-missing items. An existing relation with a different + Principal is reported as a conflict, never overwritten. + """ + + _validate_migration_owner(owner) + context = migration_context(MIGRATION_OPERATION, transport=transport) + plan = await plan_ownership_migration(application, access, scope_ids) + items = ( + *(gap for scope in plan.scopes for gap in scope.artifact_gaps), + *(gap for scope in plan.scopes for gap in scope.candidate_gaps), + ) + if dry_run: + preview: list[OwnershipDisposition] = [] + for item in items: + if isinstance(item, CandidateOwnershipGap): + preview.extend( + OwnershipDisposition( + gap=ArtifactOwnershipGap( + scope_id=item.scope_id, + family=item.family, + artifact_id=item.target_artifact_id or "", + entry_id=entry_id, + ), + outcome="applied", + ) + for entry_id in item.entry_ids + ) + preview.append(OwnershipDisposition(gap=item, outcome="applied")) + return OwnershipMigrationResult(applied=tuple(preview), conflicts=()) + applied: list[OwnershipDisposition] = [] + conflicts: list[OwnershipDisposition] = [] + for item in items: + item_applied, item_conflicts = await _apply_gap(access, item, owner, context) + applied.extend(item_applied) + conflicts.extend(item_conflicts) + return OwnershipMigrationResult(applied=tuple(applied), conflicts=tuple(conflicts)) + + +async def _apply_gap( + access: AccessControlService, + item: ArtifactOwnershipGap | CandidateOwnershipGap, + owner: PrincipalRef, + context: AccessAuditContext, +) -> tuple[list[OwnershipDisposition], list[OwnershipDisposition]]: + """Write one idempotent item; conflicts report the loser without failing the run.""" + + if isinstance(item, ArtifactOwnershipGap): + idempotency_key, resource = _owner_identity(item) + try: + await access.establish_artifact_owner(resource, owner, idempotency_key=idempotency_key, context=context) + except AccessConflictError as error: + return [], [_conflict(item, error)] + return [OwnershipDisposition(gap=item, outcome="applied")], [] + applied: list[OwnershipDisposition] = [] + conflicts: list[OwnershipDisposition] = [] + for entry_id in item.entry_ids: + entry_gap = ArtifactOwnershipGap( + scope_id=item.scope_id, + family=item.family, + artifact_id=item.target_artifact_id or "", + entry_id=entry_id, + ) + identity_key, entry_resource = _owner_identity(entry_gap) + try: + await access.establish_artifact_owner( + entry_resource, + owner, + idempotency_key=identity_key, + context=context, + ) + except AccessConflictError as error: + conflicts.append(_conflict(entry_gap, error)) + continue + applied.append(OwnershipDisposition(gap=entry_gap, outcome="applied")) + try: + await _attest_gap_owner(access, item, owner) + except AccessConflictError as error: + conflicts.append(_conflict(item, error)) + return applied, conflicts + applied.append(OwnershipDisposition(gap=item, outcome="applied")) + return applied, conflicts + + +def _conflict( + gap: ArtifactOwnershipGap | CandidateOwnershipGap, + error: AccessConflictError, +) -> OwnershipDisposition: + return OwnershipDisposition(gap=gap, outcome="conflict", detail=error.code) + + +def _validate_migration_owner(owner: PrincipalRef) -> None: + if owner.type not in {"user", "service"}: + raise AccessInvalidRequestError("principal") + if not owner.id or owner.id != owner.id.strip() or len(owner.id) > 256: + raise AccessInvalidRequestError("principal") + + +async def _artifact_gaps( + application: Application, + access: AccessControlService | None, + scope_id: str, +) -> tuple[ArtifactOwnershipGap, ...]: + records = application.records.for_scope(scope_id) + + async def family_gaps(family: str) -> tuple[ArtifactOwnershipGap, ...]: + gaps: list[ArtifactOwnershipGap] = [] + cursor: str | None = None + while True: + page = await records.query_artifacts(family, cursor=cursor, limit=RESOURCE_PAGE_SIZE) + for record in page.items: + resource = ResourceRef.artifact(scope_id, family=family, artifact_id=record.artifact_id) + if access is not None and await access.artifact_owner(resource) is not None: + continue + gaps.append(ArtifactOwnershipGap(scope_id=scope_id, family=family, artifact_id=record.artifact_id)) + cursor = page.next_cursor + if cursor is None: + return tuple(gaps) + + gaps: list[ArtifactOwnershipGap] = [] + for family in OWNED_ARTIFACT_FAMILIES: + gaps.extend(await family_gaps(family)) + if application.handoff is not None and await application.handoff.for_scope(scope_id).revisions(): + resource = ResourceRef.artifact(scope_id, family="handoff", artifact_id="handoff") + if access is None or await access.artifact_owner(resource) is None: + gaps.append(ArtifactOwnershipGap(scope_id=scope_id, family="handoff", artifact_id="handoff")) + return tuple(gaps) + + +async def _candidate_gaps( + application: Application, + access: AccessControlService | None, + scope_id: str, +) -> tuple[CandidateOwnershipGap, ...]: + from powercontext.builtin.review.models import CandidateStatus + from powercontext.builtin.runtime.models import ListCandidatesRequest + + review = application.review.for_scope(scope_id) + gaps: list[CandidateOwnershipGap] = [] + cursor: str | None = None + while True: + page = await review.list( + ListCandidatesRequest(status=CandidateStatus.PENDING, cursor=cursor, limit=CANDIDATE_PAGE_SIZE) + ) + for candidate in page.candidates: + if candidate.candidate_kind != "artifact": + continue + if access is not None and await access.candidate_owner(scope_id, candidate.candidate_id) is not None: + continue + target = candidate.target + gaps.append( + CandidateOwnershipGap( + scope_id=scope_id, + candidate_id=candidate.candidate_id, + family=candidate.family, + version=candidate.version, + entry_ids=_memory_entry_ids(candidate) if candidate.family == "memory" else (), + target_family=None if target is None else target.family, + target_artifact_id=None if target is None else target.artifact_id, + ) + ) + cursor = page.next_cursor + if cursor is None: + return tuple(gaps) + + +async def _attest_gap_owner( + access: AccessControlService, + gap: CandidateOwnershipGap, + owner: PrincipalRef, +) -> None: + target = ( + None + if gap.target_family is None or gap.target_artifact_id is None + else ResourceRef.artifact(gap.scope_id, family=gap.target_family, artifact_id=gap.target_artifact_id) + ) + await access.attest_candidate_owner( + scope_id=gap.scope_id, + candidate_id=gap.candidate_id, + family=gap.family, + proposed_owner=owner, + target=target, + idempotency_key=f"candidate-owner:{gap.scope_id}:{gap.candidate_id}", + ) + + +def _memory_entry_ids(candidate: object) -> tuple[str, ...]: + changes = getattr(getattr(candidate, "proposal", None), "changes", None) + if not isinstance(changes, Sequence): + return () + entry_ids: list[str] = [] + for change in changes: + entry_id = getattr(change, "entry_id", None) + if isinstance(entry_id, str) and entry_id not in entry_ids: + entry_ids.append(entry_id) + return tuple(entry_ids) + + +def _owner_identity(gap: ArtifactOwnershipGap) -> tuple[str, ResourceRef]: + if gap.entry_id is not None: + resource = ResourceRef.artifact( + gap.scope_id, + family=gap.family, + artifact_id=gap.artifact_id, + selector=MemoryEntrySelector(entry_id=gap.entry_id), + ) + return f"base-artifact-owner:{sha256(resource.key.encode()).hexdigest()}", resource + if gap.family == "profile" and gap.artifact_id == "profile": + key = f"profile-owner:{gap.scope_id}" + else: + key = f"{gap.family}-owner:{gap.scope_id}:{gap.artifact_id}" + return key, ResourceRef.artifact(gap.scope_id, family=gap.family, artifact_id=gap.artifact_id) diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py new file mode 100644 index 0000000000..233b70e54f --- /dev/null +++ b/tests/e2e/test_access_ownership_migration.py @@ -0,0 +1,395 @@ +# 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. + +"""Public regressions for the supported Access enforcement switch (#2299). + +Data accepted while Access was disabled stays readable but immutable after +switching to enforced mode until an administrator migrates the missing +ownership data. These tests keep the switch flow honest: pre-switch inventory, +explicit attribution, idempotent application, preserved denials for +unauthorized identities, and conflict reporting. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import httpx + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import RuntimeConfig +from powercontext.server.authentication import AuthenticationResult, ProviderReadiness +from powercontext.server.authz import AccessConflictError, PrincipalRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.factory import create_server_app +from powercontext.server.ownership import apply_ownership_migration +from powercontext.server.settings import AccessControlConfig, McpConfig, MetricsConfig, ServerSettings + +ADMIN = PrincipalRef(type="service", id="admin") +SERVER_TOKEN = PrincipalRef(type="service", id="server-token") +DEPLOYMENT = "ownership-migration" + + +class _Authentication: + async def authenticate(self, request): + identity = request.headers.get("authorization", "Bearer admin").removeprefix("Bearer ") + subject = ( + ADMIN + if identity == "admin" + else SERVER_TOKEN + if identity == "server-token" + else PrincipalRef(type="user", id=identity) + ) + return AuthenticationResult(subject=subject) + + async def readiness(self): + return ProviderReadiness(ready=True) + + +def _settings(database: SQLiteConfig, mode: str) -> ServerSettings: + return ServerSettings( + database=database, + runtime=RuntimeConfig(artifact_processing_families=()), + access=AccessControlConfig(mode=mode, deployment_id=DEPLOYMENT), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + + +def checked(response: httpx.Response, expected: int) -> dict: + data = response.json() + assert response.status_code == expected, data + return data + + +async def _create_legacy_data(client: httpx.AsyncClient) -> tuple[str, str, dict]: + scope = checked( + await client.post( + "/v1/scopes", + json={"title": "Legacy scope", "summary": "Created before enforcement", "idempotency_key": "legacy"}, + ), + 201, + )["scope_id"] + artifact = checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": "fact", "text": "Existing fact."}}, + ), + 201, + ) + source = checked( + await client.post( + "/v1/sources/content", + json={"scope_id": scope, "source_id": "legacy-source", "content": "A test passed."}, + ), + 202, + )["source"] + candidate = checked( + await client.post( + "/v1/experience/propose", + json={ + "scope_id": scope, + "source_refs": [source], + "artifact_refs": [], + "proposal": { + "situation": "API changes.", + "action": "Run tests.", + "outcome": "Tests pass.", + "lesson": "Run tests before delivery.", + }, + }, + ), + 201, + ) + return scope, f"/v1/scopes/{scope}/artifacts/atomic-memory/{artifact['artifact_id']}", candidate + + +def test_disabled_data_becomes_mutable_again_after_ownership_migration(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}") + + app = create_server_app( + settings=_settings(database, "disabled"), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, artifact_path, candidate = await _create_legacy_data(client) + plan = checked( + await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]}), + 200, + ) + assert plan["mode"] == "disabled" + scope_plan = next(item for item in plan["scopes"] if item["scope_id"] == scope) + assert [gap["family"] for gap in scope_plan["artifact_gaps"]] == ["atomic-memory"] + assert [gap["candidate_id"] for gap in scope_plan["candidate_gaps"]] == [candidate["candidate_id"]] + apply_while_disabled = await client.post( + "/v1/access/ownership/apply", + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, + ) + assert apply_while_disabled.status_code == 503 + assert apply_while_disabled.json()["error"]["code"] == "ownership_migration_unavailable" + + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + headers = {"Authorization": "Bearer admin"} + artifact_headers = {**headers, "If-Match": '"revision:1"'} + assert ( + await client.put( + artifact_path, + headers=artifact_headers, + json={"content": {"kind": "fact", "text": "Updated fact."}}, + ) + ).status_code == 403 + assert ( + await client.post( + "/v1/candidates/approve", + headers=headers, + json={ + "scope_id": scope, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ) + ).status_code == 503 + + plan = checked( + await client.post("/v1/access/ownership/plan", headers=headers, json={"scope_ids": [scope]}), + 200, + ) + scope_plan = next(item for item in plan["scopes"] if item["scope_id"] == scope) + assert [gap["family"] for gap in scope_plan["artifact_gaps"]] == ["atomic-memory"] + + background_plan = checked( + await client.post( + "/v1/access/ownership/plan", + headers=headers, + json={ + "scope_ids": [scope], + "background_principal": {"type": "service", "id": "background-worker"}, + }, + ), + 200, + ) + assert [gap["scope_id"] for gap in background_plan["background"]] == [scope] + assert background_plan["background"][0]["principal_id"] == "background-worker" + assert background_plan["background"][0]["allowed"] is False + + preview = checked( + await client.post( + "/v1/access/ownership/apply", + headers=headers, + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope], "dry_run": True}, + ), + 200, + ) + assert preview["dry_run"] is True + assert len(preview["applied"]) == 2 + assert preview["conflicts"] == [] + + applied = checked( + await client.post( + "/v1/access/ownership/apply", + headers=headers, + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, + ), + 200, + ) + assert sorted(item["kind"] for item in applied["applied"]) == [ + "artifact_owner", + "candidate_attestation", + ] + assert applied["conflicts"] == [] + + updated = checked( + await client.put( + artifact_path, + headers=artifact_headers, + json={"content": {"kind": "fact", "text": "Updated fact."}}, + ), + 200, + ) + assert updated["revision"] >= 2 + approved = checked( + await client.post( + "/v1/candidates/approve", + headers=headers, + json={ + "scope_id": scope, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ), + 200, + ) + assert approved["status"] == "approved" + + repeated = checked( + await client.post( + "/v1/access/ownership/apply", + headers=headers, + json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, + ), + 200, + ) + assert repeated["applied"] == [] + assert repeated["conflicts"] == [] + + unprivileged = {"Authorization": "Bearer worker"} + denied_plan = await client.post( + "/v1/access/ownership/plan", headers=unprivileged, json={"scope_ids": [scope]} + ) + assert denied_plan.status_code == 403 + denied_apply = await client.post( + "/v1/access/ownership/apply", + headers=unprivileged, + json={"owner": {"type": "user", "id": "worker"}, "scope_ids": [scope]}, + ) + assert denied_apply.status_code == 403 + + asyncio.run(scenario()) + + +def test_offline_cli_migration_repairs_the_switch(tmp_path: Path) -> None: + """The offline command plans and applies without a running Server, before or after the switch.""" + + async def scenario() -> None: + from powercontext.server.cli import _access_ownership_maintenance + + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'cli.db'}") + app = create_server_app( + settings=_settings(database, "disabled"), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, artifact_path, candidate = await _create_legacy_data(client) + + settings = _settings(database, "disabled") + planned = await _access_ownership_maintenance(settings, "plan", owner=None, scope_ids=(scope,)) + assert planned is True + applied = await _access_ownership_maintenance( + settings, "apply", owner="service:server-token", scope_ids=(scope,) + ) + assert applied is True + + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + headers = {"Authorization": "Bearer admin"} + assert ( + await client.put( + artifact_path, + headers={"Authorization": "Bearer server-token", "If-Match": '"revision:1"'}, + json={"content": {"kind": "fact", "text": "Updated fact."}}, + ) + ).status_code == 200 + approved = checked( + await client.post( + "/v1/candidates/approve", + headers=headers, + json={ + "scope_id": scope, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ), + 200, + ) + assert approved["status"] == "approved" + again = await _access_ownership_maintenance( + settings, "apply", owner="service:server-token", scope_ids=(scope,) + ) + assert again is True + + asyncio.run(scenario()) + + +def test_apply_reports_conflicting_ownership_without_failing(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'conflict.db'}") + app = create_server_app( + settings=_settings(database, "disabled"), + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, artifact_path, _ = await _create_legacy_data(client) + artifact_id = artifact_path.rsplit("/", 1)[-1] + + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + application = app.state.application + original_establish = access.establish_artifact_owner + attempts = 0 + + async def conflicting_establish(resource, owner, *, idempotency_key, context): + nonlocal attempts + if resource.artifact_id == artifact_id: + attempts += 1 + raise AccessConflictError("artifact-owner") + return await original_establish(resource, owner, idempotency_key=idempotency_key, context=context) + + access.establish_artifact_owner = conflicting_establish # type: ignore[method-assign] + result = await apply_ownership_migration( + application, + access, + [scope], + owner=ADMIN, + ) + assert attempts == 1 + assert [item.gap.artifact_id for item in result.conflicts] == [artifact_id] + assert result.conflicts[0].detail == "artifact-owner" + assert result.applied + + asyncio.run(scenario()) From 1c693b45f025acab9234d5f083fc311d926932b6 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Thu, 29 Oct 2026 17:13:59 +0800 Subject: [PATCH 02/25] fix(access): preserve migration ownership and check worker access --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 6 +- desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/operate/deploy-server.md | 20 +- docs/zh/docs/operate/deploy-server.md | 18 +- openapi/powercontext.yaml | 11 +- src/powercontext/http/_generated/models.py | 2 +- src/powercontext/http/_generated/schema.py | 45 ++- src/powercontext/server/app.py | 13 +- src/powercontext/server/cli.py | 35 +- src/powercontext/server/ownership.py | 124 +++++-- tests/e2e/test_access_ownership_migration.py | 307 +++++++++++++++++- tests/e2e/test_owner_recovery_http.py | 92 +++++- 13 files changed, 608 insertions(+), 69 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index da9f190823..74e8f326ad 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "8777c3453eab7b4c9d7aa30b04c07d94e8579ebb9ba0331d83874b6711090a6d", + "contractSha256": "e37820efe88057fb7c77968b6c28d18c9698a919695d9c83e229ca0ca95cc08d", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 6f7a87e9e9..916f31bad1 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -2294,7 +2294,7 @@ export interface paths { put?: never; /** * Inventory ownership data missing after switching Access enforcement on - * @description Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet protect anything. + * @description Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet protect anything. A background Principal check requires an available Access store; otherwise it reports 503 relationship_management_unavailable. Use the offline ownership command to check the proposed background Principal before enforcement is enabled. */ post: operations["plan_access_ownership"]; delete?: never; @@ -2314,7 +2314,7 @@ export interface paths { put?: never; /** * Backfill missing Artifact owners and Candidate owner attestations - * @description Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when they disagree with the named Principal. Every item is one idempotent write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. + * @description Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when they disagree with the named Principal. Identities with recorded ownership recovery evidence are reported as conflicts and must retain their original attribution. Every item is one idempotent write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. */ post: operations["apply_access_ownership"]; delete?: never; @@ -4984,7 +4984,7 @@ export interface components { PlanAccessOwnershipRequest: { /** @description Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. */ scope_ids?: string[]; - /** @description Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each affected Scope is checked for its scope.contribute authority. */ + /** @description Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required. */ background_principal?: components["schemas"]["AccessPrincipal"] | null; }; /** @enum {string} */ diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 6fa119703e..36f98fe990 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 = "8777c3453eab7b4c9d7aa30b04c07d94e8579ebb9ba0331d83874b6711090a6d"; +export const contractSha256 = "e37820efe88057fb7c77968b6c28d18c9698a919695d9c83e229ca0ca95cc08d"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index fb2548b0ab..7f69ea7015 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -194,9 +194,12 @@ so an administrator migrates the missing attribution explicitly: 1. Back up the database. The offline command below works while the Server is stopped, before or after the switch: 2. Inventory the affected data with `powercontext server access-ownership-migrate --action plan`. The same command - also runs before the switch, while `ACCESS_MODE=disabled`. Name the configured background Principal with - `POST /v1/access/ownership/plan` and `background_principal` (for example `ACCESS_BACKGROUND_PRINCIPAL_ID`) to - also list Scopes where scheduled processing would be denied for missing `scope.contribute` authority. + also runs before the switch, while `ACCESS_MODE=disabled`. Add + `--background-principal service:BACKGROUND_ID` with the future `ACCESS_BACKGROUND_PRINCIPAL_ID` to check + every selected Scope for `scope.contribute` and existing Profile write permission. These checks still run + after all ownership gaps have been filled. HTTP planning accepts `background_principal` when an Access + store is available; otherwise it returns `503 relationship_management_unavailable`. Use the offline + command for this check before enabling enforcement. 3. Backfill with an explicitly named owning Principal — for a single-user static-token deployment that is the `server-token` service Principal: @@ -211,12 +214,19 @@ so an administrator migrates the missing attribution explicitly: conflicts, never overwritten. Run `apply` again with another `--owner` when different identities must own different Scopes; multi-user deployments keep this identity boundary explicit. + The inventory includes merged and retired Artifact identities so supported restoration remains authorized. + HTTP `apply` and its preview report `owner-recovery-pending` or + `owner-recovery-delivered_relation_missing` when durable recovery evidence already records an identity. + They preserve that evidence and do not replace its owner. Complete pending ownership delivery through the + [ownership recovery procedure](../workflows/scopes-and-access.md#recover-external-ownership-writes). + Without a stopped-deployment window, the same flow is available over HTTP on a running Server: `POST /v1/access/ownership/apply` with `{"owner": {"type": "service", "id": "server-token"}, "dry_run": true}` - previews first; both HTTP operations require `server.admin` and `apply` reports + previews first; in enforced mode both HTTP operations require `server.admin`, and `apply` reports `503 ownership_migration_unavailable` while `ACCESS_MODE=disabled`. 4. Verify: modify a previously immutable Artifact, approve a previously blocked Candidate, and confirm `plan` now - reports no gaps. Any identity without the migrated ownership or `server.admin` keeps receiving `403`. + reports no ownership gaps. Include the background Principal in the plan and check that `background` is empty. + Identities without the required Artifact write or Candidate review authority still receive `403`. ## Check the deployment diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index bdfcc76e23..50aca1c829 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -178,9 +178,10 @@ Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与 1. 先备份数据库。下面的离线命令在 Server 停止时执行,切换前后都可以运行。 2. 用 `powercontext server access-ownership-migrate --action plan` 盘点受影响的数据。`ACCESS_MODE=disabled` - 期间同样可以运行。把已配置的后台 Principal(例如 `ACCESS_BACKGROUND_PRINCIPAL_ID`)作为 - `background_principal` 传入 `POST /v1/access/ownership/plan`,还可以列出因缺少 `scope.contribute` 权限、 - 定时处理会被拒绝的 Scope。 + 期间同样可以运行。添加 `--background-principal service:后台身份ID`,填写切换后准备使用的 + `ACCESS_BACKGROUND_PRINCIPAL_ID`,即可检查每个指定 Scope 的 `scope.contribute` 和已有 Profile 写权限。 + owner 缺口补齐后仍会执行这些检查。HTTP 预检在授权存储可用时接受 `background_principal`;否则返回 + `503 relationship_management_unavailable`。开启认证前请使用离线命令执行后台权限预检。 3. 用显式指定的归属 Principal 回补。单用户静态 token 部署就是 `server-token` 服务 Principal: ```bash @@ -193,11 +194,16 @@ Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与 attestation 会被保留,冲突时明确报告,不会被覆盖。不同身份需要拥有不同 Scope 时,用另一个 `--owner` 再次执行;多用户部署由此保持明确的身份边界。 + 盘点包含已合并和已退休的 Artifact 身份,以保留恢复操作所需的权限。如果持久化恢复记录已经确定归属, + HTTP `apply` 及其预览会报告 `owner-recovery-pending` 或 `owner-recovery-delivered_relation_missing`, + 并保留原始记录及其归属。待投递的归属信息应按[归属恢复流程](../workflows/scopes-and-access.md)继续处理。 + 如果没有停机窗口,也可以对运行中的 Server 走 HTTP:`POST /v1/access/ownership/apply`(先用 - `dry_run: true` 预览)。两个 HTTP 操作都要求 `server.admin`;`ACCESS_MODE=disabled` 时 `apply` 返回 + `dry_run: true` 预览)。认证模式下两个 HTTP 操作都要求 `server.admin`;`ACCESS_MODE=disabled` 时 `apply` 返回 `503 ownership_migration_unavailable`。 -4. 验证:修改一个此前不可变的 Artifact,审批一个此前被阻塞的 Candidate,并确认 `plan` 不再报告缺口。没有 - 迁移归属或 `server.admin` 权限的身份仍会收到 `403`。 +4. 验证:修改一个此前不可变的 Artifact,审批一个此前被阻塞的 Candidate,并确认 `plan` 不再报告归属缺口。 + 预检时传入后台 Principal,确认 `background` 为空。没有相应 Artifact 写权限或 Candidate 审批权限的身份 + 仍会收到 `403`。 ## 检查部署 diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index d9f6d59c10..8a28863077 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5074,7 +5074,9 @@ paths: Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet - protect anything. + protect anything. A background Principal check requires an available Access store; otherwise it + reports 503 relationship_management_unavailable. Use the offline ownership command to check the + proposed background Principal before enforcement is enabled. x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true @@ -5105,7 +5107,9 @@ paths: description: >- Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when - they disagree with the named Principal. Every item is one idempotent write, so interrupted or + they disagree with the named Principal. Identities with recorded ownership recovery evidence + are reported as conflicts and must retain their original attribution. Every item is one idempotent + write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. @@ -12168,7 +12172,8 @@ components: nullable: true description: >- Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each - affected Scope is checked for its scope.contribute authority. + selected Scope is checked for scope.contribute and write permission on an existing Profile, + including after all ownership gaps have been filled. An available Access store is required. OwnershipGapKind: type: string enum: [artifact_owner, candidate_attestation] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index fb42ece5b8..9a021561d8 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3126,7 +3126,7 @@ class PlanAccessOwnershipRequest(BaseModel): background_principal: Annotated[ AccessPrincipal | None, Field( - description="Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each affected Scope is checked for its scope.contribute authority." + description="Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required." ), ] = None diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 0dd6a9e6ac..31f6013500 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5511,7 +5511,15 @@ "administrator; in disabled mode " "completes without authentication " "because the Server cannot yet " - "protect anything.", + "protect anything. A background " + "Principal check requires an " + "available Access store; " + "otherwise it reports 503 " + "relationship_management_unavailable. " + "Use the offline ownership " + "command to check the proposed " + "background Principal before " + "enforcement is enabled.", "operationId": "plan_access_ownership", "requestBody": { "content": { @@ -5546,9 +5554,13 @@ "attestations are preserved and " "reported as conflicts when they " "disagree with the named " - "Principal. Every item is one " - "idempotent write, so " - "interrupted or repeated runs " + "Principal. Identities with " + "recorded ownership recovery " + "evidence are reported as " + "conflicts and must retain their " + "original attribution. Every " + "item is one idempotent write, " + "so interrupted or repeated runs " "refill only still-missing " "items. Set dry_run first to " "preview the exact items. " @@ -12115,14 +12127,33 @@ "as " "ACCESS_BACKGROUND_PRINCIPAL_ID; " "each " - "affected " + "selected " "Scope " "is " "checked " "for " - "its " "scope.contribute " - "authority.", + "and " + "write " + "permission " + "on " + "an " + "existing " + "Profile, " + "including " + "after " + "all " + "ownership " + "gaps " + "have " + "been " + "filled. " + "An " + "available " + "Access " + "store " + "is " + "required.", "nullable": True, }, }, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 5b6ad56046..d00a9e22bf 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -211,6 +211,9 @@ from powercontext.builtin.records import ( ArtifactWrite as RuntimeArtifactWrite, ) +from powercontext.builtin.records import ( + LogicalArtifactRecord as RuntimeLogicalArtifactRecord, +) from powercontext.builtin.records import ( SourceRecord as RuntimeSourceRecord, ) @@ -969,6 +972,8 @@ async def get_artifact_revision( execution_context: Any = None, ) -> RuntimeArtifactRecord: ... + async def logical_artifacts(self) -> tuple[RuntimeLogicalArtifactRecord, ...]: ... + async def query_artifacts( self, family: str, @@ -2043,6 +2048,8 @@ async def list_access_audit(payload: ListAccessAuditRequest, request: Request) - async def plan_access_ownership(payload: PlanAccessOwnershipRequest, request: Request) -> AccessOwnershipPlan: application = _require_application(request) scope_ids = await _migration_scope_ids(payload.scope_ids, request) + access = request.app.state.access_control + background = None if payload.background_principal is None else _access_principal(payload.background_principal) if request.app.state.access_mode == "enforced": access = _require_access_control(request) await access.require( @@ -2050,10 +2057,7 @@ async def plan_access_ownership(payload: PlanAccessOwnershipRequest, request: Re *_binding_administrative_check(None, deployment_id=access.deployment_id), context=_access_audit_context(PLAN_ACCESS_OWNERSHIP.operation_id), ) - background = None if payload.background_principal is None else _access_principal(payload.background_principal) - plan = await plan_ownership_migration(application, access, scope_ids, background_principal=background) - else: - plan = await plan_ownership_migration(application, None, scope_ids) + plan = await plan_ownership_migration(application, access, scope_ids, background_principal=background) return AccessOwnershipPlan( mode=TransportAccessControlMode(request.app.state.access_mode), scopes=[ @@ -2093,6 +2097,7 @@ async def apply_access_ownership(payload: ApplyAccessOwnershipRequest, request: scope_ids, owner=_access_principal(payload.owner), dry_run=payload.dry_run, + owner_recovery=request.app.state.owner_recovery, ) return AccessOwnershipApplyResult( dry_run=payload.dry_run, diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index af2427db4a..a5413a83ce 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -523,6 +523,10 @@ def access_ownership_migrate( list[str] | None, typer.Option(help="Scope to migrate; repeat the option for several Scopes. Every Scope when omitted."), ] = None, + background_principal: Annotated[ + str | None, + typer.Option(help="Background Principal as TYPE:ID to check while planning."), + ] = None, maintenance_confirmed: Annotated[ bool, typer.Option(help="Confirm every Server process using the deployment database is stopped."), @@ -541,9 +545,29 @@ def access_ownership_migrate( raise typer.BadParameter( # noqa: TRY003 "apply requires --maintenance-confirmed after stopping every Server process" ) + background_ref = None + if background_principal is not None: + principal_type, separator, principal_id = background_principal.partition(":") + if ( + not separator + or principal_type not in {"user", "service"} + or not principal_id + or principal_id != principal_id.strip() + or len(principal_id) > 255 + ): + raise typer.BadParameter("background Principal must use TYPE:ID") # noqa: TRY003 + background_ref = PrincipalRef(type=principal_type, id=principal_id) env_file = resolve_server_environment_file(env_file, discover=True) with server_settings_context(env_file=env_file) as settings: - ready = asyncio.run(_access_ownership_maintenance(settings, action, owner=owner, scope_ids=scope_id or ())) + ready = asyncio.run( + _access_ownership_maintenance( + settings, + action, + owner=owner, + scope_ids=scope_id or (), + background_principal=background_ref, + ) + ) if not ready: raise typer.Exit(code=1) @@ -554,6 +578,7 @@ async def _access_ownership_maintenance( *, owner: str | None, scope_ids: Sequence[str], + background_principal: PrincipalRef | None = None, ) -> bool: config = BuiltinConfig( code=settings.code, @@ -572,7 +597,13 @@ async def _access_ownership_maintenance( raise typer.BadParameter("ownership migration requires a runtime with Scope support") # noqa: TRY003 scopes = list(scope_ids) or [scope.scope_id for scope in await runtime.scopes.list()] if action == "plan": - plan = await plan_ownership_migration(runtime, access, scopes, transport="background") + plan = await plan_ownership_migration( + runtime, + access, + scopes, + background_principal=background_principal, + transport="background", + ) typer.echo(json.dumps(ownership_report(plan))) return True owner_type, _, owner_id = cast(str, owner).partition(":") diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 0dd7969bf2..d74d086433 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -34,6 +34,7 @@ AccessAuditContext, AccessConflictError, AccessInvalidRequestError, + AccessUnavailableError, PrincipalRef, ResourceRef, ) @@ -42,6 +43,7 @@ if TYPE_CHECKING: from powercontext.builtin.runtime import BuiltinRuntime from powercontext.server.app import ServerApplication + from powercontext.server.authz.owner_recovery import OwnerRecovery from powercontext.server.authz.service import AccessControlService Application = ServerApplication | BuiltinRuntime @@ -143,8 +145,11 @@ async def plan_ownership_migration( administrator runs before enabling enforcement. """ + if background_principal is not None and access is None: + raise AccessUnavailableError("relationship_management_unavailable") scopes: list[ScopeOwnershipPlan] = [] - for scope_id in scope_ids: + selected = tuple(dict.fromkeys(scope_ids)) + for scope_id in selected: scopes.append( ScopeOwnershipPlan( scope_id=scope_id, @@ -155,8 +160,7 @@ async def plan_ownership_migration( background: list[BackgroundPermissionGap] = [] if background_principal is not None and access is not None: context = migration_context(PLAN_OPERATION, transport=transport) - affected = sorted({scope.scope_id for scope in scopes if scope.artifact_gaps or scope.candidate_gaps}) - for scope_id in affected: + for scope_id in selected: decision = await access.check( background_principal, AccessAction.SCOPE_CONTRIBUTE, @@ -171,6 +175,24 @@ async def plan_ownership_migration( reason_code=decision.reason_code, ) ) + continue + records = application.records.for_scope(scope_id) + page = await records.query_artifacts("profile", cursor=None, limit=RESOURCE_PAGE_SIZE) + if any(record.artifact_id == "profile" for record in page.items): + profile_decision = await access.check( + background_principal, + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact(scope_id, family="profile", artifact_id="profile"), + context=context, + ) + if not profile_decision.allowed: + background.append( + BackgroundPermissionGap( + scope_id=scope_id, + principal_id=background_principal.id, + reason_code=profile_decision.reason_code, + ) + ) return OwnershipPlan(scopes=tuple(scopes), background=tuple(background)) @@ -182,6 +204,7 @@ async def apply_ownership_migration( owner: PrincipalRef, dry_run: bool = False, transport: str = "http", + owner_recovery: OwnerRecovery | None = None, ) -> OwnershipMigrationResult: """Backfill the relations missing per plan, attributed to ``owner``. @@ -192,11 +215,29 @@ async def apply_ownership_migration( _validate_migration_owner(owner) context = migration_context(MIGRATION_OPERATION, transport=transport) - plan = await plan_ownership_migration(application, access, scope_ids) - items = ( - *(gap for scope in plan.scopes for gap in scope.artifact_gaps), - *(gap for scope in plan.scopes for gap in scope.candidate_gaps), - ) + items: list[ArtifactOwnershipGap | CandidateOwnershipGap] = [] + conflicts: list[OwnershipDisposition] = [] + for scope_id in dict.fromkeys(scope_ids): + inventory = ( + *await _artifact_gaps(application, None, scope_id), + *await _candidate_gaps(application, None, scope_id), + ) + for item in inventory: + if isinstance(item, ArtifactOwnershipGap): + relation = await access.artifact_owner(_owner_identity(item)[1]) + existing_owner = None if relation is None else relation.owner + conflict_code = "artifact-owner" + else: + attestation = await access.candidate_owner(item.scope_id, item.candidate_id) + existing_owner = None if attestation is None else attestation.proposed_owner + conflict_code = "candidate-owner" + if existing_owner is None: + items.append(item) + elif existing_owner != owner: + conflicts.append(_conflict(item, AccessConflictError(conflict_code))) + protected = await _owner_recovery_conflicts(owner_recovery, items) + protected_keys = {_migration_item_key(item.gap) for item in protected} + items = [item for item in items if _migration_item_key(item) not in protected_keys] if dry_run: preview: list[OwnershipDisposition] = [] for item in items: @@ -214,9 +255,9 @@ async def apply_ownership_migration( for entry_id in item.entry_ids ) preview.append(OwnershipDisposition(gap=item, outcome="applied")) - return OwnershipMigrationResult(applied=tuple(preview), conflicts=()) + return OwnershipMigrationResult(applied=tuple(preview), conflicts=(*conflicts, *protected)) applied: list[OwnershipDisposition] = [] - conflicts: list[OwnershipDisposition] = [] + conflicts.extend(protected) for item in items: item_applied, item_conflicts = await _apply_gap(access, item, owner, context) applied.extend(item_applied) @@ -224,6 +265,46 @@ async def apply_ownership_migration( return OwnershipMigrationResult(applied=tuple(applied), conflicts=tuple(conflicts)) +async def _owner_recovery_conflicts( + recovery: OwnerRecovery | None, + items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap], +) -> list[OwnershipDisposition]: + if recovery is None: + return [] + conflicts: list[OwnershipDisposition] = [] + by_identity = {_migration_item_key(item): item for item in items} + for scope_id in dict.fromkeys(item.scope_id for item in items): + for kind in ("artifact", "candidate"): + after: tuple[str, str] | None = None + while True: + page = await recovery.inventory_gaps(scope_id, kind=kind, after=after) + for evidence in page.items: + if evidence.operation_id is None: + continue + key = ( + ResourceRef.artifact(scope_id, family=evidence.family, artifact_id=evidence.identity).key + if kind == "artifact" + else evidence.identity + ) + item = by_identity.get((scope_id, kind, key)) + if item is not None: + conflicts.append( + OwnershipDisposition( + gap=item, outcome="conflict", detail=f"owner-recovery-{evidence.reason}" + ) + ) + after = page.after + if after is None: + break + return conflicts + + +def _migration_item_key(item: ArtifactOwnershipGap | CandidateOwnershipGap) -> tuple[str, str, str]: + if isinstance(item, ArtifactOwnershipGap): + return (item.scope_id, "artifact", _owner_identity(item)[1].key) + return (item.scope_id, "candidate", item.candidate_id) + + async def _apply_gap( access: AccessControlService, item: ArtifactOwnershipGap | CandidateOwnershipGap, @@ -290,23 +371,14 @@ async def _artifact_gaps( ) -> tuple[ArtifactOwnershipGap, ...]: records = application.records.for_scope(scope_id) - async def family_gaps(family: str) -> tuple[ArtifactOwnershipGap, ...]: - gaps: list[ArtifactOwnershipGap] = [] - cursor: str | None = None - while True: - page = await records.query_artifacts(family, cursor=cursor, limit=RESOURCE_PAGE_SIZE) - for record in page.items: - resource = ResourceRef.artifact(scope_id, family=family, artifact_id=record.artifact_id) - if access is not None and await access.artifact_owner(resource) is not None: - continue - gaps.append(ArtifactOwnershipGap(scope_id=scope_id, family=family, artifact_id=record.artifact_id)) - cursor = page.next_cursor - if cursor is None: - return tuple(gaps) - gaps: list[ArtifactOwnershipGap] = [] - for family in OWNED_ARTIFACT_FAMILIES: - gaps.extend(await family_gaps(family)) + for record in await records.logical_artifacts(): + if record.family not in OWNED_ARTIFACT_FAMILIES: + continue + resource = ResourceRef.artifact(scope_id, family=record.family, artifact_id=record.artifact_id) + if access is not None and await access.artifact_owner(resource) is not None: + continue + gaps.append(ArtifactOwnershipGap(scope_id, record.family, record.artifact_id)) if application.handoff is not None and await application.handoff.for_scope(scope_id).revisions(): resource = ResourceRef.artifact(scope_id, family="handoff", artifact_id="handoff") if access is None or await access.artifact_owner(resource) is None: diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index 233b70e54f..0b2d6f95d6 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -24,17 +24,24 @@ from __future__ import annotations import asyncio +import json +from contextlib import asynccontextmanager from pathlib import Path +from typing import Any, Literal import httpx +import pytest +from pydantic import SecretStr +from typer.testing import CliRunner from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import RuntimeConfig +from powercontext.cli.app import create_cli from powercontext.server.authentication import AuthenticationResult, ProviderReadiness -from powercontext.server.authz import AccessConflictError, PrincipalRef +from powercontext.server.authz import AccessAuditContext, AccessConflictError, PrincipalRef, ResourceRef from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.factory import create_server_app -from powercontext.server.ownership import apply_ownership_migration +from powercontext.server.ownership import ArtifactOwnershipGap, apply_ownership_migration from powercontext.server.settings import AccessControlConfig, McpConfig, MetricsConfig, ServerSettings ADMIN = PrincipalRef(type="service", id="admin") @@ -58,23 +65,26 @@ async def readiness(self): return ProviderReadiness(ready=True) -def _settings(database: SQLiteConfig, mode: str) -> ServerSettings: +def _settings(database: SQLiteConfig, mode: Literal["disabled", "enforced"]) -> ServerSettings: return ServerSettings( database=database, - runtime=RuntimeConfig(artifact_processing_families=()), + runtime=RuntimeConfig( + artifact_processing_families=(), + atomic_memory_preview_signing_secret=SecretStr("ownership-migration-test-signing-key"), + ), access=AccessControlConfig(mode=mode, deployment_id=DEPLOYMENT), mcp=McpConfig(enabled=False), metrics=MetricsConfig(enabled=False), ) -def checked(response: httpx.Response, expected: int) -> dict: +def checked(response: httpx.Response, expected: int) -> dict[str, Any]: data = response.json() assert response.status_code == expected, data return data -async def _create_legacy_data(client: httpx.AsyncClient) -> tuple[str, str, dict]: +async def _create_legacy_data(client: httpx.AsyncClient) -> tuple[str, str, dict[str, Any]]: scope = checked( await client.post( "/v1/scopes", @@ -137,6 +147,12 @@ async def scenario() -> None: scope_plan = next(item for item in plan["scopes"] if item["scope_id"] == scope) assert [gap["family"] for gap in scope_plan["artifact_gaps"]] == ["atomic-memory"] assert [gap["candidate_id"] for gap in scope_plan["candidate_gaps"]] == [candidate["candidate_id"]] + background_without_store = await client.post( + "/v1/access/ownership/plan", + json={"scope_ids": [scope], "background_principal": {"type": "service", "id": "future-worker"}}, + ) + assert background_without_store.status_code == 503 + assert background_without_store.json()["error"]["code"] == "relationship_management_unavailable" apply_while_disabled = await client.post( "/v1/access/ownership/apply", json={"owner": {"type": "service", "id": "admin"}, "scope_ids": [scope]}, @@ -275,6 +291,276 @@ async def scenario() -> None: asyncio.run(scenario()) +@asynccontextmanager +async def _enforced_server(database: SQLiteConfig, tmp_path: Path): + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + yield client, access + + +@pytest.mark.parametrize("retire_result_before_switch", [False, True]) +def test_migration_preserves_authority_to_undo_a_legacy_merge( + tmp_path: Path, retire_result_before_switch: bool +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'merged.db'}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, first_path, _ = await _create_legacy_data(client) + second = checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": "fact", "text": "Second fact."}}, + ), + 201, + ) + states = [ + checked(await client.get(path + "/state"), 200) + for path in (first_path, f"/v1/scopes/{scope}/artifacts/atomic-memory/{second['artifact_id']}") + ] + merged = checked( + await client.post( + "/v1/atomic-memory/merges", + json={ + "scope_id": scope, + "inputs": [ + {"artifact": state["artifact"], "state_version": state["state_version"]} for state in states + ], + "content": {"kind": "fact", "text": "Combined fact."}, + }, + ), + 200, + ) + output = next(item for item in merged["records"] if item["state"] == "active") + identities = {item["artifact"]["artifact_id"] for item in merged["records"]} + payload = { + "scope_id": scope, + "target": {"artifact_id": output["artifact"]["artifact_id"]}, + "operation": "undo_merge", + } + if retire_result_before_switch: + preview = checked(await client.post("/v1/atomic-memory/restoration-previews", json=payload), 200) + checked( + await client.post( + "/v1/atomic-memory/restorations", json={**payload, "preview_token": preview["preview_token"]} + ), + 200, + ) + + async with _enforced_server(database, tmp_path) as (client, access): + plan = checked(await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]}), 200) + assert {item["artifact_id"] for item in plan["scopes"][0]["artifact_gaps"]} == identities + applied = checked( + await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": ADMIN.type, "id": ADMIN.id}}, + ), + 200, + ) + assert applied["conflicts"] == [] + if retire_result_before_switch: + resource = ResourceRef.artifact( + scope, family="atomic-memory", artifact_id=output["artifact"]["artifact_id"] + ) + owner = await access.artifact_owner(resource) + assert owner is not None and owner.owner == ADMIN + state = checked( + await client.get(f"/v1/scopes/{scope}/artifacts/atomic-memory/{resource.artifact_id}/state"), 200 + ) + assert state["state"] == "retired" + return + preview = checked(await client.post("/v1/atomic-memory/restoration-previews", json=payload), 200) + restored = checked( + await client.post( + "/v1/atomic-memory/restorations", json={**payload, "preview_token": preview["preview_token"]} + ), + 200, + ) + assert {item["artifact_id"] for item in restored["restored"]} == { + state["artifact"]["artifact_id"] for state in states + } + assert [item["artifact_id"] for item in restored["retired"]] == [output["artifact"]["artifact_id"]] + + asyncio.run(scenario()) + + +def test_existing_owner_and_attestation_conflicts_are_reported_in_preview_and_apply(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'existing-owners.db'}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, path, candidate = await _create_legacy_data(client) + original = checked(await client.get(path), 200) + other = PrincipalRef(type="user", id="original-owner") + resource = ResourceRef.artifact(scope, family="atomic-memory", artifact_id=path.rsplit("/", 1)[-1]) + async with _enforced_server(database, tmp_path) as (client, access): + owner = await access.establish_artifact_owner( + resource, + other, + idempotency_key="original-owner", + context=AccessAuditContext(transport="http", operation="fixture"), + ) + attestation = await access.attest_candidate_owner( + scope_id=scope, + candidate_id=candidate["candidate_id"], + family="experience", + proposed_owner=other, + target=None, + idempotency_key="original-candidate-owner", + ) + for dry_run in (True, False): + result = checked( + await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": ADMIN.type, "id": ADMIN.id}, "dry_run": dry_run}, + ), + 200, + ) + assert result["applied"] == [] + assert {item["conflict_code"] for item in result["conflicts"]} == {"artifact-owner", "candidate-owner"} + assert await access.artifact_owner(resource) == owner + assert await access.candidate_owner(scope, candidate["candidate_id"]) == attestation + retained = checked(await client.get(path, headers={"Authorization": "Bearer original-owner"}), 200) + assert retained["revision"] == original["revision"] + assert retained["content"] == original["content"] + + asyncio.run(scenario()) + + +def test_background_checks_remain_after_migration_and_cover_profile_writes(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'background.db'}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, _, _ = await _create_legacy_data(client) + checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "profile", "content": {"content": "# Legacy profile"}}, + ), + 201, + ) + async with _enforced_server(database, tmp_path) as (client, _): + checked( + await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": ADMIN.type, "id": ADMIN.id}}, + ), + 200, + ) + checked( + await client.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": "worker"}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "worker-contribution", + }, + ), + 201, + ) + checked( + await client.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": ADMIN.type, "id": ADMIN.id}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": "owner-contribution", + }, + ), + 201, + ) + for principal, expected_gaps in (("unbound-worker", 1), ("worker", 1), (ADMIN.id, 0)): + plan = checked( + await client.post( + "/v1/access/ownership/plan", + json={"scope_ids": [scope], "background_principal": {"type": "service", "id": principal}}, + ), + 200, + ) + assert plan["scopes"][0]["artifact_gaps"] == [] + assert len(plan["background"]) == expected_gaps + if expected_gaps: + assert plan["background"][0]["scope_id"] == scope + + asyncio.run(scenario()) + + +def test_offline_cli_checks_future_background_principal_before_enforcement(tmp_path: Path) -> None: + from powercontext.server.cli import app as server_cli + + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'cli-background.db'}") + + async def seed() -> str: + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, _, _ = await _create_legacy_data(client) + return scope + + scope = asyncio.run(seed()) + environment = tmp_path / "server.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + f"POWERCONTEXT_SERVER_ACCESS_MODE=disabled\nPOWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID={DEPLOYMENT}\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + result = CliRunner().invoke( + create_cli([server_cli]), + [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "plan", + "--scope-id", + scope, + "--background-principal", + "service:future-worker", + ], + ) + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert [item["principal_id"] for item in report["background"]] == ["future-worker"] + assert [item["scope_id"] for item in report["background"]] == [scope] + + +@pytest.mark.parametrize("principal", ["missing-type", "user:", "group:workers", "service: worker"]) +def test_cli_rejects_invalid_background_identity(principal: str) -> None: + from powercontext.server.cli import app as server_cli + + result = CliRunner().invoke( + create_cli([server_cli]), ["server", "access-ownership-migrate", "--background-principal", principal] + ) + assert result.exit_code == 2 + + def test_offline_cli_migration_repairs_the_switch(tmp_path: Path) -> None: """The offline command plans and applies without a running Server, before or after the switch.""" @@ -342,7 +628,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_apply_reports_conflicting_ownership_without_failing(tmp_path: Path) -> None: +def test_apply_reports_conflicting_ownership_without_failing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def scenario() -> None: database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'conflict.db'}") app = create_server_app( @@ -380,7 +666,7 @@ async def conflicting_establish(resource, owner, *, idempotency_key, context): raise AccessConflictError("artifact-owner") return await original_establish(resource, owner, idempotency_key=idempotency_key, context=context) - access.establish_artifact_owner = conflicting_establish # type: ignore[method-assign] + monkeypatch.setattr(access, "establish_artifact_owner", conflicting_establish) result = await apply_ownership_migration( application, access, @@ -388,7 +674,10 @@ async def conflicting_establish(resource, owner, *, idempotency_key, context): owner=ADMIN, ) assert attempts == 1 - assert [item.gap.artifact_id for item in result.conflicts] == [artifact_id] + assert len(result.conflicts) == 1 + gap = result.conflicts[0].gap + assert isinstance(gap, ArtifactOwnershipGap) + assert gap.artifact_id == artifact_id assert result.conflicts[0].detail == "artifact-owner" assert result.applied diff --git a/tests/e2e/test_owner_recovery_http.py b/tests/e2e/test_owner_recovery_http.py index 09d8efdcc4..69c396e165 100644 --- a/tests/e2e/test_owner_recovery_http.py +++ b/tests/e2e/test_owner_recovery_http.py @@ -17,7 +17,8 @@ from __future__ import annotations import asyncio -from contextlib import asynccontextmanager +import sqlite3 +from contextlib import asynccontextmanager, closing from typing import cast import httpx @@ -182,6 +183,95 @@ async def scenario(): asyncio.run(scenario()) +@pytest.mark.parametrize(("kind", "count"), [("artifact", 1), ("candidate", 101)]) +def test_ownership_migration_cannot_replace_pending_original_attribution(tmp_path, kind, count): + async def scenario(): + async with _server(tmp_path, interrupted=True) as (app, client, access): + scope_id = await _scope(client) + if kind == "artifact": + created = await client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "profile", "content": {"content": "# Original private profile"}}, + ) + assert created.status_code == 503, created.text + else: + source = await client.post(f"/v1/scopes/{scope_id}/sources", json={"content": "Original evidence."}) + assert source.status_code == 201, source.text + for index in range(count): + created = await client.post( + "/v1/experience/propose", + json={ + "scope_id": scope_id, + "proposal": { + "situation": f"Observation {index}.", + "action": "Retain the original proposer.", + "outcome": "Ownership delivery is pending.", + "lesson": "Preserve recorded attribution.", + }, + "source_refs": [{"name": "content", "source_id": source.json()["source_id"]}], + "artifact_refs": [], + }, + ) + assert created.status_code == 503, created.text + pending = await app.state.owner_recovery.pending(scope_id=scope_id, limit=500) + assert len(pending) == count + for dry_run in (True, False): + response = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": dry_run}, + ) + assert response.status_code == 200, response.text + result = response.json() + assert result["applied"] == [] + assert len(result["conflicts"]) == count + assert {item["conflict_code"] for item in result["conflicts"]} == {"owner-recovery-pending"} + access.relationships.unavailable = False + for intent in pending: + await app.state.owner_recovery.replay(intent.operation_id) + if intent.resource is not None: + relation = await access.artifact_owner(intent.resource) + assert relation is not None and relation.owner == ALICE + else: + attestation = await access.candidate_owner(scope_id, intent.candidate_id) + assert attestation is not None and attestation.proposed_owner == ALICE + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + + asyncio.run(scenario()) + + +def test_migration_preserves_completed_ownership_evidence_after_external_relation_loss(tmp_path): + async def scenario(): + async with _server(tmp_path, interrupted=True) as (app, client, access): + scope_id = await _scope(client) + created = await client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "profile", "content": {"content": "# Original private profile"}}, + ) + assert created.status_code == 503, created.text + (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) + access.relationships.unavailable = False + await app.state.owner_recovery.replay(intent.operation_id) + # Model out-of-band loss in the external relationship store after delivery was acknowledged. + with closing(sqlite3.connect(tmp_path / "relationships.db")) as connection: + connection.execute("DELETE FROM pc_access_owners WHERE owner_kind = 'artifact'") + connection.commit() + for dry_run in (True, False): + response = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": dry_run}, + ) + assert response.status_code == 200, response.text + result = response.json() + assert result["applied"] == [] + assert [item["conflict_code"] for item in result["conflicts"]] == [ + "owner-recovery-delivered_relation_missing" + ] + assert await access.artifact_owner(intent.resource) is None + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + + asyncio.run(scenario()) + + @pytest.mark.parametrize("operation", ["publication", "handoff"]) def test_idempotent_retry_requires_the_original_owner_audit_to_complete(tmp_path, monkeypatch, operation): async def scenario(): From 15c9d9323dd071e595430c7b9d8a3ca68b4a4bee Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Thu, 29 Oct 2026 18:33:48 +0800 Subject: [PATCH 03/25] fix(access): retain ownership migration recovery evidence --- src/powercontext/server/cli.py | 4 + src/powercontext/server/ownership.py | 60 +++++++++----- tests/e2e/test_owner_recovery_http.py | 110 ++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 21 deletions(-) diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index a5413a83ce..b29e676912 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -51,6 +51,7 @@ from powercontext.server.app import access_readiness_checks from powercontext.server.authz import PrincipalRef from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.owner_recovery import OwnerRecovery from powercontext.server.background_metrics import serve_background_metrics from powercontext.server.configuration import ( ServerConfigurationError, @@ -595,6 +596,8 @@ async def _access_ownership_maintenance( runtime = await resources.enter_async_context(open_builtin_runtime(config)) if runtime.scopes is None: raise typer.BadParameter("ownership migration requires a runtime with Scope support") # noqa: TRY003 + recovery = OwnerRecovery(runtime.primary_database, access) + await recovery.initialize() scopes = list(scope_ids) or [scope.scope_id for scope in await runtime.scopes.list()] if action == "plan": plan = await plan_ownership_migration( @@ -613,6 +616,7 @@ async def _access_ownership_maintenance( scopes, owner=PrincipalRef(type=owner_type, id=owner_id), transport="background", + owner_recovery=recovery, ) report = ownership_report(result) typer.echo(json.dumps(report)) diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index d74d086433..08d065a998 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -39,11 +39,12 @@ ResourceRef, ) from powercontext.server.authz.models import MemoryEntrySelector +from powercontext.server.authz.owner_recovery import flush_owner_writes, owner_recovery_session, owner_write_access if TYPE_CHECKING: from powercontext.builtin.runtime import BuiltinRuntime from powercontext.server.app import ServerApplication - from powercontext.server.authz.owner_recovery import OwnerRecovery + from powercontext.server.authz.owner_recovery import OwnerCommit, OwnerRecovery from powercontext.server.authz.service import AccessControlService Application = ServerApplication | BuiltinRuntime @@ -235,36 +236,53 @@ async def apply_ownership_migration( items.append(item) elif existing_owner != owner: conflicts.append(_conflict(item, AccessConflictError(conflict_code))) + elif isinstance(item, ArtifactOwnershipGap) and not dry_run: + # The relation may have committed before its external audit failed. + # Complete the original intent before an idempotent retry succeeds. + async with owner_recovery_session(owner_recovery): + await flush_owner_writes(artifact=_owner_identity(item)[1]) protected = await _owner_recovery_conflicts(owner_recovery, items) protected_keys = {_migration_item_key(item.gap) for item in protected} items = [item for item in items if _migration_item_key(item) not in protected_keys] if dry_run: - preview: list[OwnershipDisposition] = [] - for item in items: - if isinstance(item, CandidateOwnershipGap): - preview.extend( - OwnershipDisposition( - gap=ArtifactOwnershipGap( - scope_id=item.scope_id, - family=item.family, - artifact_id=item.target_artifact_id or "", - entry_id=entry_id, - ), - outcome="applied", - ) - for entry_id in item.entry_ids - ) - preview.append(OwnershipDisposition(gap=item, outcome="applied")) - return OwnershipMigrationResult(applied=tuple(preview), conflicts=(*conflicts, *protected)) + return OwnershipMigrationResult(applied=_preview_gaps(items), conflicts=(*conflicts, *protected)) applied: list[OwnershipDisposition] = [] conflicts.extend(protected) for item in items: - item_applied, item_conflicts = await _apply_gap(access, item, owner, context) + if owner_recovery is None: + item_applied, item_conflicts = await _apply_gap(access, item, owner, context) + else: + async with ( + owner_recovery_session(owner_recovery), + owner_recovery.database.transaction() as connection, + ): + writer = owner_write_access(access, connection) + item_applied, item_conflicts = await _apply_gap(writer, item, owner, context) applied.extend(item_applied) conflicts.extend(item_conflicts) return OwnershipMigrationResult(applied=tuple(applied), conflicts=tuple(conflicts)) +def _preview_gaps(items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap]) -> tuple[OwnershipDisposition, ...]: + preview: list[OwnershipDisposition] = [] + for item in items: + if isinstance(item, CandidateOwnershipGap): + preview.extend( + OwnershipDisposition( + gap=ArtifactOwnershipGap( + scope_id=item.scope_id, + family=item.family, + artifact_id=item.target_artifact_id or "", + entry_id=entry_id, + ), + outcome="applied", + ) + for entry_id in item.entry_ids + ) + preview.append(OwnershipDisposition(gap=item, outcome="applied")) + return tuple(preview) + + async def _owner_recovery_conflicts( recovery: OwnerRecovery | None, items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap], @@ -306,7 +324,7 @@ def _migration_item_key(item: ArtifactOwnershipGap | CandidateOwnershipGap) -> t async def _apply_gap( - access: AccessControlService, + access: AccessControlService | OwnerCommit, item: ArtifactOwnershipGap | CandidateOwnershipGap, owner: PrincipalRef, context: AccessAuditContext, @@ -424,7 +442,7 @@ async def _candidate_gaps( async def _attest_gap_owner( - access: AccessControlService, + access: AccessControlService | OwnerCommit, gap: CandidateOwnershipGap, owner: PrincipalRef, ) -> None: diff --git a/tests/e2e/test_owner_recovery_http.py b/tests/e2e/test_owner_recovery_http.py index 69c396e165..f891e9254b 100644 --- a/tests/e2e/test_owner_recovery_http.py +++ b/tests/e2e/test_owner_recovery_http.py @@ -17,16 +17,19 @@ from __future__ import annotations import asyncio +import json import sqlite3 from contextlib import asynccontextmanager, closing from typing import cast import httpx import pytest +from typer.testing import CliRunner from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import RuntimeConfig +from powercontext.cli.app import create_cli from powercontext.server.authentication import AuthenticationResult, ProviderReadiness from powercontext.server.authz import AccessUnavailableError, PrincipalRef, RelationshipStore, ResourceRef from powercontext.server.authz.composition import open_builtin_access_control @@ -272,6 +275,113 @@ async def scenario(): asyncio.run(scenario()) +@pytest.mark.parametrize("completed", [False, True]) +def test_offline_migration_preserves_external_owner_evidence(tmp_path, completed): + from powercontext.server.cli import app as server_cli + + async def seed(): + async with _server(tmp_path, interrupted=True) as (app, client, access): + scope_id = await _scope(client) + created = await client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "profile", "content": {"content": "# Original private profile"}}, + ) + assert created.status_code == 503, created.text + (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) + if completed: + access.relationships.unavailable = False + await app.state.owner_recovery.replay(intent.operation_id) + return scope_id, intent + + scope_id, intent = asyncio.run(seed()) + environment = tmp_path / "offline.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" + f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{tmp_path / 'domain.db'}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=external-test\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + result = CliRunner().invoke( + create_cli([server_cli]), + [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "apply", + "--owner", + "user:bob", + "--scope-id", + scope_id, + "--maintenance-confirmed", + ], + ) + assert result.exit_code == 1, result.output + report = json.loads(result.output) + assert report["applied"] == [] + reason = "delivered_relation_missing" if completed else "pending" + assert [item["detail"] for item in report["conflicts"]] == [f"owner-recovery-{reason}"] + + async def verify(): + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'domain.db'}") + async with open_builtin_access_control(database, deployment_id="external-test") as local: + assert await local.artifact_owner(intent.resource) is None + async with _server(tmp_path) as (app, _, access): + await app.state.owner_recovery.replay(intent.operation_id) + relation = await access.artifact_owner(intent.resource) + assert relation is not None and relation.owner == ALICE + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + + asyncio.run(verify()) + + +def test_migration_retries_complete_external_owner_audit(tmp_path, monkeypatch): + async def scenario(): + async with _server(tmp_path) as (app, client, access): + scope_id = await _scope(client) + await app.state.application.records.for_scope(scope_id).create_artifact( + "profile", ArtifactWrite(content={"content": "# Legacy profile"}) + ) + resource = ResourceRef.artifact(scope_id, family="profile", artifact_id="profile") + payload = {"scope_ids": [scope_id], "owner": {"type": ALICE.type, "id": ALICE.id}} + + async def unavailable(event): + if event.reason_code == "artifact-owner-established": + raise AccessUnavailableError("external_audit_unavailable") + return await append(event) + + append = access.audit.append_audit + with monkeypatch.context() as patch: + patch.setattr(access.audit, "append_audit", unavailable) + patch.setattr(access.audit, "append_audit_once", unavailable) + for _ in range(2): + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 503, response.text + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == ALICE + pending = await app.state.owner_recovery.pending(scope_id=scope_id) + assert len(pending) == 1 and pending[0].resource == resource + preview = await client.post("/v1/access/ownership/apply", json={**payload, "dry_run": True}) + assert preview.status_code == 200, preview.text + assert await app.state.owner_recovery.pending(scope_id=scope_id) == pending + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 200, response.text + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + audit = await access.audit.list_audit(resource=resource, limit=100) + owners = [event for event in audit if event.reason_code == "artifact-owner-established"] + assert len(owners) == 1 and owners[0].principal == ALICE + assert owners[0].operation == "access.ownership.apply" + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 200, response.text + audit = await access.audit.list_audit(resource=resource, limit=100) + assert [event for event in audit if event.reason_code == "artifact-owner-established"] == owners + + asyncio.run(scenario()) + + @pytest.mark.parametrize("operation", ["publication", "handoff"]) def test_idempotent_retry_requires_the_original_owner_audit_to_complete(tmp_path, monkeypatch, operation): async def scenario(): From 87d7e1fdf4a505c5ce5e00639210e058d02cc01c Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Thu, 29 Oct 2026 22:02:36 +0800 Subject: [PATCH 04/25] fix(ci): synchronize ownership migration contracts and bundles --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/VALIDATION.md | 42 ++++++++++++++++++- .../src/connections/compatibility.json | 20 +++++++++ docs/en/docs/operate/deploy-server.md | 5 ++- docs/zh/docs/operate/deploy-server.md | 4 +- e2e/bub/src/powercontext_e2e/access_audit.py | 2 + .../powercontext-hooks/node/worker.cjs | 22 ++++++++++ .../dsh/plugins/powercontext/lib/index.js | 22 ++++++++++ 9 files changed, 116 insertions(+), 5 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index f88df0b7e4..3e5fb6cbb0 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -164,7 +164,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For qualified current Server sources, explicitly choose `sqlite-atomic-dd62723d-v1` or `sqlite-atomic-4ffdf4e9-v1` according to the recorded Server sources and wheel, then check the connection before selecting a Scope. Both records match this bundled contract and retain their independently verified operation sets. Historical profiles, including `sqlite-atomic-c535d816-v1`, retain their original Server sources, contracts and operation sets and do not match this bundled contract. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Historical qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For the qualified current Server sources, explicitly choose `sqlite-atomic-15c9d932-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and preserves its independently verified operation set. Historical profiles retain their original Server sources, contracts and operation sets. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 2dc1f2f924..b7a1cd4bd8 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -147,7 +147,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于已资格化的当前 Server 源码,请按记录的 Server 源码和 wheel 明确选择 `sqlite-atomic-dd62723d-v1` 或 `sqlite-atomic-4ffdf4e9-v1`,检查连接后再选择范围。两项记录均匹配本构建的契约,并保留各自独立验证的操作集合。包括 `sqlite-atomic-c535d816-v1` 在内的历史配置保留原有 Server 源码、契约和操作集合,不匹配本构建的打包契约。已记录的 Server 源码和 wheel 身份必须与实际部署一致。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。历史资格证据和剩余要求见[验证说明](VALIDATION.md)。 +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于已验证的当前 Server 源码,请明确选择 `sqlite-atomic-15c9d932-v1`,检查连接后再选择范围。该记录匹配本构建的契约,并保留独立验证的操作集合。历史配置保留原有 Server 源码、契约和操作集合。记录的 Server 源码和 wheel 身份必须与实际部署一致。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的验证证据和剩余要求见[验证说明](VALIDATION.md)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index b9d7ec787c..b71373e783 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,7 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract has source-specific qualifications `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` below. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. The `dd62723d` record covers the migration-framework sources; the `4ffdf4e9` record covers the earlier release-preparation sources. The `sqlite-atomic-c535d816-v1`, `sqlite-atomic-98275dbb-v1`, `sqlite-atomic-1da99747-v1`, `sqlite-atomic-8f10c560-v1`, `sqlite-atomic-d9a78567-v1`, `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the profiles below. The current contract has the source-specific qualification `sqlite-atomic-15c9d932-v1`. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. The other records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -59,6 +59,46 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Current Server protocol qualification +#### Server 15c9d932 protocol qualification + +`sqlite-atomic-15c9d932-v1` qualifies Server sources at +`15c9d9323dd071e595430c7b9d8a3ca68b4a4bee` for OpenAPI digest +`e37820efe88057fb7c77968b6c28d18c9698a919695d9c83e229ca0ca95cc08d`. +The wheel is `powercontext-1.4.2.dev49+g15c9d9323-py3-none-any.whl`, SHA-256 +`7c943cb6a43b7e2e359f70cc0779d2f998b8ed79079eadfcb60b93c6f30b5bb0`, built from that clean commit. +All 618 packaged source files in `powercontext/` and `powercontext_service_bootstrap/` match the +recorded source bytes; generated version metadata is excluded. The sorted source manifest digest is +`b6ded3cf00b2a08488c6ccea43126b48045ca3f57ff1824557d6a0e26d1109b2` +(one `wheel/path sha256` line per file, with a final newline). OpenAPI, project configuration and +the lock file also match the recorded commit. + +On 2026-10-29, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA +and base path, and injected-provider/enforced access. The eleven recorded operations cover +discovery, health, Principal, Scope selection and pagination, Atomic Memory save/search/exact +revision reads, identity changes, same-identity revocation, committed writes with lost responses +without replay, and Server survival after client exit. The native probe uses the unmodified +product modules and locked dependencies. The harness reports `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit 15c9d9323dd071e595430c7b9d8a3ca68b4a4bee +uv run --frozen python desktop/tests/real_server.py +``` + +Local evidence is under ignored `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the stated SQLite protocol +behavior. Installed GUI workflows, persistent native credentials, Windows/Intel execution, signing, +and accessibility require their separate checks. Historical records retain their exact identities. + +本记录绑定上述源码、契约和 wheel。四种真实 SQLite/HTTP 连接模式均已通过,覆盖发现、 +Scope、Atomic Memory、身份变化、撤权及响应丢失后的不确定写入。该结果不替代安装后 GUI、 +原生凭据持久化、Windows/Intel、签名或无障碍验收;历史资格记录保留原始身份。 + #### Server 4ffdf4e9 protocol qualification `sqlite-atomic-4ffdf4e9-v1` qualifies the v1.4.1 preparation sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index aaf7d8133b..8dc7181112 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -266,5 +266,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-c535d816-protocol-qualification" + }, + { + "id": "sqlite-atomic-15c9d932-v1", + "serverCommit": "15c9d9323dd071e595430c7b9d8a3ca68b4a4bee", + "contractSha256": "e37820efe88057fb7c77968b6c28d18c9698a919695d9c83e229ca0ca95cc08d", + "artifactSha256": "7c943cb6a43b7e2e359f70cc0779d2f998b8ed79079eadfcb60b93c6f30b5bb0", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-15c9d932-protocol-qualification" } ] diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index 7f69ea7015..f1adb490a2 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -215,10 +215,13 @@ so an administrator migrates the missing attribution explicitly: different Scopes; multi-user deployments keep this identity boundary explicit. The inventory includes merged and retired Artifact identities so supported restoration remains authorized. - HTTP `apply` and its preview report `owner-recovery-pending` or + Offline `apply`, HTTP `apply`, and the HTTP preview report `owner-recovery-pending` or `owner-recovery-delivered_relation_missing` when durable recovery evidence already records an identity. They preserve that evidence and do not replace its owner. Complete pending ownership delivery through the [ownership recovery procedure](../workflows/scopes-and-access.md#recover-external-ownership-writes). + With an external Access store, migration retains durable ownership delivery work if its audit write fails. + Repeating the same HTTP `apply` completes that original audit before reporting success, even if the owner + relation already exists. A preview does not replay pending work. Without a stopped-deployment window, the same flow is available over HTTP on a running Server: `POST /v1/access/ownership/apply` with `{"owner": {"type": "service", "id": "server-token"}, "dry_run": true}` diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 50aca1c829..063521df4a 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -195,8 +195,10 @@ Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与 再次执行;多用户部署由此保持明确的身份边界。 盘点包含已合并和已退休的 Artifact 身份,以保留恢复操作所需的权限。如果持久化恢复记录已经确定归属, - HTTP `apply` 及其预览会报告 `owner-recovery-pending` 或 `owner-recovery-delivered_relation_missing`, + 离线 `apply`、HTTP `apply` 及 HTTP 预览会报告 `owner-recovery-pending` 或 `owner-recovery-delivered_relation_missing`, 并保留原始记录及其归属。待投递的归属信息应按[归属恢复流程](../workflows/scopes-and-access.md)继续处理。 + 使用外部 Access 存储时,如果审计写入失败,迁移会保留可重放的归属投递任务。即使 owner 关系已存在, + 重复同一 HTTP `apply` 也会先完成原始审计,再报告成功。预览不会重放待处理任务。 如果没有停机窗口,也可以对运行中的 Server 走 HTTP:`POST /v1/access/ownership/apply`(先用 `dry_run: true` 预览)。认证模式下两个 HTTP 操作都要求 `server.admin`;`ACCESS_MODE=disabled` 时 `apply` 返回 diff --git a/e2e/bub/src/powercontext_e2e/access_audit.py b/e2e/bub/src/powercontext_e2e/access_audit.py index e62de78991..4c95ddc400 100644 --- a/e2e/bub/src/powercontext_e2e/access_audit.py +++ b/e2e/bub/src/powercontext_e2e/access_audit.py @@ -59,6 +59,8 @@ "revoke_access_binding", "replace_access_binding", "list_access_audit", + "plan_access_ownership", + "apply_access_ownership", }) # Checks the Server reports to the caller rather than enforces: which actions a Candidate offers, and the Access API's # permission check. Denying one does not reject the request that asked. diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index 5278008abb..754a4c6f5e 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -1541,6 +1541,28 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 517a6c85b2..6576389f5b 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1610,6 +1610,28 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); From a4dc0b111711b8d612c896ef91f5bc52fd32a28a Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Fri, 30 Oct 2026 09:03:09 +0800 Subject: [PATCH 05/25] fix(access): bound ownership migration idempotency keys --- src/powercontext/server/ownership.py | 16 +++- tests/e2e/test_access_ownership_migration.py | 98 ++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 08d065a998..ad7439bbe8 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -24,6 +24,7 @@ from __future__ import annotations +import json from collections.abc import Sequence from dataclasses import dataclass from hashlib import sha256 @@ -457,7 +458,7 @@ async def _attest_gap_owner( family=gap.family, proposed_owner=owner, target=target, - idempotency_key=f"candidate-owner:{gap.scope_id}:{gap.candidate_id}", + idempotency_key=_migration_key("candidate-owner", gap.scope_id, gap.candidate_id), ) @@ -483,7 +484,16 @@ def _owner_identity(gap: ArtifactOwnershipGap) -> tuple[str, ResourceRef]: ) return f"base-artifact-owner:{sha256(resource.key.encode()).hexdigest()}", resource if gap.family == "profile" and gap.artifact_id == "profile": - key = f"profile-owner:{gap.scope_id}" + key = _migration_key("profile-owner", gap.scope_id) else: - key = f"{gap.family}-owner:{gap.scope_id}:{gap.artifact_id}" + key = _migration_key(f"{gap.family}-owner", gap.scope_id, gap.artifact_id) return key, ResourceRef.artifact(gap.scope_id, family=gap.family, artifact_id=gap.artifact_id) + + +def _migration_key(prefix: str, *identity: str) -> str: + """Keep accepted keys stable; hash complete identity parts for longer keys.""" + key = f"{prefix}:{':'.join(identity)}" + if len(key) <= 255: + return key + encoded = json.dumps((prefix, *identity), ensure_ascii=False, separators=(",", ":")).encode() + return f"{prefix}:{sha256(encoded).hexdigest()}" diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index 0b2d6f95d6..0c4f1d70de 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -36,6 +36,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import RuntimeConfig +from powercontext.builtin.scope import ScopeApplication, ScopeDraft from powercontext.cli.app import create_cli from powercontext.server.authentication import AuthenticationResult, ProviderReadiness from powercontext.server.authz import AccessAuditContext, AccessConflictError, PrincipalRef, ResourceRef @@ -309,6 +310,103 @@ async def _enforced_server(database: SQLiteConfig, tmp_path: Path): yield client, access +@pytest.mark.parametrize("scope_length", [240, 256]) +@pytest.mark.parametrize("kind", ["experience", "profile", "candidate"]) +def test_long_scope_ownership_migration_matches_preview(tmp_path: Path, scope_length: int, kind: str) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'long-scope.db'}") + scope_id = "s" * scope_length + artifacts: list[dict[str, Any]] = [] + candidate: dict[str, Any] | None = None + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scopes = ScopeApplication(app.state.application.primary_database, id_factory=lambda: scope_id) + await scopes.create( + ScopeDraft(title="Long Scope", summary="Legacy ownership", idempotency_key="long-scope") + ) + if kind == "candidate": + source = checked( + await client.post( + "/v1/sources/content", + json={"scope_id": scope_id, "source_id": "legacy-source", "content": "A test passed."}, + ), + 202, + )["source"] + candidate = checked( + await client.post( + "/v1/experience/propose", + json={ + "scope_id": scope_id, + "source_refs": [source], + "artifact_refs": [], + "proposal": { + "situation": "An identity is long.", + "action": "Migrate its owner.", + "outcome": "The owner is recorded.", + "lesson": "Accept supported identities.", + }, + }, + ), + 201, + ) + else: + for index in range(2 if kind == "experience" else 1): + content = ( + {"content": "# Legacy profile"} + if kind == "profile" + else { + "situation": "An identity is long.", + "action": "Migrate its owner.", + "outcome": "The owner is recorded.", + "lesson": f"Preserve identity {index}.", + } + ) + created = checked( + await client.post( + f"/v1/scopes/{scope_id}/artifacts", json={"family": kind, "content": content} + ), + 201, + ) + artifacts.append( + checked( + await client.get(f"/v1/scopes/{scope_id}/artifacts/{kind}/{created['artifact_id']}"), 200 + ) + ) + async with _enforced_server(database, tmp_path) as (client, access): + payload = {"scope_ids": [scope_id], "owner": {"type": ADMIN.type, "id": ADMIN.id}} + preview = checked(await client.post("/v1/access/ownership/apply", json={**payload, "dry_run": True}), 200) + assert preview["conflicts"] == [] + assert len(preview["applied"]) == len(artifacts) + int(candidate is not None) + applied = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert applied["applied"] == preview["applied"] + assert applied["conflicts"] == [] + keys = [] + for artifact in artifacts: + resource = ResourceRef.artifact(scope_id, family=kind, artifact_id=artifact["artifact_id"]) + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == ADMIN + keys.append(relation.idempotency_key) + restored = checked( + await client.get(f"/v1/scopes/{scope_id}/artifacts/{kind}/{artifact['artifact_id']}"), 200 + ) + assert restored["artifact_id"] == artifact["artifact_id"] + assert restored["revision"] == artifact["revision"] + assert restored["content"] == artifact["content"] + if candidate is not None: + attestation = await access.candidate_owner(scope_id, candidate["candidate_id"]) + assert attestation is not None and attestation.proposed_owner == ADMIN + keys.append(attestation.idempotency_key) + assert all(len(key) <= 255 for key in keys) + assert len(set(keys)) == len(keys) + repeated = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert repeated["applied"] == repeated["conflicts"] == [] + + asyncio.run(scenario()) + + @pytest.mark.parametrize("retire_result_before_switch", [False, True]) def test_migration_preserves_authority_to_undo_a_legacy_merge( tmp_path: Path, retire_result_before_switch: bool From cf6741eb0137eeba85ddbb154207c2de577a0fb2 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Fri, 30 Oct 2026 17:11:51 +0800 Subject: [PATCH 06/25] fix(desktop): qualify merged ownership and manifest contract --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/VALIDATION.md | 44 ++++++++++++++++++- .../src/connections/compatibility.json | 20 +++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 733e448002..63483427d5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -172,7 +172,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For the qualified current Server sources, explicitly choose `sqlite-atomic-15c9d932-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and preserves its independently verified operation set. Historical profiles retain their original Server sources, contracts and operation sets. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For the qualified current Server sources, explicitly choose `sqlite-atomic-db9c8d6e-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and preserves its independently verified operation set. Historical profiles retain their original Server sources, contracts and operation sets. The recorded Server source and wheel identities must match the deployment you intend to use. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 3b4666cf22..544d6f2183 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -155,7 +155,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于已验证的当前 Server 源码,请明确选择 `sqlite-atomic-15c9d932-v1`,检查连接后再选择范围。该记录匹配本构建的契约,并保留独立验证的操作集合。历史配置保留原有 Server 源码、契约和操作集合。记录的 Server 源码和 wheel 身份必须与实际部署一致。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的验证证据和剩余要求见[验证说明](VALIDATION.md)。 +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于已验证的当前 Server 源码,请明确选择 `sqlite-atomic-db9c8d6e-v1`,检查连接后再选择范围。该记录匹配本构建的契约,并保留独立验证的操作集合。历史配置保留原有 Server 源码、契约和操作集合。记录的 Server 源码和 wheel 身份必须与实际部署一致。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的验证证据和剩余要求见[验证说明](VALIDATION.md)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 3d32ae2990..93acf29c6e 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,7 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves every recorded Server source, OpenAPI digest, qualification-wheel digest and operation set. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting a record whose tested sources, wheel and contract match the deployment, then checking the connection before business operations become available. A record with another contract digest cannot qualify this bundled contract or extend its operation set. CI fixture evidence is separate from product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves every recorded Server source, OpenAPI digest, qualification-wheel digest and operation set. The bundled contract is qualified by `sqlite-atomic-db9c8d6e-v1` for the eleven operations recorded below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting a record whose tested sources, wheel and contract match the deployment, then checking the connection before business operations become available. A record with another contract digest cannot qualify this bundled contract or extend its operation set. CI fixture evidence is separate from product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -61,6 +61,48 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server db9c8d6e protocol qualification + +`sqlite-atomic-db9c8d6e-v1` records Server sources at +`db9c8d6edc49166504b39e0d8038dd0082368dee` for OpenAPI digest +`e2a8b64371385e857a3bc44a6d83e09b909a72c9fead64964723ed30fd06226c`. +The wheel is `powercontext-1.4.2.dev90+gdb9c8d6ed-py3-none-any.whl`, SHA-256 +`788a157d27765cc33c16357617d217096bcb3bf056c5f1ecb512e6c14d209e3c`, built from that clean source commit. +All 625 packaged source files in `powercontext/` and `powercontext_service_bootstrap/` +match that commit byte for byte; generated version metadata is excluded. The sorted source +manifest digest is `b962c8c852b5ef31922d6d19cad621009841a47ac288940d4794746a297f52c6` +(one `wheel/path sha256` line per file, with a final newline). OpenAPI, project configuration +and the lock file also match the recorded sources. + +On 2026-10-30, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against independent SQLite Servers loaded from this +wheel. Anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and an injected +provider with enforced access all passed. The unchanged native probe exercised discovery, +health, Principal, Scope selection and pagination, Atomic Memory save/search/exact revision +reads, identity changes, same-identity revocation, committed writes with lost responses without +replay, and Server survival after client exit. The candidate fixture supplies the exact +source/contract/wheel identity; the product record includes only the eleven exercised operations. +The harness reports `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit db9c8d6edc49166504b39e0d8038dd0082368dee +uv run --frozen python desktop/tests/real_server.py +``` + +Local evidence is in ignored `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the listed protocol +behavior. It does not establish native Windows/Intel execution, installed GUI workflows, +persistent credentials, signing or full release-upgrade acceptance. Every historical record +retains its original source, contract, artifact and operation identities. + +本记录绑定上述源码、契约和 wheel。原生探针通过四种真实 SQLite/HTTP 连接模式, +覆盖所列十一项操作、身份变化、撤权及响应丢失后的不确定写入。该验证不替代 Windows/Intel、 +安装界面、原生凭据持久化、签名或完整升级验收。历史资格记录保留原有身份与证据。 + #### Server 15c9d932 protocol qualification `sqlite-atomic-15c9d932-v1` qualifies Server sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 0d5ca1675a..6fadd05d9d 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -324,5 +324,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-15c9d932-protocol-qualification" + }, + { + "id": "sqlite-atomic-db9c8d6e-v1", + "serverCommit": "db9c8d6edc49166504b39e0d8038dd0082368dee", + "contractSha256": "e2a8b64371385e857a3bc44a6d83e09b909a72c9fead64964723ed30fd06226c", + "artifactSha256": "788a157d27765cc33c16357617d217096bcb3bf056c5f1ecb512e6c14d209e3c", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-db9c8d6e-protocol-qualification" } ] From 77b2a0c0ba581234c99c00e34244d6f93f22d031 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Fri, 30 Oct 2026 19:30:38 +0800 Subject: [PATCH 07/25] fix(access): isolate ownership maintenance and retain audit context Co-Authored-By: Claude Code --- .github/workflows/master.yml | 2 + .../builtin/runtime/composition.py | 9 +- src/powercontext/server/app.py | 9 +- src/powercontext/server/cli.py | 2 +- src/powercontext/server/ownership.py | 15 +- tests/e2e/test_access_ownership_migration.py | 87 +++++++++++ .../test_ownership_maintenance_dispatch.py | 138 ++++++++++++++++++ 7 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/test_ownership_maintenance_dispatch.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index eb33a9e187..07d0d90066 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -647,6 +647,8 @@ jobs: run: >- uv run --locked --no-sync pytest -q tests/e2e/test_access_ownership_migration.py::test_offline_cli_checks_future_background_principal_before_enforcement + tests/e2e/test_access_ownership_migration.py::test_migration_audit_preserves_request_context + tests/e2e/test_ownership_maintenance_dispatch.py tests/e2e/test_owner_recovery_http.py::test_offline_migration_preserves_external_owner_evidence - name: Verify bootstrap receipt persistence and failure recovery on Windows diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 4c21e9c5a6..dd597c5a91 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -532,6 +532,7 @@ async def open_builtin_runtime( cursor_secret: bytes | None = None, handoff_verification_keys: tuple[bytes, ...] = (), recall_effort_sink: RecallEffortSink | None = None, + dispatch_processing: bool = True, _archive_recovery: bool = False, ) -> AsyncIterator[BuiltinRuntime]: """Open the selected database, inference adapters, and built-in runtime. @@ -540,6 +541,10 @@ async def open_builtin_runtime( An explicit ``recall_effort_sink`` replaces that recorder rather than adding a second observation. A disabled gate invokes neither recorder nor sink. + Set ``dispatch_processing=False`` for offline maintenance. This skips supervisor + startup without changing deployment configuration, manifest validation, or archive + recovery behavior. + ``worker_initializer`` is an optional bootstrap for automatically composed spawn workers. A ``WorkerInitializerPreparation`` can own per-launch resources through a context manager yielding the picklable child bootstrap. Its resources remain @@ -891,6 +896,7 @@ async def run_profile(scope_id, high): contexts, processing_bindings, tracing=tracing, + dispatch_processing=dispatch_processing, recovery=_archive_recovery, processing_observer=processing_observer, ) @@ -1084,10 +1090,11 @@ async def _open_artifact_processing_supervisor( bindings: Sequence[ArtifactProcessingBinding], *, tracing: RuntimeTracing | None = None, + dispatch_processing: bool = True, recovery: bool = False, processing_observer: Callable[[str, str, float], None] | None = None, ) -> AsyncIterator[ArtifactProcessingSupervisors | None]: - if recovery or config.runtime.artifact_processing_role == "api" or not bindings: + if not dispatch_processing or recovery or config.runtime.artifact_processing_role == "api" or not bindings: yield None return groups = ( diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index c4a2937881..03cad4fd1a 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -2057,7 +2057,13 @@ async def plan_access_ownership(payload: PlanAccessOwnershipRequest, request: Re *_binding_administrative_check(None, deployment_id=access.deployment_id), context=_access_audit_context(PLAN_ACCESS_OWNERSHIP.operation_id), ) - plan = await plan_ownership_migration(application, access, scope_ids, background_principal=background) + plan = await plan_ownership_migration( + application, + access, + scope_ids, + background_principal=background, + context=_access_audit_context(PLAN_ACCESS_OWNERSHIP.operation_id), + ) return AccessOwnershipPlan( mode=TransportAccessControlMode(request.app.state.access_mode), scopes=[ @@ -2098,6 +2104,7 @@ async def apply_access_ownership(payload: ApplyAccessOwnershipRequest, request: owner=_access_principal(payload.owner), dry_run=payload.dry_run, owner_recovery=request.app.state.owner_recovery, + context=_access_audit_context(APPLY_ACCESS_OWNERSHIP.operation_id), ) return AccessOwnershipApplyResult( dry_run=payload.dry_run, diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index d507fd7e66..e846d9bedc 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -652,7 +652,7 @@ async def _access_ownership_maintenance( access = await resources.enter_async_context( open_builtin_access_control(settings.database, deployment_id=settings.access.deployment_id) ) - runtime = await resources.enter_async_context(open_builtin_runtime(config)) + runtime = await resources.enter_async_context(open_builtin_runtime(config, dispatch_processing=False)) if runtime.scopes is None: raise typer.BadParameter("ownership migration requires a runtime with Scope support") # noqa: TRY003 recovery = OwnerRecovery(runtime.primary_database, access) diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index ad7439bbe8..117083be76 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -26,7 +26,7 @@ import json from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from hashlib import sha256 from typing import TYPE_CHECKING @@ -120,7 +120,11 @@ class OwnershipMigrationResult: conflicts: tuple[OwnershipDisposition, ...] -def migration_context(operation: str, *, transport: str = "http") -> AccessAuditContext: +def migration_context( + operation: str, *, transport: str = "http", context: AccessAuditContext | None = None +) -> AccessAuditContext: + if context is not None: + return replace(context, operation=operation) return AccessAuditContext(transport=transport, operation=operation) @@ -139,6 +143,7 @@ async def plan_ownership_migration( *, background_principal: PrincipalRef | None = None, transport: str = "http", + context: AccessAuditContext | None = None, ) -> OwnershipPlan: """Inventory missing Artifact owners and Candidate attestations for Scopes. @@ -161,7 +166,8 @@ async def plan_ownership_migration( ) background: list[BackgroundPermissionGap] = [] if background_principal is not None and access is not None: - context = migration_context(PLAN_OPERATION, transport=transport) + # The checked background Principal does not inherit the administrator's groups. + context = replace(migration_context(PLAN_OPERATION, transport=transport, context=context), subject_groups=()) for scope_id in selected: decision = await access.check( background_principal, @@ -207,6 +213,7 @@ async def apply_ownership_migration( dry_run: bool = False, transport: str = "http", owner_recovery: OwnerRecovery | None = None, + context: AccessAuditContext | None = None, ) -> OwnershipMigrationResult: """Backfill the relations missing per plan, attributed to ``owner``. @@ -216,7 +223,7 @@ async def apply_ownership_migration( """ _validate_migration_owner(owner) - context = migration_context(MIGRATION_OPERATION, transport=transport) + context = migration_context(MIGRATION_OPERATION, transport=transport, context=context) items: list[ArtifactOwnershipGap | CandidateOwnershipGap] = [] conflicts: list[OwnershipDisposition] = [] for scope_id in dict.fromkeys(scope_ids): diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index 420355d891..de306ea1af 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -726,6 +726,93 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.parametrize("store", ["local", "external", "external-retry"]) +def test_migration_audit_preserves_request_context(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, store: str) -> None: + from powercontext.builtin.records import ArtifactWrite + from powercontext.server.authz import AccessUnavailableError + + actor = PrincipalRef(type="service", id="trusted-delegate") + owner = PrincipalRef(type="user", id="bob") + + class DelegatedAuthentication(_Authentication): + async def authenticate(self, request): + authentication = await super().authenticate(request) + return AuthenticationResult(subject=authentication.subject, actor=actor) + + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'audit-domain.db').as_posix()}") + relationships = ( + database + if store == "local" + else SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'audit-access.db').as_posix()}") + ) + seed = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + seed.router.lifespan_context(seed), + httpx.AsyncClient(transport=httpx.ASGITransport(app=seed), base_url="http://test") as client, + ): + scope = checked( + await client.post( + "/v1/scopes", + json={"title": "Audit context", "summary": "Legacy ownership", "idempotency_key": "audit"}, + ), + 201, + )["scope_id"] + await seed.state.application.records.for_scope(scope).create_artifact( + "profile", ArtifactWrite(content={"content": "# Legacy profile"}) + ) + async with open_builtin_access_control( + relationships, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT + ) as access: + app = create_server_app( + settings=_settings(database, "enforced"), + authentication_provider=DelegatedAuthentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + resource = ResourceRef.artifact(scope, family="profile", artifact_id="profile") + payload = {"scope_ids": [scope], "owner": {"type": owner.type, "id": owner.id}} + original_request_id = None + if store == "external-retry": + + async def unavailable(event): + raise AccessUnavailableError("external_audit_unavailable") + + with monkeypatch.context() as patch: + patch.setattr(access.audit, "append_audit_once", unavailable) + failed = await client.post("/v1/access/ownership/apply", json=payload) + assert failed.status_code == 503, failed.text + original_request_id = failed.headers["X-PowerContext-Request-ID"] + assert len(await app.state.owner_recovery.pending(scope_id=scope)) == 1 + response = await client.post("/v1/access/ownership/apply", json=payload) + assert response.status_code == 200, response.text + request_id = original_request_id or response.headers["X-PowerContext-Request-ID"] + events = await access.audit.list_audit(resource=resource, limit=100) + owners = [event for event in events if event.reason_code == "artifact-owner-established"] + assert len(owners) == 1 + assert owners[0].principal == owner + assert owners[0].request_id == request_id + assert owners[0].actor == actor + assert owners[0].transport == "http" + assert owners[0].operation == "access.ownership.apply" + preview = await client.post( + "/v1/access/ownership/plan", + json={"scope_ids": [scope], "background_principal": payload["owner"]}, + ) + assert preview.status_code == 200, preview.text + scope_events = await access.audit.list_audit(resource=ResourceRef.scope(scope), limit=100) + planned = [event for event in scope_events if event.operation == "access.ownership.plan"] + assert planned + assert all(event.request_id == preview.headers["X-PowerContext-Request-ID"] for event in planned) + assert all(event.actor == actor for event in planned) + + asyncio.run(scenario()) + + def test_apply_reports_conflicting_ownership_without_failing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def scenario() -> None: database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'conflict.db'}") diff --git a/tests/e2e/test_ownership_maintenance_dispatch.py b/tests/e2e/test_ownership_maintenance_dispatch.py new file mode 100644 index 0000000000..a26da0414f --- /dev/null +++ b/tests/e2e/test_ownership_maintenance_dispatch.py @@ -0,0 +1,138 @@ +# 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. + +"""Offline ownership maintenance must leave scheduled processing untouched.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +import pytest +from typer.testing import CliRunner + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.supervision import ( + ArtifactProcessingBindingStateRepository, + ArtifactProcessingLeaseRepository, +) +from powercontext.builtin.runtime import BuiltinConfig, InferenceConfig, RuntimeConfig, open_builtin_contexts +from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS +from powercontext.builtin.scope import ScopeDraft +from powercontext.cli.app import create_cli +from powercontext.server.authz import PrincipalRef, ResourceRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.cli import app as server_cli + + +def _business_snapshot(path: Path) -> dict[str, list[tuple[Any, ...]]]: + with sqlite3.connect(path) as connection: + tables = connection.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() + return { + name: sorted(connection.execute(f'SELECT * FROM "{name}"').fetchall(), key=repr) # noqa: S608 + for (name,) in tables + if name.startswith("pc_") and not name.startswith("pc_access_") + } + + +@pytest.mark.parametrize("action", ["plan", "apply"]) +def test_ownership_cli_preserves_pending_scheduled_work(tmp_path: Path, action: Literal["plan", "apply"]) -> None: + database_path = tmp_path / "maintenance.db" + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path.as_posix()}") + config = BuiltinConfig( + database=database, + inference=InferenceConfig(generation_model="test"), + runtime=RuntimeConfig(artifact_processing_families=("memory",), memory_schedule_seconds=60), + ) + + async def seed() -> tuple[str, str]: + async with open_builtin_contexts(config) as contexts: + scope_ids = [] + for title in ("Migration target", "Unrelated scope"): + scope = await contexts.scopes.create(ScopeDraft(title=title, summary=title, idempotency_key=title)) + scope_ids.append(scope.scope_id) + await contexts.records.capture_source( + scope.scope_id, "content", "pending-source", "Keep this pending.", {} + ) + (artifact,) = await contexts.records.create_atomic_memories( + scope_ids[0], ({"kind": "fact", "text": "Legacy fact."},) + ) + async with contexts.database.transaction() as connection: + binding = FAMILY_BINDINGS["memory"] + states = ArtifactProcessingBindingStateRepository() + await states.start_scan(connection, binding, datetime(2020, 1, 1, tzinfo=UTC)) + await states.finish_scan(connection, binding) + await ArtifactProcessingLeaseRepository().start_single_process_term(connection, "stopped-server") + return scope_ids[0], artifact.artifact_id + + scope_id, artifact_id = asyncio.run(seed()) + before = _business_snapshot(database_path) + environment = tmp_path / "server.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=ownership-maintenance-dispatch\n" + "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=test\n" + "POWERCONTEXT_SERVER_RUNTIME='" + config.runtime.model_dump_json() + "'\n", + encoding="utf-8", + ) + arguments = [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + action, + "--scope-id", + scope_id, + ] + if action == "apply": + arguments.extend(["--owner", "service:operator", "--maintenance-confirmed"]) + result = CliRunner().invoke(create_cli([server_cli]), arguments) + assert result.exit_code == 0, result.output or repr(result.exception) + report = json.loads(result.output) + if action == "plan": + assert len(report["scopes"][0]["artifact_gaps"]) == 1 + else: + assert len(report["applied"]) == 1 + assert report["applied"][0]["gap"]["family"] == "atomic-memory" + assert report["conflicts"] == [] + + after = _business_snapshot(database_path) + assert after["pc_artifact_processing_intents"] == before["pc_artifact_processing_intents"] + assert after["pc_artifact_processing_binding_states"] == before["pc_artifact_processing_binding_states"] + assert after["pc_artifact_processing_leases"] == before["pc_artifact_processing_leases"] + assert after == before + + if action == "apply": + + async def check_audit() -> None: + async with open_builtin_access_control(database, deployment_id="ownership-maintenance-dispatch") as access: + assert access.audit is not None + events = await access.audit.list_audit( + resource=ResourceRef.artifact(scope_id, family="atomic-memory", artifact_id=artifact_id) + ) + owners = [event for event in events if event.reason_code == "artifact-owner-established"] + assert len(owners) == 1 + assert owners[0].principal == PrincipalRef(type="service", id="operator") + assert owners[0].request_id is None + assert owners[0].actor is None + assert owners[0].transport == "background" + assert owners[0].operation == "access.ownership.apply" + + asyncio.run(check_audit()) From d9585b1539c2e06b6c1c23f2335ce93a29c6c197 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sat, 31 Oct 2026 14:35:59 +0800 Subject: [PATCH 08/25] fix(ci): retain merge-time platform and fixture adaptations The rebase rebuilt the merge commit from its parents, so manual merge resolutions that were not part of either parent were lost. Restore them: - Guard Linux-only peer-credential/socket constants in Source background process control for non-Linux type checking. - Accept the allow_missing_database keyword that the MySQL migration caller passes in the vector admission fixture. - Name the test database explicitly in the code publication fixtures, as the SeekDB store signature now requires. Co-Authored-By: Claude Code --- src/powercontext/source_management/background.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/powercontext/source_management/background.py b/src/powercontext/source_management/background.py index 93581b8bb4..22d1f1a608 100644 --- a/src/powercontext/source_management/background.py +++ b/src/powercontext/source_management/background.py @@ -150,6 +150,8 @@ def _address(directory): def _control(listener, identity, state_path): + if sys.platform != "linux": + raise ManagementError("unsupported", "Source process control requires Linux") stopping = False while True: try: From c00e075702020d17eb42501551e69fef4692ae3f Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sat, 31 Oct 2026 14:35:59 +0800 Subject: [PATCH 09/25] test(desktop): qualify the rebased contract on real SQLite Servers Record sqlite-atomic-bfaff459-v1 for the bundled OpenAPI contract 4aeefdd6... and Server source bfaff459..., built from the clean rebased merge commit as powercontext-1.4.2.dev97+gbfaff4594-py3-none-any.whl (sha256 b8291955...). All 666 packaged runtime files match the recorded source bytes, and the real Server harness passed anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and enforced-access provider modes on macOS ARM64, covering the eleven recorded protocol operations. The superseded 285620ac record is removed. Historical records retain their original identities, operation sets and evidence. The bilingual guides now point users at the matching record. Co-Authored-By: Claude Code --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/VALIDATION.md | 26 +++++++++---------- .../src/connections/compatibility.json | 8 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index cbcc1a9829..e20c623225 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -172,7 +172,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For Server sources at `581be595a51b06fdbed208349fc8c2066c80b1b3`, explicitly select `sqlite-atomic-581be595-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and qualifies its eleven recorded protocol operations through real SQLite Server tests on macOS ARM64. The recorded Server source and wheel identities must match the intended deployment. All historical records retain their original Server source, contract, wheel and operation identities. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining platform requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For Server sources at `bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b`, explicitly select `sqlite-atomic-bfaff459-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and qualifies its eleven recorded protocol operations through real SQLite Server tests on macOS ARM64. The recorded Server source and wheel identities must match the intended deployment. All historical records retain their original Server source, contract, wheel and operation identities. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining platform requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index bbb97c1381..e146ea4b9f 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -155,7 +155,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于源码为 `581be595a51b06fdbed208349fc8c2066c80b1b3` 的 Server,请明确选择 `sqlite-atomic-581be595-v1`,检查连接后再选择范围。该记录匹配本构建的打包契约,所列十一项协议操作已在 macOS ARM64 上通过真实 SQLite Server 验证。记录的 Server 源码和 wheel 身份必须与实际部署一致。全部历史记录保留原有 Server 源码、契约、wheel 和操作集合。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的证据和剩余平台要求见[验证说明](VALIDATION.md)。 +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于源码为 `bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b` 的 Server,请明确选择 `sqlite-atomic-bfaff459-v1`,检查连接后再选择范围。该记录匹配本构建的打包契约,所列十一项协议操作已在 macOS ARM64 上通过真实 SQLite Server 验证。记录的 Server 源码和 wheel 身份必须与实际部署一致。全部历史记录保留原有 Server 源码、契约、wheel 和操作集合。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的证据和剩余平台要求见[验证说明](VALIDATION.md)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 59d9a7e870..a090f85031 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,7 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves every recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract is qualified by `sqlite-atomic-581be595-v1` for the eleven protocol operations recorded below, using real SQLite Servers on macOS ARM64. All twenty-four historical records retain their original identities, operation sets and evidence. Ordinary builds require explicitly selecting the record whose tested Server source, wheel and contract match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify another Server source. CI fixture evidence is separate from product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves every recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract is qualified by `sqlite-atomic-bfaff459-v1` for the eleven protocol operations recorded below, using real SQLite Servers on macOS ARM64. All twenty-four historical records retain their original identities, operation sets and evidence. Ordinary builds require explicitly selecting the record whose tested Server source, wheel and contract match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify another Server source. CI fixture evidence is separate from product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -61,21 +61,21 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records -#### Server 581be595 protocol qualification +#### Server bfaff459 protocol qualification -`sqlite-atomic-581be595-v1` records Server sources at -`581be595a51b06fdbed208349fc8c2066c80b1b3` for OpenAPI digest +`sqlite-atomic-bfaff459-v1` records Server sources at +`bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b` for OpenAPI digest `4aeefdd602a11ebece4c74f120b0cc8671aa3cd62386822326198ca7a4b88ca5`. -The wheel is `powercontext-1.4.2.dev112+g581be595a-py3-none-any.whl`, SHA-256 -`ded0d06803205e6b34a93cf9c33e76897f65b5880e2530e5501006a5d05ce6c8`, built from that clean commit. -All 667 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +The wheel is `powercontext-1.4.2.dev97+gbfaff4594-py3-none-any.whl`, SHA-256 +`b8291955e6bf242e504d35679cd74277cc46c4be9b80f593fd995ba14af74b06`, built from that clean commit. +All 666 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the recorded source bytes. The sorted runtime manifest digest is -`10a7e17f59654d053d8c9c8ac568d6f3be9f54707384d27fa18aca72c5d233fb` +`aeaaa92d42234bd68e672c973065e7c35e16e774dc09551f349ee6b9bce66c68` (one `wheel/path sha256` line per file, with a final newline). Distribution version metadata is outside this runtime manifest. OpenAPI, project configuration and the lock file also match that commit. -On 2026-11-01, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +On 2026-10-31, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing `desktop/tests/real_server.py` harness against independent SQLite Server processes loaded from this wheel. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and an injected provider with enforced access. The eleven recorded operations @@ -86,7 +86,7 @@ committed writes with lost responses without replay, and Server survival after c The native `server_probe` compiled the unchanged product modules and locked dependencies; its executable SHA-256 is -`a4dfd54788c843a37c1ee4919513d92da5925519a2264ca9be273a0fd0540ca7`. +`a615f5931bdbc799c422b5311c09a9c9b47aae22e72123182de28f9f41b93540`. All native build source, Cargo manifest and lock bytes match the recorded commit. The completed harness report records `desktopWorkingTreeDirty=false`. @@ -95,7 +95,7 @@ Reproduce from the recorded commit with an empty wheel output directory: ```sh uv build --wheel --out-dir desktop/.artifacts/server-wheel cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe -uv run --frozen python desktop/tests/fixture_qualification.py --server-commit 581be595a51b06fdbed208349fc8c2066c80b1b3 +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b uv run --frozen python desktop/tests/real_server.py ``` @@ -106,7 +106,7 @@ installed GUI workflows, persistent native credentials, signing, notarization or release-upgrade acceptance. All twenty-four historical records retain their exact identities and evidence. -本记录绑定上述准确源码、契约和 wheel;667 个运行时文件均与记录提交逐字节一致。 +本记录绑定上述准确源码、契约和 wheel;666 个运行时文件均与记录提交逐字节一致。 四种真实 SQLite/HTTP 连接模式均通过,覆盖所列十一项协议操作、身份变化、同身份撤权、 Scope 分页及响应丢失后不重放已提交写入。原生探针使用该提交的产品模块及锁定依赖。 该验证不替代 Windows/Intel、安装后 GUI、原生凭据持久化、签名、公证或完整升级验收。 @@ -1088,7 +1088,7 @@ Wheel: `powercontext-1.4.1.dev273+g079de1030.d20261031-py3-none-any.whl`. Wheel SHA-256: `9915db1a7341622d8f22d97b1c02aa4cbd50a7c3a3be3dc9d9346cb77b3da56f`. The development version is build metadata; the formal release version is unchanged. -On 2026-11-01, Linux x86_64, Python 3.12.12 and Rust 1.95.0 passed all four built-wheel +On 2026-10-31, Linux x86_64, Python 3.12.12 and Rust 1.95.0 passed all four built-wheel SQLite modes in `desktop/tests/real_server.py`: anonymous loopback, Bearer, HTTPS with an explicit CA/base path, and injected-provider/enforced access. The existing native probe covers the eleven declared protocol operations, including server identity, save/search/exact diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 6a37bfc157..8c194dc9e0 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -462,10 +462,10 @@ "evidence": "desktop/VALIDATION.md#source-and-artifact-merge-87b6fae2-protocol-qualification" }, { - "id": "sqlite-atomic-581be595-v1", - "serverCommit": "581be595a51b06fdbed208349fc8c2066c80b1b3", + "id": "sqlite-atomic-bfaff459-v1", + "serverCommit": "bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b", "contractSha256": "4aeefdd602a11ebece4c74f120b0cc8671aa3cd62386822326198ca7a4b88ca5", - "artifactSha256": "ded0d06803205e6b34a93cf9c33e76897f65b5880e2530e5501006a5d05ce6c8", + "artifactSha256": "b8291955e6bf242e504d35679cd74277cc46c4be9b80f593fd995ba14af74b06", "operations": [ "get_access_principal", "get_artifact_revision", @@ -479,6 +479,6 @@ "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-581be595-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-bfaff459-protocol-qualification" } ] From fa0a73f411067c20daa56a7f7c7a5faab03757a3 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sun, 1 Nov 2026 08:12:52 +0800 Subject: [PATCH 10/25] fix(access): migrate custom Artifact families and bound owner keys Ownership migration only inventoried built-in families, so a registered custom family stayed ownerless and its revision writes stayed denied by ARTIFACT_WRITE. Inventories now include families from persisted plugin declarations. Scope- and Candidate-derived owner keys could exceed the Access limit of 255 characters. One bounded key builder keeps accepted spellings stable and digests longer identities, and every owner write site uses it. Co-Authored-By: Claude Code --- .../builtin/runtime/artifact_plugins.py | 6 +- src/powercontext/server/app.py | 15 ++--- src/powercontext/server/authz/__init__.py | 3 + src/powercontext/server/authz/keys.py | 39 ++++++++++++ src/powercontext/server/factory.py | 11 ++-- src/powercontext/server/ownership.py | 38 +++++++----- .../server/processing_security.py | 12 +++- tests/e2e/test_access_ownership_migration.py | 18 ++++++ tests/e2e/test_artifact_plugins.py | 59 ++++++++++++++++++- 9 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 src/powercontext/server/authz/keys.py diff --git a/src/powercontext/builtin/runtime/artifact_plugins.py b/src/powercontext/builtin/runtime/artifact_plugins.py index db91b2f59e..64689fad9f 100644 --- a/src/powercontext/builtin/runtime/artifact_plugins.py +++ b/src/powercontext/builtin/runtime/artifact_plugins.py @@ -296,7 +296,7 @@ async def _commit_window( security, prepared_indexes=None, ): - from powercontext.server.authz import AccessAction, ResourceRef + from powercontext.server.authz import AccessAction, ResourceRef, bounded_idempotency_key validated = _validate_drafts(declaration, source_evidence, artifact_evidence, drafts) repository = custom_artifact_repository(declaration["family"], sources, prepared=prepared_indexes) @@ -318,7 +318,9 @@ async def _commit_window( await bound.establish_artifact_owner( resource, security.principal, - idempotency_key=f"background-custom-owner:{assignment.scope_id}:{draft.family}:{draft.artifact_id}", + idempotency_key=bounded_idempotency_key( + "background-custom-owner", assignment.scope_id, draft.family, draft.artifact_id + ), context=security.context, ) else: diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 30a7208669..d8d97d2759 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -862,6 +862,7 @@ ReplaceBinding, ResourceRef, access_control_for_mode, + bounded_idempotency_key, ) from powercontext.server.authz.models import ( ROLE_ACTIONS, @@ -2757,7 +2758,7 @@ async def on_commit(connection, artifact, candidate): await bound.establish_artifact_owner( resource, principal, - idempotency_key=f"profile-owner:{request.scope_id}", + idempotency_key=bounded_idempotency_key("profile-owner", request.scope_id), context=context, ) if candidate is not None: @@ -2767,7 +2768,7 @@ async def on_commit(connection, artifact, candidate): family="profile", proposed_owner=principal, target=None if candidate.target is None else resource, - idempotency_key=f"candidate-owner:{request.scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", request.scope_id, candidate.candidate_id), ) result = await application.profiles.flush( @@ -3682,7 +3683,7 @@ async def on_commit(connection, artifact): await owner_write_access(access, connection).establish_artifact_owner( ResourceRef.artifact(request.scope_id, family="handoff", artifact_id=artifact.artifact_id), principal, - idempotency_key=f"handoff-owner:{request.scope_id}:{artifact.artifact_id}", + idempotency_key=bounded_idempotency_key("handoff-owner", request.scope_id, artifact.artifact_id), context=context, ) committed = True @@ -3700,7 +3701,7 @@ async def on_commit(connection, artifact): family="handoff", artifact_id=result.artifact_id, ), - idempotency_key=f"handoff-owner:{request.scope_id}:{result.artifact_id}", + idempotency_key=bounded_idempotency_key("handoff-owner", request.scope_id, result.artifact_id), operation=COMMIT_HANDOFF.operation_id, ) elif access is not None and not committed: @@ -4560,7 +4561,7 @@ async def _attest_candidate_owner( family=family, proposed_owner=_require_principal(), target=logical_target, - idempotency_key=f"candidate-owner:{scope_id}:{candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate_id), ) @@ -4616,7 +4617,7 @@ async def on_commit(connection, candidate): family=target.family, artifact_id=target.artifact_id, ), - idempotency_key=f"candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate.candidate_id), ) return {"on_commit": on_commit} @@ -4791,7 +4792,7 @@ async def approve_candidate( artifact_id=artifact.artifact_id, ), attestation.proposed_owner, - idempotency_key=f"candidate-artifact-owner:{request.scope_id}:{request.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-artifact-owner", request.scope_id, request.candidate_id), context=_access_audit_context(APPROVE_CANDIDATE.operation_id), ) return await _candidate_response(http_request, request.scope_id, result) diff --git a/src/powercontext/server/authz/__init__.py b/src/powercontext/server/authz/__init__.py index f5adf83c04..e4d7784499 100644 --- a/src/powercontext/server/authz/__init__.py +++ b/src/powercontext/server/authz/__init__.py @@ -25,6 +25,7 @@ AccessInvalidRequestError, AccessUnavailableError, ) +from powercontext.server.authz.keys import IDEMPOTENCY_KEY_LIMIT, bounded_idempotency_key from powercontext.server.authz.models import ( DEFAULT_DEPLOYMENT_ID, PUBLIC_ACCESS_ACTIONS, @@ -93,6 +94,7 @@ def __getattr__(name: str) -> object: __all__ = ( "DEFAULT_DEPLOYMENT_ID", + "IDEMPOTENCY_KEY_LIMIT", "PUBLIC_ACCESS_ACTIONS", "ROLE_CARDINALITIES", "AccessAction", @@ -143,4 +145,5 @@ def __getattr__(name: str) -> object: "ResourceRef", "ResourceSearchRequest", "access_control_for_mode", + "bounded_idempotency_key", ) diff --git a/src/powercontext/server/authz/keys.py b/src/powercontext/server/authz/keys.py new file mode 100644 index 0000000000..242ea2f2ce --- /dev/null +++ b/src/powercontext/server/authz/keys.py @@ -0,0 +1,39 @@ +# 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. + +"""Deterministic idempotency keys for Access owner writes.""" + +from __future__ import annotations + +import json +from hashlib import sha256 + +IDEMPOTENCY_KEY_LIMIT = 255 + + +def bounded_idempotency_key(prefix: str, *identity: str) -> str: + """Build a stable key that Access accepts for any identity width. + + Accepted keys keep their historical spelling. Identities that would exceed + the Access limit are replaced by a digest of every component, so distinct + identities cannot collapse into the same key. + """ + key = f"{prefix}:{':'.join(identity)}" + if len(key) <= IDEMPOTENCY_KEY_LIMIT: + return key + encoded = json.dumps((prefix, *identity), ensure_ascii=False, separators=(",", ":")).encode() + return f"{prefix}:{sha256(encoded).hexdigest()}" + + +__all__ = ("IDEMPOTENCY_KEY_LIMIT", "bounded_idempotency_key") diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 04c10b48f7..8759987fd7 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -83,6 +83,7 @@ PrincipalRef, ResourceRef, access_control_for_mode, + bounded_idempotency_key, ) from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.authz.owner_recovery import ( @@ -604,7 +605,9 @@ async def on_commit(connection, candidates): family=candidate.target.family, artifact_id=candidate.target.artifact_id, ), - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key( + "background-candidate-owner", scope_id, candidate.candidate_id + ), ) for candidate in candidates ) @@ -627,7 +630,7 @@ async def on_commit(connection, candidates): family="experience", proposed_owner=principal, target=None, - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate_id}", + idempotency_key=bounded_idempotency_key("background-candidate-owner", scope_id, candidate_id), ) for candidate_id in result.candidate_ids ) @@ -663,7 +666,7 @@ async def on_commit(connection, artifact, candidate): await bound.establish_artifact_owner( resource, principal, - idempotency_key=f"profile-owner:{scope_id}", + idempotency_key=bounded_idempotency_key("profile-owner", scope_id), context=context, ) if candidate is not None: @@ -673,7 +676,7 @@ async def on_commit(connection, artifact, candidate): family="profile", proposed_owner=principal, target=None if candidate.target is None else resource, - idempotency_key=f"candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate.candidate_id), ) recovery = ( diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 117083be76..342288a950 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -24,7 +24,6 @@ from __future__ import annotations -import json from collections.abc import Sequence from dataclasses import dataclass, replace from hashlib import sha256 @@ -38,6 +37,7 @@ AccessUnavailableError, PrincipalRef, ResourceRef, + bounded_idempotency_key, ) from powercontext.server.authz.models import MemoryEntrySelector from powercontext.server.authz.owner_recovery import flush_owner_writes, owner_recovery_session, owner_write_access @@ -156,11 +156,12 @@ async def plan_ownership_migration( raise AccessUnavailableError("relationship_management_unavailable") scopes: list[ScopeOwnershipPlan] = [] selected = tuple(dict.fromkeys(scope_ids)) + families = await _owned_artifact_families(application) for scope_id in selected: scopes.append( ScopeOwnershipPlan( scope_id=scope_id, - artifact_gaps=await _artifact_gaps(application, access, scope_id), + artifact_gaps=await _artifact_gaps(application, access, scope_id, families), candidate_gaps=await _candidate_gaps(application, access, scope_id), ) ) @@ -226,9 +227,10 @@ async def apply_ownership_migration( context = migration_context(MIGRATION_OPERATION, transport=transport, context=context) items: list[ArtifactOwnershipGap | CandidateOwnershipGap] = [] conflicts: list[OwnershipDisposition] = [] + families = await _owned_artifact_families(application) for scope_id in dict.fromkeys(scope_ids): inventory = ( - *await _artifact_gaps(application, None, scope_id), + *await _artifact_gaps(application, None, scope_id, families), *await _candidate_gaps(application, None, scope_id), ) for item in inventory: @@ -390,16 +392,29 @@ def _validate_migration_owner(owner: PrincipalRef) -> None: raise AccessInvalidRequestError("principal") +async def _owned_artifact_families(application: Application) -> frozenset[str]: + """Families whose Artifact lifecycle Access enforces, including saved plugins.""" + + from powercontext.server.artifact_plugins import read_artifact_declarations + + families = set(OWNED_ARTIFACT_FAMILIES) + database = getattr(application, "primary_database", None) + if database is not None: + families.update(declaration["family"] for declaration in await read_artifact_declarations(database)) + return frozenset(families) + + async def _artifact_gaps( application: Application, access: AccessControlService | None, scope_id: str, + families: frozenset[str], ) -> tuple[ArtifactOwnershipGap, ...]: records = application.records.for_scope(scope_id) gaps: list[ArtifactOwnershipGap] = [] for record in await records.logical_artifacts(): - if record.family not in OWNED_ARTIFACT_FAMILIES: + if record.family not in families: continue resource = ResourceRef.artifact(scope_id, family=record.family, artifact_id=record.artifact_id) if access is not None and await access.artifact_owner(resource) is not None: @@ -465,7 +480,7 @@ async def _attest_gap_owner( family=gap.family, proposed_owner=owner, target=target, - idempotency_key=_migration_key("candidate-owner", gap.scope_id, gap.candidate_id), + idempotency_key=bounded_idempotency_key("candidate-owner", gap.scope_id, gap.candidate_id), ) @@ -491,16 +506,7 @@ def _owner_identity(gap: ArtifactOwnershipGap) -> tuple[str, ResourceRef]: ) return f"base-artifact-owner:{sha256(resource.key.encode()).hexdigest()}", resource if gap.family == "profile" and gap.artifact_id == "profile": - key = _migration_key("profile-owner", gap.scope_id) + key = bounded_idempotency_key("profile-owner", gap.scope_id) else: - key = _migration_key(f"{gap.family}-owner", gap.scope_id, gap.artifact_id) + key = bounded_idempotency_key(f"{gap.family}-owner", gap.scope_id, gap.artifact_id) return key, ResourceRef.artifact(gap.scope_id, family=gap.family, artifact_id=gap.artifact_id) - - -def _migration_key(prefix: str, *identity: str) -> str: - """Keep accepted keys stable; hash complete identity parts for longer keys.""" - key = f"{prefix}:{':'.join(identity)}" - if len(key) <= 255: - return key - encoded = json.dumps((prefix, *identity), ensure_ascii=False, separators=(",", ":")).encode() - return f"{prefix}:{sha256(encoded).hexdigest()}" diff --git a/src/powercontext/server/processing_security.py b/src/powercontext/server/processing_security.py index 17b7cddd74..c20edd5457 100644 --- a/src/powercontext/server/processing_security.py +++ b/src/powercontext/server/processing_security.py @@ -36,6 +36,7 @@ CandidateOwnerAttestation, PrincipalRef, ResourceRef, + bounded_idempotency_key, ) from powercontext.server.authz.repository import RelationalAccessRepository from powercontext.server.authz.service import BuiltinAuthorizationProvider @@ -117,7 +118,9 @@ async def experience_commit(self, connection: AsyncConnection, candidates: Seque family="experience", proposed_owner=self.principal, target=None, - idempotency_key=f"background-candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key( + "background-candidate-owner", scope_id, candidate.candidate_id + ), ) for candidate in candidates ) @@ -149,7 +152,10 @@ async def profile_commit( if artifact is not None: if await bound.artifact_owner(resource) is None: await bound.establish_artifact_owner( - resource, self.principal, idempotency_key=f"profile-owner:{scope_id}", context=self.context + resource, + self.principal, + idempotency_key=bounded_idempotency_key("profile-owner", scope_id), + context=self.context, ) else: await bound.require(self.principal, AccessAction.ARTIFACT_WRITE, resource, context=self.context) @@ -162,7 +168,7 @@ async def profile_commit( family="profile", proposed_owner=self.principal, target=None if candidate.target is None else resource, - idempotency_key=f"candidate-owner:{scope_id}:{candidate.candidate_id}", + idempotency_key=bounded_idempotency_key("candidate-owner", scope_id, candidate.candidate_id), ) async def topic_commit(self, connection: AsyncConnection, scope_id: str, _operations: Sequence[Any]) -> None: diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index de306ea1af..9bca2988d5 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -399,6 +399,24 @@ async def scenario() -> None: attestation = await access.candidate_owner(scope_id, candidate["candidate_id"]) assert attestation is not None and attestation.proposed_owner == ADMIN keys.append(attestation.idempotency_key) + approved = checked( + await client.post( + "/v1/candidates/approve", + json={ + "scope_id": scope_id, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ), + 200, + ) + assert approved["status"] == "approved" + target = approved["result_artifact"] + relation = await access.artifact_owner( + ResourceRef.artifact(scope_id, family=target["family"], artifact_id=target["artifact_id"]) + ) + assert relation is not None and relation.owner == ADMIN + keys.append(relation.idempotency_key) assert all(len(key) <= 255 for key in keys) assert len(set(keys)) == len(keys) repeated = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) diff --git a/tests/e2e/test_artifact_plugins.py b/tests/e2e/test_artifact_plugins.py index 85df6a2f28..904acde3ba 100644 --- a/tests/e2e/test_artifact_plugins.py +++ b/tests/e2e/test_artifact_plugins.py @@ -27,8 +27,14 @@ from typing import Any import httpx +import pytest import yaml +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.authz import AccessAction, AccessAuditContext, AccessDeniedError, PrincipalRef, ResourceRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.profiles import register_custom_artifact_access_family + EXAMPLE = Path(__file__).resolve().parents[2] / "examples/artifact_plugins/incident_report" SUMMARY_EXAMPLE = EXAMPLE.parent / "ticket_summary" ADMIN_AUTHORIZATION_VALUE = "artifact-plugin-e2e-admin" @@ -99,6 +105,7 @@ async def running_server( directory_from_env: bool = True, database_url: str | None = None, database_settings: dict[str, Any] | None = None, + access_mode: str = "enforced", ): with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) @@ -115,13 +122,14 @@ async def running_server( "POWERCONTEXT_SERVER_RUNTIME": json.dumps({"artifact_processing_families": []}), "POWERCONTEXT_SERVER_WORKSPACE": str(directory), "POWERCONTEXT_HOME": str(directory / "home"), - "POWERCONTEXT_SERVER_AUTH_ENABLED": "true", - "POWERCONTEXT_SERVER_AUTH_TOKEN": ADMIN_AUTHORIZATION_VALUE, - "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", + "POWERCONTEXT_SERVER_AUTH_ENABLED": "true" if access_mode != "disabled" else "false", + "POWERCONTEXT_SERVER_ACCESS_MODE": access_mode, "POWERCONTEXT_SERVER_MCP_ENABLED": "false", "POWERCONTEXT_SERVER_METRICS_ENABLED": "true", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", }) + if access_mode != "disabled": + env["POWERCONTEXT_SERVER_AUTH_TOKEN"] = ADMIN_AUTHORIZATION_VALUE (directory / "plugins").mkdir(exist_ok=True) if directory_from_env: env["POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR"] = str(directory / "plugins") @@ -1021,3 +1029,48 @@ async def _repair_failed_restore(tmp_path: Path) -> None: assert repaired["revision"] == 1 assert repaired["content"]["title"] == "Repaired: during-failure" assert len((await query(client, healthy_scope, "ticket-summary"))["results"]) == 1 + + +def test_ownership_migration_restores_a_registered_custom_family(tmp_path: Path) -> None: + asyncio.run(_migrate_registered_custom_family(tmp_path)) + + +async def _migrate_registered_custom_family(tmp_path: Path) -> None: + plugin = deploy_plugin(EXAMPLE, tmp_path / "migration-v1", None) + async with running_server(tmp_path, access_mode="disabled") as client: + scope = await create_scope(client, "custom-migration") + await reload_plugin(client, tmp_path, plugin, 0) + await change(client, "enable", 1, "enabled") + await capture(client, scope, "legacy-report") + report = (await contents(client, scope, "incident-report", 1))[0] + # A disabled plugin stays disabled after restart, so no replay repairs the legacy owner. + await change(client, "disable", 2, "disabled") + async with running_server(tmp_path) as client: + plan = (await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]})).json() + assert [(gap["family"], gap["artifact_id"]) for gap in plan["scopes"][0]["artifact_gaps"]] == [ + ("incident-report", report["artifact_id"]) + ] + applied = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope], "owner": {"type": "service", "id": "server-token"}}, + ) + assert applied.status_code == 200, applied.text + assert [item["kind"] for item in applied.json()["applied"]] == ["artifact_owner"] + async with open_builtin_access_control(SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}")) as access: + register_custom_artifact_access_family("incident-report") + resource = ResourceRef.artifact(scope, family="incident-report", artifact_id=report["artifact_id"]) + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == PrincipalRef(type="service", id="server-token") + await access.require( + relation.owner, + AccessAction.ARTIFACT_WRITE, + resource, + context=AccessAuditContext(transport="test", operation="artifact.write"), + ) + with pytest.raises(AccessDeniedError): + await access.require( + PrincipalRef(type="user", id="worker"), + AccessAction.ARTIFACT_WRITE, + resource, + context=AccessAuditContext(transport="test", operation="artifact.write"), + ) From 317f19c00677241e217781cdae8c9947c18e2d38 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sun, 1 Nov 2026 08:17:01 +0800 Subject: [PATCH 11/25] chore(api): regenerate models after the rebased contract The checked-in model order did not match the generator output for the resolved contract. Regenerate so make contract-test stays reproducible. Co-Authored-By: Claude Code --- src/powercontext/http/_generated/models.py | 196 ++++++++++----------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 1b7d01ab7c..07df243b58 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3264,95 +3264,6 @@ class AccessPrincipal(BaseModel): description: Annotated[StrictStr | None, Field(max_length=255, min_length=1)] = None -class ScopeId1(RootModel[StrictStr]): - root: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class PlanAccessOwnershipRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_ids: Annotated[ - list[ScopeId1] | None, - Field( - description="Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments.", - min_length=1, - ), - ] = None - background_principal: Annotated[ - AccessPrincipal | None, - Field( - description="Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required." - ), - ] = None - - -class OwnershipGapKind(StrEnum): - ARTIFACT_OWNER = "artifact_owner" - CANDIDATE_ATTESTATION = "candidate_attestation" - - -class OwnershipGapItem(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - kind: OwnershipGapKind - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - family: Annotated[StrictStr, Field(max_length=128, min_length=1)] - artifact_id: Annotated[StrictStr | None, Field(max_length=256)] = None - entry_id: Annotated[StrictStr | None, Field(max_length=256)] = None - candidate_id: Annotated[StrictStr | None, Field(max_length=128)] = None - version: Annotated[StrictInt | None, Field(ge=1)] = None - conflict_code: Annotated[StrictStr | None, Field(max_length=64)] = None - - -class AccessOwnershipScopePlan(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - artifact_gaps: list[OwnershipGapItem] - candidate_gaps: list[OwnershipGapItem] - - -class Allowed(Enum): - BOOLEAN_FALSE = False - - -class BackgroundPermissionGap(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - principal_id: Annotated[StrictStr, Field(max_length=255, min_length=1)] - allowed: Allowed - reason_code: Annotated[StrictStr | None, Field(max_length=64)] - - -class ApplyAccessOwnershipRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - owner: AccessPrincipal - scope_ids: Annotated[ - list[ScopeId1] | None, - Field( - description="Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments.", - min_length=1, - ), - ] = None - dry_run: StrictBool = False - - -class AccessOwnershipApplyResult(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - dry_run: StrictBool - applied: list[OwnershipGapItem] - conflicts: list[OwnershipGapItem] - - class Type3(StrEnum): GROUP = "group" @@ -4050,6 +3961,104 @@ class SourceInstancesQuery(BaseModel): view: View1 = View1.DISCOVERY +class ScopeId1(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1)] + + +class PlanAccessOwnershipRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_ids: Annotated[ + list[ScopeId1] | None, + Field( + description="Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments.", + min_length=1, + ), + ] = None + background_principal: Annotated[ + AccessPrincipal | None, + Field( + description="Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required." + ), + ] = None + + +class OwnershipGapKind(StrEnum): + ARTIFACT_OWNER = "artifact_owner" + CANDIDATE_ATTESTATION = "candidate_attestation" + + +class OwnershipGapItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: OwnershipGapKind + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + family: Annotated[StrictStr, Field(max_length=128, min_length=1)] + artifact_id: Annotated[StrictStr | None, Field(max_length=256)] = None + entry_id: Annotated[StrictStr | None, Field(max_length=256)] = None + candidate_id: Annotated[StrictStr | None, Field(max_length=128)] = None + version: Annotated[StrictInt | None, Field(ge=1)] = None + conflict_code: Annotated[StrictStr | None, Field(max_length=64)] = None + + +class AccessOwnershipScopePlan(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + artifact_gaps: list[OwnershipGapItem] + candidate_gaps: list[OwnershipGapItem] + + +class Allowed(Enum): + BOOLEAN_FALSE = False + + +class BackgroundPermissionGap(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + principal_id: Annotated[StrictStr, Field(max_length=255, min_length=1)] + allowed: Allowed + reason_code: Annotated[StrictStr | None, Field(max_length=64)] + + +class AccessOwnershipPlan(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: AccessControlMode + scopes: list[AccessOwnershipScopePlan] + background: list[BackgroundPermissionGap] + + +class ApplyAccessOwnershipRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + owner: AccessPrincipal + scope_ids: Annotated[ + list[ScopeId1] | None, + Field( + description="Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments.", + min_length=1, + ), + ] = None + dry_run: StrictBool = False + + +class AccessOwnershipApplyResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + dry_run: StrictBool + applied: list[OwnershipGapItem] + conflicts: list[OwnershipGapItem] + + class RecallManifestRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -5274,15 +5283,6 @@ class SourcePage(BaseModel): next_cursor: Annotated[StrictStr | None, Field(...)] -class AccessOwnershipPlan(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - mode: AccessControlMode - scopes: list[AccessOwnershipScopePlan] - background: list[BackgroundPermissionGap] - - class ArtifactFamilyAccessCapability(BaseModel): model_config = ConfigDict( extra="forbid", From f369ea3eab2f9a12970066d61e1aee502c63109f Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sun, 1 Nov 2026 08:21:41 +0800 Subject: [PATCH 12/25] test(desktop): requalify the rebased contract on real SQLite Servers The previous record referenced a commit that no longer exists after the rebase. Rebuilt the wheel from 581be595, checked 667 runtime files byte-for-byte, reran the four SQLite modes, and recompiled the native probe with the locked toolchain. Co-Authored-By: Claude Code --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/VALIDATION.md | 26 +++++++++---------- .../src/connections/compatibility.json | 8 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index e20c623225..cbcc1a9829 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -172,7 +172,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For Server sources at `bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b`, explicitly select `sqlite-atomic-bfaff459-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and qualifies its eleven recorded protocol operations through real SQLite Server tests on macOS ARM64. The recorded Server source and wheel identities must match the intended deployment. All historical records retain their original Server source, contract, wheel and operation identities. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining platform requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For Server sources at `581be595a51b06fdbed208349fc8c2066c80b1b3`, explicitly select `sqlite-atomic-581be595-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and qualifies its eleven recorded protocol operations through real SQLite Server tests on macOS ARM64. The recorded Server source and wheel identities must match the intended deployment. All historical records retain their original Server source, contract, wheel and operation identities. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining platform requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index e146ea4b9f..bbb97c1381 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -155,7 +155,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于源码为 `bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b` 的 Server,请明确选择 `sqlite-atomic-bfaff459-v1`,检查连接后再选择范围。该记录匹配本构建的打包契约,所列十一项协议操作已在 macOS ARM64 上通过真实 SQLite Server 验证。记录的 Server 源码和 wheel 身份必须与实际部署一致。全部历史记录保留原有 Server 源码、契约、wheel 和操作集合。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的证据和剩余平台要求见[验证说明](VALIDATION.md)。 +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于源码为 `581be595a51b06fdbed208349fc8c2066c80b1b3` 的 Server,请明确选择 `sqlite-atomic-581be595-v1`,检查连接后再选择范围。该记录匹配本构建的打包契约,所列十一项协议操作已在 macOS ARM64 上通过真实 SQLite Server 验证。记录的 Server 源码和 wheel 身份必须与实际部署一致。全部历史记录保留原有 Server 源码、契约、wheel 和操作集合。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的证据和剩余平台要求见[验证说明](VALIDATION.md)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index a090f85031..59d9a7e870 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,7 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves every recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract is qualified by `sqlite-atomic-bfaff459-v1` for the eleven protocol operations recorded below, using real SQLite Servers on macOS ARM64. All twenty-four historical records retain their original identities, operation sets and evidence. Ordinary builds require explicitly selecting the record whose tested Server source, wheel and contract match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify another Server source. CI fixture evidence is separate from product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves every recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract is qualified by `sqlite-atomic-581be595-v1` for the eleven protocol operations recorded below, using real SQLite Servers on macOS ARM64. All twenty-four historical records retain their original identities, operation sets and evidence. Ordinary builds require explicitly selecting the record whose tested Server source, wheel and contract match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify another Server source. CI fixture evidence is separate from product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -61,21 +61,21 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records -#### Server bfaff459 protocol qualification +#### Server 581be595 protocol qualification -`sqlite-atomic-bfaff459-v1` records Server sources at -`bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b` for OpenAPI digest +`sqlite-atomic-581be595-v1` records Server sources at +`581be595a51b06fdbed208349fc8c2066c80b1b3` for OpenAPI digest `4aeefdd602a11ebece4c74f120b0cc8671aa3cd62386822326198ca7a4b88ca5`. -The wheel is `powercontext-1.4.2.dev97+gbfaff4594-py3-none-any.whl`, SHA-256 -`b8291955e6bf242e504d35679cd74277cc46c4be9b80f593fd995ba14af74b06`, built from that clean commit. -All 666 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +The wheel is `powercontext-1.4.2.dev112+g581be595a-py3-none-any.whl`, SHA-256 +`ded0d06803205e6b34a93cf9c33e76897f65b5880e2530e5501006a5d05ce6c8`, built from that clean commit. +All 667 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the recorded source bytes. The sorted runtime manifest digest is -`aeaaa92d42234bd68e672c973065e7c35e16e774dc09551f349ee6b9bce66c68` +`10a7e17f59654d053d8c9c8ac568d6f3be9f54707384d27fa18aca72c5d233fb` (one `wheel/path sha256` line per file, with a final newline). Distribution version metadata is outside this runtime manifest. OpenAPI, project configuration and the lock file also match that commit. -On 2026-10-31, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing +On 2026-11-01, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran the existing `desktop/tests/real_server.py` harness against independent SQLite Server processes loaded from this wheel. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and an injected provider with enforced access. The eleven recorded operations @@ -86,7 +86,7 @@ committed writes with lost responses without replay, and Server survival after c The native `server_probe` compiled the unchanged product modules and locked dependencies; its executable SHA-256 is -`a615f5931bdbc799c422b5311c09a9c9b47aae22e72123182de28f9f41b93540`. +`a4dfd54788c843a37c1ee4919513d92da5925519a2264ca9be273a0fd0540ca7`. All native build source, Cargo manifest and lock bytes match the recorded commit. The completed harness report records `desktopWorkingTreeDirty=false`. @@ -95,7 +95,7 @@ Reproduce from the recorded commit with an empty wheel output directory: ```sh uv build --wheel --out-dir desktop/.artifacts/server-wheel cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe -uv run --frozen python desktop/tests/fixture_qualification.py --server-commit bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit 581be595a51b06fdbed208349fc8c2066c80b1b3 uv run --frozen python desktop/tests/real_server.py ``` @@ -106,7 +106,7 @@ installed GUI workflows, persistent native credentials, signing, notarization or release-upgrade acceptance. All twenty-four historical records retain their exact identities and evidence. -本记录绑定上述准确源码、契约和 wheel;666 个运行时文件均与记录提交逐字节一致。 +本记录绑定上述准确源码、契约和 wheel;667 个运行时文件均与记录提交逐字节一致。 四种真实 SQLite/HTTP 连接模式均通过,覆盖所列十一项协议操作、身份变化、同身份撤权、 Scope 分页及响应丢失后不重放已提交写入。原生探针使用该提交的产品模块及锁定依赖。 该验证不替代 Windows/Intel、安装后 GUI、原生凭据持久化、签名、公证或完整升级验收。 @@ -1088,7 +1088,7 @@ Wheel: `powercontext-1.4.1.dev273+g079de1030.d20261031-py3-none-any.whl`. Wheel SHA-256: `9915db1a7341622d8f22d97b1c02aa4cbd50a7c3a3be3dc9d9346cb77b3da56f`. The development version is build metadata; the formal release version is unchanged. -On 2026-10-31, Linux x86_64, Python 3.12.12 and Rust 1.95.0 passed all four built-wheel +On 2026-11-01, Linux x86_64, Python 3.12.12 and Rust 1.95.0 passed all four built-wheel SQLite modes in `desktop/tests/real_server.py`: anonymous loopback, Bearer, HTTPS with an explicit CA/base path, and injected-provider/enforced access. The existing native probe covers the eleven declared protocol operations, including server identity, save/search/exact diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 8c194dc9e0..6a37bfc157 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -462,10 +462,10 @@ "evidence": "desktop/VALIDATION.md#source-and-artifact-merge-87b6fae2-protocol-qualification" }, { - "id": "sqlite-atomic-bfaff459-v1", - "serverCommit": "bfaff4594528ffc32d7f3aa8b6bdd8e16f4c925b", + "id": "sqlite-atomic-581be595-v1", + "serverCommit": "581be595a51b06fdbed208349fc8c2066c80b1b3", "contractSha256": "4aeefdd602a11ebece4c74f120b0cc8671aa3cd62386822326198ca7a4b88ca5", - "artifactSha256": "b8291955e6bf242e504d35679cd74277cc46c4be9b80f593fd995ba14af74b06", + "artifactSha256": "ded0d06803205e6b34a93cf9c33e76897f65b5880e2530e5501006a5d05ce6c8", "operations": [ "get_access_principal", "get_artifact_revision", @@ -479,6 +479,6 @@ "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-bfaff459-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-581be595-protocol-qualification" } ] From 0b39db6bcab57a916e879e2e1cc499d277a2f28d Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sun, 1 Nov 2026 12:18:51 +0800 Subject: [PATCH 13/25] fix(access): cover custom families in offline ownership recovery The offline access-ownership-migrate process registers no custom Artifact family profiles. Its recovery preflight therefore inventoried only the built-in families and wrote a competing owner over a pending external intent for a registered custom family; replay then failed with artifact-owner. Migration now passes the family set it accepted from persisted declarations into the recovery inventory, and a family without a local profile reports its evidence instead of raising. Co-Authored-By: Claude Code --- release/upgrade-support.json | 2 +- .../server/authz/owner_recovery.py | 20 ++++-- src/powercontext/server/ownership.py | 7 +- tests/e2e/test_artifact_plugins.py | 67 ++++++++++++++++++- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 012689e264..23ec3c5ac2 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,7 +3,7 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "2c22da66e72eaf308aaf1314d3b60eb818600c3ec75caf4ca67b848db726bf2f", + "persistence_sha256": "e353cb455c823180b0847f7b3b44fce5dcea8bb29d312eecc3f14e83c1e950de", "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule." }, "cases": [], diff --git a/src/powercontext/server/authz/owner_recovery.py b/src/powercontext/server/authz/owner_recovery.py index 80afed2fb8..757757d54e 100644 --- a/src/powercontext/server/authz/owner_recovery.py +++ b/src/powercontext/server/authz/owner_recovery.py @@ -17,7 +17,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Sequence +from collections.abc import AsyncIterator, Collection, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from dataclasses import dataclass, replace @@ -178,16 +178,25 @@ async def inventory_gaps( kind: Literal["artifact", "candidate"] = "artifact", after: tuple[str, str] | None = None, limit: int = 100, + families: Collection[str] | None = None, ) -> OwnerGapPage: - """Inspect metadata in bounded pages; unknown history grants no authority.""" + """Inspect metadata in bounded pages; unknown history grants no authority. + + Callers that accept families from persisted declarations pass them in; + the process-local profiles do not know those families in another process. + """ if kind not in {"artifact", "candidate"}: raise AccessInvalidRequestError("owner-recovery-kind") if not 1 <= limit <= 500: raise AccessInvalidRequestError("limit") table = ARTIFACT_HEADS_TABLE if kind == "artifact" else CANDIDATE_HEADS_TABLE identity = table.c.artifact_id if kind == "artifact" else table.c.candidate_id - families = tuple(family for family, profile in ARTIFACT_FAMILY_PROFILES.items() if profile.enabled) - statement = select(table.c.family, identity).where(table.c.scope_id == scope_id, table.c.family.in_(families)) + selected = ( + tuple(family for family, profile in ARTIFACT_FAMILY_PROFILES.items() if profile.enabled) + if families is None + else tuple(dict.fromkeys(families)) + ) + statement = select(table.c.family, identity).where(table.c.scope_id == scope_id, table.c.family.in_(selected)) if kind == "candidate": statement = statement.where(table.c.candidate_kind == "artifact") if after is not None: @@ -198,7 +207,8 @@ async def inventory_gaps( items = [] for family, object_id in rows[:limit]: resource = ResourceRef.artifact(scope_id, family=family, artifact_id=object_id) - if kind == "artifact" and ARTIFACT_FAMILY_PROFILES[family].share_unit == "memory_entry": + profile = ARTIFACT_FAMILY_PROFILES.get(family) + if kind == "artifact" and profile is not None and profile.share_unit == "memory_entry": items.append(OwnerGap(kind, scope_id, family, object_id, "legacy_selector_required", None)) continue owner = ( diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 342288a950..4e951fbf85 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -24,7 +24,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Collection, Sequence from dataclasses import dataclass, replace from hashlib import sha256 from typing import TYPE_CHECKING @@ -251,7 +251,7 @@ async def apply_ownership_migration( # Complete the original intent before an idempotent retry succeeds. async with owner_recovery_session(owner_recovery): await flush_owner_writes(artifact=_owner_identity(item)[1]) - protected = await _owner_recovery_conflicts(owner_recovery, items) + protected = await _owner_recovery_conflicts(owner_recovery, items, families) protected_keys = {_migration_item_key(item.gap) for item in protected} items = [item for item in items if _migration_item_key(item) not in protected_keys] if dry_run: @@ -296,6 +296,7 @@ def _preview_gaps(items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap]) async def _owner_recovery_conflicts( recovery: OwnerRecovery | None, items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap], + families: Collection[str], ) -> list[OwnershipDisposition]: if recovery is None: return [] @@ -305,7 +306,7 @@ async def _owner_recovery_conflicts( for kind in ("artifact", "candidate"): after: tuple[str, str] | None = None while True: - page = await recovery.inventory_gaps(scope_id, kind=kind, after=after) + page = await recovery.inventory_gaps(scope_id, kind=kind, after=after, families=families) for evidence in page.items: if evidence.operation_id is None: continue diff --git a/tests/e2e/test_artifact_plugins.py b/tests/e2e/test_artifact_plugins.py index 904acde3ba..cf82978250 100644 --- a/tests/e2e/test_artifact_plugins.py +++ b/tests/e2e/test_artifact_plugins.py @@ -30,9 +30,10 @@ import pytest import yaml -from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.server.authz import AccessAction, AccessAuditContext, AccessDeniedError, PrincipalRef, ResourceRef from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.authz.owner_recovery import OwnerRecovery from powercontext.server.authz.profiles import register_custom_artifact_access_family EXAMPLE = Path(__file__).resolve().parents[2] / "examples/artifact_plugins/incident_report" @@ -1074,3 +1075,67 @@ async def _migrate_registered_custom_family(tmp_path: Path) -> None: resource, context=AccessAuditContext(transport="test", operation="artifact.write"), ) + + +def test_offline_migration_reports_pending_custom_family_owner(tmp_path: Path) -> None: + asyncio.run(_offline_migration_reports_pending_custom_family_owner(tmp_path)) + + +async def _offline_migration_reports_pending_custom_family_owner(tmp_path: Path) -> None: + plugin = deploy_plugin(EXAMPLE, tmp_path / "pending-v1", None) + async with running_server(tmp_path, access_mode="disabled") as client: + scope = await create_scope(client, "custom-pending") + await reload_plugin(client, tmp_path, plugin, 0) + await change(client, "enable", 1, "enabled") + await capture(client, scope, "pending-report") + (report,) = await contents(client, scope, "incident-report", 1) + await change(client, "disable", 2, "disabled") + + resource = ResourceRef.artifact(scope, family="incident-report", artifact_id=report["artifact_id"]) + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}") + async with open_builtin_access_control(database) as access, SQLiteProfile.open(database, tables=()) as profile: + recovery = OwnerRecovery(profile.database, access) + await recovery.initialize() + async with profile.database.transaction() as connection: + await recovery.for_transaction(connection).establish_artifact_owner( + resource, + PrincipalRef(type="user", id="alice"), + idempotency_key="custom-family-pending", + context=AccessAuditContext(transport="test", operation="artifact.write"), + ) + assert [item.resource for item in await recovery.pending(scope_id=scope)] == [resource] + + # A separate process holds no custom family profile; only persisted declarations identify it. + environment = tmp_path / "offline.env" + environment.write_text( + "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" + f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{(tmp_path / 'server.db').as_posix()}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + result = await asyncio.create_subprocess_exec( + str(Path(sys.executable).with_name("powercontext")), + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "apply", + "--owner", + "user:bob", + "--scope-id", + scope, + "--maintenance-confirmed", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await result.communicate() + assert result.returncode == 1, (stdout + stderr).decode() + migrated = json.loads(stdout) + assert migrated["applied"] == [] + assert [item["detail"] for item in migrated["conflicts"]] == ["owner-recovery-pending"] + + async with open_builtin_access_control(database) as access: + register_custom_artifact_access_family("incident-report") + assert await access.artifact_owner(resource) is None From 7f2f42da39e9c49341d870a329f7f1092bcbfb8b Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Sun, 1 Nov 2026 16:41:32 +0800 Subject: [PATCH 14/25] test(desktop): qualify merged ownership and receipt contract Record four real SQLite Server connection modes for clean source f3a90b2f and the combined OpenAPI contract. Preserve all historical qualification identities and update both locale guides to the executed profile. Co-Authored-By: Claude Code --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/VALIDATION.md | 47 ++++++++++++++++++- .../src/connections/compatibility.json | 20 ++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index cbcc1a9829..c83938ddbc 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -172,7 +172,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For Server sources at `581be595a51b06fdbed208349fc8c2066c80b1b3`, explicitly select `sqlite-atomic-581be595-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and qualifies its eleven recorded protocol operations through real SQLite Server tests on macOS ARM64. The recorded Server source and wheel identities must match the intended deployment. All historical records retain their original Server source, contract, wheel and operation identities. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining platform requirements are documented in the [validation guide](VALIDATION.md). +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. For Server sources at `f3a90b2fbc9edf9ab503e532fdc1c29418746a7b`, explicitly select `sqlite-atomic-f3a90b2f-v1`, then check the connection before selecting a Scope. This record matches the bundled contract and qualifies its eleven recorded protocol operations through real SQLite Server tests on macOS ARM64. The recorded Server source and wheel identities must match the intended deployment. All historical records retain their original Server source, contract, wheel and operation identities. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Source-specific evidence and remaining platform requirements are documented in the [validation guide](VALIDATION.md). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index bbb97c1381..b03d315537 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -155,7 +155,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于源码为 `581be595a51b06fdbed208349fc8c2066c80b1b3` 的 Server,请明确选择 `sqlite-atomic-581be595-v1`,检查连接后再选择范围。该记录匹配本构建的打包契约,所列十一项协议操作已在 macOS ARM64 上通过真实 SQLite Server 验证。记录的 Server 源码和 wheel 身份必须与实际部署一致。全部历史记录保留原有 Server 源码、契约、wheel 和操作集合。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的证据和剩余平台要求见[验证说明](VALIDATION.md)。 +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。对于源码为 `f3a90b2fbc9edf9ab503e532fdc1c29418746a7b` 的 Server,请明确选择 `sqlite-atomic-f3a90b2f-v1`,检查连接后再选择范围。该记录匹配本构建的打包契约,所列十一项协议操作已在 macOS ARM64 上通过真实 SQLite Server 验证。记录的 Server 源码和 wheel 身份必须与实际部署一致。全部历史记录保留原有 Server 源码、契约、wheel 和操作集合。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。各源码的证据和剩余平台要求见[验证说明](VALIDATION.md)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 1129d568ff..c862775c67 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,8 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-atomic-bae5d714-v1` records the current bundled contract's eleven protocol operations qualified below. Historical records retain their exact identities and evidence, including `sqlite-source-artifact-87b6fae2-v1` and the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. -The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-atomic-f3a90b2f-v1` records the current bundled contract's eleven protocol operations qualified below. All thirty historical records retain their exact identities, operation sets and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI fixture evidence is separate from product qualification. Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing an available matching profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path and digest, then validate the diagnostic response schema. The observed version is optional report information; a registration does not require a version. @@ -60,6 +59,50 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server f3a90b2f protocol qualification + +`sqlite-atomic-f3a90b2f-v1` records Server sources at +`f3a90b2fbc9edf9ab503e532fdc1c29418746a7b` for OpenAPI digest +`9c35d3d80035213a52598a62cd748fd5febb3a9afde69a5908b0dee40aaf73c2`. +The wheel is `powercontext-1.4.2.dev140+gf3a90b2fb-py3-none-any.whl`, SHA-256 +`76f142ccbe406411c60b5ba91429350ea8ebbd1f449275a82b6cfd5213981f9e`, built from that clean commit. +All 672 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +match the recorded source bytes. The sorted runtime manifest digest is +`6c8570ecaf816db49d1d5b45ecbabbcdfaa552e38199103bcbdce8181e907f08` +(one `wheel/path sha256` line per file, with a final newline). Generated distribution version +metadata is excluded. OpenAPI, project configuration and the lock file also match that commit. + +On 2026-11-01, macOS 26.5.2 ARM64 with Python 3.12.13 and Rust 1.95.0 ran +`desktop/tests/real_server.py` against independent SQLite Server processes loaded from this wheel. +All four modes passed: anonymous loopback, static Bearer, HTTPS with explicit CA/base path, +and an injected provider with enforced access. The eleven recorded operations cover discovery, +health, Principal, Scope selection and pagination, Atomic Memory save/search/exact revision reads, +identity changes, same-identity revocation, committed writes with lost responses without replay, +and Server survival after client exit. The native probe uses the committed product modules and +locked dependencies; its SHA-256 is +`e94b4df948cc5f1a4230a03c9f9cb4a422c9a0d5bdc2ddff7d964a0e8f828459`. +The harness records `desktopWorkingTreeDirty=false`. + +Reproduce from the recorded commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --frozen python desktop/tests/fixture_qualification.py --server-commit f3a90b2fbc9edf9ab503e532fdc1c29418746a7b +uv run --frozen python desktop/tests/real_server.py +``` + +Ignored local evidence is in `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This qualifies the listed SQLite protocol +behavior on that host. It does not establish Windows/Intel execution, installed GUI workflows, +persistent native credentials, signing, notarization or complete release-upgrade acceptance. +All historical records retain their original source, contract, artifact and operation identities. + +本记录绑定上述准确源码、契约和 wheel;672 个运行时文件均与记录提交逐字节一致。 +四种真实 SQLite/HTTP 连接模式均通过,覆盖所列十一项协议操作、身份变化、同身份撤权、 +Scope 分页及响应丢失后不重放已提交写入。该验证不替代 Windows/Intel、安装后 GUI、 +原生凭据持久化、签名、公证或完整升级验收。全部历史记录保留原有身份、操作集合和证据。 + #### Server 581be595 protocol qualification `sqlite-atomic-581be595-v1` records Server sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 8433afd7e2..9ad206bdbe 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -577,5 +577,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-bae5d714-protocol-qualification" + }, + { + "id": "sqlite-atomic-f3a90b2f-v1", + "serverCommit": "f3a90b2fbc9edf9ab503e532fdc1c29418746a7b", + "contractSha256": "9c35d3d80035213a52598a62cd748fd5febb3a9afde69a5908b0dee40aaf73c2", + "artifactSha256": "76f142ccbe406411c60b5ba91429350ea8ebbd1f449275a82b6cfd5213981f9e", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-f3a90b2f-protocol-qualification" } ] From e08cc47903e8c283cc165f22a3c44d33e3d38368 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Mon, 2 Nov 2026 07:20:19 +0800 Subject: [PATCH 15/25] fix(access): preserve handoff attribution during migration --- .github/workflows/master.yml | 1 + docs/en/docs/operate/deploy-server.md | 3 + docs/zh/docs/operate/deploy-server.md | 6 +- release/upgrade-support.json | 4 +- src/powercontext/server/authz/composition.py | 15 +- src/powercontext/server/authz/service.py | 28 +++ src/powercontext/server/cli.py | 8 +- src/powercontext/server/ownership.py | 13 +- tests/e2e/test_access_ownership_migration.py | 241 ++++++++++++++++++- tests/e2e/test_owner_recovery_http.py | 62 ++++- 10 files changed, 360 insertions(+), 21 deletions(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index dbddf22d96..fac4e93428 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -708,6 +708,7 @@ jobs: run: >- uv run --locked --no-sync pytest -q tests/e2e/test_access_ownership_migration.py::test_offline_cli_checks_future_background_principal_before_enforcement + tests/e2e/test_access_ownership_migration.py::test_offline_background_plan_uses_configured_static_preset_without_grants tests/e2e/test_access_ownership_migration.py::test_migration_audit_preserves_request_context tests/e2e/test_ownership_maintenance_dispatch.py tests/e2e/test_owner_recovery_http.py::test_offline_migration_preserves_external_owner_evidence diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index f1adb490a2..c42f90b666 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -200,6 +200,8 @@ so an administrator migrates the missing attribution explicitly: after all ownership gaps have been filled. HTTP planning accepts `background_principal` when an Access store is available; otherwise it returns `503 relationship_management_unavailable`. Use the offline command for this check before enabling enforcement. + For a configured single-token deployment, the check includes the worker's static Scope preset without + creating bindings. An existing Profile still requires its own Artifact write permission. 3. Backfill with an explicitly named owning Principal — for a single-user static-token deployment that is the `server-token` service Principal: @@ -215,6 +217,7 @@ so an administrator migrates the missing attribution explicitly: different Scopes; multi-user deployments keep this identity boundary explicit. The inventory includes merged and retired Artifact identities so supported restoration remains authorized. + It also includes committed Handoffs and each published Handoff copy under its recorded identity. Offline `apply`, HTTP `apply`, and the HTTP preview report `owner-recovery-pending` or `owner-recovery-delivered_relation_missing` when durable recovery evidence already records an identity. They preserve that evidence and do not replace its owner. Complete pending ownership delivery through the diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 063521df4a..d7f0bd38f6 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -180,7 +180,8 @@ Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与 2. 用 `powercontext server access-ownership-migrate --action plan` 盘点受影响的数据。`ACCESS_MODE=disabled` 期间同样可以运行。添加 `--background-principal service:后台身份ID`,填写切换后准备使用的 `ACCESS_BACKGROUND_PRINCIPAL_ID`,即可检查每个指定 Scope 的 `scope.contribute` 和已有 Profile 写权限。 - owner 缺口补齐后仍会执行这些检查。HTTP 预检在授权存储可用时接受 `background_principal`;否则返回 + owner 缺口补齐后仍会执行这些检查。已配置单 Token 的部署会计入后台的静态 Scope 权限预设,预检不会创建授权。 + 已有 Profile 仍须单独通过 Artifact 写权限检查。HTTP 预检在授权存储可用时接受 `background_principal`;否则返回 `503 relationship_management_unavailable`。开启认证前请使用离线命令执行后台权限预检。 3. 用显式指定的归属 Principal 回补。单用户静态 token 部署就是 `server-token` 服务 Principal: @@ -194,7 +195,8 @@ Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与 attestation 会被保留,冲突时明确报告,不会被覆盖。不同身份需要拥有不同 Scope 时,用另一个 `--owner` 再次执行;多用户部署由此保持明确的身份边界。 - 盘点包含已合并和已退休的 Artifact 身份,以保留恢复操作所需的权限。如果持久化恢复记录已经确定归属, + 盘点包含已合并和已退休的 Artifact 身份,以及已提交 Handoff 和各发布副本的实际身份,以保留恢复操作所需的权限。 + 如果持久化恢复记录已经确定归属, 离线 `apply`、HTTP `apply` 及 HTTP 预览会报告 `owner-recovery-pending` 或 `owner-recovery-delivered_relation_missing`, 并保留原始记录及其归属。待投递的归属信息应按[归属恢复流程](../workflows/scopes-and-access.md)继续处理。 使用外部 Access 存储时,如果审计写入失败,迁移会保留可重放的归属投递任务。即使 owner 关系已存在, diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 9332e3ee7f..ae167e6f9e 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "0e44abdf781ee4cf95d1879c89dacd4fc101354fd300f5c070ee293faf58505c", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule." + "persistence_sha256": "d59dba9cd16c8449771e4fb32a15cc29839c06bc3f07c8ee826040f99c5c0662", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule. Ownership maintenance can configure the existing static Scope preset without bootstrapping administrator or Scope bindings; planning inspects that preset without changing grants. This composition change alters no stored schema, identity, row encoding, owner recovery payload, or upgrade registration. The read-only static-preset check uses existing binding receipts and preserves revoked grants; it adds no stored fields or writes." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/server/authz/composition.py b/src/powercontext/server/authz/composition.py index 8f37d3739c..8db3da30f5 100644 --- a/src/powercontext/server/authz/composition.py +++ b/src/powercontext/server/authz/composition.py @@ -50,8 +50,13 @@ async def open_builtin_access_control( bootstrap_administrators: Sequence[PrincipalRef] = (), server_administrators: Sequence[PrincipalRef] = (), deployment_id: str = DEFAULT_DEPLOYMENT_ID, + static_scope_principal: PrincipalRef | None = None, ) -> AsyncIterator[AccessControlService]: - """Open Access; only legacy bootstrap administrators receive the static Scope preset.""" + """Open Access with the configured static Scope preset. + + Maintenance can declare the preset without bootstrapping administrator roles. + Declaring it does not materialize Scope bindings. + """ async with _open_access_repository(database) as repository: await _bootstrap_server_roles( @@ -72,7 +77,13 @@ async def open_builtin_access_control( relationship_management=True, group_subjects=False, ), - static_scope_principal=bootstrap_administrators[0] if len(bootstrap_administrators) == 1 else None, + static_scope_principal=( + static_scope_principal + if static_scope_principal is not None + else bootstrap_administrators[0] + if len(bootstrap_administrators) == 1 + else None + ), ) diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 5372f4a88b..512a5949a2 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -896,6 +896,34 @@ def can_bootstrap_static_scope( actions = ROLE_ACTIONS if resource.type is AccessResourceType.SCOPE else ROLE_CHILD_ACTIONS return any(action in actions[role] for role in _STATIC_SCOPE_ROLES) + async def static_scope_bootstrap_would_grant( + self, principal: PrincipalRef, action: AccessAction, resource: ResourceRef + ) -> bool: + """Inspect whether a denied check can gain a new local preset binding. + + An existing preset receipt is final, including after revocation. This + preview must not treat that receipt as a grant or recreate its binding. + """ + if not self.can_bootstrap_static_scope(principal, action, resource): + return False + from powercontext.server.authz.casbin import CasbinAuthorizationProvider + from powercontext.server.authz.repository import RelationalAccessRepository + + repository = self.relationships + if type(repository) is not RelationalAccessRepository or type(self.provider) not in { + BuiltinAuthorizationProvider, + CasbinAuthorizationProvider, + }: + return False + actions = ROLE_ACTIONS if resource.type is AccessResourceType.SCOPE else ROLE_CHILD_ACTIONS + for role in _STATIC_SCOPE_ROLES: + if action not in actions[role]: + continue + key = f"static-preset:{self.deployment_id}:{principal.id}:{resource.scope_id}:{role.value}" + if len(key) <= 255 and not await _access_call(repository.static_scope_initialized(principal, (key,))): + return True + return False + async def bootstrap_static_scope( self, principal: PrincipalRef | None, diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index e846d9bedc..cbc620afc3 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -650,7 +650,13 @@ async def _access_ownership_maintenance( ) async with AsyncExitStack() as resources: access = await resources.enter_async_context( - open_builtin_access_control(settings.database, deployment_id=settings.access.deployment_id) + open_builtin_access_control( + settings.database, + deployment_id=settings.access.deployment_id, + static_scope_principal=( + PrincipalRef(type="service", id="server-token") if settings.auth.token is not None else None + ), + ) ) runtime = await resources.enter_async_context(open_builtin_runtime(config, dispatch_processing=False)) if runtime.scopes is None: diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 4e951fbf85..59582b1099 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -50,7 +50,7 @@ Application = ServerApplication | BuiltinRuntime -OWNED_ARTIFACT_FAMILIES = ("atomic-memory", "experience", "skill", "profile") +OWNED_ARTIFACT_FAMILIES = ("atomic-memory", "experience", "skill", "profile", "handoff") RESOURCE_PAGE_SIZE = 100 CANDIDATE_PAGE_SIZE = 100 @@ -176,7 +176,12 @@ async def plan_ownership_migration( ResourceRef.scope(scope_id), context=context, ) - if not decision.allowed: + if not decision.allowed and not ( + decision.reason_code in {"no-matching-binding", "no-matching-policy"} + and await access.static_scope_bootstrap_would_grant( + background_principal, AccessAction.SCOPE_CONTRIBUTE, ResourceRef.scope(scope_id) + ) + ): background.append( BackgroundPermissionGap( scope_id=scope_id, @@ -421,10 +426,6 @@ async def _artifact_gaps( if access is not None and await access.artifact_owner(resource) is not None: continue gaps.append(ArtifactOwnershipGap(scope_id, record.family, record.artifact_id)) - if application.handoff is not None and await application.handoff.for_scope(scope_id).revisions(): - resource = ResourceRef.artifact(scope_id, family="handoff", artifact_id="handoff") - if access is None or await access.artifact_owner(resource) is None: - gaps.append(ArtifactOwnershipGap(scope_id=scope_id, family="handoff", artifact_id="handoff")) return tuple(gaps) diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index 9bca2988d5..ee3e4e84d2 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -39,11 +39,11 @@ from powercontext.builtin.scope import ScopeApplication, ScopeDraft from powercontext.cli.app import create_cli from powercontext.server.authentication import AuthenticationResult, ProviderReadiness -from powercontext.server.authz import AccessAuditContext, AccessConflictError, PrincipalRef, ResourceRef +from powercontext.server.authz import AccessAction, AccessAuditContext, AccessConflictError, PrincipalRef, ResourceRef from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.factory import create_server_app from powercontext.server.ownership import ArtifactOwnershipGap, apply_ownership_migration -from powercontext.server.settings import AccessControlConfig, McpConfig, MetricsConfig, ServerSettings +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, MetricsConfig, ServerSettings ADMIN = PrincipalRef(type="service", id="admin") SERVER_TOKEN = PrincipalRef(type="service", id="server-token") @@ -127,6 +127,243 @@ async def _create_legacy_data(client: httpx.AsyncClient) -> tuple[str, str, dict return scope, f"/v1/scopes/{scope}/artifacts/atomic-memory/{artifact['artifact_id']}", candidate +@pytest.mark.parametrize("profile_owner", [None, "server-token", "other", "revoked"]) +def test_background_plan_accounts_for_static_preset_without_granting_bindings( + tmp_path: Path, profile_owner: str | None +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'static-background.db').as_posix()}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with app.router.lifespan_context(app): + scope = await app.state.application.scopes.create( + ScopeDraft(title="Legacy", summary="No static bindings", idempotency_key="static-background") + ) + if profile_owner in {"server-token", "other"}: + from powercontext.builtin.records import ArtifactWrite + + await app.state.application.records.for_scope(scope.scope_id).create_artifact( + "profile", ArtifactWrite(content={"content": "# Legacy profile"}) + ) + settings = _settings(database, "enforced").model_copy( + update={"auth": BearerAuthConfig(token=SecretStr("static-test-token"))} + ) + app = create_server_app(settings=settings, scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": "Bearer static-test-token"}, + ) as client, + ): + access = app.state.access_control + context = AccessAuditContext(transport="background", operation="test.background") + if profile_owner in {"server-token", "other"}: + await access.establish_artifact_owner( + ResourceRef.artifact(scope.scope_id, family="profile", artifact_id="profile"), + PrincipalRef(type="service", id=profile_owner), + idempotency_key="static-profile-owner", + context=context, + ) + resource = ResourceRef.scope(scope.scope_id) + if profile_owner == "revoked": + checked( + await client.post(f"/v1/scopes/{scope.scope_id}/sources", json={"content": "Initialize preset."}), + 201, + ) + bindings = checked( + await client.post( + "/v1/access/bindings/list", + json={ + "management_resource": {"type": "scope", "scope_id": scope.scope_id}, + "role": "scope.contributor", + }, + ), + 200, + ) + (binding,) = bindings["items"] + checked( + await client.post( + "/v1/access/bindings/revoke", + json={ + "binding_id": binding["binding_id"], + "expected_version": binding["version"], + "idempotency_key": "revoke-static", + }, + ), + 200, + ) + assert not ( + await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) + ).allowed + for principal, gaps in (("server-token", int(profile_owner in {"other", "revoked"})), ("other-worker", 1)): + plan = checked( + await client.post( + "/v1/access/ownership/plan", + json={ + "scope_ids": [scope.scope_id], + "background_principal": {"type": "service", "id": principal}, + }, + ), + 200, + ) + assert len(plan["background"]) == gaps + assert not ( + await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) + ).allowed + # The worker materializes this configured preset before its real authorization. + await access.bootstrap_static_scope(SERVER_TOKEN, scope.scope_id, context=context) + decision = await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) + assert decision.allowed == (profile_owner != "revoked") + + asyncio.run(scenario()) + + +def test_migration_inventories_published_handoff_identities(tmp_path: Path) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'published.db').as_posix()}") + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, + ): + scope, _, _ = await _create_legacy_data(client) + original = checked( + await client.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "handoff", + "content": { + "schema": "powercontext.handoff.v1", + "objective": "Completed task.", + "state": [ + { + "text": "Verified task state.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": "legacy-source"}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + }, + }, + ), + 201, + ) + target = checked( + await client.post( + "/v1/scopes", json={"title": "Target", "summary": "Copies", "idempotency_key": "copy-target"} + ), + 201, + )["scope_id"] + for key in ("first-copy", "second-copy"): + checked( + await client.post( + "/v1/artifact-publications", + json={ + "source": { + "scope_id": scope, + "artifact": { + "family": "handoff", + "artifact_id": "handoff", + "revision": original["revision"], + }, + }, + "target_scope_id": target, + "idempotency_key": key, + }, + ), + 201, + ) + records = await app.state.application.records.for_scope(target).logical_artifacts() + identities = {record.artifact_id for record in records if record.family == "handoff"} + assert len(identities) == 2 and all(identity.startswith("pub_") for identity in identities) + async with _enforced_server(database, tmp_path) as (client, access): + plan = checked(await client.post("/v1/access/ownership/plan", json={"scope_ids": [target]}), 200) + assert {item["artifact_id"] for item in plan["scopes"][0]["artifact_gaps"]} == identities + payload = {"scope_ids": [target], "owner": {"type": ADMIN.type, "id": ADMIN.id}} + preview = checked(await client.post("/v1/access/ownership/apply", json={**payload, "dry_run": True}), 200) + applied = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert preview["applied"] == applied["applied"] + assert applied["conflicts"] == [] + for identity in identities: + resource = ResourceRef.artifact(target, family="handoff", artifact_id=identity) + relation = await access.artifact_owner(resource) + assert relation is not None and relation.owner == ADMIN + await access.require( + ADMIN, + AccessAction.ARTIFACT_WRITE, + resource, + context=AccessAuditContext(transport="http", operation="test.migrated-handoff"), + ) + repeated = checked(await client.post("/v1/access/ownership/apply", json=payload), 200) + assert repeated["applied"] == [] and repeated["conflicts"] == [] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("static_token", [False, True]) +def test_offline_background_plan_uses_configured_static_preset_without_grants( + tmp_path: Path, static_token: bool +) -> None: + from powercontext.server.cli import app as server_cli + + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'offline-static.db').as_posix()}") + + async def seed() -> str: + app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") + async with app.router.lifespan_context(app): + scope = await app.state.application.scopes.create( + ScopeDraft(title="Legacy", summary="Static preview", idempotency_key="offline-static") + ) + return scope.scope_id + + scope_id = asyncio.run(seed()) + environment = tmp_path / "offline-static.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + f"POWERCONTEXT_SERVER_ACCESS_MODE={'enforced' if static_token else 'disabled'}\n" + f"POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID={DEPLOYMENT}\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n" + + ("POWERCONTEXT_SERVER_AUTH_TOKEN=static-test-token\n" if static_token else ""), + encoding="utf-8", + ) + result = CliRunner().invoke( + create_cli([server_cli]), + [ + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "plan", + "--scope-id", + scope_id, + "--background-principal", + "service:server-token", + ], + ) + assert result.exit_code == 0, result.output + assert len(json.loads(result.output)["background"]) == (0 if static_token else 1) + + async def verify() -> None: + async with open_builtin_access_control(database, deployment_id=DEPLOYMENT) as access: + decision = await access.check( + SERVER_TOKEN, + AccessAction.SCOPE_CONTRIBUTE, + ResourceRef.scope(scope_id), + context=AccessAuditContext(transport="background", operation="test.offline-static"), + ) + assert not decision.allowed + + asyncio.run(verify()) + + def test_disabled_data_becomes_mutable_again_after_ownership_migration(tmp_path: Path) -> None: async def scenario() -> None: database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}") diff --git a/tests/e2e/test_owner_recovery_http.py b/tests/e2e/test_owner_recovery_http.py index b11863fb74..fea3c2b1ed 100644 --- a/tests/e2e/test_owner_recovery_http.py +++ b/tests/e2e/test_owner_recovery_http.py @@ -276,21 +276,71 @@ async def scenario(): @pytest.mark.parametrize("completed", [False, True]) -def test_offline_migration_preserves_external_owner_evidence(tmp_path, completed): +@pytest.mark.parametrize("operation", ["profile", "handoff", "publication"]) +def test_offline_migration_preserves_external_owner_evidence(tmp_path, completed, operation): from powercontext.server.cli import app as server_cli async def seed(): - async with _server(tmp_path, interrupted=True) as (app, client, access): + async with _server(tmp_path) as (app, client, access): scope_id = await _scope(client) - created = await client.post( - f"/v1/scopes/{scope_id}/artifacts", - json={"family": "profile", "content": {"content": "# Original private profile"}}, - ) + path = f"/v1/scopes/{scope_id}/artifacts" + payload = {"family": "profile", "content": {"content": "# Original private profile"}} + if operation != "profile": + source = await client.post(f"/v1/scopes/{scope_id}/sources", json={"content": "Verified task state."}) + assert source.status_code == 201, source.text + content = { + "schema": "powercontext.handoff.v1", + "objective": "Continue verified work.", + "state": [ + { + "text": "Verified task state.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": source.json()["source_id"]}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + } + payload = {"family": "handoff", "content": content} + if operation == "publication": + original = await client.post(path, json=payload) + assert original.status_code == 201, original.text + target = await client.post( + "/v1/scopes", + json={"title": "Target", "summary": "Recovery", "idempotency_key": "recovery-target"}, + ) + assert target.status_code == 201, target.text + destination = target.json()["scope_id"] + path = "/v1/artifact-publications" + payload = { + "source": { + "scope_id": scope_id, + "artifact": {"family": "handoff", "artifact_id": "handoff", "revision": 1}, + }, + "target_scope_id": destination, + "idempotency_key": "recovery-publication", + } + scope_id = destination + access.relationships.unavailable = True + created = await client.post(path, json=payload) assert created.status_code == 503, created.text (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) if completed: access.relationships.unavailable = False await app.state.owner_recovery.replay(intent.operation_id) + else: + preview = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": True}, + ) + assert preview.status_code == 200, preview.text + assert preview.json()["applied"] == [] + assert [item["conflict_code"] for item in preview.json()["conflicts"]] == ["owner-recovery-pending"] return scope_id, intent scope_id, intent = asyncio.run(seed()) From 2ee2da386b93e621404a96ac701233452ab19fc0 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Mon, 2 Nov 2026 09:03:31 +0800 Subject: [PATCH 16/25] fix(persistence): retry transient artifact FTS DDL locks --- release/upgrade-support.json | 4 +- .../builtin/persistence/schema.py | 32 ++++- .../test_artifact_search_schema.py | 130 ++++++++++++++++++ 3 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 tests/builtin/persistence/test_artifact_search_schema.py diff --git a/release/upgrade-support.json b/release/upgrade-support.json index ae167e6f9e..c7a4ff01f6 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "d59dba9cd16c8449771e4fb32a15cc29839c06bc3f07c8ee826040f99c5c0662", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule. Ownership maintenance can configure the existing static Scope preset without bootstrapping administrator or Scope bindings; planning inspects that preset without changing grants. This composition change alters no stored schema, identity, row encoding, owner recovery payload, or upgrade registration. The read-only static-preset check uses existing binding receipts and preserves revoked grants; it adds no stored fields or writes." + "persistence_sha256": "c80a35aaf5e8270383fd367abd399b0162e99aa7cabdd5b38ba11393377c088d", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule. Ownership maintenance can configure the existing static Scope preset without bootstrapping administrator or Scope bindings; planning inspects that preset without changing grants. This composition change alters no stored schema, identity, row encoding, owner recovery payload, or upgrade registration. The read-only static-preset check uses existing binding receipts and preserves revoked grants; it adds no stored fields or writes. Unmanaged Artifact full-text initialization retries only transient error 1205 on its exact CREATE FULLTEXT INDEX statement, with finite asynchronous backoff and fresh index-existence checks. It does not replay table or business writes, end the caller transaction, change managed-schema initialization, or alter stored layouts, recovery payloads, index definitions, identities, or migration registrations." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/schema.py b/src/powercontext/builtin/persistence/schema.py index b82a72dc52..96b7f736a6 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio from collections.abc import Sequence from sqlalchemy import Connection, MetaData, Table, inspect, text @@ -60,9 +61,7 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) ) if any(table.name == "pc_artifact_search_text" for table in tables): - from powercontext.builtin.persistence.artifact_search_schema import ensure_search_support - - await connection.run_sync(ensure_search_support) + await _ensure_artifact_search_support(connection) if connection.dialect.name != "sqlite" and any(table.name == "pc_artifact_vector_spaces" for table in tables): from sqlalchemy import select @@ -78,6 +77,33 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) await connection.run_sync(provision_native_spaces) +async def _ensure_artifact_search_support(connection: AsyncConnection) -> None: + from powercontext.builtin.persistence.artifact_search_schema import ensure_search_support + + delays = (0.1, 0.2, 0.4, 0.8, 1.6) + for attempt in range(len(delays) + 1): + try: + await connection.run_sync(ensure_search_support) + except DBAPIError as error: + # OceanBase can reject index creation with a transient DDL lock. + # Recheck this derived index without replaying table/business writes + # or ending the caller's transaction, as for Topic Memory FTS. + if ( + connection.dialect.name != "mysql" + or mysql_error_code(error) != 1205 + or error.statement + != ( + "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts " + "ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" + ) + or attempt == len(delays) + ): + raise + await asyncio.sleep(delays[attempt]) + else: + return + + def _create_mysql_tables(connection: Connection, tables: Sequence[Table]) -> None: """Use atomic table creation and normalize only verified index-name races. diff --git a/tests/builtin/persistence/test_artifact_search_schema.py b/tests/builtin/persistence/test_artifact_search_schema.py new file mode 100644 index 0000000000..65d5becf28 --- /dev/null +++ b/tests/builtin/persistence/test_artifact_search_schema.py @@ -0,0 +1,130 @@ +# 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. + +"""Artifact FTS initialization preserves the caller's transaction during DDL contention.""" + +import asyncio +from typing import cast +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy +from pymysql.err import OperationalError as MySQLOperationalError +from sqlalchemy.dialects import mysql +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence import schema +from powercontext.builtin.persistence.tables import ARTIFACT_SEARCH_TABLES, ARTIFACT_SEARCH_TEXT_TABLE + +_DDL = "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" + + +class _SearchSchema: + dialect = mysql.dialect() + + def __init__(self, failures, *, concurrent_index=False): + self.failures = list(failures) + self.concurrent_index = concurrent_index + self.index_exists = False + self.statements = [] + + def get_table_names(self): + return [table.name for table in ARTIFACT_SEARCH_TABLES] + + def has_table(self, name, **_kwargs): + return name in self.get_table_names() + + def get_columns(self, name): + return [{"name": column.name} for table in ARTIFACT_SEARCH_TABLES if table.name == name for column in table.c] + + def get_indexes(self, _name): + return [{"name": "ix_pc_artifact_search_fts"}] if self.index_exists else [] + + def exec_driver_sql(self, statement): + self.statements.append(statement) + assert statement == _DDL + if self.failures: + if self.concurrent_index: + self.index_exists = True + raise self.failures.pop(0) + self.index_exists = True + + +def _connection(monkeypatch, backend): + monkeypatch.setattr(schema, "inspect", lambda _connection: backend) + monkeypatch.setattr(sqlalchemy, "inspect", lambda _connection: backend) + connection = AsyncMock(spec=AsyncConnection) + connection.dialect = backend.dialect + + async def run_sync(callback): + return callback(backend) + + connection.run_sync.side_effect = run_sync + return connection + + +@pytest.mark.parametrize("concurrent_index", [False, True]) +def test_artifact_fts_recovers_from_a_transient_ddl_lock_without_ending_the_transaction(monkeypatch, concurrent_index): + async def scenario(): + error = OperationalError(_DDL, None, MySQLOperationalError(1205, "DDL lock conflict")) + backend = _SearchSchema([error], concurrent_index=concurrent_index) + connection = _connection(monkeypatch, backend) + await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) + assert backend.index_exists + assert backend.statements == ([_DDL] if concurrent_index else [_DDL, _DDL]) + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + + asyncio.run(scenario()) + + +def test_artifact_fts_bounds_persistent_lock_retries(monkeypatch): + async def scenario(): + error = OperationalError(_DDL, None, MySQLOperationalError(1205, "DDL lock conflict")) + backend = _SearchSchema([error] * 20) + connection = _connection(monkeypatch, backend) + with pytest.raises(OperationalError) as raised: + async with asyncio.timeout(5): + await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) + assert raised.value is error + assert 1 <= len(backend.statements) <= 6 + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", [1142, 1213, "cancelled", "other-statement"]) +def test_artifact_fts_preserves_other_errors_and_cancellation(monkeypatch, failure): + async def scenario(): + error = ( + asyncio.CancelledError() + if failure == "cancelled" + else OperationalError( + "SHOW FULL COLUMNS FROM pc_artifact_search_text" if failure == "other-statement" else _DDL, + None, + MySQLOperationalError(1205 if failure == "other-statement" else failure, "DDL failed"), + ) + ) + backend = _SearchSchema([error]) + connection = _connection(monkeypatch, backend) + with pytest.raises(type(error)) as raised: + await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) + assert raised.value is error + assert not backend.index_exists + connection.commit.assert_not_awaited() + connection.rollback.assert_not_awaited() + + asyncio.run(scenario()) From 126244e9b192d20e7a140a6dbf60be5184ca163d Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Mon, 2 Nov 2026 13:03:01 +0800 Subject: [PATCH 17/25] fix(access): protect every migrated candidate identity --- .github/workflows/master.yml | 1 + docs/en/docs/operate/deploy-server.md | 4 +- docs/zh/docs/operate/deploy-server.md | 1 + release/upgrade-support.json | 4 +- src/powercontext/server/authz/service.py | 3 +- src/powercontext/server/ownership.py | 58 +++--- tests/e2e/test_access_ownership_migration.py | 78 +++++--- .../e2e/test_candidate_ownership_migration.py | 185 ++++++++++++++++++ 8 files changed, 276 insertions(+), 58 deletions(-) create mode 100644 tests/e2e/test_candidate_ownership_migration.py diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index fac4e93428..43c3bed1a0 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -712,6 +712,7 @@ jobs: tests/e2e/test_access_ownership_migration.py::test_migration_audit_preserves_request_context tests/e2e/test_ownership_maintenance_dispatch.py tests/e2e/test_owner_recovery_http.py::test_offline_migration_preserves_external_owner_evidence + tests/e2e/test_candidate_ownership_migration.py - name: Verify bootstrap receipt persistence and failure recovery on Windows run: >- diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index c42f90b666..b2bda77494 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -220,7 +220,9 @@ so an administrator migrates the missing attribution explicitly: It also includes committed Handoffs and each published Handoff copy under its recorded identity. Offline `apply`, HTTP `apply`, and the HTTP preview report `owner-recovery-pending` or `owner-recovery-delivered_relation_missing` when durable recovery evidence already records an identity. - They preserve that evidence and do not replace its owner. Complete pending ownership delivery through the + They preserve that evidence and do not replace its owner. Candidate recovery checks cover the families accepted + by migration, including Prompt Candidates, independently of which families require standalone Artifact ownership. + Complete pending ownership delivery through the [ownership recovery procedure](../workflows/scopes-and-access.md#recover-external-ownership-writes). With an external Access store, migration retains durable ownership delivery work if its audit write fails. Repeating the same HTTP `apply` completes that original audit before reporting success, even if the owner diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index d7f0bd38f6..5471c79fbd 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -199,6 +199,7 @@ Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与 如果持久化恢复记录已经确定归属, 离线 `apply`、HTTP `apply` 及 HTTP 预览会报告 `owner-recovery-pending` 或 `owner-recovery-delivered_relation_missing`, 并保留原始记录及其归属。待投递的归属信息应按[归属恢复流程](../workflows/scopes-and-access.md)继续处理。 + Candidate 恢复预检覆盖迁移接受的家族,包括 Prompt Candidate,不受独立 Artifact owner 家族范围限制。 使用外部 Access 存储时,如果审计写入失败,迁移会保留可重放的归属投递任务。即使 owner 关系已存在, 重复同一 HTTP `apply` 也会先完成原始审计,再报告成功。预览不会重放待处理任务。 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index c7a4ff01f6..f26da179b4 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "c80a35aaf5e8270383fd367abd399b0162e99aa7cabdd5b38ba11393377c088d", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule. Ownership maintenance can configure the existing static Scope preset without bootstrapping administrator or Scope bindings; planning inspects that preset without changing grants. This composition change alters no stored schema, identity, row encoding, owner recovery payload, or upgrade registration. The read-only static-preset check uses existing binding receipts and preserves revoked grants; it adds no stored fields or writes. Unmanaged Artifact full-text initialization retries only transient error 1205 on its exact CREATE FULLTEXT INDEX statement, with finite asynchronous backoff and fresh index-existence checks. It does not replay table or business writes, end the caller transaction, change managed-schema initialization, or alter stored layouts, recovery payloads, index definitions, identities, or migration registrations." + "persistence_sha256": "7a6eba111a596c12f631d87764b3d49562e4daed42a7de923fe7534937eec836", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The Access boundary adds the ownership_migration_unavailable unavailable-code message for the supported post-switch ownership migration entry point; it changes no table layout, stored encoding, migration step or recovery rule. Ownership maintenance can configure the existing static Scope preset without bootstrapping administrator or Scope bindings; planning inspects that preset without changing grants. This composition change alters no stored schema, identity, row encoding, owner recovery payload, or upgrade registration. The read-only static-preset check uses existing binding receipts and preserves revoked grants; it adds no stored fields or writes. Unmanaged Artifact full-text initialization retries only transient error 1205 on its exact CREATE FULLTEXT INDEX statement, with finite asynchronous backoff and fresh index-existence checks. It does not replay table or business writes, end the caller transaction, change managed-schema initialization, or alter stored layouts, recovery payloads, index definitions, identities, or migration registrations. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 512a5949a2..59db190258 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -919,8 +919,9 @@ async def static_scope_bootstrap_would_grant( for role in _STATIC_SCOPE_ROLES: if action not in actions[role]: continue + # Match bootstrap's complete internal receipt key, not public request-key limits. key = f"static-preset:{self.deployment_id}:{principal.id}:{resource.scope_id}:{role.value}" - if len(key) <= 255 and not await _access_call(repository.static_scope_initialized(principal, (key,))): + if not await _access_call(repository.static_scope_initialized(principal, (key,))): return True return False diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 59582b1099..9282c95a52 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -24,10 +24,10 @@ from __future__ import annotations -from collections.abc import Collection, Sequence +from collections.abc import Sequence from dataclasses import dataclass, replace from hashlib import sha256 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from powercontext.server.authz import ( AccessAction, @@ -256,7 +256,7 @@ async def apply_ownership_migration( # Complete the original intent before an idempotent retry succeeds. async with owner_recovery_session(owner_recovery): await flush_owner_writes(artifact=_owner_identity(item)[1]) - protected = await _owner_recovery_conflicts(owner_recovery, items, families) + protected = await _owner_recovery_conflicts(owner_recovery, items) protected_keys = {_migration_item_key(item.gap) for item in protected} items = [item for item in items if _migration_item_key(item) not in protected_keys] if dry_run: @@ -301,39 +301,43 @@ def _preview_gaps(items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap]) async def _owner_recovery_conflicts( recovery: OwnerRecovery | None, items: Sequence[ArtifactOwnershipGap | CandidateOwnershipGap], - families: Collection[str], ) -> list[OwnershipDisposition]: if recovery is None: return [] conflicts: list[OwnershipDisposition] = [] by_identity = {_migration_item_key(item): item for item in items} - for scope_id in dict.fromkeys(item.scope_id for item in items): - for kind in ("artifact", "candidate"): - after: tuple[str, str] | None = None - while True: - page = await recovery.inventory_gaps(scope_id, kind=kind, after=after, families=families) - for evidence in page.items: - if evidence.operation_id is None: - continue - key = ( - ResourceRef.artifact(scope_id, family=evidence.family, artifact_id=evidence.identity).key - if kind == "artifact" - else evidence.identity + families_by_kind: dict[tuple[str, Literal["artifact", "candidate"]], set[str]] = {} + for item in items: + scope_id, kind, _ = _migration_item_key(item) + families_by_kind.setdefault((scope_id, kind), set()).add(item.family) + # Candidate attribution also covers families without standalone Artifact ownership. + # Protect exactly the identities accepted above instead of borrowing another whitelist. + for (scope_id, kind), families in families_by_kind.items(): + after: tuple[str, str] | None = None + while True: + page = await recovery.inventory_gaps(scope_id, kind=kind, after=after, families=families) + for evidence in page.items: + if evidence.operation_id is None: + continue + key = ( + ResourceRef.artifact(scope_id, family=evidence.family, artifact_id=evidence.identity).key + if kind == "artifact" + else evidence.identity + ) + item = by_identity.get((scope_id, kind, key)) + if item is not None: + conflicts.append( + OwnershipDisposition(gap=item, outcome="conflict", detail=f"owner-recovery-{evidence.reason}") ) - item = by_identity.get((scope_id, kind, key)) - if item is not None: - conflicts.append( - OwnershipDisposition( - gap=item, outcome="conflict", detail=f"owner-recovery-{evidence.reason}" - ) - ) - after = page.after - if after is None: - break + after = page.after + if after is None: + break return conflicts -def _migration_item_key(item: ArtifactOwnershipGap | CandidateOwnershipGap) -> tuple[str, str, str]: +def _migration_item_key( + item: ArtifactOwnershipGap | CandidateOwnershipGap, +) -> tuple[str, Literal["artifact", "candidate"], str]: if isinstance(item, ArtifactOwnershipGap): return (item.scope_id, "artifact", _owner_identity(item)[1].key) return (item.scope_id, "candidate", item.candidate_id) diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index ee3e4e84d2..91b1a4abed 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -39,7 +39,15 @@ from powercontext.builtin.scope import ScopeApplication, ScopeDraft from powercontext.cli.app import create_cli from powercontext.server.authentication import AuthenticationResult, ProviderReadiness -from powercontext.server.authz import AccessAction, AccessAuditContext, AccessConflictError, PrincipalRef, ResourceRef +from powercontext.server.authz import ( + AccessAction, + AccessAuditContext, + AccessConflictError, + AccessRole, + BindingSearchRequest, + PrincipalRef, + ResourceRef, +) from powercontext.server.authz.composition import open_builtin_access_control from powercontext.server.factory import create_server_app from powercontext.server.ownership import ArtifactOwnershipGap, apply_ownership_migration @@ -128,14 +136,20 @@ async def _create_legacy_data(client: httpx.AsyncClient) -> tuple[str, str, dict @pytest.mark.parametrize("profile_owner", [None, "server-token", "other", "revoked"]) +@pytest.mark.parametrize("scope_length", [None, 220, 256]) def test_background_plan_accounts_for_static_preset_without_granting_bindings( - tmp_path: Path, profile_owner: str | None + tmp_path: Path, profile_owner: str | None, scope_length: int | None ) -> None: async def scenario() -> None: database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'static-background.db').as_posix()}") app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") async with app.router.lifespan_context(app): - scope = await app.state.application.scopes.create( + scopes = ( + app.state.application.scopes + if scope_length is None + else ScopeApplication(app.state.application.primary_database, id_factory=lambda: "s" * scope_length) + ) + scope = await scopes.create( ScopeDraft(title="Legacy", summary="No static bindings", idempotency_key="static-background") ) if profile_owner in {"server-token", "other"}: @@ -171,27 +185,18 @@ async def scenario() -> None: await client.post(f"/v1/scopes/{scope.scope_id}/sources", json={"content": "Initialize preset."}), 201, ) - bindings = checked( - await client.post( - "/v1/access/bindings/list", - json={ - "management_resource": {"type": "scope", "scope_id": scope.scope_id}, - "role": "scope.contributor", - }, - ), - 200, + bindings = await access.list_bindings( + SERVER_TOKEN, + BindingSearchRequest(management_resource=resource, role=AccessRole.SCOPE_CONTRIBUTOR), + context=context, ) - (binding,) = bindings["items"] - checked( - await client.post( - "/v1/access/bindings/revoke", - json={ - "binding_id": binding["binding_id"], - "expected_version": binding["version"], - "idempotency_key": "revoke-static", - }, - ), - 200, + (binding,) = bindings.items + await access.revoke_binding( + SERVER_TOKEN, + binding.binding_id, + expected_version=binding.version, + idempotency_key="revoke-static", + context=context, ) assert not ( await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) @@ -211,10 +216,23 @@ async def scenario() -> None: assert not ( await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) ).allowed - # The worker materializes this configured preset before its real authorization. - await access.bootstrap_static_scope(SERVER_TOKEN, scope.scope_id, context=context) + written = await client.post( + f"/v1/scopes/{scope.scope_id}/sources", json={"content": "Exercise the actual static bootstrap."} + ) + assert written.status_code == (403 if profile_owner == "revoked" else 201), written.text decision = await access.check(SERVER_TOKEN, AccessAction.SCOPE_CONTRIBUTE, resource, context=context) assert decision.allowed == (profile_owner != "revoked") + after = checked( + await client.post( + "/v1/access/ownership/plan", + json={ + "scope_ids": [scope.scope_id], + "background_principal": {"type": "service", "id": "server-token"}, + }, + ), + 200, + ) + assert len(after["background"]) == int(profile_owner in {"other", "revoked"}) asyncio.run(scenario()) @@ -308,8 +326,9 @@ async def scenario() -> None: @pytest.mark.parametrize("static_token", [False, True]) +@pytest.mark.parametrize("scope_length", [None, 220, 256]) def test_offline_background_plan_uses_configured_static_preset_without_grants( - tmp_path: Path, static_token: bool + tmp_path: Path, static_token: bool, scope_length: int | None ) -> None: from powercontext.server.cli import app as server_cli @@ -318,7 +337,12 @@ def test_offline_background_plan_uses_configured_static_preset_without_grants( async def seed() -> str: app = create_server_app(settings=_settings(database, "disabled"), scheduler_path=tmp_path / "scheduler.db") async with app.router.lifespan_context(app): - scope = await app.state.application.scopes.create( + scopes = ( + app.state.application.scopes + if scope_length is None + else ScopeApplication(app.state.application.primary_database, id_factory=lambda: "s" * scope_length) + ) + scope = await scopes.create( ScopeDraft(title="Legacy", summary="Static preview", idempotency_key="offline-static") ) return scope.scope_id diff --git a/tests/e2e/test_candidate_ownership_migration.py b/tests/e2e/test_candidate_ownership_migration.py new file mode 100644 index 0000000000..1b26296977 --- /dev/null +++ b/tests/e2e/test_candidate_ownership_migration.py @@ -0,0 +1,185 @@ +# 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. + +"""Candidate recovery follows accepted identities, independently of Artifact ownership.""" + +import asyncio +import json +import os +import sqlite3 +import sys +from contextlib import closing +from pathlib import Path + +import pytest + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite +from powercontext.builtin.runtime import ( + BuiltinConfig, + CaptureSource, + CreateDreamRunRequest, + GetCandidateRequest, + GetDreamRunRequest, + InferenceConfig, + RuntimeConfig, +) +from powercontext.builtin.scope import ScopeDraft +from powercontext.server.authz.composition import open_builtin_access_control +from tests.e2e.dream_support import open_dream_runtime, process_pending +from tests.e2e.test_dream_skill_prompt import PromptGenerator +from tests.e2e.test_owner_recovery_http import ALICE, _server + + +@pytest.mark.parametrize("completed", [False, True]) +@pytest.mark.parametrize("check_http", [False, True], ids=["cli", "http-and-cli"]) +def test_prompt_candidate_recovery_blocks_competing_http_and_offline_migration( + tmp_path: Path, completed: bool, check_http: bool +) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{(tmp_path / 'domain.db').as_posix()}") + # Seed through the supported complete Server schema before using the SDK Dream path. + async with _server(tmp_path): + pass + async with open_dream_runtime( + BuiltinConfig( + database=database, + runtime=RuntimeConfig(artifact_processing_families=()), + inference=InferenceConfig(generation_model="test"), + ), + dream_generator=PromptGenerator(), + ) as runtime: + scope = await runtime.scopes.create( + ScopeDraft(title="Prompt recovery", summary="Original attribution", idempotency_key="prompt-recovery") + ) + scope_id = scope.scope_id + prompt = await runtime.records.for_scope(scope_id).create_artifact( + "prompt", + ArtifactWrite( + prompt_key="atomic_memory.extract", + content={ + "schema_version": "powercontext.prompt.v1", + "mode": "custom", + "instructions": "Always treat trips as moves.", + "demonstrations": [], + }, + ), + ) + target = ArtifactRef(family="prompt", artifact_id=prompt.artifact_id, revision=prompt.revision) + source = await runtime.sources.for_scope(scope_id).capture( + CaptureSource( + source_id="correction", content="A two-day visit does not change the user's residence.", metadata={} + ) + ) + dream = runtime.dream.for_scope(scope_id) + run = await dream.create( + CreateDreamRunRequest( + operation="revise_prompt", + target=target, + artifacts=(target,), + sources=(source.source_ref,), + idempotency_key="correct-trip", + ) + ) + await process_pending(runtime) + run = await dream.get(GetDreamRunRequest(run_id=run.run_id)) + assert run.candidate is not None, run + candidate_id = run.candidate.candidate_id + candidate_before = await runtime.review.for_scope(scope_id).get( + GetCandidateRequest(candidate_id=candidate_id) + ) + + async with _server(tmp_path, interrupted=True) as (app, client, access): + initial = await client.post( + "/v1/access/ownership/apply", json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "alice"}} + ) + assert initial.status_code == 503, initial.text + (intent,) = await app.state.owner_recovery.pending(scope_id=scope_id) + assert intent.kind == "candidate" and intent.candidate_id == candidate_id and intent.principal == ALICE + if completed: + access.relationships.unavailable = False + await app.state.owner_recovery.replay(intent.operation_id) + # An external relation can be lost after delivery; the primary evidence remains authoritative. + with closing(sqlite3.connect(tmp_path / "relationships.db")) as external: + external.execute( + "DELETE FROM pc_access_owners WHERE owner_kind = 'candidate' AND scope_id = ?", (scope_id,) + ) + external.commit() + assert await access.candidate_owner(scope_id, candidate_id) is None + reason = "delivered_relation_missing" if completed else "pending" + if check_http: + for dry_run in (True, False): + result = await client.post( + "/v1/access/ownership/apply", + json={"scope_ids": [scope_id], "owner": {"type": "user", "id": "bob"}, "dry_run": dry_run}, + ) + assert result.status_code == 200, result.text + assert result.json()["applied"] == [] + assert [(item["candidate_id"], item["conflict_code"]) for item in result.json()["conflicts"]] == [ + (candidate_id, f"owner-recovery-{reason}") + ] + + environment = tmp_path / "offline.env" + environment.write_text( + "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" + f"POWERCONTEXT_SERVER_DATABASE_URL={database.url}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" + "POWERCONTEXT_SERVER_ACCESS_DEPLOYMENT_ID=external-test\n" + "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", + encoding="utf-8", + ) + process = await asyncio.create_subprocess_exec( + str(Path(sys.executable).with_name("powercontext.exe" if os.name == "nt" else "powercontext")), + "server", + "access-ownership-migrate", + "--env-file", + str(environment), + "--action", + "apply", + "--owner", + "user:bob", + "--scope-id", + scope_id, + "--maintenance-confirmed", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + async with asyncio.timeout(30): + stdout, stderr = await process.communicate() + finally: + if process.returncode is None: + process.kill() + await process.wait() + assert process.returncode == 1, (stdout + stderr).decode() + result = json.loads(stdout) + assert result["applied"] == [] + assert [(item["gap"]["candidate_id"], item["detail"]) for item in result["conflicts"]] == [ + (candidate_id, f"owner-recovery-{reason}") + ] + async with open_builtin_access_control(database, deployment_id="external-test") as local: + assert await local.candidate_owner(scope_id, candidate_id) is None + async with _server(tmp_path) as (app, _client, access): + if not completed: + await app.state.owner_recovery.replay(intent.operation_id) + attestation = await access.candidate_owner(scope_id, candidate_id) + assert attestation is not None and attestation.proposed_owner == ALICE + assert await app.state.owner_recovery.pending(scope_id=scope_id) == () + candidate_after = await app.state.application.review.for_scope(scope_id).get( + GetCandidateRequest(candidate_id=candidate_id) + ) + assert candidate_after.model_dump(mode="json") == candidate_before.model_dump(mode="json") + + asyncio.run(scenario()) From e784750d1238bb37341d04225e7d419cb71bbd95 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Tue, 3 Nov 2026 09:29:50 +0800 Subject: [PATCH 18/25] fix(desktop): qualify merged server contract --- desktop/VALIDATION.md | 37 +++++++++++++++++++ .../src/connections/compatibility.json | 20 ++++++++++ 2 files changed, 57 insertions(+) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 9e926815b1..aa2922efb0 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -166,6 +166,43 @@ packaging, signing, and native credential persistence remain separate checks. 未修改验收断言或超时;通过 CPU 亲和性避开已观测到的虚拟机调度等待。该记录不替代数据库升级、 Windows/macOS 安装包、签名、原生凭据持久化或模型质量验收。 +#### Server 3cefa45a protocol qualification + +`sqlite-atomic-3cefa45a-v1` qualifies Server sources at +`3cefa45ad08490423391148439c9eb73f1d48f58` with OpenAPI digest +`d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d`. +The wheel is `powercontext-1.4.2.dev174+g3cefa45ad-py3-none-any.whl`, SHA-256 +`cead75231f0f6961735dd117f454731d989ba96880a08c988c24aa0bae71c38e`. +All 686 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +match the recorded source bytes. The sorted runtime manifest digest is +`70dbff69bfbd0011fcab5389b6956ec21f015f3c01f8755ed0c200d2237f31e7` +(one `wheel/path sha256` line per file, with a final newline). + +On 2026-11-03, macOS 26.5.2 ARM64 with Python 3.12.13 ran the unchanged +`desktop/tests/real_server.py` against independent SQLite Server processes loaded from this wheel. +Anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, and injected-provider/enforced +access all passed. The eleven operations cover Server discovery, liveness/readiness, Principal, +capabilities, Scope listing/detail/default, Atomic Memory save/search, and exact revision reads. +The provider case verified identity changes, same-identity revocation, and 51 same-title Scopes. +Every mode verified a committed write with a lost response without replay and Server survival after +client exit. All four fixture Servers shut down normally. `desktopWorkingTreeDirty=false`. + +The Rust 1.95.0 native probe was built from the product Cargo lock. Its SHA-256 is +`0c156f7a2447449f5ecdcc94d997f3766c9109fe1d5e3da749db25004e1d6c0c`. +Ignored local reports are under `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/qualification-wheel-identity.json`. This record qualifies the listed SQLite +protocol operations. Installed UI, platform packaging, persistent native credentials, model +protection behavior, and database upgrades require their separate checks. + +Reproduce from the recorded clean commit with an empty wheel output directory: + +```sh +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit 3cefa45ad08490423391148439c9eb73f1d48f58 +uv run --no-sync python desktop/tests/real_server.py +``` + #### Server 2d782c2a protocol qualification `sqlite-atomic-2d782c2a-v1` qualifies Server sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 163d69d5bb..3f6bc30f4b 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -716,5 +716,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-112db11e-protocol-qualification" + }, + { + "id": "sqlite-atomic-3cefa45a-v1", + "serverCommit": "3cefa45ad08490423391148439c9eb73f1d48f58", + "contractSha256": "d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d", + "artifactSha256": "cead75231f0f6961735dd117f454731d989ba96880a08c988c24aa0bae71c38e", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-3cefa45a-protocol-qualification" } ] From 8544da9056aa0cb9facd6462faf82c1eae880a3e Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Tue, 3 Nov 2026 10:37:26 +0800 Subject: [PATCH 19/25] test(zcode): accept committed capture timeouts --- tests/e2e/test_zcode_service_chain.py | 33 +++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index 8203a09b7f..017c21684b 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -64,7 +64,14 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR def _invoke_hook( - *, node: str, base_url: str, scope_id: str | None, session_id: str, turn_id: str, prompt: str + *, + node: str, + base_url: str, + scope_id: str | None, + session_id: str, + turn_id: str, + prompt: str, + accepted_stderr: tuple[str, ...] = ("",), ) -> dict[str, Any]: environment = dict(os.environ) environment.pop("POWERCONTEXT_ZCODE_SCOPE_ID", None) @@ -88,7 +95,7 @@ def _invoke_hook( env=environment, ) assert result.returncode == 0, result.stderr - assert result.stderr == "" + assert result.stderr in accepted_stderr return json.loads(result.stdout) @@ -106,6 +113,22 @@ def _wait_for_processed_memory(client: httpx.Client, scope_id: str, text: str) - pytest.fail("The original flush must commit the captured source") +def _delay_first_capture_response(): + delayed = False + + async def middleware(request, call_next): + nonlocal delayed + response = await call_next(request) + if request.url.path == "/v1/sources/content" and not delayed: + # The source commits before this delay. The Hook must report an + # unknown receipt, and the caller must verify the committed source. + delayed = True + await asyncio.sleep(1.2) + return response + + return middleware + + @pytest.mark.parametrize("slow_flush_receipt", [False, True]) def test_zcode_source_is_processed_and_recalled_in_a_new_session( tmp_path: Path, monkeypatch, slow_flush_receipt @@ -135,6 +158,8 @@ async def observe_flush(request, call_next): await asyncio.sleep(1.2) return response + app.middleware("http")(_delay_first_capture_response()) + with socket.socket() as socket_probe: socket_probe.bind(("127.0.0.1", 0)) port = socket_probe.getsockname()[1] @@ -212,6 +237,10 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: session_id="binding-probe", turn_id="probe-1", prompt="Scope binding synthetic probe.", + accepted_stderr=( + "", + '{"component":"powercontext.zcode","stage":"capture","code":"timeout"}\n', + ), ) assert session_scope in probe["hookSpecificOutput"]["additionalContext"] probe_sources = client.get(f"/v1/scopes/{session_scope}/sources") From 26b169e6f0f5f7e2c318e5f9ee6295ec123bafb2 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Tue, 3 Nov 2026 20:51:57 +0800 Subject: [PATCH 20/25] fix(ci): budget time for sequential SeekDB checks --- .github/workflows/master.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 9cad2753be..789be62309 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -471,7 +471,10 @@ jobs: code-seekdb: needs: [quality, tests] if: ${{ !cancelled() && needs.quality.result == 'success' && contains(fromJSON('["success","skipped"]'), needs.tests.result) && (fromJSON(needs.quality.outputs.suites || '{}').database) }} - timeout-minutes: 30 + # Shard 0 also runs indexing, bootstrap (20m), and archive validation (5m) + # before migrations (25m). Preserve each step's deadline and leave time + # for environment setup and diagnostic uploads. + timeout-minutes: 60 runs-on: ubuntu-latest strategy: fail-fast: false From cc256f2225835b95013a460dda952153bf86ef21 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Tue, 3 Nov 2026 21:16:03 +0800 Subject: [PATCH 21/25] docs(desktop): point setup to the qualified current profile --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/VALIDATION.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index f724fbb4d5..98eb0952fa 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -178,7 +178,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records when their contract differs from this build. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced. The `sqlite-atomic-2d782c2a-v1` record matches this build's contract and qualifies eleven SQLite protocol operations against the exact Server source and wheel recorded in the validation guide. Select it only after comparing that tested build with your deployment. Existing records retain their original Server source, contract, wheel and operation identities, including the historical `sqlite-atomic-f3a90b2f-v1` qualification. The `ci-fixtures` build is for disposable acceptance tests and does not qualify a production deployment. Qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md#compatibility-scope). +Historical profiles appear as disabled records when their contract differs from this build. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced. The `sqlite-atomic-3cefa45a-v1` record matches this build's contract and qualifies eleven SQLite protocol operations against the exact Server source and wheel recorded in the validation guide. Select it only after comparing that tested build with your deployment. Existing records retain their original Server source, contract, wheel and operation identities, including the historical `sqlite-atomic-f3a90b2f-v1` qualification. The `ci-fixtures` build is for disposable acceptance tests and does not qualify a production deployment. Qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md#compatibility-scope). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 21365e738e..862c165483 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -161,7 +161,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置在契约不匹配时显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。`sqlite-atomic-2d782c2a-v1` 与当前构建的契约匹配,已针对验证说明中记录的精确 Server 源码和 wheel 验证 11 项 SQLite 协议操作;请先核对该受测构建与实际部署,再明确选择。现有记录保留各自原始的 Server 源码、契约、wheel 和操作身份。`ci-fixtures` 构建仅用于临时验收测试,不代表实际部署已通过兼容性验证。资格证据和剩余要求见[验证说明](VALIDATION.md#compatibility-scope)。 +历史配置在契约不匹配时显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。`sqlite-atomic-3cefa45a-v1` 与当前构建的契约匹配,已针对验证说明中记录的精确 Server 源码和 wheel 验证 11 项 SQLite 协议操作;请先核对该受测构建与实际部署,再明确选择。现有记录保留各自原始的 Server 源码、契约、wheel 和操作身份。`ci-fixtures` 构建仅用于临时验收测试,不代表实际部署已通过兼容性验证。资格证据和剩余要求见[验证说明](VALIDATION.md#compatibility-scope)。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index aa2922efb0..5ab7962865 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -118,7 +118,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-112db11e-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-3cefa45a-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. Historical record `sqlite-atomic-f3a90b2f-v1` remains preserved with its original identities and is disabled for this contract. From 67ad261b0d22313e17a611d16f81ccfd7683d195 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Tue, 3 Nov 2026 21:28:06 +0800 Subject: [PATCH 22/25] fix(access): bound static Scope preset receipt keys --- release/upgrade-support.json | 4 +- src/powercontext/server/authz/service.py | 29 ++++++++-- tests/e2e/test_access_control_regressions.py | 60 ++++++++++++++++++++ tests/e2e/test_access_ownership_migration.py | 9 +++ 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index fa77eaaf8c..a1c4f174c8 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "a39716b94917917404c9ecc54406171bb8aa1e6c029160821e48a160628aa2fe", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration." + "persistence_sha256": "892a9ad6b459026bb9626c755d7a0ef4eef3ed4688c507887a2feaab1736e525", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/server/authz/service.py b/src/powercontext/server/authz/service.py index 59db190258..206a56fa21 100644 --- a/src/powercontext/server/authz/service.py +++ b/src/powercontext/server/authz/service.py @@ -84,6 +84,15 @@ ) +def _static_scope_receipt_keys( + deployment_id: str, principal_id: str, scope_id: str, role: AccessRole +) -> tuple[str, str]: + legacy = f"static-preset:{deployment_id}:{principal_id}:{scope_id}:{role.value}" + # The relationship schema stores keys in VARCHAR(255), including internal presets. + key = legacy if len(legacy) <= 255 else f"static-preset:sha256:{hashlib.sha256(legacy.encode()).hexdigest()}" + return key, legacy + + @dataclass(frozen=True, slots=True) class AuthorizedResourceFilter: """Complete bounded identities and parent constraints produced before data access.""" @@ -919,10 +928,12 @@ async def static_scope_bootstrap_would_grant( for role in _STATIC_SCOPE_ROLES: if action not in actions[role]: continue - # Match bootstrap's complete internal receipt key, not public request-key limits. - key = f"static-preset:{self.deployment_id}:{principal.id}:{resource.scope_id}:{role.value}" - if not await _access_call(repository.static_scope_initialized(principal, (key,))): - return True + key, legacy = _static_scope_receipt_keys(self.deployment_id, principal.id, resource.scope_id, role) + if await _access_call(repository.static_scope_initialized(principal, (key,))): + continue + if key != legacy and await _access_call(repository.static_scope_initialized(principal, (legacy,))): + continue + return True return False async def bootstrap_static_scope( @@ -941,14 +952,20 @@ async def bootstrap_static_scope( resource = ResourceRef.scope(scope_id) keys = tuple( - f"static-preset:{self.deployment_id}:{actor.id}:{scope_id}:{role.value}" for role in _STATIC_SCOPE_ROLES + _static_scope_receipt_keys(self.deployment_id, actor.id, scope_id, role)[0] for role in _STATIC_SCOPE_ROLES ) repository = self._relationship_writer() if type(repository) is RelationalAccessRepository and await repository.static_scope_initialized(actor, keys): return now = self._clock() for role in _STATIC_SCOPE_ROLES: - key = f"static-preset:{self.deployment_id}:{actor.id}:{scope_id}:{role.value}" + key, legacy = _static_scope_receipt_keys(self.deployment_id, actor.id, scope_id, role) + if ( + key != legacy + and type(repository) is RelationalAccessRepository + and await _access_call(repository.static_scope_initialized(actor, (legacy,))) + ): + continue binding = AccessBinding( binding_id=str(uuid4()), subject=actor, diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index 757fa85878..4ed44e32f4 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -2150,3 +2150,63 @@ async def scenario(): assert "Alpha scope fact." not in denied.text asyncio.run(scenario()) + + +def test_static_preset_preserves_revoked_legacy_long_scope_receipts(tmp_path): + from datetime import UTC, datetime + from uuid import uuid4 + + from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts + from powercontext.server.authz import AccessBinding, AccessBindingState, AccessControlService, AccessRole + from powercontext.server.authz.repository import RelationalAccessRepository + + async def scenario(): + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'legacy-preset.db'}")) + async with open_builtin_contexts(config) as contexts: + scope_id = "s" * 256 + await contexts.get(scope_id) + actor = PrincipalRef(type="service", id="server-token") + repository = RelationalAccessRepository(contexts.database) + access = AccessControlService( + BuiltinAuthorizationProvider(repository), + relationships=repository, + audit=repository, + static_scope_principal=actor, + ) + resource = ResourceRef.scope(scope_id) + for role in ( + AccessRole.SCOPE_VIEWER, + AccessRole.SCOPE_CONTRIBUTOR, + AccessRole.SCOPE_REVIEWER, + AccessRole.SCOPE_DELEGATOR, + ): + binding = await repository.create_binding( + AccessBinding( + binding_id=str(uuid4()), + subject=actor, + resource=resource, + role=role, + granted_by=actor, + reason="static bearer preset", + created_at=datetime.now(UTC), + expires_at=None, + state=AccessBindingState.ACTIVE, + version=1, + policy_revision="pending", + idempotency_key=f"static-preset:{access.deployment_id}:{actor.id}:{scope_id}:{role.value}", + ) + ) + if role is AccessRole.SCOPE_CONTRIBUTOR: + await repository.revoke_binding( + binding.binding_id, + expected_version=1, + idempotency_key="revoke-legacy", + revoked_at=datetime.now(UTC), + revoked_by=actor, + ) + assert not await access.static_scope_bootstrap_would_grant(actor, AccessAction.SCOPE_CONTRIBUTE, resource) + await access.bootstrap_static_scope(actor, scope_id, context=AUDIT) + assert not (await access.check(actor, AccessAction.SCOPE_CONTRIBUTE, resource, context=AUDIT)).allowed + assert (await access.check(actor, AccessAction.SCOPE_READ, resource, context=AUDIT)).allowed + + asyncio.run(scenario()) diff --git a/tests/e2e/test_access_ownership_migration.py b/tests/e2e/test_access_ownership_migration.py index 91b1a4abed..ac845e80d3 100644 --- a/tests/e2e/test_access_ownership_migration.py +++ b/tests/e2e/test_access_ownership_migration.py @@ -25,6 +25,7 @@ import asyncio import json +import sqlite3 from contextlib import asynccontextmanager from pathlib import Path from typing import Any, Literal @@ -170,6 +171,14 @@ async def scenario() -> None: headers={"Authorization": "Bearer static-test-token"}, ) as client, ): + # SQLite normally accepts oversized VARCHAR values. Enforce the strict + # relationship-key limit here without claiming a native MySQL run. + with sqlite3.connect(tmp_path / "static-background.db") as connection: + connection.execute( + "CREATE TRIGGER reject_oversized_static_key BEFORE INSERT ON pc_access_relationships " + "WHEN length(NEW.idempotency_key) > 255 " + "BEGIN SELECT RAISE(ABORT, 'idempotency_key too long'); END" + ) access = app.state.access_control context = AccessAuditContext(transport="background", operation="test.background") if profile_owner in {"server-token", "other"}: From 899f7c07949f6b4a57335484dc35dac95b1ac20f Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Tue, 3 Nov 2026 22:17:11 +0800 Subject: [PATCH 23/25] test(http): separate admission ordering from CI scheduling --- tests/test_server_http_limits.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_server_http_limits.py b/tests/test_server_http_limits.py index 3e22373909..5957780d9d 100644 --- a/tests/test_server_http_limits.py +++ b/tests/test_server_http_limits.py @@ -349,15 +349,18 @@ async def observe_send(message: Message) -> None: first = asyncio.create_task(_invoke(app, response_send=observe_send)) second: asyncio.Task[tuple[list[Message], int]] | None = None try: - await asyncio.wait_for(cleaning_up.wait(), timeout=1) - await asyncio.wait_for(response_sent.wait(), timeout=1) - second = asyncio.create_task(_invoke(app)) - await asyncio.wait_for(second_started.wait(), timeout=1) - cleanup.set() - await first - overloaded, _ = await _invoke(app) - assert overloaded[0]["status"] == 503 - assert calls == 2 + # Assert event ordering; the watchdog detects deadlocks without + # imposing a one-second scheduling budget on loaded CI workers. + async with asyncio.timeout(10): + await cleaning_up.wait() + await response_sent.wait() + second = asyncio.create_task(_invoke(app)) + await second_started.wait() + cleanup.set() + await first + overloaded, _ = await _invoke(app) + assert overloaded[0]["status"] == 503 + assert calls == 2 finally: cleanup.set() second_release.set() From bf781bb4292142cf05cc68e509b6e57f51bdbec9 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Wed, 4 Nov 2026 02:05:30 +0800 Subject: [PATCH 24/25] fix(ci): refresh OpenCode bundles and retry busy DMG unmounts --- .github/workflows/desktop.yml | 4 +- .../plugins/powercontext/lib/index.js | 253 ++++++++++++++++++ .../opencode/plugins/powercontext/lib/tui.js | 253 ++++++++++++++++++ 3 files changed, 508 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 1e34a3644e..d64fff44e0 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -215,10 +215,10 @@ jobs: else build_status=$? fi - if [ "$attempt" -eq 3 ] || ! grep -Fq 'hdiutil: create failed - Resource busy' "$attempt_log"; then + if [ "$attempt" -eq 3 ] || ! grep -Eq "hdiutil: (create failed - Resource busy|couldn't eject .* - Resource busy)" "$attempt_log"; then exit "$build_status" fi - echo "::warning::DMG creation found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." + echo "::warning::DMG creation or unmount found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." sleep "$((attempt * 5))" done - name: Upload failed macOS build diagnostics diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 5f91887ad3..7ff9332c51 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -234,6 +234,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_artifact_plugins: { + method: "GET", + path: "/v1/admin/artifact-plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_plugin: { + method: "GET", + path: "/v1/admin/artifact-plugins/{binding_name}", + location: null, + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + enable_all_artifact_plugins: { + method: "POST", + path: "/v1/admin/artifact-plugins/enable-all", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + reload_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/reload", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, + enable_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/enable", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, + disable_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/disable", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, + unload_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/unload", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -1540,6 +1617,182 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + pc_source_capabilities: { + method: "POST", + path: "/v1/source-management/capabilities", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/source-management/add", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_status: { + method: "POST", + path: "/v1/source-management/status", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "POST", + path: "/v1/source-management/target-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instances: { + method: "POST", + path: "/v1/source-management/instances", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instance_set_enabled: { + method: "POST", + path: "/v1/source-management/instance-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_configure: { + method: "POST", + path: "/v1/source-management/configure", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + load_source_write_plugin: { + method: "POST", + path: "/v1/source-management/write/load", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_open: { + method: "POST", + path: "/v1/source-management/channel/open", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_catalog: { + method: "POST", + path: "/v1/source-management/channel/catalog", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_poll: { + method: "POST", + path: "/v1/source-management/channel/poll", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_reply: { + method: "POST", + path: "/v1/source-management/channel/reply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_close: { + method: "POST", + path: "/v1/source-management/channel/close", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_instance_exchange: { + method: "POST", + path: "/v1/source-management/instances/{instance_id}/{operation}", + location: "body", + scopeMode: "none", + pathParameters: ["instance_id", "operation"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index dc10be1cdb..5ed210b516 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -234,6 +234,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_artifact_plugins: { + method: "GET", + path: "/v1/admin/artifact-plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_plugin: { + method: "GET", + path: "/v1/admin/artifact-plugins/{binding_name}", + location: null, + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + enable_all_artifact_plugins: { + method: "POST", + path: "/v1/admin/artifact-plugins/enable-all", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + reload_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/reload", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, + enable_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/enable", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, + disable_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/disable", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, + unload_artifact_plugin: { + method: "POST", + path: "/v1/admin/artifact-plugins/{binding_name}/unload", + location: "body", + scopeMode: "none", + pathParameters: ["binding_name"], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] + }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -1540,6 +1617,182 @@ const OPERATIONS = { headerParams: [], successStatuses: [200], emptyStatuses: [] + }, + pc_source_capabilities: { + method: "POST", + path: "/v1/source-management/capabilities", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/source-management/add", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_status: { + method: "POST", + path: "/v1/source-management/status", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "POST", + path: "/v1/source-management/target-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instances: { + method: "POST", + path: "/v1/source-management/instances", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_instance_set_enabled: { + method: "POST", + path: "/v1/source-management/instance-set-enabled", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_configure: { + method: "POST", + path: "/v1/source-management/configure", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + load_source_write_plugin: { + method: "POST", + path: "/v1/source-management/write/load", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_open: { + method: "POST", + path: "/v1/source-management/channel/open", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_catalog: { + method: "POST", + path: "/v1/source-management/channel/catalog", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_poll: { + method: "POST", + path: "/v1/source-management/channel/poll", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_reply: { + method: "POST", + path: "/v1/source-management/channel/reply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_channel_close: { + method: "POST", + path: "/v1/source-management/channel/close", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + source_instance_exchange: { + method: "POST", + path: "/v1/source-management/instances/{instance_id}/{operation}", + location: "body", + scopeMode: "none", + pathParameters: ["instance_id", "operation"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + plan_access_ownership: { + method: "POST", + path: "/v1/access/ownership/plan", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + apply_access_ownership: { + method: "POST", + path: "/v1/access/ownership/apply", + location: "body", + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); From 0ba4f707f642fbe9422e9f963a2d8ee1491cbe15 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Wed, 4 Nov 2026 02:58:23 +0800 Subject: [PATCH 25/25] test: publish native child pid atomically --- tests/test_native_command.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_native_command.py b/tests/test_native_command.py index 481ca2c117..d5eb42d3ff 100644 --- a/tests/test_native_command.py +++ b/tests/test_native_command.py @@ -228,7 +228,10 @@ def observe(event): os._exit(7) child = '''import os, sys, time from pathlib import Path -Path(sys.argv[1]).write_text(str(os.getpid())) +ready = Path(sys.argv[1]) +temporary = ready.with_suffix('.tmp') +temporary.write_text(str(os.getpid())) +os.replace(temporary, ready) deadline = time.monotonic() + 30 while not Path(sys.argv[2]).exists() and time.monotonic() < deadline: time.sleep(0.05)