diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 152bbede2b..5be80d4d27 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -1840,6 +1840,30 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/scopes/{scope_id}/artifact-handles/{family}/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Resolve a stable Artifact handle + * @description Resolve one scope-local handle to the exact immutable Artifact revision it names. + */ + get: operations["resolve_artifact_handle"]; + /** + * Bind a stable Artifact handle + * @description Idempotently and permanently bind one scope-local handle to an exact immutable Artifact revision. A committed binding cannot be updated, released, or deleted. Repeating the same binding succeeds, while binding the key to another Artifact revision returns 409. Verify the family, Artifact ID, revision, and key before committing the binding; a mistakenly bound key cannot be reused in that Scope. + */ + put: operations["bind_artifact_handle"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}": { parameters: { query?: never; @@ -2478,6 +2502,15 @@ export interface components { artifact_id: string; revision: number; }; + BindArtifactHandleRequest: { + artifact_id: string; + revision: number; + }; + ArtifactHandleBinding: { + family: string; + key: string; + artifact: components["schemas"]["ArtifactReference"]; + }; ArtifactAddress: { scope_id: string; artifact: components["schemas"]["ArtifactReference"]; @@ -8116,6 +8149,71 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + resolve_artifact_handle: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The exact Artifact revision bound to the handle. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactHandleBinding"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + bind_artifact_handle: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: string; + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BindArtifactHandleRequest"]; + }; + }; + responses: { + /** @description The existing or newly committed handle binding. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactHandleBinding"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; get_artifact: { parameters: { query?: never; diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 33f6d93a83..31acac62a1 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -276,7 +276,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_handles,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` diff --git a/docs/en/docs/workflows/artifacts.md b/docs/en/docs/workflows/artifacts.md index db018fb2c1..c00700b5bf 100644 --- a/docs/en/docs/workflows/artifacts.md +++ b/docs/en/docs/workflows/artifacts.md @@ -38,6 +38,15 @@ Use the scoped HTTP routes in the [API contract](../develop/http-api.md): Keep the family, Artifact ID, and exact Revision when citing a result or handing it to another Agent. The current head can advance while a historical Revision remains unchanged. +## Name an exact revision with a stable handle + +A stable handle gives one exact Artifact Revision a human-readable, Scope-local name. Bind it with +`PUT /v1/scopes/{scope_id}/artifact-handles/{family}/{key}` and resolve it with `GET` on the same route. + +Bindings are permanent. After `(scope_id, family, key)` is committed, it cannot be updated, released, or deleted. +Repeating the same binding is idempotent; binding the key to another Artifact Revision returns `409 Conflict`. +Verify the family, Artifact ID, Revision, and key before binding. If a key is bound by mistake, choose a new key. + Artifact `sources` preserve each source's `source_type` and `source_id`, including dynamically registered types such as `note`. Treat `source_type` as an open Source name when reading lists, details, and historical revisions. Source IDs retain accepted Unicode characters and interior spaces, such as `用户偏好` and `release notes`. diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index fbf9c58414..d3102f929f 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -267,7 +267,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_handles,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` diff --git a/docs/zh/docs/workflows/artifacts.md b/docs/zh/docs/workflows/artifacts.md index ecab86db1e..382b7845ef 100644 --- a/docs/zh/docs/workflows/artifacts.md +++ b/docs/zh/docs/workflows/artifacts.md @@ -35,6 +35,15 @@ Replace。缺少 `If-Match` 返回 `428`,ETag 过期返回 `412`;接口不 引用结果或向另一个 Agent 交付时,保留家族、Artifact ID 和精确 Revision。 当前 head 可以前进,历史 Revision 保持不变。 +## 使用 Stable Handle 命名精确 Revision + +Stable Handle 为一个精确的 Artifact Revision 提供人类可读、Scope 内有效的名称。通过 +`PUT /v1/scopes/{scope_id}/artifact-handles/{family}/{key}` 创建绑定,并通过同一路由的 `GET` 解析绑定。 + +绑定是永久的。`(scope_id, family, key)` 一旦提交,就不能更新、释放或删除。重复提交完全相同的绑定是幂等操作; +将同一个 key 绑定到另一个 Artifact Revision 会返回 `409 Conflict`。绑定前应确认 family、Artifact ID、Revision +和 key;如果错误绑定了某个 key,应改用新的 key。 + Artifact 的 `sources` 会保留每个来源的 `source_type` 和 `source_id`,包括 `note` 等动态注册的类型。 读取列表、详情和历史版本时,应将 `source_type` 作为开放的 Source 名称处理。 Source ID 会保留采集时接受的 Unicode 字符和内部空格,例如 `用户偏好` 和 `release notes`。 diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 17d49c5cb0..d4409ed73b 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1271,6 +1271,36 @@ const OPERATIONS$1 = { successStatuses: [200], emptyStatuses: [] }, + resolve_artifact_handle: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifact-handles/{family}/{key}", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "key" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + bind_artifact_handle: { + method: "PUT", + path: "/v1/scopes/{scope_id}/artifact-handles/{family}/{key}", + location: "body", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "key" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_artifact: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", @@ -1905,6 +1935,7 @@ const PUBLIC_ERROR_CODES = new Set([ "scope_not_found", "memory_not_found", "artifact_not_found", + "artifact_handle_not_found", "candidate_not_found", "handoff_evidence_not_found", "source_definition_not_found", diff --git a/integrations/dsh/plugins/powercontext/src/diagnostics.ts b/integrations/dsh/plugins/powercontext/src/diagnostics.ts index bb18b767e1..6bdebfc50f 100644 --- a/integrations/dsh/plugins/powercontext/src/diagnostics.ts +++ b/integrations/dsh/plugins/powercontext/src/diagnostics.ts @@ -35,7 +35,7 @@ const COMPATIBILITY_OR_AVAILABILITY_PATHS = new Set([ // Only public protocol codes may cross the diagnostic/model boundary. const PUBLIC_ERROR_CODES = new Set([ - 'not_found', 'scope_not_found', 'memory_not_found', 'artifact_not_found', + 'not_found', 'scope_not_found', 'memory_not_found', 'artifact_not_found', 'artifact_handle_not_found', 'candidate_not_found', 'handoff_evidence_not_found', 'source_definition_not_found', 'external_skill_not_found', 'conflict', 'revision_conflict', 'memory_entry_inactive', 'memory_capacity_exceeded', 'tag_precondition_failed', diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 1e7d8c3a28..104f93c7ed 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -116,6 +116,8 @@ export const OPERATIONS = { get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'source_type', 'source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['tag','tag_match','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_artifact_handle: { method: 'GET', path: '/v1/scopes/{scope_id}/artifact-handles/{family}/{key}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + bind_artifact_handle: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifact-handles/{family}/{key}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, replace_artifact: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, get_artifact_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, diff --git a/integrations/dsh/plugins/powercontext/tests/diagnostics.spec.ts b/integrations/dsh/plugins/powercontext/tests/diagnostics.spec.ts index 036605ccc9..e0be684775 100644 --- a/integrations/dsh/plugins/powercontext/tests/diagnostics.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/diagnostics.spec.ts @@ -94,6 +94,8 @@ describe('host-visible diagnostic classification', () => { describe('published error code filter', () => { it('passes a published protocol code through unchanged', () => { expect(publicErrorCode('scope_not_found')).toBe('scope_not_found') + expect(publicErrorCode('artifact_handle_not_found')).toBe('artifact_handle_not_found') + // Both codes the server returns with HTTP 412, and the one it returns with 409 // when a Memory is at its capacity budget, reach the caller as themselves. expect(publicErrorCode('tag_precondition_failed')).toBe('tag_precondition_failed') diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 1e7d8c3a28..104f93c7ed 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -116,6 +116,8 @@ export const OPERATIONS = { get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'source_type', 'source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['tag','tag_match','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_artifact_handle: { method: 'GET', path: '/v1/scopes/{scope_id}/artifact-handles/{family}/{key}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + bind_artifact_handle: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifact-handles/{family}/{key}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, replace_artifact: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, get_artifact_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 1e7d8c3a28..104f93c7ed 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -116,6 +116,8 @@ export const OPERATIONS = { get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'source_type', 'source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['tag','tag_match','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_artifact_handle: { method: 'GET', path: '/v1/scopes/{scope_id}/artifact-handles/{family}/{key}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + bind_artifact_handle: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifact-handles/{family}/{key}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, replace_artifact: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, get_artifact_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 57c3ff085a..dcb2378621 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3942,6 +3942,79 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifact-handles/{family}/{key}: + get: + tags: [artifacts] + summary: Resolve a stable Artifact handle + description: Resolve one scope-local handle to the exact immutable Artifact revision it names. + operationId: resolve_artifact_handle + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + - name: family + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + - name: key + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'} + responses: + "200": + description: The exact Artifact revision bound to the handle. + content: + application/json: + schema: {$ref: "#/components/schemas/ArtifactHandleBinding"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "422": {$ref: "#/components/responses/InvalidRequest"} + "503": {$ref: "#/components/responses/Unavailable"} + "500": {$ref: "#/components/responses/InternalError"} + put: + tags: [artifacts] + summary: Bind a stable Artifact handle + description: >- + Idempotently and permanently bind one scope-local handle to an exact immutable Artifact revision. + A committed binding cannot be updated, released, or deleted. Repeating the same binding succeeds, while + binding the key to another Artifact revision returns 409. Verify the family, Artifact ID, revision, and key + before committing the binding; a mistakenly bound key cannot be reused in that Scope. + operationId: bind_artifact_handle + x-powercontext-access: {action: scope.admin, resource: {type: scope, scope-id-from: scope_id}} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + - name: family + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + - name: key + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'} + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/BindArtifactHandleRequest"} + responses: + "200": + description: The existing or newly committed handle binding. + content: + application/json: + schema: {$ref: "#/components/schemas/ArtifactHandleBinding"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "409": {$ref: "#/components/responses/Conflict"} + "422": {$ref: "#/components/responses/InvalidRequest"} + "503": {$ref: "#/components/responses/Unavailable"} + "500": {$ref: "#/components/responses/InternalError"} /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}: get: tags: [artifacts] @@ -5812,6 +5885,36 @@ components: revision: type: integer minimum: 1 + BindArtifactHandleRequest: + type: object + additionalProperties: false + required: [artifact_id, revision] + properties: + artifact_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + ArtifactHandleBinding: + type: object + additionalProperties: false + required: [family, key, artifact] + properties: + family: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + key: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + artifact: + $ref: "#/components/schemas/ArtifactReference" ArtifactAddress: type: object additionalProperties: false diff --git a/src/powercontext/artifacts/__init__.py b/src/powercontext/artifacts/__init__.py index 99f79dc103..9131ede48c 100644 --- a/src/powercontext/artifacts/__init__.py +++ b/src/powercontext/artifacts/__init__.py @@ -20,6 +20,7 @@ ArtifactDraft, ArtifactLineage, ArtifactRef, + StableHandle, ) from powercontext.artifacts.protocols import ArtifactCatalog, ArtifactStore from powercontext.artifacts.search import ( @@ -51,4 +52,5 @@ "ArtifactSearcher", "ArtifactStore", "ChannelScore", + "StableHandle", ] diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index 21b2c03d0b..5f8eefffe0 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -16,15 +16,23 @@ from __future__ import annotations +import re from collections.abc import Mapping from typing import Any, ClassVar, Generic, TypeVar from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator from powercontext.errors import InvalidArtifactReferenceError -from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH +from powercontext.limits import ( + MAX_ARTIFACT_FAMILY_LENGTH, + MAX_ARTIFACT_HANDLE_KEY_LENGTH, + MAX_ARTIFACT_ID_LENGTH, + MAX_SCOPE_ID_LENGTH, +) from powercontext.sources.models import SourceRef +_HANDLE_KEY_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + ContentT = TypeVar("ContentT", covariant=True) _LEGACY_MEMORY_FAMILY = "memory" @@ -55,6 +63,39 @@ def validate_identity(cls, value: str, info) -> str: return value +class StableHandle(BaseModel): + """A human-copyable, scope-local handle for an Artifact revision.""" + + family: str + key: str + + @field_validator("family") + @classmethod + def validate_family(cls, value: str) -> str: + _validate_reference_part("family", value) + if len(value) > MAX_ARTIFACT_FAMILY_LENGTH: + raise InvalidArtifactReferenceError( + "family", + f"must not exceed {MAX_ARTIFACT_FAMILY_LENGTH} characters", + ) + return value + + @field_validator("key") + @classmethod + def validate_key(cls, value: str) -> str: + if len(value) > MAX_ARTIFACT_HANDLE_KEY_LENGTH: + raise InvalidArtifactReferenceError( + "key", + f"must not exceed {MAX_ARTIFACT_HANDLE_KEY_LENGTH} characters", + ) + if _HANDLE_KEY_PATTERN.fullmatch(value) is None: + raise InvalidArtifactReferenceError( + "key", + "must contain lowercase ASCII letters, digits, and single hyphens", + ) + return value + + class _ArtifactValue(BaseModel): """Shared immutable configuration for artifact-family content values. diff --git a/src/powercontext/builtin/persistence/artifact_handles.py b/src/powercontext/builtin/persistence/artifact_handles.py new file mode 100644 index 0000000000..522988c7cf --- /dev/null +++ b/src/powercontext/builtin/persistence/artifact_handles.py @@ -0,0 +1,117 @@ +# 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. + +"""Scope-local stable handles for exact Artifact revisions.""" + +from sqlalchemy import insert, select +from sqlalchemy.exc import IntegrityError + +from powercontext.artifacts import ArtifactRef, StableHandle +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import ( + InvalidRepositoryArgumentError, + RepositoryNotFoundError, + StoredPayloadConflictError, +) +from powercontext.builtin.persistence.tables import ARTIFACT_HANDLES_TABLE, ARTIFACTS_TABLE + + +class ArtifactHandleRepository: + """Bind immutable, human-copyable names to exact Artifact revisions.""" + + def __init__(self, database: AsyncDatabase, /) -> None: + self._database = database + + async def bind(self, scope_id: str, handle: StableHandle, target: ArtifactRef, /) -> ArtifactRef: + if handle.family != target.family: + raise InvalidRepositoryArgumentError("family", "handle and target families must match") + + try: + async with self._database.transaction() as connection: + existing = await _resolve(connection, scope_id, handle) + if existing is not None: + return _require_same_binding(scope_id, handle, existing, target) + if not await _target_exists(connection, scope_id, target): + raise RepositoryNotFoundError("artifact", (scope_id, *target.model_dump().values())) + await connection.execute( + insert(ARTIFACT_HANDLES_TABLE).values( + scope_id=scope_id, + family=handle.family, + handle_key=handle.key, + artifact_id=target.artifact_id, + revision=target.revision, + ) + ) + except IntegrityError as error: + async with self._database.transaction() as connection: + existing = await _resolve(connection, scope_id, handle) + target_exists = await _target_exists(connection, scope_id, target) + if existing is not None: + # A concurrent binder won the handle-key race. Preserve + # idempotency only when it committed the requested target. + return _require_same_binding(scope_id, handle, existing, target) + if not target_exists: + raise RepositoryNotFoundError("artifact", (scope_id, *target.model_dump().values())) from error + raise + return target + + async def resolve(self, scope_id: str, handle: StableHandle, /) -> ArtifactRef: + async with self._database.transaction() as connection: + target = await _resolve(connection, scope_id, handle) + if target is None: + raise RepositoryNotFoundError("artifact_handle", (scope_id, handle.family, handle.key)) + return target + + +async def _resolve(connection, scope_id: str, handle: StableHandle) -> ArtifactRef | None: + row = ( + await connection.execute( + select(ARTIFACT_HANDLES_TABLE.c.artifact_id, ARTIFACT_HANDLES_TABLE.c.revision).where( + ARTIFACT_HANDLES_TABLE.c.scope_id == scope_id, + ARTIFACT_HANDLES_TABLE.c.family == handle.family, + ARTIFACT_HANDLES_TABLE.c.handle_key == handle.key, + ) + ) + ).one_or_none() + if row is None: + return None + return ArtifactRef(family=handle.family, artifact_id=str(row.artifact_id), revision=int(row.revision)) + + +async def _target_exists(connection, scope_id: str, target: ArtifactRef) -> bool: + revision = ( + await connection.execute( + select(ARTIFACTS_TABLE.c.revision).where( + ARTIFACTS_TABLE.c.scope_id == scope_id, + ARTIFACTS_TABLE.c.family == target.family, + ARTIFACTS_TABLE.c.artifact_id == target.artifact_id, + ARTIFACTS_TABLE.c.revision == target.revision, + ) + ) + ).scalar_one_or_none() + return revision is not None + + +def _require_same_binding( + scope_id: str, + handle: StableHandle, + existing: ArtifactRef, + requested: ArtifactRef, +) -> ArtifactRef: + if existing != requested: + raise StoredPayloadConflictError("artifact_handle", (scope_id, handle.family, handle.key)) + return existing + + +__all__ = ["ArtifactHandleRepository"] diff --git a/src/powercontext/builtin/persistence/migrations/mysql_resources/versions/p0004_artifact_handles.py b/src/powercontext/builtin/persistence/migrations/mysql_resources/versions/p0004_artifact_handles.py new file mode 100644 index 0000000000..6c611bd7e2 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/mysql_resources/versions/p0004_artifact_handles.py @@ -0,0 +1,24 @@ +# 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. + +"""Create the registered Artifact handle table.""" + +from alembic import context + +revision = "p0004" +down_revision = "p0003" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/mysql_schema.py b/src/powercontext/builtin/persistence/migrations/mysql_schema.py index b3502db1bc..e2128c09e3 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql_schema.py +++ b/src/powercontext/builtin/persistence/migrations/mysql_schema.py @@ -14,7 +14,7 @@ """Recognize the frozen MySQL layouts without importing application metadata. -Only the reviewed linear p0001--p0003 history is supported. DDL recovery accepts +Only the reviewed linear p0001--p0004 history is supported. DDL recovery accepts an exact prefix of the next revision, never an arbitrary collection of objects. The caller owns maintenance evidence, backup, the pinned database lock and the version row; this module never writes or stamps a revision. @@ -35,10 +35,20 @@ from .models import MigrationError, digest _LEXEME = re.compile(r"\s+|`(?:``|[^`])*`|'(?:''|\\.|[^'\\])*'|[A-Za-z_][A-Za-z_0-9$]*|[0-9]+|>=|<=|<>|!=|[(),.=<>+-]") -_REVISIONS = ("p0001", "p0002", "p0003") +_REVISIONS = ("p0001", "p0002", "p0003", "p0004") _CITATION_TABLES = ("pc_artifacts", "pc_artifact_candidate_versions") _FAMILY_CHECK = "ck_pc_artifact_tags_family" _CONTROL_TABLE = "pc_schema_revision" +_ARTIFACT_HANDLES_DDL = """CREATE TABLE pc_artifact_handles ( + scope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + family VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + handle_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + artifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (scope_id, family, handle_key), + FOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, + CONSTRAINT ck_pc_artifact_handles_revision_positive CHECK (revision > 0) +)""" _PHYSICAL_VALUES = frozenset({ "row_format", "compression", @@ -145,6 +155,7 @@ def __init__(self, bundle: MigrationBundle) -> None: "p0001": tuple(baseline), "p0002": tuple(f"ALTER TABLE {table} ADD COLUMN memory_citations MEDIUMBLOB" for table in _CITATION_TABLES), "p0003": (f"ALTER TABLE pc_artifact_tags DROP CHECK {_FAMILY_CHECK}",), + "p0004": (_ARTIFACT_HANDLES_DDL,), } inventory = _empty_inventory() self._prefixes: dict[str, list[dict[str, Any]]] = {} @@ -159,7 +170,7 @@ def __init__(self, bundle: MigrationBundle) -> None: frozen_current = _empty_inventory() for statement in current: _apply_statement(frozen_current, statement) - if frozen_current != self._schemas["p0003"]: + if frozen_current != self._schemas["p0004"]: raise MigrationError("invalid_bundle", "Frozen MySQL layouts disagree with the registered revision steps.") @staticmethod diff --git a/src/powercontext/builtin/persistence/migrations/resources/manifest.json b/src/powercontext/builtin/persistence/migrations/resources/manifest.json index 65c798ea8b..16364daa4f 100644 --- a/src/powercontext/builtin/persistence/migrations/resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/resources/manifest.json @@ -4,11 +4,13 @@ "schemas/pre_dream.json" ], "p0002": [], - "p0003": [] + "p0003": [], + "p0004": [] }, "sqlite_baselines": { "e1cae089a8a565291a7a1f782cea1f6c6e1a6cdd9eab75ef0c4ca61dfda453cb": "p0001", - "fcee685ed307ee6d02fe5e950ed4e80be75f38d19de902ccacf443f21ee8c73d": "p0003" + "fcee685ed307ee6d02fe5e950ed4e80be75f38d19de902ccacf443f21ee8c73d": "p0003", + "8cb2d41e53cde751c8d0ef4023c344ec5ec78d9e579366c1c93d17de30aa5ea4": "p0004" }, "sqlite_fingerprints": { "p0001": [ @@ -20,13 +22,18 @@ "p0003": [ "9958fa0413ed3c16442df27dc48057d438e2cf3f7e4312c5a914cccd9d3acaab", "fcee685ed307ee6d02fe5e950ed4e80be75f38d19de902ccacf443f21ee8c73d" + ], + "p0004": [ + "f65244be963a382a62565118ff874cd775ab1ea33cf51a49881b40391e47c051", + "8cb2d41e53cde751c8d0ef4023c344ec5ec78d9e579366c1c93d17de30aa5ea4" ] }, "affected_objects": [ "pc_artifacts", "pc_artifact_heads", "pc_artifact_candidate_versions", - "pc_artifact_tags" + "pc_artifact_tags", + "pc_artifact_handles" ], "retained_objects": [ "pc_retained_p0002_artifact_tags" diff --git a/src/powercontext/builtin/persistence/migrations/resources/schemas/v1_1_0.json b/src/powercontext/builtin/persistence/migrations/resources/schemas/v1_1_0.json index f728735586..6240cca182 100644 --- a/src/powercontext/builtin/persistence/migrations/resources/schemas/v1_1_0.json +++ b/src/powercontext/builtin/persistence/migrations/resources/schemas/v1_1_0.json @@ -6,7 +6,8 @@ "pc_artifacts", "pc_artifact_heads", "pc_artifact_candidate_versions", - "pc_artifact_tags" + "pc_artifact_tags", + "pc_artifact_handles" ], "sqlite": [ "CREATE TABLE pc_artifacts (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tcontent BLOB NOT NULL, \n\tmemory_citations BLOB, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision)\n)", @@ -14,7 +15,8 @@ "CREATE TABLE pc_artifact_candidate_versions (\n\tscope_id VARCHAR(256) NOT NULL, \n\tcandidate_id VARCHAR(128) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tproposal BLOB NOT NULL, \n\tsource_refs BLOB NOT NULL, \n\tartifact_refs BLOB NOT NULL, \n\tmemory_citations BLOB, \n\ttarget_family VARCHAR(128), \n\ttarget_artifact_id VARCHAR(128), \n\ttarget_revision INTEGER, \n\treason TEXT, \n\tPRIMARY KEY (scope_id, candidate_id, version), \n\tFOREIGN KEY(scope_id, target_family, target_artifact_id, target_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_candidate_versions_version_positive CHECK (version > 0), \n\tCONSTRAINT ck_pc_artifact_candidate_versions_target_complete CHECK ((target_family IS NULL AND target_artifact_id IS NULL AND target_revision IS NULL) OR (target_family IS NOT NULL AND target_artifact_id IS NOT NULL AND target_revision > 0))\n)", "CREATE TABLE pc_artifact_tags (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\ttarget_type VARCHAR(12) NOT NULL, \n\ttarget_id VARCHAR(128) NOT NULL, \n\ttag_key_hash BLOB NOT NULL, \n\ttag_key VARCHAR(128) NOT NULL, \n\ttag VARCHAR(64) NOT NULL, \n\tassigned_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, target_type, target_id, tag_key_hash), \n\tFOREIGN KEY(scope_id, family, artifact_id) REFERENCES pc_artifact_heads (scope_id, family, artifact_id) ON DELETE CASCADE, \n\tCONSTRAINT ck_pc_artifact_tags_target CHECK ((target_type = 'artifact' AND target_id = artifact_id) OR (target_type = 'memory_entry' AND family = 'memory'))\n)", "CREATE INDEX ix_pc_artifact_tags_family_key ON pc_artifact_tags (scope_id, family, tag_key_hash, target_type, artifact_id, target_id)", - "CREATE INDEX ix_pc_artifact_tags_key ON pc_artifact_tags (scope_id, tag_key_hash, family, target_type, artifact_id, target_id)" + "CREATE INDEX ix_pc_artifact_tags_key ON pc_artifact_tags (scope_id, tag_key_hash, family, target_type, artifact_id, target_id)", + "CREATE TABLE pc_artifact_handles (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\thandle_key VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tPRIMARY KEY (scope_id, family, handle_key), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_handles_revision_positive CHECK (revision > 0)\n)" ], "mysql": [ "CREATE TABLE pc_artifacts (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfamily VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tartifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\trevision INTEGER NOT NULL, \n\tcontent MEDIUMBLOB NOT NULL, \n\tmemory_citations MEDIUMBLOB, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision)\n)", @@ -22,6 +24,7 @@ "CREATE TABLE pc_artifact_candidate_versions (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tcandidate_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tversion INTEGER NOT NULL, \n\tfamily VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tproposal MEDIUMBLOB NOT NULL, \n\tsource_refs MEDIUMBLOB NOT NULL, \n\tartifact_refs MEDIUMBLOB NOT NULL, \n\tmemory_citations MEDIUMBLOB, \n\ttarget_family VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\ttarget_artifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\ttarget_revision INTEGER, \n\treason MEDIUMTEXT, \n\tPRIMARY KEY (scope_id, candidate_id, version), \n\tFOREIGN KEY(scope_id, target_family, target_artifact_id, target_revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_candidate_versions_version_positive CHECK (version > 0), \n\tCONSTRAINT ck_pc_artifact_candidate_versions_target_complete CHECK ((target_family IS NULL AND target_artifact_id IS NULL AND target_revision IS NULL) OR (target_family IS NOT NULL AND target_artifact_id IS NOT NULL AND target_revision > 0))\n)", "CREATE TABLE pc_artifact_tags (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfamily VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tartifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\ttarget_type VARCHAR(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\ttarget_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\ttag_key_hash BINARY(32) NOT NULL, \n\ttag_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\ttag VARCHAR(64) NOT NULL, \n\tassigned_at DATETIME NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, target_type, target_id, tag_key_hash), \n\tFOREIGN KEY(scope_id, family, artifact_id) REFERENCES pc_artifact_heads (scope_id, family, artifact_id) ON DELETE CASCADE, \n\tCONSTRAINT ck_pc_artifact_tags_target CHECK ((target_type = 'artifact' AND target_id = artifact_id) OR (target_type = 'memory_entry' AND family = 'memory'))\n)", "CREATE INDEX ix_pc_artifact_tags_family_key ON pc_artifact_tags (scope_id, family, tag_key_hash, target_type, artifact_id, target_id)", - "CREATE INDEX ix_pc_artifact_tags_key ON pc_artifact_tags (scope_id, tag_key_hash, family, target_type, artifact_id, target_id)" + "CREATE INDEX ix_pc_artifact_tags_key ON pc_artifact_tags (scope_id, tag_key_hash, family, target_type, artifact_id, target_id)", + "CREATE TABLE pc_artifact_handles (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfamily VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thandle_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tartifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\trevision INTEGER NOT NULL, \n\tPRIMARY KEY (scope_id, family, handle_key), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision) REFERENCES pc_artifacts (scope_id, family, artifact_id, revision) ON DELETE RESTRICT, \n\tCONSTRAINT ck_pc_artifact_handles_revision_positive CHECK (revision > 0)\n)" ] } diff --git a/src/powercontext/builtin/persistence/migrations/resources/versions/p0004_artifact_handles.py b/src/powercontext/builtin/persistence/migrations/resources/versions/p0004_artifact_handles.py new file mode 100644 index 0000000000..1a4472db0b --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/resources/versions/p0004_artifact_handles.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. + +"""Add scope-local handles that target exact Artifact revisions.""" + +import sqlalchemy as sa +from alembic import op + +revision = "p0004" +down_revision = "p0003" + + +def upgrade() -> None: + op.create_table( + "pc_artifact_handles", + sa.Column("scope_id", sa.String(length=256), nullable=False), + sa.Column("family", sa.String(length=128), nullable=False), + sa.Column("handle_key", sa.String(length=128), nullable=False), + sa.Column("artifact_id", sa.String(length=128), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.CheckConstraint("revision > 0", name="ck_pc_artifact_handles_revision_positive"), + sa.ForeignKeyConstraint( + ("scope_id", "family", "artifact_id", "revision"), + ("pc_artifacts.scope_id", "pc_artifacts.family", "pc_artifacts.artifact_id", "pc_artifacts.revision"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("scope_id", "family", "handle_key"), + ) diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index e5e547981a..50528108d1 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -28,8 +28,9 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import Artifact, ArtifactRef +from powercontext.artifacts import Artifact, ArtifactRef, StableHandle from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError +from powercontext.builtin.persistence.artifact_handles import ArtifactHandleRepository from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec from powercontext.builtin.persistence.database import AsyncDatabase @@ -138,6 +139,7 @@ def __init__( self._processing_pending = processing_pending self._source_processing_bindings = source_processing_bindings self._topic_memory_list_reader = topic_memory_list_reader + self._artifact_handles = ArtifactHandleRepository(database) self._tags = RelationalTagService( database, artifacts, @@ -148,6 +150,26 @@ def __init__( atomic_write_authorizer=atomic_memory_tag_authorizer, ) + async def bind_artifact_handle( + self, + scope_id: str, + handle: StableHandle, + target: ArtifactRef, + /, + ) -> ArtifactRef: + try: + return await self._artifact_handles.bind(scope_id, handle, target) + except RepositoryNotFoundError as error: + raise BaseValueNotFoundError(error.kind, error.identity) from None + except StoredPayloadConflictError as error: + raise BaseValueConflictError(error.kind, error.identity) from None + + async def resolve_artifact_handle(self, scope_id: str, handle: StableHandle, /) -> ArtifactRef: + try: + return await self._artifact_handles.resolve(scope_id, handle) + except RepositoryNotFoundError as error: + raise BaseValueNotFoundError(error.kind, error.identity) from None + async def get_tags(self, scope_id: str, target: TagTarget) -> ArtifactTagSet: return await self._tags.get(scope_id, target) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index cf0ae0d6ee..c3edefbe07 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -36,6 +36,7 @@ from powercontext.limits import ( MAX_ARTIFACT_FAMILY_LENGTH, + MAX_ARTIFACT_HANDLE_KEY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_BINDING_NAME_LENGTH, MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH, @@ -197,6 +198,27 @@ def _entry_text_type(): Column("content", _canonical_payload_type(), nullable=False), ) +ARTIFACT_HANDLES_TABLE = Table( + "pc_artifact_handles", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("handle_key", identity_string(MAX_ARTIFACT_HANDLE_KEY_LENGTH), primary_key=True), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), nullable=False), + Column("revision", Integer, nullable=False), + ForeignKeyConstraint( + ("scope_id", "family", "artifact_id", "revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="RESTRICT", + ), + CheckConstraint("revision > 0", name="ck_pc_artifact_handles_revision_positive"), +) + ARTIFACT_HEADS_TABLE = Table( "pc_artifact_heads", SHARED_METADATA, @@ -952,6 +974,7 @@ def _entry_text_type(): SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ARTIFACTS_TABLE, + ARTIFACT_HANDLES_TABLE, ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index 56f8df85af..04df2883a2 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -22,7 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactRef, StableHandle from powercontext.sources import SourceRef if TYPE_CHECKING: @@ -289,6 +289,16 @@ async def create_atomic_memories( self, scope_id: str, contents: tuple[dict[str, JsonValue], ...], *, execution_context: Any = None ) -> tuple[ArtifactCreated, ...]: ... + async def bind_artifact_handle( + self, + scope_id: str, + handle: StableHandle, + target: ArtifactRef, + /, + ) -> ArtifactRef: ... + + async def resolve_artifact_handle(self, scope_id: str, handle: StableHandle, /) -> ArtifactRef: ... + async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> ArtifactRecord: ... async def get_artifact_revision( diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 7c8b617626..75650cfd20 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -33,7 +33,7 @@ from pydantic import BaseModel, JsonValue, ValidationError from powercontext._logging import log_safely -from powercontext.artifacts import ArtifactLineage, ArtifactRef +from powercontext.artifacts import ArtifactLineage, ArtifactRef, StableHandle from powercontext.artifacts.search import ( ArtifactSearchExecutionContext, ArtifactSearchOutcome, @@ -592,6 +592,14 @@ async def create_artifact( self.scope_id, family, write, execution_context=execution_context ) + async def bind_artifact_handle(self, handle: StableHandle, target: ArtifactRef, /) -> ArtifactRef: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await self._runtime._records().bind_artifact_handle(self.scope_id, handle, target) + + async def resolve_artifact_handle(self, handle: StableHandle, /) -> ArtifactRef: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().resolve_artifact_handle(self.scope_id, handle) + async def get_artifact(self, family: str, artifact_id: str, /) -> ArtifactRecord: async with self._runtime._scope_operation(self.scope_id): return await self._runtime._records().get_artifact(self.scope_id, family, artifact_id) diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 6c6c81ee42..e199783e0d 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -45,10 +45,12 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactCreated, + ArtifactHandleBinding, ArtifactPage, ArtifactPublication, ArtifactRevision, ArtifactRevisionPage, + BindArtifactHandleRequest, BootstrapContext, BootstrapContextRequest, BootstrapDeliveryReceipt, @@ -215,6 +217,7 @@ ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, + BIND_ARTIFACT_HANDLE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, CLEAR_SCOPE_BINDING, @@ -306,6 +309,7 @@ REPLACE_ARTIFACT, REPLACE_ARTIFACT_TAGS, REPLACE_MEMORY_ENTRY_TAGS, + RESOLVE_ARTIFACT_HANDLE, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -813,6 +817,29 @@ async def get_artifact_revision( }, ) + async def bind_artifact_handle( + self, + scope_id: str, + family: str, + key: str, + request: BindArtifactHandleRequest, + ) -> ArtifactHandleBinding: + """Idempotently bind a scope-local handle to an exact Artifact revision.""" + + return await self._request( + BIND_ARTIFACT_HANDLE, + request, + path_parameters={"scope_id": scope_id, "family": family, "key": key}, + ) + + async def resolve_artifact_handle(self, scope_id: str, family: str, key: str) -> ArtifactHandleBinding: + """Resolve a scope-local handle to its exact Artifact revision.""" + + return await self._request( + RESOLVE_ARTIFACT_HANDLE, + path_parameters={"scope_id": scope_id, "family": family, "key": key}, + ) + async def list_artifacts(self, scope_id: str, family: str, request: ListArtifactsRequest) -> ArtifactPage: """List current Artifact heads for one family.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 2ab3ed23e8..b0198916e9 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -55,6 +55,7 @@ ArtifactCollectionItem, ArtifactCreated, ArtifactFamilyAccessCapability, + ArtifactHandleBinding, ArtifactInventoryStatistics, ArtifactPage, ArtifactPublication, @@ -85,6 +86,7 @@ AtomicMemoryWriteContent, AuthorizationNote, BaseArtifactFamily, + BindArtifactHandleRequest, BootstrapContext, BootstrapContextItem, BootstrapContextItemKind, @@ -497,6 +499,7 @@ "ArtifactCollectionItem", "ArtifactCreated", "ArtifactFamilyAccessCapability", + "ArtifactHandleBinding", "ArtifactInventoryStatistics", "ArtifactPage", "ArtifactPublication", @@ -527,6 +530,7 @@ "AtomicMemoryWriteContent", "AuthorizationNote", "BaseArtifactFamily", + "BindArtifactHandleRequest", "BootstrapContext", "BootstrapContextItem", "BootstrapContextItemKind", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 8df012c5e6..7111a3050e 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -247,6 +247,23 @@ class ArtifactReference(BaseModel): revision: Annotated[StrictInt, Field(ge=1)] +class BindArtifactHandleRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + revision: Annotated[StrictInt, Field(ge=1)] + + +class ArtifactHandleBinding(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + key: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + artifact: ArtifactReference + + class ArtifactAddress(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index c3a9b8d5f6..d7aeb4d78d 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -22,6 +22,7 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactCreated, + ArtifactHandleBinding, ArtifactPage, ArtifactPublication, ArtifactRevision, @@ -35,6 +36,7 @@ AtomicMemoryRestorationRequest, AtomicMemoryRestorationResponse, AtomicMemoryStateResponse, + BindArtifactHandleRequest, BootstrapContext, BootstrapContextRequest, BootstrapDeliveryReceipt, @@ -2882,6 +2884,55 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) +RESOLVE_ARTIFACT_HANDLE = Operation[None, ArtifactHandleBinding]( + method="GET", + path="/v1/scopes/{scope_id}/artifact-handles/{family}/{key}", + operation_id="resolve_artifact_handle", + request_type=None, + request_location=None, + path_parameters=("scope_id", "family", "key"), + response_type=ArtifactHandleBinding, + success_status=200, + summary="Resolve a stable Artifact handle", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: {"description": "The exact Artifact revision bound to the handle."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +BIND_ARTIFACT_HANDLE = Operation[BindArtifactHandleRequest, ArtifactHandleBinding]( + method="PUT", + path="/v1/scopes/{scope_id}/artifact-handles/{family}/{key}", + operation_id="bind_artifact_handle", + request_type=BindArtifactHandleRequest, + request_location="body", + path_parameters=("scope_id", "family", "key"), + response_type=ArtifactHandleBinding, + success_status=200, + summary="Bind a stable Artifact handle", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: {"description": "The existing or newly committed handle binding."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.admin", resource="scope", scope_id_field="scope_id", resolver="request"), +) + GET_ARTIFACT = Operation[None, ArtifactRevision]( method="GET", path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3d0e641414..bf86682eb0 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3983,6 +3983,170 @@ }, } }, + "/v1/scopes/{scope_id}/artifact-handles/{family}/{key}": { + "get": { + "tags": ["artifacts"], + "summary": "Resolve a stable Artifact handle", + "description": "Resolve one scope-local handle to the exact immutable Artifact revision it names.", + "operationId": "resolve_artifact_handle", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "key", + "in": "path", + "required": True, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + }, + ], + "responses": { + "200": { + "description": "The exact Artifact revision bound to the handle.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactHandleBinding"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + "put": { + "tags": ["artifacts"], + "summary": "Bind a stable Artifact handle", + "description": "Idempotently " + "and " + "permanently " + "bind " + "one " + "scope-local " + "handle " + "to an " + "exact " + "immutable " + "Artifact " + "revision. " + "A " + "committed " + "binding " + "cannot " + "be " + "updated, " + "released, " + "or " + "deleted. " + "Repeating " + "the " + "same " + "binding " + "succeeds, " + "while " + "binding " + "the " + "key " + "to " + "another " + "Artifact " + "revision " + "returns " + "409. " + "Verify " + "the " + "family, " + "Artifact " + "ID, " + "revision, " + "and " + "key " + "before " + "committing " + "the " + "binding; " + "a " + "mistakenly " + "bound " + "key " + "cannot " + "be " + "reused " + "in " + "that " + "Scope.", + "operationId": "bind_artifact_handle", + "x-powercontext-access": { + "action": "scope.admin", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "key", + "in": "path", + "required": True, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BindArtifactHandleRequest"}} + }, + }, + "responses": { + "200": { + "description": "The existing or newly committed handle binding.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactHandleBinding"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + }, "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}": { "get": { "tags": ["artifacts"], @@ -5668,6 +5832,30 @@ "type": "object", "required": ["family", "artifact_id", "revision"], }, + "BindArtifactHandleRequest": { + "properties": { + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "revision": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["artifact_id", "revision"], + }, + "ArtifactHandleBinding": { + "properties": { + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "key", "artifact"], + }, "ArtifactAddress": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index 90d8984ea5..0feece8a39 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -34,3 +34,4 @@ MAX_EXTERNAL_SKILL_HOST_ID_LENGTH = 128 MAX_EXTERNAL_SKILL_LOCATOR_LENGTH = 2_000 MAX_POLICY_REVISION_LENGTH = 64 +MAX_ARTIFACT_HANDLE_KEY_LENGTH = 128 diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index e30f6ec999..5116b2519d 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -48,7 +48,7 @@ from typing_extensions import override from powercontext._logging import log_safely -from powercontext.artifacts import ArtifactAddress, ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef, StableHandle from powercontext.artifacts.search import ( ArtifactSearchExecutionContext, ArtifactSearchFamilyNotFound, @@ -401,11 +401,14 @@ ArtifactCollectionItem, ArtifactCreated, ArtifactFamilyAccessCapability, + ArtifactHandleBinding, ArtifactPage, ArtifactReadFamily, + ArtifactReference, ArtifactRevision, ArtifactRevisionPage, BaseArtifactFamily, + BindArtifactHandleRequest, BootstrapContext, BootstrapContextRequest, BootstrapDeliveryReceipt, @@ -655,6 +658,7 @@ API_TITLE, API_VERSION, APPROVE_ARTIFACT_CANDIDATE, + BIND_ARTIFACT_HANDLE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, CLEAR_SCOPE_BINDING, @@ -747,6 +751,7 @@ REPLACE_ARTIFACT, REPLACE_ARTIFACT_TAGS, REPLACE_MEMORY_ENTRY_TAGS, + RESOLVE_ARTIFACT_HANDLE, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -886,6 +891,10 @@ async def create_artifact( /, ) -> RuntimeArtifactCreated: ... + async def bind_artifact_handle(self, handle: StableHandle, target: ArtifactRef, /) -> ArtifactRef: ... + + async def resolve_artifact_handle(self, handle: StableHandle, /) -> ArtifactRef: ... + async def get_artifact(self, family: str, artifact_id: str, /) -> RuntimeArtifactRecord: ... async def get_artifact_revision( @@ -1420,6 +1429,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, FLUSH_PROFILE, flush_profile) _add_route(app, GET_SOURCE, get_source) _add_route(app, CREATE_ARTIFACT, create_artifact) + _add_route(app, BIND_ARTIFACT_HANDLE, bind_artifact_handle) + _add_route(app, RESOLVE_ARTIFACT_HANDLE, resolve_artifact_handle) _add_route(app, GET_MEMORY_ENTRY_TAGS, get_memory_entry_tags) _add_route(app, REPLACE_MEMORY_ENTRY_TAGS, replace_memory_entry_tags) _add_route(app, GET_ARTIFACT_TAGS, get_artifact_tags) @@ -2430,6 +2441,42 @@ async def create_artifact( return _artifact_created_response(result) +async def bind_artifact_handle( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + key: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")], + request: BindArtifactHandleRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactHandleBinding: + handle = StableHandle(family=family, key=key) + target = ArtifactRef(family=family, artifact_id=request.artifact_id, revision=request.revision) + result = await application.records.for_scope(scope_id).bind_artifact_handle(handle, target) + return _artifact_handle_binding(handle, result) + + +async def resolve_artifact_handle( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + key: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactHandleBinding: + handle = StableHandle(family=family, key=key) + result = await application.records.for_scope(scope_id).resolve_artifact_handle(handle) + return _artifact_handle_binding(handle, result) + + +def _artifact_handle_binding(handle: StableHandle, target: ArtifactRef) -> ArtifactHandleBinding: + return ArtifactHandleBinding( + family=handle.family, + key=handle.key, + artifact=ArtifactReference( + family=target.family, + artifact_id=target.artifact_id, + revision=target.revision, + ), + ) + + def _list_artifacts_query( limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, diff --git a/tests/builtin/persistence/test_artifact_handles.py b/tests/builtin/persistence/test_artifact_handles.py new file mode 100644 index 0000000000..8db5c092fd --- /dev/null +++ b/tests/builtin/persistence/test_artifact_handles.py @@ -0,0 +1,149 @@ +# 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. + +import asyncio +from contextlib import asynccontextmanager + +import pytest +from sqlalchemy import insert +from sqlalchemy.exc import IntegrityError + +from powercontext.artifacts import ArtifactRef, StableHandle +from powercontext.builtin.persistence.artifact_handles import ArtifactHandleRepository +from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE, BUILTIN_TABLES + + +class _Result: + def __init__(self, value) -> None: + self._value = value + + def one_or_none(self): + return self._value + + def scalar_one_or_none(self): + return self._value + + +class _Connection: + def __init__(self, results) -> None: + self._results = iter(results) + + async def execute(self, _statement): + result = next(self._results) + if isinstance(result, BaseException): + raise result + return _Result(result) + + +class _Database: + def __init__(self, *connections: _Connection) -> None: + self._connections = iter(connections) + + @asynccontextmanager + async def transaction(self): + yield next(self._connections) + + +def test_bind_and_resolve_exact_artifact_revision() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + repository = ArtifactHandleRepository(profile.database) + target = ArtifactRef(family="profile", artifact_id="profile-1", revision=1) + handle = StableHandle(family="profile", key="support-agent") + async with profile.database.transaction() as connection: + await connection.execute( + insert(ARTIFACTS_TABLE).values( + scope_id="scope-a", + family=target.family, + artifact_id=target.artifact_id, + revision=target.revision, + content=b"{}", + ) + ) + + assert await repository.bind("scope-a", handle, target) == target + assert await repository.bind("scope-a", handle, target) == target + assert await repository.resolve("scope-a", handle) == target + + asyncio.run(scenario()) + + +def test_handle_is_scope_local_and_cannot_be_rebound() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + repository = ArtifactHandleRepository(profile.database) + handle = StableHandle(family="profile", key="support-agent") + first = ArtifactRef(family="profile", artifact_id="profile-1", revision=1) + second = ArtifactRef(family="profile", artifact_id="profile-2", revision=1) + async with profile.database.transaction() as connection: + await connection.execute( + insert(ARTIFACTS_TABLE), + [ + { + "scope_id": scope_id, + "family": target.family, + "artifact_id": target.artifact_id, + "revision": target.revision, + "content": b"{}", + } + for scope_id in ("scope-a", "scope-b") + for target in (first, second) + ], + ) + + await repository.bind("scope-a", handle, first) + await repository.bind("scope-b", handle, second) + + assert await repository.resolve("scope-a", handle) == first + assert await repository.resolve("scope-b", handle) == second + with pytest.raises(StoredPayloadConflictError): + await repository.bind("scope-a", handle, second) + + asyncio.run(scenario()) + + +def test_missing_handle_and_target_are_explicit() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + repository = ArtifactHandleRepository(profile.database) + handle = StableHandle(family="profile", key="missing") + target = ArtifactRef(family="profile", artifact_id="missing", revision=1) + + with pytest.raises(RepositoryNotFoundError, match="artifact_handle"): + await repository.resolve("scope-a", handle) + with pytest.raises(RepositoryNotFoundError, match="artifact"): + await repository.bind("scope-a", handle, target) + + asyncio.run(scenario()) + + +def test_foreign_key_race_reports_the_missing_artifact_not_a_missing_handle() -> None: + async def scenario() -> None: + integrity_error = IntegrityError("insert artifact handle", {}, Exception("foreign key failed")) + database = _Database( + _Connection([None, 1, integrity_error]), + _Connection([None, None]), + ) + repository = ArtifactHandleRepository(database) # ty: ignore[invalid-argument-type] + handle = StableHandle(family="profile", key="support-agent") + target = ArtifactRef(family="profile", artifact_id="profile-1", revision=1) + + with pytest.raises(RepositoryNotFoundError) as error: + await repository.bind("scope-a", handle, target) + assert error.value.kind == "artifact" + assert error.value.identity == ("scope-a", "profile", "profile-1", 1) + + asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 07e9694ec8..17365988d1 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -298,10 +298,10 @@ def test_mysql_initialization_and_shared_evidence_noop(backend: _BackendTarget) assert result.state == "ready" assert result.changed assert result.backup_state == "not_required" - assert result.revision == "p0003" + assert result.revision == "p0004" replacement = backend.runner() assert replacement.plan().state == "ready" - assert replacement.verify().revision == "p0003" + assert replacement.verify().revision == "p0004" assert not replacement.apply().changed @@ -321,7 +321,7 @@ def test_mysql_operator_backup_choices_preserve_baseline_data_without_fork( result = _apply(runner, policy=policy) assert result.backup_state == ("user_confirmed" if policy == "manual" else "skipped") state = _read_state(backend) - assert state["revision"] == "p0003" + assert state["revision"] == "p0004" assert state["content"] == [(b'{"content":"preserved"}',)] assert state["tags"] == [("Keep",)] assert "memory_citations" in state["columns"]["pc_artifacts"] @@ -389,7 +389,7 @@ def test_mysql_committed_partial_ddl_resumes_from_shared_original_evidence(backe replacement = _acknowledge_known_completed_interruption(backend, recovery) assert _apply(replacement, policy="manual").state == "ready" state = _read_state(backend) - assert state["revision"] == "p0003" + assert state["revision"] == "p0004" assert state["content"] == [(b'{"content":"preserved"}',)] assert state["tags"] == [("Keep",)] assert replacement.verify().state == "ready" @@ -435,7 +435,7 @@ def test_mysql_empty_source_recovers_baseline_and_citation_ddl_without_probing_b ) result = _apply(replacement) assert result.backup_state == "not_required" - assert replacement.verify().revision == "p0003" + assert replacement.verify().revision == "p0004" assert _read_state(backend)["content"] == [] @@ -452,7 +452,7 @@ def test_mysql_baseline_adoption_recovers_an_empty_committed_version_table(backe assert recovered.adopt_baseline replacement = _acknowledge_known_completed_interruption(backend, recovered) assert _apply(replacement, policy="manual").state == "ready" - assert replacement.verify().revision == "p0003" + assert replacement.verify().revision == "p0004" assert _read_state(backend)["tags"] == [("Keep",)] @@ -478,7 +478,7 @@ def interrupt(journal, evidence) -> None: assert recovery.source_revision == "p0002" replacement = _acknowledge_known_completed_interruption(backend, recovery) assert _apply(replacement, policy="manual").state == "ready" - assert replacement.verify().revision == "p0003" + assert replacement.verify().revision == "p0004" assert _read_state(backend)["content"] == [(b'{"content":"preserved"}',)] @@ -487,7 +487,7 @@ def test_mysql_completed_head_with_an_interrupted_run_requires_exact_acknowledge pytest.skip("embedded seekdb closes its engine and has no remote in-flight run receipt") _load_baseline(backend) assert _apply(backend.runner(), policy="manual").state == "ready" - assert backend.runner().verify().revision == "p0003" + assert backend.runner().verify().revision == "p0004" def committed_write_then_interrupt( connection: Connection | None, _identity: BackendIdentity, verify: Callable[[], None] @@ -501,12 +501,12 @@ def committed_write_then_interrupt( with pytest.raises(_InterruptedDDL): backend.connections().run(committed_write_then_interrupt, writable=True) before = _read_state(backend) - assert before["revision"] == "p0003" + assert before["revision"] == "p0004" assert before["tags"] == [("Confirmed",)] replacement = backend.runner(backup_factory=_forbidden_automatic_backup) recovery = replacement.plan() assert recovery.state == "recovery_required" - assert recovery.source_revision == "p0003" + assert recovery.source_revision == "p0004" assert recovery.revisions == () pending = (recovery.coordination or {}).get("pending_run_id") assert pending @@ -525,6 +525,6 @@ def committed_write_then_interrupt( assert result.state == "ready" assert not result.changed assert _read_state(backend) == before - assert backend.runner().verify().revision == "p0003" + assert backend.runner().verify().revision == "p0004" assert not backend.runner().apply().changed assert "pending_run_id" not in (backend.runner().plan().coordination or {}) diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index 606f3bbf79..5c0c5e7a79 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -21,9 +21,11 @@ from powercontext.builtin.persistence.tables import ( AGENT_SKILL_TARGETS_TABLE, + ARTIFACT_HANDLES_TABLE, ARTIFACTS_TABLE, BUILTIN_TABLES, SHARED_METADATA, + SHARED_TABLES, SKILL_PACKAGES_TABLE, SOURCE_CURSORS_TABLE, SOURCES_TABLE, @@ -66,6 +68,35 @@ def test_mysql_ddl_uses_utf8mb4_bin_for_identity_keys() -> None: assert "source_type VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL" in ddl +def test_artifact_handles_use_scope_local_identity_and_exact_revision_target() -> None: + primary_key = tuple(column.name for column in ARTIFACT_HANDLES_TABLE.primary_key.columns) + foreign_keys = tuple( + constraint + for constraint in ARTIFACT_HANDLES_TABLE.foreign_key_constraints + if constraint.referred_table is ARTIFACTS_TABLE + ) + + assert primary_key == ("scope_id", "family", "handle_key") + assert len(foreign_keys) == 1 + assert tuple(element.parent.name for element in foreign_keys[0].elements) == ( + "scope_id", + "family", + "artifact_id", + "revision", + ) + assert tuple(element.column.name for element in foreign_keys[0].elements) == ( + "scope_id", + "family", + "artifact_id", + "revision", + ) + assert foreign_keys[0].ondelete == "RESTRICT" + assert "ck_pc_artifact_handles_revision_positive" in { + constraint.name for constraint in ARTIFACT_HANDLES_TABLE.constraints + } + assert ARTIFACT_HANDLES_TABLE in SHARED_TABLES + + def test_mysql_ddl_uses_mediumblob_for_every_canonical_payload() -> None: dialect = mysql.dialect() expected = { diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index c39f5e2ed6..5d1166cc83 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -21,10 +21,10 @@ import pytest from pydantic import JsonValue, ValidationError -from sqlalchemy import event, func, select, update +from sqlalchemy import event, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactRef, StableHandle from powercontext.builtin.artifacts.atomic_memory import AtomicMemory, AtomicMemoryContent from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.handoff import Handoff @@ -608,6 +608,29 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_artifact_handle_resolves_through_record_service() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + handle = StableHandle(family="profile", key="support-agent") + target = ArtifactRef(family="profile", artifact_id="profile-1", revision=1) + async with profile.database.transaction() as connection: + await connection.execute( + insert(ARTIFACTS_TABLE).values( + scope_id="scope-a", + family=target.family, + artifact_id=target.artifact_id, + revision=target.revision, + content=b"{}", + ) + ) + + assert await records.bind_artifact_handle("scope-a", handle, target) == target + assert await records.resolve_artifact_handle("scope-a", handle) == target + + asyncio.run(scenario()) + + def test_artifact_create_and_replace_validate_handoff_as_json() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index d99eb4c6a7..237d000388 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -134,6 +134,29 @@ async def check_with_concurrent_write(provider, request): asyncio.run(scenario()) +@pytest.mark.parametrize("backend", ["builtin", "casbin"]) +def test_stable_handle_binding_requires_scope_admin(tmp_path: Path, backend: str) -> None: + async def scenario() -> None: + async with _server(tmp_path, backend) as (_, client, _): + scope = await _scope(client) + await _grant(client, scope, "contributor", "scope.contributor") + created = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "profile", "content": {"content": "# Support agent"}}, + ) + assert created.status_code == 201, created.text + path = f"/v1/scopes/{scope}/artifact-handles/profile/support-agent" + payload = {"artifact_id": created.json()["artifact_id"], "revision": created.json()["revision"]} + + denied = await client.put(path, headers={"Authorization": "Bearer contributor"}, json=payload) + assert denied.status_code == 403, denied.text + + bound = await client.put(path, json=payload) + assert bound.status_code == 200, bound.text + + asyncio.run(scenario()) + + @pytest.mark.parametrize("backend", ["builtin", "casbin"]) @pytest.mark.parametrize("target_contributor", [False, True]) def test_missing_subject_target_preserves_authorization(tmp_path, backend, target_contributor): diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 1e41283fed..7fd252accb 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -862,6 +862,8 @@ def test_base_access_contract_includes_revision_history_and_tags() -> None: ("/v1/scopes/{scope_id}/sources", "get"): "list_sources", ("/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", "get"): "get_source", ("/v1/scopes/{scope_id}/artifacts", "post"): "create_artifact", + ("/v1/scopes/{scope_id}/artifact-handles/{family}/{key}", "get"): "resolve_artifact_handle", + ("/v1/scopes/{scope_id}/artifact-handles/{family}/{key}", "put"): "bind_artifact_handle", ("/v1/scopes/{scope_id}/artifacts/{family}", "get"): "list_artifacts", ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", "get"): "get_artifact", ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", "put"): "replace_artifact", @@ -878,7 +880,9 @@ def test_base_access_contract_includes_revision_history_and_tags() -> None: actual_operations = { (path, method): operation["operationId"] for path, path_item in paths.items() - if path.startswith("/v1/scopes/{scope_id}/sources") or path.startswith("/v1/scopes/{scope_id}/artifacts") + if path.startswith("/v1/scopes/{scope_id}/sources") + or path.startswith("/v1/scopes/{scope_id}/artifacts") + or path.startswith("/v1/scopes/{scope_id}/artifact-handles") for method, operation in path_item.items() } assert actual_operations == expected_operations diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index a79cd14d67..4ab37ac708 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -28,8 +28,10 @@ ArtifactLineage, ArtifactRef, ArtifactStore, + StableHandle, ) from powercontext.context import Artifacts +from powercontext.limits import MAX_ARTIFACT_HANDLE_KEY_LENGTH from powercontext.sources import SourceRef @@ -90,6 +92,27 @@ def test_artifact_reference_rejects_invalid_identity( assert error.value.errors()[0]["loc"][0] == field +@pytest.mark.parametrize("key", ["a", "payment-retry-policy", "policy2", "a" * MAX_ARTIFACT_HANDLE_KEY_LENGTH]) +def test_stable_handle_accepts_canonical_keys(key: str) -> None: + assert StableHandle(family="profile", key=key).key == key + + +@pytest.mark.parametrize( + "key", + ["", "Payment-Retry-Policy", "payment_retry_policy", "-policy", "policy-", "policy--retry", "policy key"], +) +def test_stable_handle_rejects_noncanonical_keys(key: str) -> None: + with pytest.raises(ValidationError) as error: + StableHandle(family="profile", key=key) + assert error.value.errors()[0]["loc"] == ("key",) + + +def test_stable_handle_rejects_keys_over_the_length_limit() -> None: + with pytest.raises(ValidationError) as error: + StableHandle(family="profile", key="a" * (MAX_ARTIFACT_HANDLE_KEY_LENGTH + 1)) + assert error.value.errors()[0]["loc"] == ("key",) + + @pytest.mark.parametrize( ("factory", "field"), [ diff --git a/tests/test_client.py b/tests/test_client.py index cf2e158e5e..221532f044 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,6 +41,7 @@ AccessSubject, ArtifactAccessResource, ArtifactReference, + BindArtifactHandleRequest, CaptureContentSourceRequest, ExactScopeSelection, FlushTopicMemoryRequest, @@ -143,6 +144,41 @@ def respond(request: httpx.Request) -> httpx.Response: assert len(sent) == 1 assert json.loads(sent[0].content) == payload + +def test_artifact_handle_client_binds_and_resolves_exact_revision() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "family": "profile", + "key": "support-agent", + "artifact": {"family": "profile", "artifact_id": "profile-1", "revision": 2}, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as transport: + client = PowerContextClient("https://memory.example", http_client=transport) + bound = await client.bind_artifact_handle( + "scope-a", + "profile", + "support-agent", + BindArtifactHandleRequest(artifact_id="profile-1", revision=2), + ) + resolved = await client.resolve_artifact_handle("scope-a", "profile", "support-agent") + + assert bound == resolved + assert [request.method for request in requests] == ["PUT", "GET"] + assert [request.url.path for request in requests] == [ + "/v1/scopes/scope-a/artifact-handles/profile/support-agent", + "/v1/scopes/scope-a/artifact-handles/profile/support-agent", + ] + assert json.loads(requests[0].content) == {"artifact_id": "profile-1", "revision": 2} + assert requests[1].content == b"" + asyncio.run(scenario()) diff --git a/tests/test_scope_api.py b/tests/test_scope_api.py index c7b7d2662a..1da179dc29 100644 --- a/tests/test_scope_api.py +++ b/tests/test_scope_api.py @@ -24,6 +24,62 @@ from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +def test_artifact_handle_bind_and_resolve_public_path(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'handles.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + with TestClient(app) as client: + scope_id = client.get("/v1/scopes/default").json()["scope_id"] + created = client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "profile", "content": {"content": "# Chinese support agent"}}, + ) + assert created.status_code == 201 + artifact = created.json() + path = f"/v1/scopes/{scope_id}/artifact-handles/profile/support-agent" + expected = { + "family": "profile", + "key": "support-agent", + "artifact": { + "family": "profile", + "artifact_id": artifact["artifact_id"], + "revision": artifact["revision"], + }, + } + + bound = client.put( + path, + json={"artifact_id": artifact["artifact_id"], "revision": artifact["revision"]}, + ) + assert bound.status_code == 200 + assert bound.json() == expected + assert client.get(path).json() == expected + + artifact_path = f"/v1/scopes/{scope_id}/artifacts/profile/{artifact['artifact_id']}" + revised = client.put( + artifact_path, + headers={"If-Match": created.headers["ETag"]}, + json={"content": {"content": "# English support agent"}}, + ) + assert revised.status_code == 200 + assert revised.json()["revision"] == 2 + assert client.get(path).json() == expected + + conflicting = client.put( + path, + json={"artifact_id": artifact["artifact_id"], "revision": revised.json()["revision"]}, + ) + assert conflicting.status_code == 409 + assert conflicting.json()["error"]["details"] == {"kind": "artifact_handle"} + + missing = client.get(f"/v1/scopes/{scope_id}/artifact-handles/profile/missing") + assert missing.status_code == 404 + assert missing.json()["error"]["code"] == "artifact_handle_not_found" + + @pytest.mark.parametrize("binding_kind", ["durable", "default"]) def test_missing_binding_target_is_a_conflict_without_fallback_or_replacement(tmp_path, binding_kind) -> None: database_path = tmp_path / "missing-target.db"