{{ t.not_found }}
{{ selected_note.text }}
{% elif data.notes %}{{ t.not_found }}
{{ t.notes_list }}{{ t.notes_no_match if search_query else t.notes_empty_body }}
diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json
index cabc73625d..d402cc9a0d 100644
--- a/desktop/src-tauri/src/transport/operations.json
+++ b/desktop/src-tauri/src/transport/operations.json
@@ -1,5 +1,5 @@
{
- "contractSha256": "9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024",
+ "contractSha256": "0df92a02d48ab6380983b92d03c2cea02444ede7429b96b09865bd94b35d71f0",
"operations": {
"get_liveness": {
"method": "GET",
diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts
index 4bb9f35529..8c7e2f7154 100644
--- a/desktop/ui/src/generated/api.d.ts
+++ b/desktop/ui/src/generated/api.d.ts
@@ -1669,7 +1669,7 @@ export interface paths {
get: operations["get_artifact"];
/**
* Replace the current Artifact head
- * @description Commit a complete next revision when If-Match identifies the current head. Replacing a Prompt requires current scope.admin authority, including switching to Auto and restoring an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation.
+ * @description Commit a complete next revision when If-Match identifies the current head. Replacing a Prompt requires current scope.admin authority, including switching to Auto and restoring an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation. Profile replacement uses the same conditional write. To roll back Profile, Prompt, Experience, Skill, Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted canonical content matches that revision and differs from the current head, then commits a new revision. Memory replacement cannot restore a whole historical manifest.
*/
put: operations["replace_artifact"];
post?: never;
@@ -2064,6 +2064,10 @@ export interface components {
};
ReplaceProfileArtifactRequest: {
content: components["schemas"]["ProfileWriteContent"];
+ /** @description Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set. */
+ restored_from_revision?: number | null;
+ /** @description Required when restored_from_revision or content.restored_from_revision is set. */
+ reason?: string | null;
};
ActivateHandoffRequest: {
scope_id: string;
@@ -2093,6 +2097,11 @@ export interface components {
published_at?: string | null;
/** @description Family-provided number of direct Source inputs when available. */
source_count?: number | null;
+ /** Format: date-time */
+ created_at?: string | null;
+ created_by?: components["schemas"]["AccessPrincipal"] | null;
+ restored_from_revision?: number | null;
+ reason?: string | null;
};
ArtifactCreated: {
scope_id: string;
@@ -2123,6 +2132,17 @@ export interface components {
/** @default [] */
memory_citations: components["schemas"]["MemoryCitation"][];
content_digest: string;
+ /**
+ * Format: date-time
+ * @description When this revision was committed. Absent for revisions stored before this field existed.
+ */
+ created_at?: string | null;
+ /** @description Principal that committed a rollback. Ordinary revisions omit it. */
+ created_by?: components["schemas"]["AccessPrincipal"] | null;
+ /** @description Historical revision restored by this rollback. */
+ restored_from_revision?: number | null;
+ /** @description Reason recorded for a rollback. */
+ reason?: string | null;
};
ArtifactReference: {
family: string;
@@ -3882,6 +3902,8 @@ export interface components {
};
ReplaceTopicMemoryArtifactRequest: {
content: components["schemas"]["TopicMemoryWriteContent"];
+ restored_from_revision?: number | null;
+ reason?: string | null;
};
CreatePromptArtifactRequest: {
/**
@@ -4023,6 +4045,8 @@ export interface components {
ReplaceArtifactRequest: components["schemas"]["ReplaceTopicMemoryArtifactRequest"] | components["schemas"]["ReplaceMemoryArtifactRequest"] | components["schemas"]["ReplaceExperienceArtifactRequest"] | components["schemas"]["ReplaceSkillArtifactRequest"] | components["schemas"]["ReplaceHandoffArtifactRequest"] | components["schemas"]["ReplacePromptArtifactRequest"] | components["schemas"]["ReplaceProfileArtifactRequest"];
ReplacePromptArtifactRequest: {
content: components["schemas"]["PromptContent"];
+ restored_from_revision?: number | null;
+ reason?: string | null;
};
ListArtifactRevisionsRequest: {
/** @default 50 */
@@ -4123,12 +4147,18 @@ export interface components {
};
ReplaceExperienceArtifactRequest: {
content: components["schemas"]["ExperienceProposal"];
+ restored_from_revision?: number | null;
+ reason?: string | null;
};
ReplaceSkillArtifactRequest: {
content: components["schemas"]["SkillProposal"];
+ restored_from_revision?: number | null;
+ reason?: string | null;
};
ReplaceHandoffArtifactRequest: {
content: components["schemas"]["HandoffContent"];
+ restored_from_revision?: number | null;
+ reason?: string | null;
};
SourceRecord: {
/** @description Server-owned identity attestation when this Source contains an enforced-mode Handoff Receipt. */
@@ -7421,7 +7451,7 @@ export interface operations {
};
path: {
scope_id: string;
- family: "memory" | "experience" | "skill" | "handoff" | "prompt" | "topic-memory";
+ family: "memory" | "experience" | "skill" | "handoff" | "prompt" | "topic-memory" | "profile";
artifact_id: string;
};
cookie?: never;
diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts
index 13f7b4a8dc..802bc568a6 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 = "9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024";
+export const contractSha256 = "0df92a02d48ab6380983b92d03c2cea02444ede7429b96b09865bd94b35d71f0";
export const operations = {
"get_liveness": {
"method": "GET",
diff --git a/docs/en/development/dashboard.md b/docs/en/development/dashboard.md
index 1225fdd29f..18c18d9409 100644
--- a/docs/en/development/dashboard.md
+++ b/docs/en/development/dashboard.md
@@ -6,7 +6,7 @@ The Dashboard is a content viewer for personal use and demonstrations, disabled
A user may open the Dashboard to check an agreement, find an experience relevant to a similar problem, or resume interrupted work. The page needs to make the current scope clear, help them find the relevant record, and provide access to source material when they need to check it.
-The core experience for 1.0 is reading saved content. Memories, experiences, skills and handoffs can exist independently. A scope containing only memories should work normally, and an empty scope should still have a clear entry point and page structure. Existing tools and APIs handle generation, review and saving. Reading their results should not require generation configuration.
+The core experience for 1.0 is reading saved content. Existing pages also list revision history, compare two revisions, and roll an exact stored revision forward through the same conditional replace the API already uses. Memory history is read-only because replacing a memory appends entries rather than restoring a snapshot. Memories, experiences, skills and handoffs can exist independently. A scope containing only memories should work normally, and an empty scope should still have a clear entry point and page structure. Existing tools and APIs handle generation, review and saving. Reading their results should not require generation configuration.
Scope names, summaries and saved content supply the business topic. A payment service, customer interviews and personal research can use the same interface without separate navigation for each domain. Before adding a section, explain which task it helps the user complete and whether the available data supports what it claims.
diff --git a/docs/en/rfcs/1468_scope_owned_prompt_management.md b/docs/en/rfcs/1468_scope_owned_prompt_management.md
index a1ec688e7d..1057a53bdb 100644
--- a/docs/en/rfcs/1468_scope_owned_prompt_management.md
+++ b/docs/en/rfcs/1468_scope_owned_prompt_management.md
@@ -267,12 +267,15 @@ Rollback is deliberately not a history rewrite and does not require a third endp
1. read the old exact revision;
2. read the current head and ETag;
-3. replace the current head with the old revision's `content` and the current ETag; and
+3. replace the current head with the old revision's `content`, the current ETag, `restored_from_revision`, and a
+ non-blank `reason`; and
4. receive a new revision whose content digest matches the restored revision.
For example, restoring revision 2 while revision 5 is current creates revision 6. Revisions 2 through 5 remain
-readable. Generic request audit identifies the actor and request; equality with the restored content is visible through
-the content digest. The Server never moves the head pointer backward.
+readable. The new revision stores the source revision, the reason, and the authenticated principal. Every new revision
+stores `created_at`. Equality with the restored content is visible through the content digest. The Server never moves
+the head pointer backward. Omitting both rollback fields remains an ordinary replace. Naming a source without a valid
+reason, naming the current head, or submitting content that does not match that source returns 422.
# Reference-level explanation
@@ -815,7 +818,8 @@ as every update.
- **Draft, review, and activation workflows:** duplicate Candidate/Review concepts and are not needed for the first
administrative vertical slice.
- **A dedicated rollback endpoint:** adds an action whose safe semantics are already expressed by exact read plus
- conditional replacement.
+ conditional replacement. The replace request may name the source revision and a reason; it does not add a route or
+ a permission.
Doing nothing leaves operational prompts fixed at deployment composition and forces customers to fork Runtime code or
run separate deployments for different prompt behavior.
diff --git a/docs/en/rfcs/1485_profile_artifact.md b/docs/en/rfcs/1485_profile_artifact.md
index 847926c1d6..63c2cb9a03 100644
--- a/docs/en/rfcs/1485_profile_artifact.md
+++ b/docs/en/rfcs/1485_profile_artifact.md
@@ -428,17 +428,20 @@ ordinal=0, ETag/If-Match, and lineage rules. Create uses artifact_id=profile and
Automatic commits use the same Family validation and repository but cite actual evidence rather than inventing a
manual system Source.
-Rollback needs no new API: GET a historical Revision, GET the current Head ETag, then PUT the complete Markdown with
-optional `restored_from_revision` in ProfileWriteContent. The server verifies that the historical Revision exists
-and its normalized Markdown matches, then creates a new rollback Revision. It neither moves Head back to an older
-Revision nor rewinds the Cursor. Omitting the field is an ordinary manual Replace; Create forbids it. Clients compare
-two Revisions themselves, with no new diff or history-list endpoint.
+Rollback needs no new API: GET a historical Revision, GET the current Head ETag, then PUT the complete Markdown.
+`restored_from_revision` may appear beside `content` and inside ProfileWriteContent; when both are set they are the
+same integer. A source revision requires a non-blank `reason` of at most 1024 characters. The server verifies that the
+historical Revision exists and its normalized Markdown matches, then creates a new rollback Revision that records the
+source, the reason, and the authenticated principal. It neither moves Head back to an older Revision nor rewinds the
+Cursor. Omitting both source fields is an ordinary manual Replace; Create forbids the source field. Clients compare
+two Revisions themselves. The Dashboard renders that comparison; there is no separate diff endpoint.
## Persistence inventory and example rows
BLOB examples below show decoded JSON. Foreign keys and lineage use complete identities. Timestamps and digests
are readable examples; omitted default columns are still written according to existing definitions. Existing tables
-have no DDL changes; only Profile BLOB schemas and code branches are extended.
+keep their existing constraints. Artifact rows gain nullable `created_at`, `created_by`, `restored_from_revision`, and
+`rollback_reason`. Profile BLOB schemas and code branches are extended as specified below.
```mermaid
erDiagram
diff --git a/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md b/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md
index 56316b634e..c763a29d12 100644
--- a/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md
+++ b/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md
@@ -36,7 +36,7 @@ New Handoff writes use a Scope singleton, while detail links can select historic
- Distinguish absent content, read errors, historical content, and failed downloads.
- Preserve collection return context and recover exact reading destinations after authentication.
- Preserve existing Prompt and Topic Memory routes, layouts, search, and pagination without duplicate entry points.
-- Exclude profile editing, rollback, regeneration, review, and processing policy management.
+- Exclude profile editing, regeneration, review, and processing policy management. History, comparison, and rollback of one exact stored revision stay on the existing page and reuse conditional replace.
- Exclude an all-artifacts browser, cross-Scope aggregation, batch downloads, PDF, public sharing, imports, and a new
machine-readable Agent handoff contract.
@@ -320,8 +320,8 @@ public selection and digest contract would broaden the feature, so use a Dashboa
Allowlisted relative destinations survive refreshes and copied links; browser history and Referer cannot reliably
distinguish normal navigation, direct entry, and authentication. Returning to exact detail after login preserves an
-explicit download action. Profile editing, generation, and rollback require conditional writes, review, and conflict
-recovery and remain available through existing tools and APIs.
+explicit download action. Profile editing, generation, and review remain available through existing tools and APIs.
+Rolling back one stored revision reuses conditional replace on the page that already shows that revision.
# Prior art
@@ -340,6 +340,6 @@ aggregation, Profile editing, and batch reports require separate designs.
# Future possibilities
-Future work can add conditional Profile editing, historical comparisons, and explicitly named latest-Scope report
+Future work can add conditional Profile editing and explicitly named latest-Scope report
downloads. Improvements to existing Prompt and Topic Memory pages should address demonstrated gaps separately while
retaining Scope permissions and exact references.
diff --git a/docs/zh/development/dashboard.md b/docs/zh/development/dashboard.md
index 981e277eca..481450bb04 100644
--- a/docs/zh/development/dashboard.md
+++ b/docs/zh/development/dashboard.md
@@ -6,7 +6,7 @@ Dashboard 是 PowerContext 中供个人使用和演示的内容查看器,默
用户进入 Dashboard 时,可能想确认某项约定,查找解决过类似问题的经验,或继续一项中断的工作。页面需要让用户确定正在查看哪个范围,找到相关记录,并在需要时核对原始材料。
-1.0 的核心是阅读已有内容。记忆、经验、技能和交接可以分别存在:只有记忆的范围也应当能够正常使用;没有内容的范围仍有明确的入口和页面结构。生成、审核和保存由现有工具与接口完成,阅读这些结果不应要求用户先配置生成能力。
+1.0 的核心是阅读已有内容。现有页面同时列出修订历史、比较两个修订,并通过接口已有的条件替换把某一份已保存修订写成新的当前版本。记忆只提供历史和比较,因为替换记忆是追加条目,而不是把整份快照恢复回去。记忆、经验、技能和交接可以分别存在:只有记忆的范围也应当能够正常使用;没有内容的范围仍有明确的入口和页面结构。生成、审核和保存由现有工具与接口完成,阅读这些结果不应要求用户先配置生成能力。
页面的业务主题来自范围名称、摘要和保存的内容。支付服务、客户访谈或个人研究可以使用同一套界面,不需要为每种业务另造导航。新增区块时,应先说明它帮助用户完成哪项工作,以及现有数据能否支持它表达的含义。
diff --git a/docs/zh/rfcs/1468_scope_owned_prompt_management.md b/docs/zh/rfcs/1468_scope_owned_prompt_management.md
index 1dcf2832fb..e01b61d359 100644
--- a/docs/zh/rfcs/1468_scope_owned_prompt_management.md
+++ b/docs/zh/rfcs/1468_scope_owned_prompt_management.md
@@ -262,11 +262,10 @@ GET /v1/scopes/project:payments/artifacts/prompt/memory.extract/revisions/2
1. 读取旧的 exact revision;
2. 读取 current head 与 ETag;
-3. 使用旧 revision 的 `content` 和 current ETag 替换 current head;
+3. 使用旧 revision 的 `content`、current ETag、`restored_from_revision` 和非空 `reason` 替换 current head;
4. 得到一个新 revision,其 content digest 与被恢复 revision 相同。
-例如,revision 5 为 current 时恢复 revision 2,会创建 revision 6。Revision 2 到 5 继续可读。通用 request audit
-标识 actor 与 request;与旧 content 相同可通过 content digest 确认。Server 绝不把 head pointer 向后移动。
+例如,revision 5 为 current 时恢复 revision 2,会创建 revision 6。Revision 2 到 5 继续可读。新 revision 记下源修订、原因和已认证主体。此后每个新 revision 都写入 `created_at`。与旧 content 相同可通过 content digest 确认。Server 绝不把 head pointer 向后移动。两个回滚字段都不提供时仍是普通替换。给出源修订但没有有效原因、源修订就是当前 head,或正文与该源修订不一致,返回 422。
# Reference-level explanation
@@ -770,7 +769,7 @@ conditional replacement 中,可以保留单调历史,并使用与所有更
- **Mutable cross-Scope Prompt reference:** 允许一个 Scope 在没有本地 revision 的情况下改变另一个 Scope 的行为。
- **Prompt DSL 或 variables:** 在出现具体需求前增加 compiler 与 injection 复杂度。
- **Draft、review 与 activation workflow:** 重复 Candidate/Review 概念,且首个 administrative vertical slice 不需要。
-- **专用 rollback endpoint:** exact read 加 conditional replacement 已经表达其安全语义。
+- **专用 rollback endpoint:** exact read 加 conditional replacement 已经表达其安全语义。替换请求可以带上源修订和原因,不新增路由,也不新增权限。
如果不做此设计,operational prompt 仍固定在 deployment composition 阶段,客户只能 fork Runtime code 或运行多个部署
来获得不同 prompt 行为。
diff --git a/docs/zh/rfcs/1485_profile_artifact.md b/docs/zh/rfcs/1485_profile_artifact.md
index f1ee7fdeec..7aca45c868 100644
--- a/docs/zh/rfcs/1485_profile_artifact.md
+++ b/docs/zh/rfcs/1485_profile_artifact.md
@@ -380,15 +380,12 @@ Approve/Reject 失败不部分推进 Cursor;终态重复请求遵循既有 Can
及现有 lineage 规则。Create 固定 artifact_id=profile,已有 Head 返回 409。自动提交使用同一 Family 校验与 repository,
但引用真实 evidence Source,不伪造人工系统 Source。
-回退无需新 API:先 GET 历史 Revision,再 GET 当前 Head 的 ETag,PUT 完整正文并可在 ProfileWriteContent 中带
-`restored_from_revision`。服务端校验历史 Revision 存在且规范化正文相同,写入新的 rollback Revision;不移动 Head 回旧版本,
-不回退 Cursor。未提供该可选字段时视为普通人工 Replace。Create 不允许指定 restored_from_revision。
-历史比较由客户端读取两个 Revision 完成,不新增 diff/history-list 接口。
+回退无需新 API:先 GET 历史 Revision,再 GET 当前 Head 的 ETag,然后 PUT 完整正文。`restored_from_revision` 可以放在 `content` 旁边,也可以放在 ProfileWriteContent 里;两处都给出时必须是同一个整数。给出源修订时必须同时给出非空 `reason`,最长 1024 个字符。服务端校验历史 Revision 存在且规范化正文相同,写入新的 rollback Revision,并记下源修订、原因和已认证主体;不移动 Head 回旧版本,不回退 Cursor。两处源修订都不提供时视为普通人工 Replace。Create 不允许指定 restored_from_revision。历史比较由客户端读取两个 Revision 完成,Dashboard 负责展示,不另设 diff 接口。
## Persistence inventory and example rows
下面示例中的 BLOB 展示解码后的 JSON,外键和 lineage 使用实际完整身份。时间、摘要使用可读示例值;省略的默认列
-在实现中仍按现有表定义写入。现有表均无 DDL 变动,只扩展 Profile 对应的类型化 BLOB schema 和代码分支。
+在实现中仍按现有表定义写入。现有表约束保持不变。Artifact 行增加可空的 `created_at`、`created_by`、`restored_from_revision` 和 `rollback_reason`。Profile 的类型化 BLOB schema 和代码分支按下文扩展。
```mermaid
erDiagram
diff --git a/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md b/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md
index 3af6e7537c..bfc336c045 100644
--- a/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md
+++ b/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md
@@ -25,7 +25,7 @@ Dashboard 已能阅读记忆、经验、技能、交接、提示词和主题记
- 明确空内容、读取错误、历史版本和下载失败的不同状态。
- 完成新阅读流程需要的目录返回状态传递,以及登录后的精确记录恢复。
- 保留现有提示词与主题记忆的路由、页面、搜索和分页行为,不增加重复入口,也不重构其布局。
-- 不实现画像编辑、回滚、重新生成、审核和处理策略管理。
+- 不实现画像编辑、重新生成、审核和处理策略管理。历史、比较,以及把某一份已保存修订写成新版本,留在现有页面上,并复用条件替换。
- 不增加统一所有制品页、跨 Scope 聚合、批量下载、PDF、公开分享、导入或 Agent 交接的新机器契约。
# Guide-level explanation
@@ -240,7 +240,7 @@ Profile 采用独立阅读入口符合其单个完整画像的内容模型,不
返回目录采用白名单站内 URL,能够在刷新和复制链接后恢复位置;依赖 `history.back()` 或 Referer 无法稳定区分正常浏览、直接链接和登录跳转。登录后返回详情并由用户再次下载,可以保留明确的操作对象。
-画像编辑、生成和回滚需要条件写入、审核与冲突恢复,会扩大本次工作范围;只读查看仍可与现有工具及 API 配合完成这些任务。
+画像编辑、生成和审核仍通过现有工具及 API 完成。把某一份已保存修订写成新版本,复用该页已经展示的条件替换。
# Prior art
@@ -256,4 +256,4 @@ Artifact REST API 已提供当前 Head、历史目录、精确 Revision、digest
# Future possibilities
-可后续增加画像条件编辑、历史比较,以及明确命名的“导出最新范围报告”。已有提示词和主题记忆页面的增强应依据实际差距独立提出,继续复用 Scope 权限与精确引用。
+可后续增加画像条件编辑,以及明确命名的“导出最新范围报告”。已有提示词和主题记忆页面的增强应依据实际差距独立提出,继续复用 Scope 权限与精确引用。
diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml
index e4d8deefef..861557a0d2 100644
--- a/openapi/powercontext.yaml
+++ b/openapi/powercontext.yaml
@@ -3664,6 +3664,10 @@ paths:
an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation.
Topic Memory replacement also requires scope.admin and preserves independently versioned tags.
Complete title, summary, and detail text replaces the head without semantic generation.
+ Profile replacement uses the same conditional write. To roll back Profile, Prompt, Experience, Skill,
+ Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted
+ canonical content matches that revision and differs from the current head, then commits a new revision.
+ Memory replacement cannot restore a whole historical manifest.
operationId: replace_artifact
x-powercontext-access: {resolver: path_artifact_write_access}
parameters:
@@ -3680,7 +3684,7 @@ paths:
required: true
schema:
type: string
- enum: [memory, experience, skill, handoff, prompt, topic-memory]
+ enum: [memory, experience, skill, handoff, prompt, topic-memory, profile]
- name: artifact_id
in: path
required: true
@@ -4766,6 +4770,18 @@ components:
properties:
content:
$ref: "#/components/schemas/ProfileWriteContent"
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ description: Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set.
+ reason:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ pattern: '.*\S.*'
+ nullable: true
+ description: Required when restored_from_revision or content.restored_from_revision is set.
ActivateHandoffRequest:
type: object
additionalProperties: false
@@ -4842,6 +4858,22 @@ components:
minimum: 0
nullable: true
description: Family-provided number of direct Source inputs when available.
+ created_at:
+ type: string
+ format: date-time
+ nullable: true
+ created_by:
+ nullable: true
+ allOf:
+ - $ref: "#/components/schemas/AccessPrincipal"
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ reason:
+ type: string
+ maxLength: 1024
+ nullable: true
ArtifactCreated:
type: object
required: [scope_id, family, artifact_id, revision, sources, artifacts]
@@ -4930,6 +4962,26 @@ components:
content_digest:
type: string
pattern: '^sha256:[0-9a-f]{64}$'
+ created_at:
+ type: string
+ format: date-time
+ nullable: true
+ description: When this revision was committed. Absent for revisions stored before this field existed.
+ created_by:
+ nullable: true
+ allOf:
+ - $ref: "#/components/schemas/AccessPrincipal"
+ description: Principal that committed a rollback. Ordinary revisions omit it.
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ description: Historical revision restored by this rollback.
+ reason:
+ type: string
+ maxLength: 1024
+ nullable: true
+ description: Reason recorded for a rollback.
ArtifactReference:
type: object
additionalProperties: false
@@ -9682,6 +9734,8 @@ components:
required: [content]
properties:
content: {$ref: '#/components/schemas/TopicMemoryWriteContent'}
+ restored_from_revision: {type: integer, minimum: 1, nullable: true}
+ reason: {type: string, minLength: 1, maxLength: 1024, pattern: '.*\S.*', nullable: true}
CreatePromptArtifactRequest:
type: object
additionalProperties: false
@@ -9948,6 +10002,16 @@ components:
properties:
content:
$ref: "#/components/schemas/PromptContent"
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ reason:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ pattern: '.*\S.*'
+ nullable: true
ListArtifactRevisionsRequest:
type: object
additionalProperties: false
@@ -10213,6 +10277,16 @@ components:
properties:
content:
$ref: "#/components/schemas/ExperienceProposal"
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ reason:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ pattern: '.*\S.*'
+ nullable: true
ReplaceSkillArtifactRequest:
type: object
additionalProperties: false
@@ -10220,6 +10294,16 @@ components:
properties:
content:
$ref: "#/components/schemas/SkillProposal"
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ reason:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ pattern: '.*\S.*'
+ nullable: true
ReplaceHandoffArtifactRequest:
type: object
additionalProperties: false
@@ -10227,6 +10311,16 @@ components:
properties:
content:
$ref: "#/components/schemas/HandoffContent"
+ restored_from_revision:
+ type: integer
+ minimum: 1
+ nullable: true
+ reason:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ pattern: '.*\S.*'
+ nullable: true
SourceRecord:
type: object
required: [scope_id, source_type, source_id, content, position, content_digest]
diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py
index 5c018d765a..75c0dd47a2 100644
--- a/src/powercontext/builtin/persistence/artifacts.py
+++ b/src/powercontext/builtin/persistence/artifacts.py
@@ -17,6 +17,7 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
+from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
@@ -40,6 +41,11 @@
InvalidRepositoryArgumentError,
RepositoryNotFoundError,
)
+from powercontext.builtin.persistence.revision_meta import (
+ attach_revision_meta,
+ meta_from_row,
+ rollback_columns,
+)
from powercontext.builtin.persistence.tables import (
ARTIFACT_HEADS_TABLE,
ARTIFACT_LINEAGE_ARTIFACTS_TABLE,
@@ -380,6 +386,8 @@ async def _insert_revision(
if not isinstance(content, BaseModel):
raise TypeError("artifact content must be a BaseModel") # noqa: TRY003
payload = dump_model(content, kind="artifact", name=artifact_type.family)
+ created_at = datetime.now(UTC)
+ stamped = rollback_columns(ref.family, ref.artifact_id, ref.revision)
await connection.execute(
insert(ARTIFACTS_TABLE).values(
scope_id=scope_id,
@@ -388,6 +396,8 @@ async def _insert_revision(
revision=ref.revision,
content=payload,
memory_citations=dump_memory_citations(lineage.memory_citations),
+ created_at=created_at,
+ **stamped,
)
)
if lineage.sources:
@@ -429,6 +439,7 @@ async def _insert_revision(
content=content,
lineage=lineage,
)
+ attach_revision_meta(artifact, meta_from_row({"created_at": created_at, **stamped}))
if artifact.as_ref() != ref:
raise IdentityMismatchError("artifact", ref, artifact.as_ref())
return artifact
@@ -472,6 +483,7 @@ def _decode_artifact(
content=content,
lineage=lineage,
)
+ attach_revision_meta(artifact, meta_from_row(row))
if artifact.as_ref() != ref:
raise IdentityMismatchError("artifact", ref, artifact.as_ref())
return artifact
diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py
index 8d5bc5c918..7d0d66599e 100644
--- a/src/powercontext/builtin/persistence/family_management.py
+++ b/src/powercontext/builtin/persistence/family_management.py
@@ -343,6 +343,15 @@ async def _canonical_content(
)
return canonical, package
+ async def canonical_content(
+ self,
+ connection: AsyncConnection,
+ scope_id: str,
+ content: BaseModel,
+ ) -> SkillContent:
+ canonical, _package = await self._canonical_content(connection, scope_id, content)
+ return canonical
+
async def create(
self,
connection: AsyncConnection,
diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py
index cea4b54cda..0767895902 100644
--- a/src/powercontext/builtin/persistence/records.py
+++ b/src/powercontext/builtin/persistence/records.py
@@ -20,7 +20,7 @@
from contextlib import aclosing
from datetime import UTC, datetime
from hashlib import sha256
-from typing import Any, cast
+from typing import Any, Literal, cast
from uuid import uuid4
import rfc8785
@@ -43,6 +43,18 @@
)
from powercontext.builtin.persistence.memory import RelationalMemoryBackend
from powercontext.builtin.persistence.processing import ArtifactProcessingPendingRepository
+from powercontext.builtin.persistence.revision_meta import (
+ PendingRollback,
+ bind_rollback,
+ reset_rollback,
+ revision_meta,
+)
+from powercontext.builtin.persistence.rollback import (
+ merged_source_revision,
+ normalize_reason,
+ prepare_profile_command,
+ require_matching_source,
+)
from powercontext.builtin.persistence.sources import SourceRepository, StoredSource
from powercontext.builtin.persistence.tables import (
ARTIFACT_HEADS_TABLE,
@@ -58,6 +70,7 @@
ArtifactListReader,
ArtifactRecord,
ArtifactRecordPage,
+ ArtifactRevisionActor,
ArtifactRevisionPage,
ArtifactRevisionPreconditionError,
ArtifactWrite,
@@ -568,11 +581,17 @@ async def replace_artifact(
expected_etag: str,
write: ArtifactWrite,
/,
+ *,
+ actor_type: str | None = None,
+ actor_id: str | None = None,
) -> ArtifactRecord:
if write.prompt_key is not None:
raise InvalidBaseAccessRequestError("prompt_key", "is not accepted for replacement")
writer = self._family_writers.get(family)
command = writer.validate_replace(write.content)
+ source_revision = merged_source_revision(family, write, command)
+ command = prepare_profile_command(command, source_revision)
+ reason = None if source_revision is None or write.reason is None else normalize_reason(write.reason)
prepared = command
if isinstance(writer, PreparingFamilyManagementWriter):
current_record = await self.get_artifact(scope_id, family, artifact_id)
@@ -611,7 +630,26 @@ async def replace_artifact(
)
try:
stored = await self._sources.add(connection, scope_id, source)
- revised = await writer.replace(connection, scope_id, current, prepared, stored.ref)
+ token = await _bind_rollback(
+ self._artifacts,
+ connection,
+ scope_id,
+ family,
+ artifact_id,
+ current,
+ command,
+ source_revision,
+ reason,
+ writer,
+ next_revision,
+ actor_type,
+ actor_id,
+ )
+ try:
+ revised = await writer.replace(connection, scope_id, current, prepared, stored.ref)
+ finally:
+ if token is not None:
+ reset_rollback(token)
except StoredPayloadConflictError as error:
raise BaseValueConflictError("source", (scope_id, CONTENT_SOURCE_NAME, source.name)) from error
except RevisionConflictError:
@@ -730,6 +768,7 @@ def _artifact_record(
artifacts=artifact.lineage.artifacts,
memory_citations=artifact.lineage.memory_citations,
content_digest=_content_digest(content),
+ **_revision_fields(artifact),
)
@@ -754,9 +793,67 @@ def _artifact_collection_item(scope_id: str, artifact: Artifact[Any]) -> Artifac
sources=artifact.lineage.sources,
artifacts=artifact.lineage.artifacts,
content_digest=_content_digest(content),
+ **_revision_fields(artifact),
+ )
+
+
+async def _bind_rollback(
+ artifacts: ArtifactRepository,
+ connection: AsyncConnection,
+ scope_id: str,
+ family: str,
+ artifact_id: str,
+ current: Artifact[Any],
+ command: Any,
+ source_revision: int | None,
+ reason: str | None,
+ writer: object,
+ next_revision: int,
+ actor_type: str | None,
+ actor_id: str | None,
+) -> Any:
+ if source_revision is None or reason is None:
+ return None
+ await require_matching_source(
+ artifacts,
+ connection,
+ scope_id,
+ family,
+ artifact_id,
+ current,
+ command,
+ source_revision,
+ writer,
+ )
+ return bind_rollback(
+ PendingRollback(
+ family=family,
+ artifact_id=artifact_id,
+ revision=next_revision,
+ restored_from_revision=source_revision,
+ reason=reason,
+ created_by_type=actor_type,
+ created_by_id=actor_id,
+ )
)
+def _revision_fields(artifact: Artifact[Any]) -> dict[str, Any]:
+ meta = revision_meta(artifact)
+ actor = None
+ if meta.created_by_type in {"user", "service"} and meta.created_by_id is not None:
+ actor = ArtifactRevisionActor(
+ type=cast(Literal["user", "service"], meta.created_by_type),
+ id=meta.created_by_id,
+ )
+ return {
+ "created_at": meta.created_at,
+ "created_by": actor,
+ "restored_from_revision": meta.restored_from_revision,
+ "reason": meta.reason,
+ }
+
+
def _artifact_etag(revision: int) -> str:
return f'"revision:{revision}"'
diff --git a/src/powercontext/builtin/persistence/revision_meta.py b/src/powercontext/builtin/persistence/revision_meta.py
new file mode 100644
index 0000000000..46479b0dd9
--- /dev/null
+++ b/src/powercontext/builtin/persistence/revision_meta.py
@@ -0,0 +1,161 @@
+# Copyright (c) 2026 OceanBase.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Immutable revision metadata stored beside artifact content."""
+
+from __future__ import annotations
+
+import json
+from contextvars import ContextVar, Token
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy import inspect
+from sqlalchemy.exc import DBAPIError
+from sqlalchemy.ext.asyncio import AsyncConnection
+from sqlalchemy.schema import CreateColumn
+
+from powercontext.artifacts import Artifact
+from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE
+from powercontext.limits import MAX_ARTIFACT_REVISION_ACTOR_LENGTH
+
+_ROLLBACK_STAMP: ContextVar[PendingRollback | None] = ContextVar("powercontext_rollback_stamp", default=None)
+
+
+@dataclass(frozen=True, slots=True)
+class RevisionMeta:
+ """Read-back metadata for one stored revision. Missing history stays empty."""
+
+ created_at: datetime | None = None
+ created_by_type: str | None = None
+ created_by_id: str | None = None
+ restored_from_revision: int | None = None
+ reason: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class PendingRollback:
+ """Rollback identity applied only to the matching inserted revision."""
+
+ family: str
+ artifact_id: str
+ revision: int
+ restored_from_revision: int
+ reason: str
+ created_by_type: str | None
+ created_by_id: str | None
+
+
+def bind_rollback(stamp: PendingRollback) -> Token[PendingRollback | None]:
+ return _ROLLBACK_STAMP.set(stamp)
+
+
+def reset_rollback(token: Token[PendingRollback | None]) -> None:
+ _ROLLBACK_STAMP.reset(token)
+
+
+def revision_meta(artifact: Artifact[Any]) -> RevisionMeta:
+ meta = getattr(artifact, "_revision_meta", None)
+ return meta if isinstance(meta, RevisionMeta) else RevisionMeta()
+
+
+def attach_revision_meta(artifact: Artifact[Any], meta: RevisionMeta) -> None:
+ object.__setattr__(artifact, "_revision_meta", meta)
+
+
+def rollback_columns(family: str, artifact_id: str, revision: int) -> dict[str, Any]:
+ """Return rollback columns when this insert is the bound rollback revision."""
+
+ stamp = _ROLLBACK_STAMP.get()
+ if stamp is None or stamp.family != family or stamp.artifact_id != artifact_id or stamp.revision != revision:
+ return {}
+ created_by = None
+ if stamp.created_by_type is not None and stamp.created_by_id is not None:
+ created_by = json.dumps(
+ {"type": stamp.created_by_type, "id": stamp.created_by_id},
+ ensure_ascii=False,
+ separators=(",", ":"),
+ )
+ return {
+ "created_by": created_by,
+ "restored_from_revision": stamp.restored_from_revision,
+ "rollback_reason": stamp.reason,
+ }
+
+
+def meta_from_row(row: dict[str, Any] | Any) -> RevisionMeta:
+ created_by_type = None
+ created_by_id = None
+ raw_actor = row.get("created_by") if hasattr(row, "get") else None
+ if isinstance(raw_actor, str) and raw_actor:
+ try:
+ actor = json.loads(raw_actor)
+ except json.JSONDecodeError:
+ actor = None
+ if isinstance(actor, dict) and actor.get("type") in {"user", "service"} and isinstance(actor.get("id"), str):
+ created_by_type = actor["type"]
+ created_by_id = actor["id"]
+ restored = row.get("restored_from_revision") if hasattr(row, "get") else None
+ reason = row.get("rollback_reason") if hasattr(row, "get") else None
+ return RevisionMeta(
+ created_at=_utc(row.get("created_at") if hasattr(row, "get") else None),
+ created_by_type=created_by_type,
+ created_by_id=created_by_id,
+ restored_from_revision=int(restored) if isinstance(restored, int) else None,
+ reason=reason if isinstance(reason, str) else None,
+ )
+
+
+def _utc(value: object) -> datetime | None:
+ if value is None:
+ return None
+ parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
+ if parsed.tzinfo is None:
+ return parsed.replace(tzinfo=UTC)
+ return parsed.astimezone(UTC)
+
+
+async def ensure_revision_metadata_schema(connection: AsyncConnection, /) -> None:
+ """Add revision metadata columns to databases created before this contract."""
+
+ for name in ("created_at", "created_by", "restored_from_revision", "rollback_reason"):
+ if await _has_column(connection, name):
+ continue
+ declaration = str(CreateColumn(ARTIFACTS_TABLE.c[name]).compile(dialect=connection.dialect))
+ try:
+ await connection.exec_driver_sql(f"ALTER TABLE {ARTIFACTS_TABLE.name} ADD COLUMN {declaration}")
+ except DBAPIError:
+ if not await _has_column(connection, name):
+ raise
+ await _widen_created_by(connection)
+
+
+async def _widen_created_by(connection: AsyncConnection) -> None:
+ """Grow an older created_by column so a maximum-length principal id can be stored."""
+
+ if connection.dialect.name == "sqlite":
+ return
+ columns = await connection.run_sync(lambda sync: inspect(sync).get_columns(ARTIFACTS_TABLE.name))
+ column = next((item for item in columns if item["name"] == "created_by"), None)
+ length = None if column is None else getattr(column["type"], "length", None)
+ if not isinstance(length, int) or length >= MAX_ARTIFACT_REVISION_ACTOR_LENGTH:
+ return
+ declaration = str(CreateColumn(ARTIFACTS_TABLE.c.created_by).compile(dialect=connection.dialect))
+ await connection.exec_driver_sql(f"ALTER TABLE {ARTIFACTS_TABLE.name} MODIFY COLUMN {declaration}")
+
+
+async def _has_column(connection: AsyncConnection, column_name: str) -> bool:
+ columns = await connection.run_sync(lambda sync: inspect(sync).get_columns(ARTIFACTS_TABLE.name))
+ return any(column["name"] == column_name for column in columns)
diff --git a/src/powercontext/builtin/persistence/rollback.py b/src/powercontext/builtin/persistence/rollback.py
new file mode 100644
index 0000000000..45e7c89f32
--- /dev/null
+++ b/src/powercontext/builtin/persistence/rollback.py
@@ -0,0 +1,133 @@
+# 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.
+
+"""Validate a conditional replace that restores one historical revision."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from powercontext.artifacts import Artifact, ArtifactRef
+from powercontext.builtin.artifacts.profile.models import ProfileContent, ProfileWriteContent
+from powercontext.builtin.artifacts.skill.models import SkillContent
+from powercontext.builtin.artifacts.skill.package import build_instruction_skill_package
+from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent, TopicMemoryProjection
+from powercontext.builtin.persistence.artifacts import ArtifactRepository
+from powercontext.builtin.persistence.errors import RepositoryNotFoundError
+from powercontext.builtin.persistence.family_management import SkillManagementWriter
+from powercontext.builtin.records import ArtifactWrite, InvalidBaseAccessRequestError
+
+ROLLBACK_FAMILIES = frozenset({"profile", "prompt", "experience", "skill", "handoff", "topic-memory"})
+_REASON_MAX = 1024
+
+
+def merged_source_revision(family: str, write: ArtifactWrite, command: BaseModel) -> int | None:
+ """Resolve the rollback source from the request and, for Profile, its content field."""
+
+ requested = write.restored_from_revision
+ embedded = command.restored_from_revision if isinstance(command, ProfileWriteContent) else None
+ if requested is not None and embedded is not None and requested != embedded:
+ raise InvalidBaseAccessRequestError(
+ "restored_from_revision",
+ "does not match content.restored_from_revision",
+ )
+ source = requested if requested is not None else embedded
+ if family == "memory" and (source is not None or write.reason is not None):
+ raise InvalidBaseAccessRequestError("restored_from_revision", "is not supported for memory")
+ if family not in ROLLBACK_FAMILIES and (source is not None or write.reason is not None):
+ raise InvalidBaseAccessRequestError("restored_from_revision", "is not supported")
+ if source is None and write.reason is None:
+ return None
+ if source is None or write.reason is None:
+ raise InvalidBaseAccessRequestError("reason", "is required when restoring a revision")
+ normalize_reason(write.reason)
+ return source
+
+
+def normalize_reason(value: str) -> str:
+ if len(value) > _REASON_MAX:
+ raise InvalidBaseAccessRequestError("reason", "must be at most 1024 characters")
+ stripped = value.strip()
+ if not stripped:
+ raise InvalidBaseAccessRequestError("reason", "must not be blank")
+ return stripped
+
+
+def prepare_profile_command(command: BaseModel, source: int | None) -> BaseModel:
+ if not isinstance(command, ProfileWriteContent):
+ return command
+ if command.restored_from_revision == source:
+ return command
+ return command.model_copy(update={"restored_from_revision": source})
+
+
+async def require_matching_source(
+ artifacts: ArtifactRepository,
+ connection: AsyncConnection,
+ scope_id: str,
+ family: str,
+ artifact_id: str,
+ current: Artifact[Any],
+ command: BaseModel,
+ source_revision: int,
+ writer: object,
+) -> None:
+ if source_revision == current.revision:
+ raise InvalidBaseAccessRequestError("restored_from_revision", "is the current head")
+ try:
+ historical = await artifacts.get(
+ connection,
+ scope_id,
+ ArtifactRef(family=family, artifact_id=artifact_id, revision=source_revision),
+ )
+ except RepositoryNotFoundError:
+ raise InvalidBaseAccessRequestError("restored_from_revision", "does not exist") from None
+ submitted = await _submitted_body(writer, connection, scope_id, family, command)
+ if submitted != _stored_body(family, historical.content):
+ raise InvalidBaseAccessRequestError("content", "does not match the restored revision")
+ if submitted == _stored_body(family, current.content):
+ raise InvalidBaseAccessRequestError("content", "matches the current head")
+
+
+async def _submitted_body(
+ writer: object,
+ connection: AsyncConnection,
+ scope_id: str,
+ family: str,
+ command: BaseModel,
+) -> object:
+ if family == "skill" and isinstance(command, SkillContent) and isinstance(writer, SkillManagementWriter):
+ if command.package is None:
+ command = build_instruction_skill_package(command).as_skill_content()
+ else:
+ command = await writer.canonical_content(connection, scope_id, command)
+ return _stored_body(family, command)
+
+
+def _stored_body(family: str, content: BaseModel) -> object:
+ if family == "profile":
+ if isinstance(content, ProfileContent | ProfileWriteContent):
+ return content.content
+ raise InvalidBaseAccessRequestError("content", "does not match the profile model")
+ if family == "topic-memory":
+ topic = content.content if isinstance(content, TopicMemoryProjection) else content
+ if not isinstance(topic, TopicMemoryContent):
+ raise InvalidBaseAccessRequestError("content", "does not match the topic memory model")
+ return (topic.title, topic.summary, topic.detail)
+ if family == "handoff":
+ return content.model_dump(mode="json", by_alias=True, exclude={"generation"})
+ return content.model_dump(mode="json", by_alias=True)
diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py
index e38d0d099f..b50cd87b40 100644
--- a/src/powercontext/builtin/persistence/tables.py
+++ b/src/powercontext/builtin/persistence/tables.py
@@ -36,6 +36,7 @@
from powercontext.limits import (
MAX_ARTIFACT_FAMILY_LENGTH,
MAX_ARTIFACT_ID_LENGTH,
+ MAX_ARTIFACT_REVISION_ACTOR_LENGTH,
MAX_BINDING_NAME_LENGTH,
MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH,
MAX_EXTERNAL_SKILL_HOST_ID_LENGTH,
@@ -195,6 +196,10 @@ def _entry_text_type():
Column("revision", Integer, primary_key=True),
Column("content", _canonical_payload_type(), nullable=False),
Column("memory_citations", _canonical_payload_type(), nullable=True),
+ Column("created_at", DateTime(timezone=True), nullable=True),
+ Column("created_by", String(MAX_ARTIFACT_REVISION_ACTOR_LENGTH), nullable=True),
+ Column("restored_from_revision", Integer, nullable=True),
+ Column("rollback_reason", String(1024), nullable=True),
)
ARTIFACT_HEADS_TABLE = Table(
diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py
index c021a5581a..95f7816cc7 100644
--- a/src/powercontext/builtin/records.py
+++ b/src/powercontext/builtin/records.py
@@ -57,11 +57,20 @@ class SourceRecordPage(_RecordModel):
next_cursor: str | None
+class ArtifactRevisionActor(_RecordModel):
+ """Principal that created a rollback revision. Ordinary revisions omit it."""
+
+ type: Literal["user", "service"]
+ id: str
+
+
class ArtifactWrite(_RecordModel):
"""Complete family-specific content for one Artifact write."""
content: dict[str, JsonValue]
prompt_key: str | None = None
+ restored_from_revision: int | None = None
+ reason: str | None = None
class ArtifactCreated(_RecordModel):
@@ -87,6 +96,10 @@ class ArtifactRecord(_RecordModel):
artifacts: tuple[ArtifactRef, ...]
memory_citations: tuple[MemoryCitation, ...] = ()
content_digest: str
+ created_at: datetime | None = None
+ created_by: ArtifactRevisionActor | None = None
+ restored_from_revision: int | None = None
+ reason: str | None = None
class ArtifactCollectionItem(_RecordModel):
@@ -103,6 +116,10 @@ class ArtifactCollectionItem(_RecordModel):
summary: str | None = None
published_at: datetime | None = None
source_count: int | None = None
+ created_at: datetime | None = None
+ created_by: ArtifactRevisionActor | None = None
+ restored_from_revision: int | None = None
+ reason: str | None = None
class LogicalArtifactRecord(_RecordModel):
@@ -338,6 +355,9 @@ async def replace_artifact(
expected_etag: str,
write: ArtifactWrite,
/,
+ *,
+ actor_type: str | None = None,
+ actor_id: str | None = None,
) -> ArtifactRecord: ...
async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: ...
diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py
index 8fc129623a..eebabba7c9 100644
--- a/src/powercontext/builtin/runtime/application.py
+++ b/src/powercontext/builtin/runtime/application.py
@@ -650,6 +650,9 @@ async def replace_artifact(
expected_etag: str,
write: ArtifactWrite,
/,
+ *,
+ actor_type: str | None = None,
+ actor_id: str | None = None,
) -> ArtifactRecord:
async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id):
return await self._runtime._records().replace_artifact(
@@ -658,6 +661,8 @@ async def replace_artifact(
artifact_id,
expected_etag,
write,
+ actor_type=actor_type,
+ actor_id=actor_id,
)
diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py
index 4f6ee5f992..1e366a9640 100644
--- a/src/powercontext/builtin/runtime/composition.py
+++ b/src/powercontext/builtin/runtime/composition.py
@@ -96,6 +96,7 @@
assert_processing_schema_ready,
bootstrap_processing_schema,
)
+from powercontext.builtin.persistence.revision_meta import ensure_revision_metadata_schema
from powercontext.builtin.persistence.scope_search_schema import ensure_scope_search_schema
from powercontext.builtin.persistence.seekdb.profile import SeekDBConfig, SeekDBProfile
from powercontext.builtin.persistence.skill_distribution_schema import ensure_skill_distribution_schema
@@ -924,6 +925,7 @@ async def open_builtin_contexts(
await ensure_skill_distribution_schema(connection)
await ensure_topic_memory_tag_schema(connection)
await ensure_dream_schema(connection)
+ await ensure_revision_metadata_schema(connection)
await ensure_scope_search_schema(connection)
# A Topic child reuses its parent's schema. It never reads or
# writes Memory/Experience projections; rebuilding their FTS
@@ -1005,6 +1007,7 @@ async def open_builtin_contexts(
await ensure_skill_distribution_schema(connection)
await ensure_topic_memory_tag_schema(connection)
await ensure_dream_schema(connection)
+ await ensure_revision_metadata_schema(connection)
await ensure_scope_search_schema(connection)
if not _topic_memory_worker:
await index.initialize(connection)
diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py
index 485ad65115..adcb05dccf 100644
--- a/src/powercontext/http/_generated/models.py
+++ b/src/powercontext/http/_generated/models.py
@@ -131,6 +131,22 @@ class ReplaceProfileArtifactRequest(BaseModel):
extra="forbid",
)
content: ProfileWriteContent
+ restored_from_revision: Annotated[
+ StrictInt | None,
+ Field(
+ description="Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set.",
+ ge=1,
+ ),
+ ] = None
+ reason: Annotated[
+ StrictStr | None,
+ Field(
+ description="Required when restored_from_revision or content.restored_from_revision is set.",
+ max_length=1024,
+ min_length=1,
+ pattern=".*\\S.*",
+ ),
+ ] = None
class ArtifactReference(BaseModel):
@@ -2003,6 +2019,8 @@ class ReplaceTopicMemoryArtifactRequest(BaseModel):
extra="forbid",
)
content: TopicMemoryWriteContent
+ restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None
+ reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None
class Family2(StrEnum):
@@ -3010,6 +3028,10 @@ class ArtifactCollectionItem(BaseModel):
source_count: Annotated[
StrictInt | None, Field(description="Family-provided number of direct Source inputs when available.", ge=0)
] = None
+ created_at: AwareDatetime | None = None
+ created_by: AccessPrincipal | None = None
+ restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None
+ reason: Annotated[StrictStr | None, Field(max_length=1024)] = None
class ArtifactCreated(BaseModel):
@@ -3044,6 +3066,17 @@ class ArtifactRevision(BaseModel):
artifacts: list[ArtifactReference]
memory_citations: Annotated[list[MemoryCitation], Field(validate_default=True)] = []
content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")]
+ created_at: Annotated[
+ AwareDatetime | None,
+ Field(description="When this revision was committed. Absent for revisions stored before this field existed."),
+ ] = None
+ created_by: Annotated[
+ AccessPrincipal | None, Field(description="Principal that committed a rollback. Ordinary revisions omit it.")
+ ] = None
+ restored_from_revision: Annotated[
+ StrictInt | None, Field(description="Historical revision restored by this rollback.", ge=1)
+ ] = None
+ reason: Annotated[StrictStr | None, Field(description="Reason recorded for a rollback.", max_length=1024)] = None
class HandoffReceiptIdentity(BaseModel):
@@ -3818,6 +3851,8 @@ class ReplaceExperienceArtifactRequest(BaseModel):
extra="forbid",
)
content: ExperienceProposal
+ restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None
+ reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None
class ReplaceSkillArtifactRequest(BaseModel):
@@ -3825,6 +3860,8 @@ class ReplaceSkillArtifactRequest(BaseModel):
extra="forbid",
)
content: SkillProposal
+ restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None
+ reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None
class SourceRecord(BaseModel):
@@ -4156,6 +4193,8 @@ class ReplacePromptArtifactRequest(BaseModel):
extra="forbid",
)
content: PromptContent
+ restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None
+ reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None
class ReplaceMemoryArtifactRequest(BaseModel):
@@ -4336,6 +4375,8 @@ class ReplaceHandoffArtifactRequest(BaseModel):
extra="forbid",
)
content: HandoffContent
+ restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None
+ reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None
class PreparedWorkHandoff(BaseModel):
diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py
index 342808089c..1e51c4196c 100644
--- a/src/powercontext/http/_generated/schema.py
+++ b/src/powercontext/http/_generated/schema.py
@@ -3770,7 +3770,59 @@
"head "
"without "
"semantic "
- "generation.",
+ "generation. "
+ "Profile "
+ "replacement "
+ "uses "
+ "the "
+ "same "
+ "conditional "
+ "write. "
+ "To "
+ "roll "
+ "back "
+ "Profile, "
+ "Prompt, "
+ "Experience, "
+ "Skill, "
+ "Handoff, "
+ "or "
+ "Topic "
+ "Memory, "
+ "set "
+ "restored_from_revision "
+ "and "
+ "reason. "
+ "The "
+ "server "
+ "checks "
+ "that "
+ "the "
+ "submitted "
+ "canonical "
+ "content "
+ "matches "
+ "that "
+ "revision "
+ "and "
+ "differs "
+ "from "
+ "the "
+ "current "
+ "head, "
+ "then "
+ "commits "
+ "a "
+ "new "
+ "revision. "
+ "Memory "
+ "replacement "
+ "cannot "
+ "restore "
+ "a "
+ "whole "
+ "historical "
+ "manifest.",
"operationId": "replace_artifact",
"x-powercontext-access": {"resolver": "path_artifact_write_access"},
"parameters": [
@@ -3786,7 +3838,7 @@
"required": True,
"schema": {
"type": "string",
- "enum": ["memory", "experience", "skill", "handoff", "prompt", "topic-memory"],
+ "enum": ["memory", "experience", "skill", "handoff", "prompt", "topic-memory", "profile"],
},
},
{
@@ -4832,7 +4884,36 @@
"required": ["family", "content"],
},
"ReplaceProfileArtifactRequest": {
- "properties": {"content": {"$ref": "#/components/schemas/ProfileWriteContent"}},
+ "properties": {
+ "content": {"$ref": "#/components/schemas/ProfileWriteContent"},
+ "restored_from_revision": {
+ "type": "integer",
+ "minimum": 1.0,
+ "description": "Historical "
+ "Profile "
+ "revision "
+ "restored "
+ "by "
+ "this "
+ "replacement. "
+ "Must "
+ "match "
+ "content.restored_from_revision "
+ "when "
+ "both "
+ "are "
+ "set.",
+ "nullable": True,
+ },
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "minLength": 1,
+ "pattern": ".*\\S.*",
+ "description": "Required when restored_from_revision or content.restored_from_revision is set.",
+ "nullable": True,
+ },
+ },
"additionalProperties": False,
"type": "object",
"required": ["content"],
@@ -4885,6 +4966,10 @@
"description": "Family-provided number of direct Source inputs when available.",
"nullable": True,
},
+ "created_at": {"type": "string", "format": "date-time", "nullable": True},
+ "created_by": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True},
+ "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True},
+ "reason": {"type": "string", "maxLength": 1024, "nullable": True},
},
"type": "object",
"required": ["scope_id", "family", "artifact_id", "revision", "sources", "artifacts", "content_digest"],
@@ -4933,6 +5018,41 @@
"default": [],
},
"content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When "
+ "this "
+ "revision "
+ "was "
+ "committed. "
+ "Absent "
+ "for "
+ "revisions "
+ "stored "
+ "before "
+ "this "
+ "field "
+ "existed.",
+ "nullable": True,
+ },
+ "created_by": {
+ "allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}],
+ "description": "Principal that committed a rollback. Ordinary revisions omit it.",
+ "nullable": True,
+ },
+ "restored_from_revision": {
+ "type": "integer",
+ "minimum": 1.0,
+ "description": "Historical revision restored by this rollback.",
+ "nullable": True,
+ },
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "description": "Reason recorded for a rollback.",
+ "nullable": True,
+ },
},
"type": "object",
"required": [
@@ -8898,7 +9018,17 @@
"required": ["family", "content"],
},
"ReplaceTopicMemoryArtifactRequest": {
- "properties": {"content": {"$ref": "#/components/schemas/TopicMemoryWriteContent"}},
+ "properties": {
+ "content": {"$ref": "#/components/schemas/TopicMemoryWriteContent"},
+ "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True},
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "minLength": 1,
+ "pattern": ".*\\S.*",
+ "nullable": True,
+ },
+ },
"additionalProperties": False,
"type": "object",
"required": ["content"],
@@ -9266,7 +9396,17 @@
]
},
"ReplacePromptArtifactRequest": {
- "properties": {"content": {"$ref": "#/components/schemas/PromptContent"}},
+ "properties": {
+ "content": {"$ref": "#/components/schemas/PromptContent"},
+ "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True},
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "minLength": 1,
+ "pattern": ".*\\S.*",
+ "nullable": True,
+ },
+ },
"additionalProperties": False,
"type": "object",
"required": ["content"],
@@ -9536,19 +9676,49 @@
"required": ["kind", "text"],
},
"ReplaceExperienceArtifactRequest": {
- "properties": {"content": {"$ref": "#/components/schemas/ExperienceProposal"}},
+ "properties": {
+ "content": {"$ref": "#/components/schemas/ExperienceProposal"},
+ "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True},
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "minLength": 1,
+ "pattern": ".*\\S.*",
+ "nullable": True,
+ },
+ },
"additionalProperties": False,
"type": "object",
"required": ["content"],
},
"ReplaceSkillArtifactRequest": {
- "properties": {"content": {"$ref": "#/components/schemas/SkillProposal"}},
+ "properties": {
+ "content": {"$ref": "#/components/schemas/SkillProposal"},
+ "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True},
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "minLength": 1,
+ "pattern": ".*\\S.*",
+ "nullable": True,
+ },
+ },
"additionalProperties": False,
"type": "object",
"required": ["content"],
},
"ReplaceHandoffArtifactRequest": {
- "properties": {"content": {"$ref": "#/components/schemas/HandoffContent"}},
+ "properties": {
+ "content": {"$ref": "#/components/schemas/HandoffContent"},
+ "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True},
+ "reason": {
+ "type": "string",
+ "maxLength": 1024,
+ "minLength": 1,
+ "pattern": ".*\\S.*",
+ "nullable": True,
+ },
+ },
"additionalProperties": False,
"type": "object",
"required": ["content"],
diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py
index 90d8984ea5..787f32212d 100644
--- a/src/powercontext/limits.py
+++ b/src/powercontext/limits.py
@@ -28,6 +28,7 @@
MAX_SOURCE_OBSERVATION_BYTES = 4 * 1024 * 1024
MAX_ARTIFACT_FAMILY_LENGTH = 128
MAX_ARTIFACT_ID_LENGTH = 128
+MAX_ARTIFACT_REVISION_ACTOR_LENGTH = 2_048
MAX_BINDING_NAME_LENGTH = 128
MAX_EXTERNAL_SKILL_NAME_LENGTH = 128
MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH = 2_000
diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py
index 23288c35c7..fd08277b7b 100644
--- a/src/powercontext/server/app.py
+++ b/src/powercontext/server/app.py
@@ -164,6 +164,7 @@
)
from powercontext.builtin.records import (
ArtifactAlreadyExistsError,
+ ArtifactRevisionActor,
ArtifactRevisionPreconditionError,
BaseAccessError,
BaseValueConflictError,
@@ -909,6 +910,9 @@ async def replace_artifact(
expected_etag: str,
write: RuntimeArtifactWrite,
/,
+ *,
+ actor_type: str | None = None,
+ actor_id: str | None = None,
) -> RuntimeArtifactRecord: ...
@@ -2705,11 +2709,14 @@ async def replace_artifact(
if family is BaseArtifactFamily.MEMORY:
current = await application.records.for_scope(scope_id).get_artifact(family.value, artifact_id)
previous_memory_entries = _memory_manifest_entry_ids(current)
+ principal = current_principal()
result = await application.records.for_scope(scope_id).replace_artifact(
family.value,
artifact_id,
expected_etag,
_artifact_write(request),
+ actor_type=None if principal is None else principal.type,
+ actor_id=None if principal is None else principal.id,
)
await _establish_new_memory_entry_owners(
http_request,
@@ -2747,6 +2754,12 @@ def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord:
)
+def _transport_actor(actor: ArtifactRevisionActor | None) -> TransportAccessPrincipal | None:
+ if actor is None:
+ return None
+ return TransportAccessPrincipal(type=actor.type, id=actor.id)
+
+
def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevision:
return ArtifactRevision(
scope_id=value.scope_id,
@@ -2758,6 +2771,10 @@ def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevisio
artifacts=[mapping.artifact_reference(ref) for ref in value.artifacts],
memory_citations=[mapping.transport_citation(ref) for ref in value.memory_citations],
content_digest=value.content_digest,
+ created_at=value.created_at,
+ created_by=_transport_actor(value.created_by),
+ restored_from_revision=value.restored_from_revision,
+ reason=value.reason,
)
@@ -2785,6 +2802,10 @@ def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) ->
summary=value.summary,
published_at=value.published_at,
source_count=value.source_count,
+ created_at=value.created_at,
+ created_by=_transport_actor(value.created_by),
+ restored_from_revision=value.restored_from_revision,
+ reason=value.reason,
)
@@ -2794,6 +2815,8 @@ def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> Ru
content = mapping.runtime_handoff_content(content)
return RuntimeArtifactWrite(
prompt_key=value.root.prompt_key.value if isinstance(value.root, CreatePromptArtifactRequest) else None,
+ restored_from_revision=getattr(value.root, "restored_from_revision", None),
+ reason=getattr(value.root, "reason", None),
content=cast(
dict[str, JsonValue],
content.model_dump(
diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py
index 74c0331ce6..f8a5c39ac2 100644
--- a/src/powercontext/server/dashboard/api.py
+++ b/src/powercontext/server/dashboard/api.py
@@ -64,11 +64,7 @@ def __init__(self, request: Request) -> None:
)
async def read(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
- try:
- async with asyncio.timeout(20):
- result = await self.client.request("GET" if payload is None else "POST", path, json=payload)
- except (TimeoutError, httpx.HTTPError) as error:
- raise ReadError(503, "service_unavailable") from error
+ result = await self.send("GET" if payload is None else "POST", path, payload)
if result.is_error:
code = "service_unavailable"
with suppress(ValueError, KeyError, TypeError):
@@ -76,6 +72,19 @@ async def read(self, path: str, payload: dict[str, Any] | None = None) -> dict[s
raise ReadError(result.status_code, code, result.headers.get("X-PowerContext-Request-ID"))
return result.json()
+ async def send(
+ self,
+ method: str,
+ path: str,
+ payload: dict[str, Any] | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> httpx.Response:
+ try:
+ async with asyncio.timeout(20):
+ return await self.client.request(method, path, json=payload, headers=headers)
+ except (TimeoutError, httpx.HTTPError) as error:
+ raise ReadError(503, "service_unavailable") from error
+
async def record(self, scope: str, family: str, artifact: str, revision: int) -> dict[str, Any]:
if family in {"experience", "skill"}:
value = await self.read(
diff --git a/src/powercontext/server/dashboard/content.py b/src/powercontext/server/dashboard/content.py
index 20d679a187..70abe85d4d 100644
--- a/src/powercontext/server/dashboard/content.py
+++ b/src/powercontext/server/dashboard/content.py
@@ -25,6 +25,7 @@
from powercontext.server.dashboard.navigation import positive_revision
from powercontext.server.dashboard.pagination import PAGE_SIZE, cursor_links, list_links, list_page
from powercontext.server.dashboard.presenters import memory_view, usage_view
+from powercontext.server.dashboard.revisions import load_history
RECORDS = {"handoff-detail": "handoff", "experience": "experience", "skill": "skill"}
@@ -68,7 +69,9 @@ async def load_notes(api: DashboardAPI, ctx: dict[str, Any]) -> None:
ctx["search_limited"] = len(result["hits"]) == 50
ctx["search_used_mode"] = result["mode"]
else:
- ctx["data"]["notes"] = memory_view(await api.read("/v1/memory/entries/list", {"scope_id": ctx["scope"]}))
+ result = await api.read("/v1/memory/entries/list", {"scope_id": ctx["scope"]})
+ ctx["data"]["notes"] = memory_view(result)
+ ctx["memory_binding"] = result.get("memory")
except ReadError as error:
ctx["errors"]["notes"] = error
@@ -107,6 +110,11 @@ async def select_note(api: DashboardAPI, request: Request, ctx: dict[str, Any])
raise ReadError(422, "invalid_request") from error
elif current:
citation = current["citation"]
+ elif ctx.get("historical_notes"):
+ # The note was added after the selected revision, so show that revision's first note instead.
+ if ctx["data"]["notes"]:
+ ctx["selected_note"] = ctx["data"]["notes"][0]
+ return
else:
raise ReadError(404, "not_found")
entry = await api.read("/v1/memory/entries/get", {"scope_id": scope, "citation": citation})
@@ -132,6 +140,15 @@ async def load_record(api: DashboardAPI, request: Request, ctx: dict[str, Any])
ctx["data"][family] = record
ctx["source_record"] = record
ctx["related_sources"] = [source for source in record["sources"] if source["source_type"] == "content"]
+ ctx["history"] = await load_history(
+ api,
+ request,
+ ctx,
+ family=family,
+ artifact_id=artifact,
+ selected=revision_number,
+ cursor_key=f"{family}_revision_cursor",
+ )
async def load_content(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None:
@@ -144,24 +161,18 @@ async def load_content(api: DashboardAPI, request: Request, ctx: dict[str, Any])
)
elif page == "notes":
await load_notes(api, ctx)
- window = list_page(
- ctx["data"]["notes"], request.query_params.get("notes_page"), request.query_params.get("entry")
- )
+ await _show_historical_notes(api, request, ctx)
+ notes_page = request.query_params.get("notes_page")
+ if ctx.get("historical_notes"):
+ notes_page = _notes_page_in_range(ctx["data"]["notes"], notes_page)
+ window = list_page(ctx["data"]["notes"], notes_page, request.query_params.get("entry"))
ctx["data"]["notes"] = window["items"]
- if ctx["search_query"]:
- entries = await asyncio.gather(
- *(
- api.read("/v1/memory/entries/get", {"scope_id": ctx["scope"], "citation": hit["citation"]})
- for hit in window["items"]
- )
- )
- ctx["data"]["notes"] = [
- {**entry, "matched_by": hit["matched_by"], "score": hit["score"]}
- for entry, hit in zip(memory_view({"entries": entries}), window["items"], strict=True)
- ]
+ if ctx.get("historical_notes") or ctx["search_query"]:
+ ctx["data"]["notes"] = await _resolve_note_texts(api, ctx["scope"], ctx["data"]["notes"])
ctx["notes_pager"] = list_links(ctx, "notes", window)
ctx["notes_page_size"] = PAGE_SIZE
await select_note(api, request, ctx)
+ await _load_memory_history(api, request, ctx)
elif page == "handoff":
await load_collection(api, request, ctx, "handoff")
elif page == "methods":
@@ -179,7 +190,7 @@ async def load_additional_page(api: DashboardAPI, request: Request, ctx: dict[st
elif page == "profile":
await load_profile(api, request, ctx)
elif page == "prompts":
- await load_prompts(api, ctx)
+ await load_prompts(api, request, ctx)
elif page in RECORDS:
await load_record(api, request, ctx)
@@ -219,9 +230,19 @@ async def load_topics(api: DashboardAPI, request: Request, ctx: dict[str, Any])
ctx["errors"]["topic_memory_selected"] = ReadError(422, "invalid_request")
except ReadError as error:
ctx["errors"]["topic_memory_selected"] = error
+ if ctx["topic_artifact"] and ctx["topic_revision"] and ctx["topic_revision"].isdigit():
+ ctx["history"] = await load_history(
+ api,
+ request,
+ ctx,
+ family="topic-memory",
+ artifact_id=ctx["topic_artifact"],
+ selected=int(ctx["topic_revision"]),
+ cursor_key="topic_revision_cursor",
+ )
-async def load_prompts(api: DashboardAPI, ctx: dict[str, Any]) -> None:
+async def load_prompts(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None:
"""Load the scoped Prompt configurations exposed by the Prompt Dashboard."""
keys = (
"memory.extract",
@@ -237,13 +258,160 @@ async def load_prompts(api: DashboardAPI, ctx: dict[str, Any]) -> None:
ctx["data"]["prompts"].append(value)
except ReadError as error:
ctx["errors"].setdefault("prompts", error)
+ selected_key = request.query_params.get("prompt_key")
+ if selected_key:
+ try:
+ configuration = await api.prompt_configuration(ctx["scope"], selected_key)
+ except ReadError:
+ configuration = None
+ artifact = None if configuration is None else configuration.get("artifact")
+ if artifact and str(artifact.get("revision", "")).isdigit():
+ selected = request.query_params.get("prompt_revision") or str(artifact["revision"])
+ if str(selected).isdigit():
+ ctx["prompt_key"] = selected_key
+ ctx["history"] = await load_history(
+ api,
+ request,
+ ctx,
+ family="prompt",
+ artifact_id=selected_key,
+ selected=int(selected),
+ cursor_key="prompt_revision_cursor",
+ )
+ try:
+ ctx["prompt_opened"] = await api.artifact_revision(
+ ctx["scope"], "prompt", selected_key, int(selected)
+ )
+ except ReadError:
+ ctx["prompt_opened"] = None
+
+
+async def _show_historical_notes(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None:
+ """Replace the current note list when an older Memory revision is open."""
+
+ displayed = _displayed_memory(ctx)
+ raw = request.query_params.get("memory_history")
+ if displayed is None or raw is None or not raw.isdigit():
+ return
+ artifact_id, head_revision = displayed
+ revision = int(raw)
+ if head_revision is None or revision == head_revision:
+ return
+ try:
+ opened = await api.artifact_revision(ctx["scope"], "memory", artifact_id, revision)
+ except ReadError:
+ ctx["data"]["notes"] = []
+ ctx["historical_notes"] = True
+ ctx["memory_revision_missing"] = True
+ return
+ ctx["data"]["notes"] = _manifest_notes(opened["content"], artifact_id, revision)
+ ctx["historical_notes"] = True
+
+
+def _notes_page_in_range(items: list[dict[str, Any]], raw: str | None) -> str | None:
+ """Drop a page number that belongs to a longer note list."""
+
+ if raw is None or not raw.isdigit() or raw.startswith("0"):
+ return raw
+ last = max(1, (len(items) + PAGE_SIZE - 1) // PAGE_SIZE)
+ return None if int(raw) > last else raw
+
+
+def _manifest_notes(content: dict[str, Any], artifact_id: str, revision: int) -> list[dict[str, Any]]:
+ manifest = content.get("manifest") if isinstance(content, dict) else None
+ entries = manifest.get("entries") if isinstance(manifest, dict) else None
+ notes = []
+ for item in entries or []:
+ if not isinstance(item, dict) or item.get("state", "active") != "active":
+ continue
+ entry_id = item.get("entry_id")
+ version_id = item.get("entry_version_id")
+ if not isinstance(entry_id, str) or not isinstance(version_id, str):
+ continue
+ notes.append({
+ "entry_id": entry_id,
+ "entry_version_id": version_id,
+ "text": "",
+ "citation": {
+ "memory_ref": {"family": "memory", "artifact_id": artifact_id, "revision": revision},
+ "entry_id": entry_id,
+ "entry_version_id": version_id,
+ },
+ })
+ return notes
+
+
+async def _resolve_note_texts(api: DashboardAPI, scope: str, notes: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ if not notes:
+ return notes
+ entries = await asyncio.gather(
+ *(api.read("/v1/memory/entries/get", {"scope_id": scope, "citation": note["citation"]}) for note in notes)
+ )
+ resolved = memory_view({"entries": entries})
+ # Search hits carry their ranking evidence; the exact entry read does not.
+ return [
+ {**entry, **{key: note[key] for key in ("matched_by", "score") if key in note}}
+ for entry, note in zip(resolved, notes, strict=True)
+ ]
+
+
+def _displayed_memory(ctx: dict[str, Any]) -> tuple[str, int | None] | None:
+ """Return the Memory whose notes are on screen, with its current head revision when known."""
+
+ note = ctx.get("selected_note")
+ note_ref = note.get("memory_ref") if isinstance(note, dict) else None
+ binding = ctx.get("memory_binding")
+ artifact_id = None
+ if isinstance(note_ref, dict) and note_ref.get("artifact_id"):
+ artifact_id = str(note_ref["artifact_id"])
+ elif isinstance(binding, dict) and binding.get("artifact_id"):
+ artifact_id = str(binding["artifact_id"])
+ if artifact_id is None:
+ return None
+ revision = binding.get("revision") if isinstance(binding, dict) else None
+ if isinstance(binding, dict) and binding.get("artifact_id") == artifact_id and isinstance(revision, int):
+ return artifact_id, revision
+ return artifact_id, None
+
+
+async def _load_memory_history(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None:
+ displayed = _displayed_memory(ctx)
+ if displayed is None:
+ return
+ artifact_id, head_revision = displayed
+ if head_revision is None:
+ try:
+ head = await api.read(f"/v1/scopes/{segment(ctx['scope'])}/artifacts/memory/{segment(artifact_id)}")
+ except ReadError:
+ return
+ head_revision = int(head["revision"])
+ ctx["memory_artifact"] = artifact_id
+ selected = request.query_params.get("memory_history") or str(head_revision)
+ if not str(selected).isdigit():
+ return
+ ctx["history"] = await load_history(
+ api,
+ request,
+ ctx,
+ family="memory",
+ artifact_id=artifact_id,
+ selected=int(selected),
+ cursor_key="memory_history_cursor",
+ )
+ if ctx["history"] is None:
+ return
+ try:
+ opened = await api.artifact_revision(ctx["scope"], "memory", artifact_id, int(selected))
+ ctx["memory_changes"] = opened["content"].get("changes", [])
+ except ReadError:
+ ctx["memory_changes"] = []
async def load_profile(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None:
query = request.query_params
revision = query.get("revision")
view = query.get("view")
- if view is not None and (view != "history" or revision is not None):
+ if view is not None and view != "history":
raise ReadError(422, "invalid_request")
scope = ctx["scope"]
base = f"/v1/scopes/{segment(scope)}/artifacts/profile/profile"
@@ -256,16 +424,24 @@ async def load_profile(api: DashboardAPI, request: Request, ctx: dict[str, Any])
if not heads["items"]:
return
selected_revision = str(heads["items"][0]["revision"])
+ params = {"limit": str(PAGE_SIZE)}
+ if view == "history" and "profile_cursor" in query:
+ params["cursor"] = query["profile_cursor"]
+ result = await api.read(base + "/revisions?" + urlencode(params))
+ ctx["profile_revisions"] = result["items"]
if view == "history":
- params = {"limit": str(PAGE_SIZE)}
- if "profile_cursor" in query:
- params["cursor"] = query["profile_cursor"]
- result = await api.read(base + "/revisions?" + urlencode(params))
- ctx["profile_revisions"] = result["items"]
ctx["profile_pager"] = cursor_links(request, ctx, "profile", result["next_cursor"])
- return
record = await api.record(scope, "profile", "profile", positive_revision(selected_revision))
ctx["data"]["profile"] = record
ctx["profile_html"] = profile_html(record["content"])
ctx["source_record"] = record
ctx["related_sources"] = [source for source in record["sources"] if source["source_type"] == "content"]
+ ctx["history"] = await load_history(
+ api,
+ request,
+ ctx,
+ family="profile",
+ artifact_id="profile",
+ selected=positive_revision(selected_revision),
+ cursor_key="profile_cursor" if view == "history" else "profile_revision_cursor",
+ )
diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json
index 0faa59eac0..db1424130d 100644
--- a/src/powercontext/server/dashboard/labels.en.json
+++ b/src/powercontext/server/dashboard/labels.en.json
@@ -235,6 +235,26 @@
"profile_current": "Return to current profile",
"profile_historical": "Historical profile",
"profile_history_empty": "No historical revisions",
+ "revision_history": "Revision history",
+ "revision_current": "Current",
+ "revision_rollback_badge": "Rollback",
+ "revision_unknown_time": "Time unknown",
+ "revision_compare": "Compare",
+ "revision_compare_missing": "That revision could not be compared.",
+ "revision_compare_truncated": "Only some of the changed notes are compared.",
+ "revision_other": "Other revision",
+ "revision_rollback": "Roll back",
+ "revision_reason": "Reason",
+ "revision_confirm": "Confirm rollback",
+ "revision_actor": "Actor",
+ "revision_from": "Restored from",
+ "revision_conflict": "The current revision changed. Open it again before retrying.",
+ "revision_rejected": "This rollback was not accepted.",
+ "revision_forbidden": "You cannot roll back this artifact.",
+ "handoff_rollback_notice": "This creates a new revision. It does not mean the previous next action still applies.",
+ "revision_same": "The content matches the current revision, so rollback is unavailable.",
+ "memory_changes": "Changes in this revision",
+ "revision_empty": "No revisions yet",
"profile_generation_mode": "Generation mode",
"profile_created_at": "Generated at",
"profile_source_window": "Source window",
diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json
index 805953d587..0ca15b2207 100644
--- a/src/powercontext/server/dashboard/labels.json
+++ b/src/powercontext/server/dashboard/labels.json
@@ -235,6 +235,26 @@
"profile_current": "返回当前画像",
"profile_historical": "历史画像",
"profile_history_empty": "暂无历史版本",
+ "revision_history": "版本历史",
+ "revision_current": "当前版本",
+ "revision_rollback_badge": "回滚产生",
+ "revision_unknown_time": "时间未知",
+ "revision_compare": "对比",
+ "revision_compare_missing": "找不到要对比的版本。",
+ "revision_compare_truncated": "变化的笔记太多,这里只对比了其中一部分。",
+ "revision_other": "另一版本",
+ "revision_rollback": "回滚",
+ "revision_reason": "原因",
+ "revision_confirm": "确认回滚",
+ "revision_actor": "操作者",
+ "revision_from": "来源版本",
+ "revision_conflict": "当前版本已经变化,请重新打开后再试。",
+ "revision_rejected": "这次回滚没有被接受。",
+ "revision_forbidden": "没有回滚权限。",
+ "handoff_rollback_notice": "这会成为一条新的版本,不表示当时的下一步仍然适用。",
+ "revision_same": "正文与当前版本相同,不能回滚。",
+ "memory_changes": "这一版的变更",
+ "revision_empty": "暂无历史版本",
"profile_generation_mode": "生成方式",
"profile_created_at": "生成时间",
"profile_source_window": "来源窗口",
diff --git a/src/powercontext/server/dashboard/revisions.py b/src/powercontext/server/dashboard/revisions.py
new file mode 100644
index 0000000000..3d08f0737d
--- /dev/null
+++ b/src/powercontext/server/dashboard/revisions.py
@@ -0,0 +1,359 @@
+# 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.
+
+"""Revision history, comparison, and rollback for the Dashboard."""
+
+from __future__ import annotations
+
+import asyncio
+import difflib
+import json
+from typing import Any
+from urllib.parse import urlencode
+
+from fastapi import Request
+
+from powercontext.server.dashboard.api import DashboardAPI, ReadError, segment
+from powercontext.server.dashboard.pagination import PAGE_SIZE
+
+ROLLBACK_FAMILIES = frozenset({"profile", "prompt", "experience", "skill", "handoff", "topic-memory"})
+_MEMORY_COMPARE_LIMIT = 32
+_SKILL_FIELDS = (
+ "name",
+ "description",
+ "instructions",
+ "validation",
+ "package",
+ "license",
+ "compatibility",
+ "metadata",
+ "allowed_tools",
+)
+_EXPERIENCE_FIELDS = ("situation", "action", "outcome", "lesson", "failure")
+_HANDOFF_FIELDS = ("objective", "state", "disposition", "next_action", "omissions")
+_TEXT_FIELDS = {"instructions", "detail", "objective", "situation", "action", "outcome", "lesson", "content"}
+
+
+def canonical_view(family: str, content: dict[str, Any]) -> object:
+ """Return the family content used to decide whether a rollback would change the head."""
+
+ if family == "profile":
+ return content.get("content")
+ if family == "topic-memory":
+ return (content.get("title"), content.get("summary"), content.get("detail"))
+ if family == "prompt":
+ return (content.get("instructions"), content.get("demonstrations"))
+ if family == "experience":
+ return {name: content.get(name) for name in _EXPERIENCE_FIELDS}
+ if family == "skill":
+ return {name: content.get(name) for name in _SKILL_FIELDS}
+ if family == "handoff":
+ return {name: content.get(name) for name in _HANDOFF_FIELDS}
+ if family == "memory":
+ return content.get("changes")
+ return content
+
+
+def replace_body(family: str, content: dict[str, Any], source_revision: int, reason: str) -> dict[str, Any]:
+ """Build a conditional replace that restores one stored revision."""
+
+ if family == "profile":
+ payload: dict[str, Any] = {"content": content.get("content")}
+ elif family == "topic-memory":
+ payload = {name: content[name] for name in ("title", "summary", "detail")}
+ elif family == "handoff":
+ payload = _handoff_payload(content)
+ elif family == "experience":
+ payload = {name: content.get(name) for name in _EXPERIENCE_FIELDS if name in content}
+ elif family == "skill":
+ required = {"name", "description", "instructions", "validation"}
+ payload = {name: content.get(name) for name in _SKILL_FIELDS if name in content or name in required}
+ else:
+ payload = content
+ return {"content": payload, "restored_from_revision": source_revision, "reason": reason}
+
+
+def _handoff_payload(content: dict[str, Any]) -> dict[str, Any]:
+ """Return handoff content in the replace contract.
+
+ Stored revisions cite sources with ``source_type``. The replace contract names that field ``name``.
+ """
+
+ payload = {
+ "objective": content.get("objective"),
+ "state": [_handoff_statement(item) for item in content.get("state") or []],
+ "disposition": content.get("disposition"),
+ "next_action": None if content.get("next_action") is None else _handoff_statement(content["next_action"]),
+ "omissions": content.get("omissions") or [],
+ }
+ if "schema" in content:
+ payload["schema"] = content["schema"]
+ return payload
+
+
+def _handoff_statement(statement: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "text": statement.get("text"),
+ "citations": [_handoff_citation(item) for item in statement.get("citations") or []],
+ }
+
+
+def _handoff_citation(citation: dict[str, Any]) -> dict[str, Any]:
+ kind = citation.get("kind")
+ if kind == "source":
+ ref = citation.get("source_ref") or {}
+ return {
+ "kind": "source",
+ "source_ref": {"name": ref.get("name") or ref.get("source_type"), "source_id": ref.get("source_id")},
+ }
+ if kind == "artifact":
+ ref = citation.get("artifact_ref") or {}
+ return {
+ "kind": "artifact",
+ "artifact_ref": {
+ "family": ref.get("family"),
+ "artifact_id": ref.get("artifact_id"),
+ "revision": ref.get("revision"),
+ },
+ }
+ if kind == "memory":
+ memory = citation.get("memory_citation") or {}
+ memory_ref = memory.get("memory_ref") or {}
+ return {
+ "kind": "memory",
+ "memory_citation": {
+ "memory_ref": {
+ "family": memory_ref.get("family"),
+ "artifact_id": memory_ref.get("artifact_id"),
+ "revision": memory_ref.get("revision"),
+ },
+ "entry_id": memory.get("entry_id"),
+ "entry_version_id": memory.get("entry_version_id"),
+ },
+ }
+ return citation
+
+
+def _active_manifest(content: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ manifest = content.get("manifest") if isinstance(content, dict) else None
+ entries = manifest.get("entries") if isinstance(manifest, dict) else None
+ selected = {}
+ for item in entries or []:
+ if not isinstance(item, dict) or item.get("state", "active") != "active":
+ continue
+ entry_id = item.get("entry_id")
+ version_id = item.get("entry_version_id")
+ if isinstance(entry_id, str) and isinstance(version_id, str):
+ selected[entry_id] = item
+ return selected
+
+
+async def _entry_text(
+ api: DashboardAPI, scope: str, artifact_id: str, revision: int, item: dict[str, Any] | None
+) -> str:
+ if item is None:
+ return ""
+ entry = await api.read(
+ "/v1/memory/entries/get",
+ {
+ "scope_id": scope,
+ "citation": {
+ "memory_ref": {"family": "memory", "artifact_id": artifact_id, "revision": revision},
+ "entry_id": item["entry_id"],
+ "entry_version_id": item["entry_version_id"],
+ },
+ },
+ )
+ text = entry.get("text")
+ return text if isinstance(text, str) else ""
+
+
+async def _memory_text_rows(
+ api: DashboardAPI,
+ scope: str,
+ artifact_id: str,
+ left_revision: int,
+ left: dict[str, Any],
+ right_revision: int,
+ right: dict[str, Any],
+) -> tuple[list[dict[str, Any]], bool]:
+ """Diff note text for entries whose exact version differs between two Memory revisions."""
+
+ left_entries = _active_manifest(left)
+ right_entries = _active_manifest(right)
+ changed = [
+ entry_id
+ for entry_id in sorted(set(left_entries) | set(right_entries))
+ if _version(left_entries, entry_id) != _version(right_entries, entry_id)
+ ]
+ if not changed:
+ return [_text_row("content", "", "")], False
+ shown = changed[:_MEMORY_COMPARE_LIMIT]
+ texts = await asyncio.gather(
+ *(
+ asyncio.gather(
+ _entry_text(api, scope, artifact_id, left_revision, left_entries.get(entry_id)),
+ _entry_text(api, scope, artifact_id, right_revision, right_entries.get(entry_id)),
+ )
+ for entry_id in shown
+ )
+ )
+ rows = [
+ _text_row(entry_id, left_text, right_text)
+ for entry_id, (left_text, right_text) in zip(shown, texts, strict=True)
+ ]
+ return rows, len(changed) > _MEMORY_COMPARE_LIMIT
+
+
+def _version(entries: dict[str, dict[str, Any]], entry_id: str) -> str | None:
+ item = entries.get(entry_id)
+ version = None if item is None else item.get("entry_version_id")
+ return version if isinstance(version, str) else None
+
+
+def comparison(family: str, left: dict[str, Any], right: dict[str, Any]) -> list[dict[str, Any]]:
+ """Readable rows for two stored content objects. The left side is the opened revision."""
+
+ if family == "profile":
+ return [_text_row("content", left.get("content"), right.get("content"))]
+ if family == "topic-memory":
+ return [_text_row(name, left.get(name), right.get(name)) for name in ("title", "summary", "detail")]
+ if family == "prompt":
+ return [
+ _text_row("instructions", left.get("instructions"), right.get("instructions")),
+ _text_row("demonstrations", left.get("demonstrations"), right.get("demonstrations")),
+ ]
+ if family == "experience":
+ return [_text_row(name, left.get(name), right.get(name)) for name in _EXPERIENCE_FIELDS]
+ if family == "skill":
+ return [_text_row(name, left.get(name), right.get(name)) for name in _SKILL_FIELDS]
+ if family == "handoff":
+ return [_text_row(name, left.get(name), right.get(name)) for name in _HANDOFF_FIELDS]
+ return [_text_row("content", left, right)]
+
+
+async def load_history(
+ api: DashboardAPI,
+ request: Request,
+ ctx: dict[str, Any],
+ *,
+ family: str,
+ artifact_id: str,
+ selected: int,
+ cursor_key: str,
+) -> dict[str, Any] | None:
+ """Load one artifact's revision list and optional comparison."""
+
+ scope = ctx["scope"]
+ base = f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/{segment(artifact_id)}"
+ params = {"limit": str(PAGE_SIZE)}
+ if request.query_params.get(cursor_key):
+ params["cursor"] = request.query_params[cursor_key]
+ try:
+ page = await api.read(base + "/revisions?" + urlencode(params))
+ head = await api.read(base)
+ except ReadError:
+ return None
+ items = page["items"]
+ head_revision = int(head["revision"])
+ compare_raw = request.query_params.get("compare")
+ compare = _revision_param(compare_raw)
+ rows = None
+ compare_error = False
+ compare_truncated = False
+ if compare is not None and compare != selected:
+ try:
+ left = await api.artifact_revision(scope, family, artifact_id, selected)
+ right = await api.artifact_revision(scope, family, artifact_id, compare)
+ if family == "memory":
+ rows, compare_truncated = await _memory_text_rows(
+ api, scope, artifact_id, selected, left["content"], compare, right["content"]
+ )
+ else:
+ rows = comparison(family, left["content"], right["content"])
+ except ReadError:
+ rows = None
+ compare_error = True
+ same_content = False
+ if family in ROLLBACK_FAMILIES and selected != head_revision:
+ try:
+ opened = await api.artifact_revision(scope, family, artifact_id, selected)
+ same_content = canonical_view(family, opened["content"]) == canonical_view(family, head["content"])
+ except ReadError:
+ same_content = False
+ return {
+ "family": family,
+ "artifact_id": artifact_id,
+ "head": head_revision,
+ "selected": selected,
+ "items": items,
+ "pager_key": cursor_key,
+ "next_cursor": page.get("next_cursor"),
+ "next_url": (
+ ctx["link"](**{cursor_key: page["next_cursor"]})
+ if page.get("next_cursor") and ctx.get("link") is not None
+ else None
+ ),
+ "can_rollback": family in ROLLBACK_FAMILIES and await _can_replace(request, api, scope, family, artifact_id),
+ "same_content": same_content,
+ "compare": compare,
+ "compare_error": compare_error,
+ "compare_truncated": compare_truncated,
+ "rows": rows,
+ "error": request.query_params.get("rollback_error"),
+ }
+
+
+def _revision_param(value: str | None) -> int | None:
+ if value is None or not value.isdigit() or value.startswith("0"):
+ return None
+ number = int(value)
+ return number if number >= 1 else None
+
+
+async def _can_replace(request: Request, api: DashboardAPI, scope: str, family: str, artifact_id: str) -> bool:
+ # Disabled mode has no access check. Treating that 503 as a denial hides rollback
+ # from the local deployment that is otherwise allowed to write.
+ if request.app.state.access_mode == "disabled":
+ return True
+ if family in {"prompt", "topic-memory"}:
+ requirement = {"action": "scope.admin", "resource": {"type": "scope", "scope_id": scope}}
+ else:
+ requirement = {
+ "action": "artifact.write",
+ "resource": {
+ "type": "artifact",
+ "scope_id": scope,
+ "identity": {"family": family, "artifact_id": artifact_id},
+ "selector": None,
+ },
+ }
+ try:
+ result = await api.read("/v1/access/check", {"match": "all", "requirements": [requirement]})
+ except ReadError:
+ return False
+ return result.get("allowed") is True
+
+
+def _text_row(name: str, left: object, right: object) -> dict[str, Any]:
+ left_text = left if isinstance(left, str) else json.dumps(left, ensure_ascii=False, indent=2, sort_keys=True)
+ right_text = right if isinstance(right, str) else json.dumps(right, ensure_ascii=False, indent=2, sort_keys=True)
+ diff = []
+ if left_text != right_text:
+ for line in difflib.ndiff(left_text.splitlines(), right_text.splitlines()):
+ if line.startswith("? "):
+ continue
+ kind = {"+": "add", "-": "remove"}.get(line[:1], "same")
+ diff.append({"kind": kind, "text": line[2:] if line[:2] in {"+ ", "- ", " "} else line})
+ return {"name": name, "diff": diff, "changed": left_text != right_text}
diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py
index 482625fcb1..16bfc4a8a3 100644
--- a/src/powercontext/server/dashboard/routes.py
+++ b/src/powercontext/server/dashboard/routes.py
@@ -36,7 +36,7 @@
)
from powercontext.server.dashboard.preferences import CATALOGS, presentation, remember_language
from powercontext.server.dashboard.presenters import source_view
-from powercontext.server.dashboard.session import login_response
+from powercontext.server.dashboard.session import login_response, same_origin
ROOT = Path(__file__).parent
LABELS = CATALOGS["zh"]
@@ -92,6 +92,19 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s
in {
"artifact",
"revision",
+ "compare",
+ "rollback_error",
+ "memory_history",
+ "memory_history_cursor",
+ "memory_artifact",
+ "prompt_key",
+ "prompt_revision",
+ "prompt_revision_cursor",
+ "topic_revision_cursor",
+ "profile_revision_cursor",
+ "experience_revision_cursor",
+ "skill_revision_cursor",
+ "handoff_revision_cursor",
"kind",
"entry",
"memory_id",
@@ -142,6 +155,7 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s
query = {"scope": params["scope"], "period": ctx["period"]}
else:
query.update(params)
+ _retain_memory_artifact(destination, ctx, query)
if destination.startswith("evidence/"):
destination = "evidence/" + segment(destination.removeprefix("evidence/"))
return (
@@ -152,6 +166,11 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s
return link
+def _retain_memory_artifact(destination: str, ctx: dict[str, Any], query: dict[str, Any]) -> None:
+ if destination == "notes" and ctx.get("memory_artifact") and query.get("memory_artifact") is None:
+ query["memory_artifact"] = ctx["memory_artifact"]
+
+
def reading_link_context(
request: Request, ctx: dict[str, Any], destination: str, params: dict[str, Any], query: dict[str, Any]
) -> None:
@@ -227,6 +246,10 @@ def initial_context(request: Request, page: str) -> dict[str, Any]:
"profile_revisions": [],
"profile_pager": None,
"profile_html": "",
+ "history": None,
+ "memory_revision_missing": False,
+ "prompt_key": None,
+ "prompt_opened": None,
"return_to": collection_return(
request.query_params.get("return_to"),
request.query_params.get("scope", ""),
@@ -380,6 +403,68 @@ async def download_handoff(request: Request) -> Response:
return render(request, ctx)
+@router.post("/rollback")
+async def rollback_revision(request: Request) -> Response:
+ if not same_origin(request):
+ return HTMLResponse(status_code=403)
+ form = await request.form()
+ scope = str(form.get("scope") or "")
+ family = str(form.get("family") or "")
+ artifact_id = str(form.get("artifact_id") or "")
+ reason = str(form.get("reason") or "")
+ try:
+ source_revision = positive_revision(str(form.get("source_revision") or ""))
+ expected_revision = positive_revision(str(form.get("expected_revision") or ""))
+ except ReadError:
+ return RedirectResponse(f"/dashboard/home?scope={scope}", status_code=303)
+ api = DashboardAPI(request)
+ try:
+ stored = await api.artifact_revision(scope, family, artifact_id, source_revision)
+ from powercontext.server.dashboard.revisions import replace_body
+
+ response = await api.send(
+ "PUT",
+ f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/{segment(artifact_id)}",
+ replace_body(family, stored["content"], source_revision, reason),
+ headers={"If-Match": f'"revision:{expected_revision}"'},
+ )
+ except ReadError as error:
+ code = "forbidden" if error.status == 403 else "rejected"
+ return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303)
+ finally:
+ await api.client.aclose()
+ if response.status_code == 412:
+ code = "revision_conflict"
+ elif response.status_code == 403:
+ code = "forbidden"
+ elif response.status_code != 200:
+ code = "rejected"
+ else:
+ created = response.json()["revision"]
+ return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None), status_code=303)
+ return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303)
+
+
+def _rollback_return(scope: str, family: str, artifact_id: str, revision: int, error: str | None) -> str:
+ params: dict[str, Any] = {"scope": scope, "rollback_error": error}
+ if family == "profile":
+ page = "profile"
+ params["revision"] = revision
+ elif family == "topic-memory":
+ page = "topics"
+ params.update(topic_artifact=artifact_id, topic_revision=revision)
+ elif family == "prompt":
+ page = "prompts"
+ params.update(prompt_key=artifact_id, prompt_revision=revision)
+ elif family in {"experience", "skill", "handoff"}:
+ page = "handoff-detail" if family == "handoff" else family
+ params.update(artifact=artifact_id, revision=revision)
+ else:
+ page = "notes"
+ params["memory_history"] = revision
+ return f"/dashboard/{page}?{urlencode({key: value for key, value in params.items() if value is not None})}"
+
+
@router.get("/{page}")
async def screen(request: Request, page: str) -> HTMLResponse:
ctx = initial_context(request, page if page in PAGES else "home")
diff --git a/src/powercontext/server/dashboard/session.py b/src/powercontext/server/dashboard/session.py
index 71070ab5dc..1a87557b6b 100644
--- a/src/powercontext/server/dashboard/session.py
+++ b/src/powercontext/server/dashboard/session.py
@@ -26,6 +26,16 @@
COOKIE_NAME = "powercontext_dashboard_token"
+def same_origin(request: Request) -> bool:
+ """Return whether the browser Origin matches this request's host and scheme."""
+
+ origin = request.headers.get("origin")
+ if not origin:
+ return False
+ parsed = urlsplit(origin)
+ return parsed.netloc == request.headers.get("host") and parsed.scheme == request.url.scheme
+
+
def login_response(
status: int = 401, *, rejected: bool = False, request: Request | None = None, next_url: str | None = None
) -> HTMLResponse:
@@ -57,12 +67,7 @@ def authentication_headers(scope: Scope) -> dict[str, str]:
async def save_session(request: Request) -> HTMLResponse | RedirectResponse:
- origin = request.headers.get("origin")
- if (
- not origin
- or urlsplit(origin).netloc != request.headers.get("host")
- or urlsplit(origin).scheme != request.url.scheme
- ):
+ if not same_origin(request):
return HTMLResponse(status_code=403)
length = request.headers.get("content-length", "0")
if not length.isdecimal() or len(length) > 6 or int(length) > 32768:
diff --git a/src/powercontext/server/dashboard/templates/components/revision-history.html b/src/powercontext/server/dashboard/templates/components/revision-history.html
new file mode 100644
index 0000000000..1e53a4e8e3
--- /dev/null
+++ b/src/powercontext/server/dashboard/templates/components/revision-history.html
@@ -0,0 +1,123 @@
+
+{% if history %}
+ {{ t.revision_same }} {{ t.revision_compare_truncated }}{{ t.revision_history }}
{{ t.revision_compare }} · {{ history.selected }} / {{ history.compare }}
+ {% for row in history.rows %}
+ {{ row.name }}
+ {% if row.changed %}
+ {% for line in row.diff %}{% if line.kind == "add" %}+ {% elif line.kind == "remove" %}- {% else %} {% endif %}{{ line.text }}
+{% endfor %}
+ {% else %}
+
{{ t.handoff_empty_body }}
{{ selected_note.text }}
{% elif data.notes %}{{ t.notes_no_match if search_query else t.notes_empty_body }}
{{ t.revision_empty }}
+ {% endif %} +{{ t.profile_empty_body }}
{{ prompt.effective.instructions }}{% elif prompt.builtin %}{{ prompt.builtin.instructions }}{% endif %}
+ {% set opened = prompt_opened if prompt_opened and prompt.prompt_key == prompt_key else none %}
+ {{ opened.content.instructions }}{% elif prompt.effective %}{{ prompt.effective.instructions }}{% elif prompt.builtin %}{{ prompt.builtin.instructions }}{% endif %}
{{ t.methods_empty_body }}
{{ topic.summary }}
{{ topic.detail }}
{{ source.name }}/{{ source.source_id }}{{ t.no_sources }}
{% endif %} {% else %}{{ t.select_topic_hint }}