From c0a225c5322be2265d05275dd58011ae2dd60313 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sun, 11 Oct 2026 15:57:02 +0800 Subject: [PATCH 1/7] feat: add stable artifact handles --- docs/en/docs/operate/troubleshoot.md | 2 +- docs/zh/docs/operate/troubleshoot.md | 2 +- .../powercontext/src/operations.generated.ts | 2 + .../powercontext/src/operations.generated.ts | 2 + .../powercontext/src/operations.generated.ts | 2 + openapi/powercontext.yaml | 99 +++++++++++++ src/powercontext/artifacts/__init__.py | 4 + src/powercontext/artifacts/models.py | 45 +++++- .../builtin/persistence/artifact_handles.py | 107 ++++++++++++++ .../versions/p0004_artifact_handles.py | 24 ++++ .../persistence/migrations/mysql_schema.py | 17 ++- .../migrations/resources/manifest.json | 13 +- .../migrations/resources/schemas/v1_1_0.json | 9 +- .../versions/p0004_artifact_handles.py | 39 ++++++ .../builtin/persistence/records.py | 25 ++++ .../builtin/persistence/tables.py | 23 +++ src/powercontext/builtin/records.py | 12 ++ .../builtin/runtime/application.py | 9 ++ src/powercontext/client/client.py | 27 ++++ src/powercontext/http/__init__.py | 4 + src/powercontext/http/_generated/models.py | 17 +++ .../http/_generated/operations.py | 53 +++++++ src/powercontext/http/_generated/schema.py | 131 ++++++++++++++++++ src/powercontext/limits.py | 1 + src/powercontext/server/app.py | 49 ++++++- .../persistence/test_artifact_handles.py | 97 +++++++++++++ .../builtin/persistence/test_mysql_schema.py | 31 +++++ tests/builtin/persistence/test_records.py | 15 ++ tests/test_api_contract.py | 6 +- tests/test_artifacts.py | 23 +++ tests/test_client.py | 34 +++++ tests/test_scope_api.py | 49 +++++++ 32 files changed, 959 insertions(+), 14 deletions(-) create mode 100644 src/powercontext/builtin/persistence/artifact_handles.py create mode 100644 src/powercontext/builtin/persistence/migrations/mysql_resources/versions/p0004_artifact_handles.py create mode 100644 src/powercontext/builtin/persistence/migrations/resources/versions/p0004_artifact_handles.py create mode 100644 tests/builtin/persistence/test_artifact_handles.py diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 16b97da35a..36cc666ac3 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -275,7 +275,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_dream_runs,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_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/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index a1d6291af3..3fb7a7c5f5 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_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_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/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 7b970a0ce6..2d9535f548 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -113,6 +113,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/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 7b970a0ce6..2d9535f548 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -113,6 +113,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 7b970a0ce6..2d9535f548 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -113,6 +113,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 65c4185399..ca39b414a0 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3834,6 +3834,75 @@ 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 bind one scope-local handle to an exact immutable Artifact revision. + operationId: bind_artifact_handle + x-powercontext-access: {action: scope.contribute, 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] @@ -5632,6 +5701,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..5a89e0fd5d 100644 --- a/src/powercontext/artifacts/__init__.py +++ b/src/powercontext/artifacts/__init__.py @@ -20,6 +20,8 @@ ArtifactDraft, ArtifactLineage, ArtifactRef, + MemoryCitation, + StableHandle, ) from powercontext.artifacts.protocols import ArtifactCatalog, ArtifactStore from powercontext.artifacts.search import ( @@ -51,4 +53,6 @@ "ArtifactSearcher", "ArtifactStore", "ChannelScore", + "MemoryCitation", + "StableHandle", ] diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index 21b2c03d0b..4519b09e43 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -16,15 +16,25 @@ from __future__ import annotations +<<<<<<< HEAD from collections.abc import Mapping from typing import Any, ClassVar, Generic, TypeVar +import re +from typing import 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 +65,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..8c6d61e38f --- /dev/null +++ b/src/powercontext/builtin/persistence/artifact_handles.py @@ -0,0 +1,107 @@ +# 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) + target_exists = ( + 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() + if target_exists is None: + 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: + # A concurrent binder may have won the unique-key race. Resolve the + # durable winner and preserve idempotency only for the same target. + existing = await self.resolve(scope_id, handle) + return _require_same_binding(scope_id, handle, existing, target) + 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)) + + +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..30f1eed1c5 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -28,8 +28,12 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncConnection +<<<<<<< HEAD from powercontext.artifacts import Artifact, ArtifactRef from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError +from powercontext.artifacts import Artifact, ArtifactRef, StableHandle +from powercontext.builtin.artifacts.memory import MemoryCitation, MemoryEntryVersion, MemoryService +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 +142,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 +153,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 6725a5a34e..79b96c8d5e 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -35,6 +35,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, @@ -196,6 +197,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, @@ -951,6 +973,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..cb9d80fb8d 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -23,6 +23,7 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactRef, MemoryCitation, StableHandle from powercontext.sources import SourceRef if TYPE_CHECKING: @@ -285,9 +286,20 @@ async def create_artifact( execution_context: Any = None, ) -> ArtifactCreated: ... +<<<<<<< HEAD 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: ... diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 9bfca705cb..ca1bcf65cf 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -31,6 +31,7 @@ from powercontext._logging import log_safely from powercontext.artifacts import ArtifactLineage, ArtifactRef +from powercontext.artifacts import ArtifactRef, StableHandle from powercontext.artifacts.search import ( ArtifactSearchExecutionContext, ArtifactSearchOutcome, @@ -565,6 +566,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 50e1e169d8..13a6a3b0e4 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, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -210,6 +212,7 @@ ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, + BIND_ARTIFACT_HANDLE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, CLEAR_SCOPE_BINDING, @@ -298,6 +301,7 @@ REPLACE_ARTIFACT, REPLACE_ARTIFACT_TAGS, REPLACE_MEMORY_ENTRY_TAGS, + RESOLVE_ARTIFACT_HANDLE, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -800,6 +804,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 dae3e44a6c..e0bf5c39d2 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, BuiltinPromptInstructions, CandidateFamily, CandidateFamilyCount, @@ -481,6 +483,7 @@ "ArtifactCollectionItem", "ArtifactCreated", "ArtifactFamilyAccessCapability", + "ArtifactHandleBinding", "ArtifactInventoryStatistics", "ArtifactPage", "ArtifactPublication", @@ -511,6 +514,7 @@ "AtomicMemoryWriteContent", "AuthorizationNote", "BaseArtifactFamily", + "BindArtifactHandleRequest", "BuiltinPromptInstructions", "CandidateFamily", "CandidateFamilyCount", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 8e865a2aa2..da7bbbcc59 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -211,6 +211,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 5cbe61dc12..99f5db5aa4 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, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -2792,6 +2794,57 @@ 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.contribute", 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 125e7078e5..4d2598446c 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3868,6 +3868,113 @@ }, } }, + "/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 bind one scope-local handle to an exact immutable Artifact revision.", + "operationId": "bind_artifact_handle", + "x-powercontext-access": { + "action": "scope.contribute", + "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"], @@ -5436,6 +5543,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 3394b3cb36..097ae923da 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, @@ -397,11 +397,14 @@ ArtifactCollectionItem, ArtifactCreated, ArtifactFamilyAccessCapability, + ArtifactHandleBinding, ArtifactPage, ArtifactReadFamily, + ArtifactReference, ArtifactRevision, ArtifactRevisionPage, BaseArtifactFamily, + BindArtifactHandleRequest, CandidatePermissions, Capabilities, CaptureContentSourceRequest, @@ -646,6 +649,7 @@ API_TITLE, API_VERSION, APPROVE_ARTIFACT_CANDIDATE, + BIND_ARTIFACT_HANDLE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, CLEAR_SCOPE_BINDING, @@ -735,6 +739,7 @@ REPLACE_ARTIFACT, REPLACE_ARTIFACT_TAGS, REPLACE_MEMORY_ENTRY_TAGS, + RESOLVE_ARTIFACT_HANDLE, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -874,6 +879,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( @@ -1385,6 +1394,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) @@ -2386,6 +2397,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..5d57dad2a1 --- /dev/null +++ b/tests/builtin/persistence/test_artifact_handles.py @@ -0,0 +1,97 @@ +# 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 + +import pytest +from sqlalchemy import insert + +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 + + +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="memory", artifact_id="memory-1", revision=1) + handle = StableHandle(family="memory", key="payment-retry-policy") + 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="memory", key="payment-retry-policy") + first = ArtifactRef(family="memory", artifact_id="memory-1", revision=1) + second = ArtifactRef(family="memory", artifact_id="memory-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="memory", key="missing") + target = ArtifactRef(family="memory", 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()) diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index a95975bf1a..e44d76fa11 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, @@ -62,6 +64,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 350b9e20cb..fd738d4cbd 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -26,6 +26,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.atomic_memory import AtomicMemory, AtomicMemoryContent +from powercontext.artifacts import ArtifactRef, StableHandle from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.handoff import Handoff from powercontext.builtin.artifacts.skill import Skill @@ -540,6 +541,20 @@ 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) + created = await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) + handle = StableHandle(family="memory", key="preferred-language") + target = ArtifactRef(family="memory", artifact_id=created.artifact_id, revision=created.revision) + + 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/test_api_contract.py b/tests/test_api_contract.py index d68e274f1d..d81cc08d94 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -765,6 +765,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", @@ -781,7 +783,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..e406121a3e 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="memory", 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="memory", 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="memory", 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 6963e91083..e7b8175c65 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,6 +41,7 @@ AccessSubject, ArtifactAccessResource, ArtifactReference, + BindArtifactHandleRequest, CaptureContentSourceRequest, ExactScopeSelection, FlushTopicMemoryRequest, @@ -110,6 +111,39 @@ def respond(request: httpx.Request) -> httpx.Response: assert result.items == [] 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": "memory", + "key": "preferred-language", + "artifact": {"family": "memory", "artifact_id": "memory-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", + "memory", + "preferred-language", + BindArtifactHandleRequest(artifact_id="memory-1", revision=2), + ) + resolved = await client.resolve_artifact_handle("scope-a", "memory", "preferred-language") + + 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/memory/preferred-language", + "/v1/scopes/scope-a/artifact-handles/memory/preferred-language", + ] + assert json.loads(requests[0].content) == {"artifact_id": "memory-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..90956a34e0 100644 --- a/tests/test_scope_api.py +++ b/tests/test_scope_api.py @@ -24,6 +24,55 @@ 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": "memory", "content": {"entries": [{"kind": "preference", "text": "Use Chinese"}]}}, + ) + assert created.status_code == 201 + artifact = created.json() + path = f"/v1/scopes/{scope_id}/artifact-handles/memory/preferred-language" + expected = { + "family": "memory", + "key": "preferred-language", + "artifact": { + "family": "memory", + "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/memory/{artifact['artifact_id']}" + revised = client.put( + artifact_path, + headers={"If-Match": created.headers["ETag"]}, + json={"content": {"entries": [{"kind": "preference", "text": "Use English"}]}}, + ) + assert revised.status_code == 200 + assert revised.json()["revision"] == 2 + assert client.get(path).json() == expected + + missing = client.get(f"/v1/scopes/{scope_id}/artifact-handles/memory/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" From 1bc4d1c417384acb33f4513acd35d6bacf577851 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sun, 11 Oct 2026 16:37:02 +0800 Subject: [PATCH 2/7] feat: add stable artifact handles --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 98 +++++++++++++++++++ desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/workflows/artifacts.md | 9 ++ docs/zh/docs/workflows/artifacts.md | 9 ++ .../dsh/plugins/powercontext/lib/index.js | 30 ++++++ openapi/powercontext.yaml | 6 +- src/powercontext/artifacts/__init__.py | 2 - src/powercontext/artifacts/models.py | 4 +- .../builtin/persistence/artifact_handles.py | 42 +++++--- .../builtin/persistence/records.py | 5 +- src/powercontext/builtin/records.py | 6 +- .../builtin/runtime/application.py | 3 +- src/powercontext/http/_generated/schema.py | 59 ++++++++++- .../persistence/test_artifact_handles.py | 66 +++++++++++-- tests/builtin/persistence/test_records.py | 20 ++-- tests/test_artifacts.py | 6 +- tests/test_client.py | 22 +++-- tests/test_scope_api.py | 23 +++-- 19 files changed, 345 insertions(+), 69 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b506763e37..8cc20d9ee2 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "6ac34926b9c7f4fa975512833ca026ec43da9073e92b53068e2a987b730f2bb8", + "contractSha256": "f25183ff4bb37236f4d6a18bd747f17c0933cc8a668087440dd2b01f716ac2f2", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 7cbd1eeeab..59bfc68075 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -1780,6 +1780,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; @@ -2389,6 +2413,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"]; @@ -7878,6 +7911,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/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 5a994dd441..c42d4a5c28 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 = "6ac34926b9c7f4fa975512833ca026ec43da9073e92b53068e2a987b730f2bb8"; +export const contractSha256 = "f25183ff4bb37236f4d6a18bd747f17c0933cc8a668087440dd2b01f716ac2f2"; export const operations = { "get_liveness": { "method": "GET", 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/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 0fdc8288e0..6571ecdb41 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1238,6 +1238,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}", diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index ca39b414a0..2e65c3f252 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3869,7 +3869,11 @@ paths: put: tags: [artifacts] summary: Bind a stable Artifact handle - description: Idempotently bind one scope-local handle to an exact immutable Artifact revision. + 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.contribute, resource: {type: scope, scope-id-from: scope_id}} parameters: diff --git a/src/powercontext/artifacts/__init__.py b/src/powercontext/artifacts/__init__.py index 5a89e0fd5d..9131ede48c 100644 --- a/src/powercontext/artifacts/__init__.py +++ b/src/powercontext/artifacts/__init__.py @@ -20,7 +20,6 @@ ArtifactDraft, ArtifactLineage, ArtifactRef, - MemoryCitation, StableHandle, ) from powercontext.artifacts.protocols import ArtifactCatalog, ArtifactStore @@ -53,6 +52,5 @@ "ArtifactSearcher", "ArtifactStore", "ChannelScore", - "MemoryCitation", "StableHandle", ] diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index 4519b09e43..5f8eefffe0 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -16,11 +16,9 @@ from __future__ import annotations -<<<<<<< HEAD +import re from collections.abc import Mapping from typing import Any, ClassVar, Generic, TypeVar -import re -from typing import ClassVar, Generic, TypeVar from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator diff --git a/src/powercontext/builtin/persistence/artifact_handles.py b/src/powercontext/builtin/persistence/artifact_handles.py index 8c6d61e38f..522988c7cf 100644 --- a/src/powercontext/builtin/persistence/artifact_handles.py +++ b/src/powercontext/builtin/persistence/artifact_handles.py @@ -42,17 +42,7 @@ async def bind(self, scope_id: str, handle: StableHandle, target: ArtifactRef, / existing = await _resolve(connection, scope_id, handle) if existing is not None: return _require_same_binding(scope_id, handle, existing, target) - target_exists = ( - 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() - if target_exists is None: + 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( @@ -63,11 +53,17 @@ async def bind(self, scope_id: str, handle: StableHandle, target: ArtifactRef, / revision=target.revision, ) ) - except IntegrityError: - # A concurrent binder may have won the unique-key race. Resolve the - # durable winner and preserve idempotency only for the same target. - existing = await self.resolve(scope_id, handle) - return _require_same_binding(scope_id, handle, existing, target) + 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: @@ -93,6 +89,20 @@ async def _resolve(connection, scope_id: str, handle: StableHandle) -> ArtifactR 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, diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 30f1eed1c5..50528108d1 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -28,11 +28,8 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncConnection -<<<<<<< HEAD -from powercontext.artifacts import Artifact, ArtifactRef -from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError from powercontext.artifacts import Artifact, ArtifactRef, StableHandle -from powercontext.builtin.artifacts.memory import MemoryCitation, MemoryEntryVersion, MemoryService +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 diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index cb9d80fb8d..04df2883a2 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -22,8 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue -from powercontext.artifacts import ArtifactRef -from powercontext.artifacts import ArtifactRef, MemoryCitation, StableHandle +from powercontext.artifacts import ArtifactRef, StableHandle from powercontext.sources import SourceRef if TYPE_CHECKING: @@ -286,11 +285,10 @@ async def create_artifact( execution_context: Any = None, ) -> ArtifactCreated: ... -<<<<<<< HEAD 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, diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index ca1bcf65cf..0e2ae1646f 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -30,8 +30,7 @@ from pydantic import BaseModel, JsonValue, ValidationError from powercontext._logging import log_safely -from powercontext.artifacts import ArtifactLineage, ArtifactRef -from powercontext.artifacts import ArtifactRef, StableHandle +from powercontext.artifacts import ArtifactLineage, ArtifactRef, StableHandle from powercontext.artifacts.search import ( ArtifactSearchExecutionContext, ArtifactSearchOutcome, diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 4d2598446c..77c196c37f 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3921,7 +3921,64 @@ "put": { "tags": ["artifacts"], "summary": "Bind a stable Artifact handle", - "description": "Idempotently bind one scope-local handle to an exact immutable Artifact revision.", + "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.contribute", diff --git a/tests/builtin/persistence/test_artifact_handles.py b/tests/builtin/persistence/test_artifact_handles.py index 5d57dad2a1..8db5c092fd 100644 --- a/tests/builtin/persistence/test_artifact_handles.py +++ b/tests/builtin/persistence/test_artifact_handles.py @@ -13,9 +13,11 @@ # 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 @@ -24,12 +26,43 @@ 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="memory", artifact_id="memory-1", revision=1) - handle = StableHandle(family="memory", key="payment-retry-policy") + 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( @@ -52,9 +85,9 @@ 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="memory", key="payment-retry-policy") - first = ArtifactRef(family="memory", artifact_id="memory-1", revision=1) - second = ArtifactRef(family="memory", artifact_id="memory-2", revision=1) + 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), @@ -86,8 +119,8 @@ 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="memory", key="missing") - target = ArtifactRef(family="memory", artifact_id="missing", revision=1) + 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) @@ -95,3 +128,22 @@ async def scenario() -> None: 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_records.py b/tests/builtin/persistence/test_records.py index fd738d4cbd..13480b97e2 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -21,12 +21,11 @@ import pytest from pydantic import JsonValue, ValidationError -from sqlalchemy import event, func, select +from sqlalchemy import event, func, insert, select from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.atomic_memory import AtomicMemory, AtomicMemoryContent 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 from powercontext.builtin.artifacts.skill import Skill @@ -545,9 +544,18 @@ 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) - created = await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) - handle = StableHandle(family="memory", key="preferred-language") - target = ArtifactRef(family="memory", artifact_id=created.artifact_id, revision=created.revision) + 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 diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index e406121a3e..4ab37ac708 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -94,7 +94,7 @@ def test_artifact_reference_rejects_invalid_identity( @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="memory", key=key).key == key + assert StableHandle(family="profile", key=key).key == key @pytest.mark.parametrize( @@ -103,13 +103,13 @@ def test_stable_handle_accepts_canonical_keys(key: str) -> None: ) def test_stable_handle_rejects_noncanonical_keys(key: str) -> None: with pytest.raises(ValidationError) as error: - StableHandle(family="memory", key=key) + 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="memory", key="a" * (MAX_ARTIFACT_HANDLE_KEY_LENGTH + 1)) + StableHandle(family="profile", key="a" * (MAX_ARTIFACT_HANDLE_KEY_LENGTH + 1)) assert error.value.errors()[0]["loc"] == ("key",) diff --git a/tests/test_client.py b/tests/test_client.py index e7b8175c65..06e65c2d1a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -111,6 +111,8 @@ def respond(request: httpx.Request) -> httpx.Response: assert result.items == [] 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] = [] @@ -120,9 +122,9 @@ def respond(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "family": "memory", - "key": "preferred-language", - "artifact": {"family": "memory", "artifact_id": "memory-1", "revision": 2}, + "family": "profile", + "key": "support-agent", + "artifact": {"family": "profile", "artifact_id": "profile-1", "revision": 2}, }, ) @@ -130,19 +132,19 @@ def respond(request: httpx.Request) -> httpx.Response: client = PowerContextClient("https://memory.example", http_client=transport) bound = await client.bind_artifact_handle( "scope-a", - "memory", - "preferred-language", - BindArtifactHandleRequest(artifact_id="memory-1", revision=2), + "profile", + "support-agent", + BindArtifactHandleRequest(artifact_id="profile-1", revision=2), ) - resolved = await client.resolve_artifact_handle("scope-a", "memory", "preferred-language") + 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/memory/preferred-language", - "/v1/scopes/scope-a/artifact-handles/memory/preferred-language", + "/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": "memory-1", "revision": 2} + 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 90956a34e0..1da179dc29 100644 --- a/tests/test_scope_api.py +++ b/tests/test_scope_api.py @@ -35,16 +35,16 @@ def test_artifact_handle_bind_and_resolve_public_path(tmp_path) -> None: scope_id = client.get("/v1/scopes/default").json()["scope_id"] created = client.post( f"/v1/scopes/{scope_id}/artifacts", - json={"family": "memory", "content": {"entries": [{"kind": "preference", "text": "Use Chinese"}]}}, + json={"family": "profile", "content": {"content": "# Chinese support agent"}}, ) assert created.status_code == 201 artifact = created.json() - path = f"/v1/scopes/{scope_id}/artifact-handles/memory/preferred-language" + path = f"/v1/scopes/{scope_id}/artifact-handles/profile/support-agent" expected = { - "family": "memory", - "key": "preferred-language", + "family": "profile", + "key": "support-agent", "artifact": { - "family": "memory", + "family": "profile", "artifact_id": artifact["artifact_id"], "revision": artifact["revision"], }, @@ -58,17 +58,24 @@ def test_artifact_handle_bind_and_resolve_public_path(tmp_path) -> None: assert bound.json() == expected assert client.get(path).json() == expected - artifact_path = f"/v1/scopes/{scope_id}/artifacts/memory/{artifact['artifact_id']}" + 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": {"entries": [{"kind": "preference", "text": "Use English"}]}}, + json={"content": {"content": "# English support agent"}}, ) assert revised.status_code == 200 assert revised.json()["revision"] == 2 assert client.get(path).json() == expected - missing = client.get(f"/v1/scopes/{scope_id}/artifact-handles/memory/missing") + 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" From 950936045aa8264a29a9f23dd09343c86737ddde Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Mon, 12 Oct 2026 10:16:29 +0800 Subject: [PATCH 3/7] test: update MySQL migration head revision --- .../test_mysql_migration_runner.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 45fc37ac30..dd833455ff 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -271,10 +271,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 @@ -294,7 +294,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"] @@ -362,7 +362,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" @@ -408,7 +408,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"] == [] @@ -425,7 +425,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",)] @@ -451,7 +451,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"}',)] @@ -460,7 +460,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] @@ -474,12 +474,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 @@ -498,6 +498,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 {}) From 4934adb03417db48f361e89bdcf9449f1cbb9397 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Mon, 12 Oct 2026 12:21:14 +0800 Subject: [PATCH 4/7] fix: require scope admin for stable handle binding --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- openapi/powercontext.yaml | 2 +- .../http/_generated/operations.py | 4 +--- src/powercontext/http/_generated/schema.py | 2 +- tests/e2e/test_access_control_regressions.py | 23 +++++++++++++++++++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 8cc20d9ee2..a3a6e4efd8 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "f25183ff4bb37236f4d6a18bd747f17c0933cc8a668087440dd2b01f716ac2f2", + "contractSha256": "a5a6a246463bcbc280fc22699a80c4946db6054a2876b622c025a5b8e05dc860", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index c42d4a5c28..33b53c2c64 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 = "f25183ff4bb37236f4d6a18bd747f17c0933cc8a668087440dd2b01f716ac2f2"; +export const contractSha256 = "a5a6a246463bcbc280fc22699a80c4946db6054a2876b622c025a5b8e05dc860"; export const operations = { "get_liveness": { "method": "GET", diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 2e65c3f252..461bae99e5 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3875,7 +3875,7 @@ paths: 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.contribute, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-access: {action: scope.admin, resource: {type: scope, scope-id-from: scope_id}} parameters: - name: scope_id in: path diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 99f5db5aa4..eb5b756f8d 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -2840,9 +2840,7 @@ class AccessRequirement(BaseModel): 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, - access=AccessRequirement( - action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" - ), + access=AccessRequirement(action="scope.admin", resource="scope", scope_id_field="scope_id", resolver="request"), ) GET_ARTIFACT = Operation[None, ArtifactRevision]( diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 77c196c37f..8544dc4d25 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -3981,7 +3981,7 @@ "Scope.", "operationId": "bind_artifact_handle", "x-powercontext-access": { - "action": "scope.contribute", + "action": "scope.admin", "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, "parameters": [ diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index e3f2cc3653..88a72a00c9 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -88,6 +88,29 @@ async def _scope(client): return result.json()["scope_id"] +@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): From 5791ca6c30f26a50cc9a9026c144fcf675cec55e Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Tue, 13 Oct 2026 10:46:13 +0800 Subject: [PATCH 5/7] fix(desktop): refresh generated contract hash --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index a3a6e4efd8..2c1726bb6b 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "a5a6a246463bcbc280fc22699a80c4946db6054a2876b622c025a5b8e05dc860", + "contractSha256": "9f22450960449b8dfef41ad01eb09565a7ada3c2336b5a22c4efbfcee7c8deaa", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 33b53c2c64..31fb376415 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 = "a5a6a246463bcbc280fc22699a80c4946db6054a2876b622c025a5b8e05dc860"; +export const contractSha256 = "9f22450960449b8dfef41ad01eb09565a7ada3c2336b5a22c4efbfcee7c8deaa"; export const operations = { "get_liveness": { "method": "GET", From 610987c001fdd243f4262dfbd0df57564f277ff8 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Tue, 13 Oct 2026 11:25:29 +0800 Subject: [PATCH 6/7] fix(zcode): refresh skill pin --- evaluation/zcode_guidance/skill-lock.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/evaluation/zcode_guidance/skill-lock.json b/evaluation/zcode_guidance/skill-lock.json index 44f8f303f5..3554b9cd83 100644 --- a/evaluation/zcode_guidance/skill-lock.json +++ b/evaluation/zcode_guidance/skill-lock.json @@ -1,11 +1,12 @@ { "schema_version": 1, "source_path": "integrations/zcode/plugins/powercontext/skills/powercontext-project-context", - "source_commit": "20583250a6bdce5bab8716321ee3911844b6de2d", + "source_commit": "b2d672d4b9bdbe9bdeb8ae1f69c4c42b69a36104", "files": { - "SKILL.md": "88b6eed1a865b395dedc9508ab6cfcb15f2fc800f13bd9d5df02b963234906a1", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "2863f4ce693408136ede70818e0d7e25d197367e90db14ab6f889488cebc394d", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" + ".powercontext-guidance.json": "655cbbb72bd53c08f9e846e315ed64d3a855eb54767e97580b00b88b237f5358", + "SKILL.md": "391cbb19e5dbc6b6b5e65c02753de702f67cead755bde07ba51efa19d01a4b55", + "references/review-publication.md": "844c3b4796d3b4e961bc943785dbe3c71960424644601b661b155fa9a6f8f3fe", + "references/scope-memory.md": "8d389bc708f68fbaadd0bd363980f970e67acdec2f1a97a622f3a8ccacfbb1b9", + "references/work-handoff.md": "248069bc7f69268ba90de38b2537fe9a385d4a220a35c54603d53e16df10988f" } } From d9e2c560e448fdc939c2fde9e73c120bc91a916e Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Tue, 13 Oct 2026 15:43:52 +0800 Subject: [PATCH 7/7] fix(dsh): expose artifact handle not-found diagnostics --- integrations/dsh/plugins/powercontext/lib/index.js | 1 + integrations/dsh/plugins/powercontext/src/diagnostics.ts | 2 +- integrations/dsh/plugins/powercontext/tests/diagnostics.spec.ts | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 0fa72451d8..def808a60c 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1913,6 +1913,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/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')