diff --git a/README.md b/README.md index aa344e1b5d..c8522a7c43 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,11 @@ host-local projection. Experience, with optional Profile snapshots and Topic Memory, then applies a shared byte budget and returns `ready` content or `empty`. Recalled content is history, not an instruction authority: the receiving Agent still follows current instructions and checks live workspace state. -- **Review before reuse.** Experience and Skill pass through a review decision that is terminal either way. The model - proposes rather than silently promoting its own output. +- **Review before reuse.** Experience and Skill pass through a review decision that is terminal either way, and + approval writes an immutable Revision. With access control enforced, approval requires `scope.review`. An + authorized Agent can approve its own Candidate; + [separate submission and review](https://powercontext.oceanbase.io/en/docs/workflows/review-candidates/#separate-agent-submission-from-review) + with distinct identities and permissions when a person must decide. - **One Server, several views of the same contract.** HTTP is the complete remote application contract. The Python Client gives typed access, MCP is a curated Agent-facing projection, and Core protocols let an application assemble its own Source, Artifact, and Trigger implementations. diff --git a/README_CN.md b/README_CN.md index da0b21fc15..68e4ecdbe0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -174,7 +174,7 @@ Experience 记录一个场景、一次动作、观察到的结果,以及可复 - **一切都带版本、可追溯。** 每个 Artifact 都以 `家族/ARTIFACT_ID@REVISION` 寻址。通过替换产生的后续 Revision 不会改变 Artifact ID,读取一个精确的引用会返回那一份历史快照,即使家族主版本已经前进。 - **生成、评审、执行是三个分开的步骤。** 模型可以提出内容,评审通过某个 Revision,导出生成宿主内副本。这三步都不会授予 Agent 执行指令的权限。各个入口目前各自暴露了什么,见[接口说明](https://powercontext.oceanbase.io/zh/docs/develop/interfaces/)。 - **召回是有边界的,不是整包倾倒。** `PreparedContext` 会按请求选择活跃的 Memory 和已通过的 Experience,并可选加入 Profile 快照和 Topic Memory,再套用一个共享的字节预算,返回 `ready` 内容或 `empty`。召回的内容是历史,不是指令权威:接收方仍然要遵循当前指令,并检查工作区的实时状态。 -- **复用之前先评审。** Experience 和 Skill 都要经过一次评审决策,无论通过还是驳回都是终态。模型负责提议,而不是悄悄把自己生成的内容提上去。 +- **复用之前先评审。** Experience 和 Skill 都要经过一次评审决策,无论通过还是驳回都是终态;批准会写入不可变 Revision。启用权限控制后,批准需要 `scope.review`。有审核权限的 Agent 仍可批准自己的 Candidate;需要由人决定时,应使用不同身份和权限[分离 Agent 提交与审核](https://powercontext.oceanbase.io/zh/docs/workflows/review-candidates/#分离-agent-提交与审核)。 - **一个 Server,同一份契约的多个视图。** HTTP 是完整的远端应用契约。Python Client 提供带类型的访问,MCP 是面向 Agent 的精选投影,Core 协议则允许应用自行组装 Source、Artifact 和 Trigger 实现。 ### 横向对比 diff --git a/README_JP.md b/README_JP.md index 4952d99ab4..197913ceba 100644 --- a/README_JP.md +++ b/README_JP.md @@ -168,7 +168,7 @@ Experience は、状況・行動・観察された結果・再利用可能な教 - **すべてがリビジョン管理され、追跡可能です。** 各 Artifact は `ファミリ/ARTIFACT_ID@REVISION` で識別されます。承認された置換が後のリビジョンを作っても ARTIFACT_ID は変わらず、正確な参照を読めば、ファミリのヘッドが進んだ後でもその時点のスナップショットが返ります。 - **生成・レビュー・実行は別々のステップです。** モデルが内容を提案し、レビューがリビジョンを承認し、エクスポートがホスト内のコピーを作ります。いずれのステップも、Agent に命令実行の権限を与えるものではありません。各入口が現在何を公開しているかは[インターフェース](https://powercontext.oceanbase.io/en/docs/develop/interfaces/)を参照してください。 - **想起はまとめてではなく、境界つきで行われます。** `PreparedContext` は設定されたセクションからアクティブな Memory と承認済み Experience を選択し、必要に応じて Profile スナップショットと Topic Memory を加え、共有のバイト予算を適用して `ready` または `empty` を返します。想起された内容は履歴であり、命令の権威ではありません。受け手の Agent は現在の指示に従い、実際のワークスペースの状態を確認します。 -- **再利用の前にレビューを通します。** Experience と Skill はレビューの決定を経ます。承認・却下のいずれも終端です。モデルは提案までを担い、自分の出力を暗黙のうちに昇格させることはありません。 +- **再利用の前にレビューを通します。** Experience と Skill はレビューの決定を経て、承認・却下のいずれも終端です。承認は不変リビジョンを書き込みます。アクセス制御が有効な場合、承認には `scope.review` が必要です。レビュー権限を持つ Agent は自分の Candidate も承認できます。人が判断する運用では、異なる ID と権限で[Agent の提出とレビューを分離](https://powercontext.oceanbase.io/en/docs/workflows/review-candidates/#separate-agent-submission-from-review)してください。 - **1 つの Server、同じ契約の複数の見え方。** HTTP が完全なリモートアプリケーション契約です。Python クライアントは型付きアクセスを提供し、MCP は Agent 向けに取捨選択された投影であり、Core プロトコルを使えばアプリケーション側で Source・Artifact・Trigger の実装を組み込めます。 ### 他手段との比較 diff --git a/docs/en/docs/develop/interfaces.md b/docs/en/docs/develop/interfaces.md index aeaa524138..d55efd02ac 100644 --- a/docs/en/docs/develop/interfaces.md +++ b/docs/en/docs/develop/interfaces.md @@ -179,6 +179,11 @@ The unified HTTP review routes are `/v1/candidates/list`, `get`, `history`, `rev `candidate_kind=artifact|tag` distinguishes proposals. Old Artifact Candidate routes are removed; callers must upgrade together. Artifact approval returns a real revision; Tag approval returns committed tags and ETag without creating a content revision. +Review decisions remain available through MCP. With access control enforced, Server authorization requires +`scope.review`; it does not prohibit an authorized proposer from approving its own Candidate. MCP tool annotations let +hosts apply confirmation policies but do not require human confirmation. See +[Review authority](../workflows/review-candidates.md#review-authority) for access modes and reviewer separation. + The `candidate` command group exposes the human Review Inbox. See [Review Candidates](../workflows/review-candidates.md) for the ordered workflow to list, inspect, revise, approve, or reject Candidates. @@ -210,8 +215,8 @@ sets `target`. Managed Skill generation also declares its provenance shape: These generation operations require `POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL`. The lower-level `propose_*` operations remain available for a human or integration that already has complete typed content and exact evidence. -Neither path approves its own Candidate. After Review approval, deterministic `searchable_text` is stored on the -existing generic Artifact head and enters the backend's rebuildable FTS index, making the Experience eligible for +Both paths return pending content; approval is a separate operation. After Review approval, deterministic +`searchable_text` is stored on the existing generic Artifact head and enters the backend's rebuildable FTS index, making the Experience eligible for `PreparedContext` recall in the same scope. Pending and rejected Candidates, all managed Skills, and historical Experience revisions remain excluded. diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index dcb2e8c864..f309cf4f5e 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -181,6 +181,10 @@ AccessControlService must disable the Dashboard; an incompatible enabled configu Disabling it does not affect team API or MCP access. For personal setup, see [Install and run](../get-started/install-and-run.md). +A shared static token does not enforce human review or prevent an Agent from approving its own Candidate. For separate +submission and approval, use distinct authenticated Principals and grant review permission only to the reviewer. +See [Separate Agent submission from review](../workflows/review-candidates.md#separate-agent-submission-from-review). + ## Check the deployment Use liveness to determine whether the process can answer HTTP requests: diff --git a/docs/en/docs/workflows/architecture.md b/docs/en/docs/workflows/architecture.md index a4301246b2..140fa2a941 100644 --- a/docs/en/docs/workflows/architecture.md +++ b/docs/en/docs/workflows/architecture.md @@ -69,9 +69,11 @@ See [deployment and operations](../operate/index.md) for deployment settings, se | Review / projection | Reviews Candidates or exports an exact approved Skill Revision to a host | Rewriting historical Revisions, installing, or executing a Skill automatically | | Database and persistence layer | Stores Scopes, Sources, Artifact Revisions, and processing state | The deployment layer's backup and recovery policy | -Model generation, human review, and execution authority are separate boundaries. Proposals requiring review commit after -approval; direct Create/Replace commits a validated Revision under the caller's governance policy. Export creates a -host-local copy without granting execution authority. `PreparedContext` is temporary for one Agent turn, not a new Artifact. +Model generation, Review approval, and execution authority are separate boundaries. Proposals requiring review commit after +approval; [review authority](review-candidates.md#review-authority) determines who can approve, without requiring a human +or a different proposer identity. Direct Create/Replace commits a validated Revision under the caller's governance +policy. Export creates a host-local copy without granting execution authority. `PreparedContext` is temporary for one +Agent turn, not a new Artifact. ## Content types currently supported diff --git a/docs/en/docs/workflows/experience-and-skill-lifecycle.md b/docs/en/docs/workflows/experience-and-skill-lifecycle.md index 56e8e02ce5..2cce5258ca 100644 --- a/docs/en/docs/workflows/experience-and-skill-lifecycle.md +++ b/docs/en/docs/workflows/experience-and-skill-lifecycle.md @@ -5,9 +5,10 @@ description: Learn how evidence becomes reviewed Experience and managed Skill Ar # Experience and Skill lifecycle -Experience and managed Skill content passes through the same review boundary. Evidence supports a proposal, a human -reviews one exact Candidate version, and approval creates an immutable Artifact Revision. Generation never approves -its own result. +Experience and managed Skill content passes through the same review boundary. Evidence supports a proposal, a reviewer +inspects one exact Candidate version, and approval creates an immutable Artifact Revision. Generation returns pending +content; approval requires a separate operation. The reviewer may be an authorized Agent, including the proposer. +For human review, [separate submission and review permissions](review-candidates.md#separate-agent-submission-from-review). ## Evidence comes first diff --git a/docs/en/docs/workflows/review-candidates.md b/docs/en/docs/workflows/review-candidates.md index 1dd0fe94e4..ffd3d665f2 100644 --- a/docs/en/docs/workflows/review-candidates.md +++ b/docs/en/docs/workflows/review-candidates.md @@ -33,6 +33,67 @@ powercontext ready Set `POWERCONTEXT_SCOPE_ID` to the existing Scope ID that contains the Candidate. This guide starts after an Experience or Skill operation has created a pending Candidate. +## Review authority + +Review is a state transition: pending content becomes an immutable Artifact Revision only after an explicit approval. +It does not by itself prove that a person reviewed the content or that the reviewer differs from the proposer. + +| Server access mode | Who can approve | +| --- | --- | +| `disabled` (default) | Any caller that can reach the review operation; no reviewer identity is enforced | +| `enforced` | An authenticated Principal with `scope.review`, subject to the Candidate's evidence and target permissions | + +HTTP, the Python Client, CLI, and MCP use the same Server authorization. A Principal with submission and review +permissions can approve its own Candidate. The built-in static bearer token represents one shared administrator +identity; sharing that token does not separate an Agent from a reviewer. + +MCP exposes `approve_candidate`, `reject_candidate`, and `revise_candidate`. Their tool annotations describe side +effects so a host can apply its confirmation policy. Annotations do not force a prompt or prove human confirmation. +Approval also does not install, publish, or authorize execution of a Skill. + +## Separate Agent submission from review + +For a workflow where an Agent submits new Experience or Skill Candidates and a person decides whether to approve: + +1. Enable `POWERCONTEXT_SERVER_ACCESS_MODE=enforced` and configure an Authentication Provider that establishes distinct + identities. See [Scopes and access](scopes-and-access.md#enable-access-control). A shared static token is insufficient. +2. Have an administrator grant the Agent `scope.contributor` and the reviewer `scope.reviewer` on the exact Scope. + Do not give the Agent review or role-management authority, including through groups or additional credentials. +3. Keep the reviewer's credentials outside the Agent's tools and process environment. Use an authenticated Client or + integration for review. The Dashboard supports personal and demonstration deployments, not team RBAC. +4. Submit a Candidate as the Agent, then read it as each identity. The Agent must receive a denial when attempting + approval through HTTP or MCP; the reviewer can approve the exact pending version. +5. If changes are needed, reject with a reason and submit a corrected new Candidate. Rejection is terminal. Revising + an existing Candidate requires both `scope.review` and the original proposer identity, so neither of these separate + roles can use `revise` in this setup. Do not grant review authority to the Agent just to edit a proposal. + +With the built-in writable Access Provider, an administrator can send these requests to +`POST /v1/access/bindings/create`. Replace the IDs with identities established by the Authentication Provider and the +existing Scope ID; request bodies assign roles and do not authenticate the named subjects. + +```json +{ + "subject": {"type": "service", "id": "coding-agent"}, + "resource": {"type": "scope", "scope_id": "scope-project"}, + "role": "scope.contributor", + "idempotency_key": "project-agent-contributor" +} +``` + +```json +{ + "subject": {"type": "user", "id": "reviewer"}, + "resource": {"type": "scope", "scope_id": "scope-project"}, + "role": "scope.reviewer", + "idempotency_key": "project-human-reviewer" +} +``` + +These assignments cover new Artifact identities. A proposal that revises an existing Artifact also requires the +applicable target permissions; the two Scope roles alone do not authorize every replacement. This workflow separates +authority through role assignments, without a Server rule prohibiting all self-approval. If one identity later receives +both permissions, it can submit and approve its own Candidate. + ## Start a Dream from existing evidence Dreaming turns selected Atomic Memory or Experience revisions into one proposal for review. It runs in the background; diff --git a/docs/en/docs/workflows/scopes-and-access.md b/docs/en/docs/workflows/scopes-and-access.md index ea3602da01..b96f5ef907 100644 --- a/docs/en/docs/workflows/scopes-and-access.md +++ b/docs/en/docs/workflows/scopes-and-access.md @@ -42,6 +42,12 @@ Inspect `GET /v1/access/me` for the active identity and provider capabilities. R Scope, and Artifact actions. Scope creation requires `server.admin`; Prompt changes require current `scope.admin`. The ability to read review candidates does not grant permission to approve them. +To let an Agent submit new Experience or Skill Candidates while a separate reviewer decides, assign +`scope.contributor` to the Agent and `scope.reviewer` to the reviewer using distinct authenticated identities. +Keep reviewer credentials out of the Agent's environment. A Principal holding both permissions can approve its own +Candidate. Follow [Separate Agent submission from review](review-candidates.md#separate-agent-submission-from-review) +for role-assignment examples, rejection and resubmission, and existing-Artifact permission limits. + Scheduled processing needs an authorized background Principal. Tag queries require `scope.read`, while editing a target's tags follows that target's write permission. Use [Tags](manage-artifact-tags.md) and [Scope profiles](use-profiles.md) for the corresponding workflows. diff --git a/docs/en/rfcs/0050_artifact_candidate_review_inbox.md b/docs/en/rfcs/0050_artifact_candidate_review_inbox.md index c3896626c5..c0a6138f68 100644 --- a/docs/en/rfcs/0050_artifact_candidate_review_inbox.md +++ b/docs/en/rfcs/0050_artifact_candidate_review_inbox.md @@ -232,8 +232,12 @@ pending Candidate; the latter reads an approved Experience Revision only by exac The generated Python Client exposes the same operations, the CLI provides `candidate list/show/approve/reject/revise`, and MCP projects all five Review operations as tools. Transport does not change Candidate validation, `expected_version` checks, or the atomic approval transaction. PowerContext does not treat -MCP visibility as an authorization boundary; deployments that require reviewer separation must control access to the -MCP endpoint. +MCP visibility as an authorization boundary. With RFC 1396 access control enforced, review decisions require +`scope.review` through every transport. With access control disabled, any caller that reaches the operation can make +a decision. An authorized proposer may approve its own Candidate. Deployments requiring separate submission and review +must use distinct authenticated Principals, withhold review authority from the submitting Agent, and keep reviewer +credentials outside its environment. MCP annotations describe side effects for host confirmation policies; they do +not enforce human confirmation. This RFC does not modify the existing Memory contract: diff --git a/docs/en/rfcs/0051_experience_skill_artifact_families.md b/docs/en/rfcs/0051_experience_skill_artifact_families.md index 81e6bba4bf..abb6bde0ab 100644 --- a/docs/en/rfcs/0051_experience_skill_artifact_families.md +++ b/docs/en/rfcs/0051_experience_skill_artifact_families.md @@ -32,8 +32,11 @@ does not create a managed Artifact. Only an explicit import or fork proposes a n Experience and managed Skill generation and evolution are advanced capabilities that require a configured generation model. Without one, Runtime generates no Candidate and provides no rule-based fallback. Artifact persistence, Review, -exact reads, and local external Skill registration continue to work independently. A model cannot approve its own -Candidate, allocate final Artifact identity, or acquire execution authority. +exact reads, and local external Skill registration continue to work independently. Generation creates pending content +without final Artifact identity or execution authority. Approval is a separate Review operation. With access control +enforced, it requires `scope.review`; an Agent holding that permission may approve its own Candidate. To reserve +approval for a separate reviewer, assign submission and review permissions to distinct authenticated identities. See +[Review Candidates](../docs/workflows/review-candidates.md#separate-agent-submission-from-review). # Motivation @@ -469,8 +472,9 @@ Without a configured generation model: - Runtime never wraps raw summaries or deterministic rule output in Experience or managed Skill content. Rules still enforce input bounds, scope, exact references, typed shape, and lineage, but those checks do not replace -semantic generation. Even when available, an LLM cannot approve Candidates, choose execution authority, invent -evidence, install Skills, or commit final Revisions. +semantic generation. A generation model supplies proposal content, not approval or final Revision commits, and must +not invent evidence. Review commits the final Revision through a separate operation; an authorized Agent can call +that operation. Generation and approval do not grant installation or execution authority. ## Identity, Revision, persistence, and projections diff --git a/docs/en/rfcs/1304_experience_skill_review_page.md b/docs/en/rfcs/1304_experience_skill_review_page.md index 04e41a3c4c..f878135507 100644 --- a/docs/en/rfcs/1304_experience_skill_review_page.md +++ b/docs/en/rfcs/1304_experience_skill_review_page.md @@ -36,7 +36,9 @@ writable. Experience and managed Skill are intentionally gated by Review. A generated proposal is untrusted, does not receive final Artifact identity, and cannot enter retrieval or PreparedContext until a reviewer approves it. This boundary is -already implemented across HTTP, the Python Client, CLI, and MCP. +already implemented across HTTP, the Python Client, CLI, and MCP. It is a lifecycle and retrieval boundary, not proof +of human or independent review. RFC 1396 governs authorization when enforced; an Agent with review authority can +approve its own Candidate. The page does not add a prohibition on self-approval or a trusted human-confirmation step. The Server Dashboard currently shows how many Candidates are pending, but it does not let a user inspect or act on them. Completing the review requires command-line or MCP calls with exact IDs, versions, proposal shapes, and evidence diff --git a/docs/en/rfcs/1351_standard_skill_package_lifecycle.md b/docs/en/rfcs/1351_standard_skill_package_lifecycle.md index a0e768e62e..ed4c44b1eb 100644 --- a/docs/en/rfcs/1351_standard_skill_package_lifecycle.md +++ b/docs/en/rfcs/1351_standard_skill_package_lifecycle.md @@ -175,8 +175,9 @@ Approval creates only the proposed managed Revision; the original snapshot remai A person may upload a complete package, or a configured generation model may propose one from exact SourceRef and ArtifactRef evidence. Generation produces package content outside the approval transaction. PowerContext validates and -stores it before creating a pending Candidate. A model cannot approve the Candidate, allocate final Artifact identity, -publish the package, or gain script authority. +stores it before creating a pending Candidate. Generation does not approve the Candidate or allocate final Artifact +identity. Approval is a separate operation available to authorized callers, including Agents; it does not prohibit +self-approval. Publication is another explicit operation, and none of these steps grants script execution authority. ## Review the package that will be published diff --git a/docs/en/rfcs/1770-decision-policy-governance.md b/docs/en/rfcs/1770-decision-policy-governance.md index a86ca75844..d3e6ee0869 100644 --- a/docs/en/rfcs/1770-decision-policy-governance.md +++ b/docs/en/rfcs/1770-decision-policy-governance.md @@ -155,7 +155,7 @@ Promotion is based on replay and labeled evidence, not on provider claims. ## Boundaries that users should not expect -This RFC does not add a general decision workflow engine. It does not let a model approve Review candidates, accept a +This RFC does not add a general decision workflow engine. Gate evaluation does not approve Review candidates, accept a Handoff, verify task completion, deactivate Memory, install a Skill, or select tools for an agent. Those remain domain actions owned by existing PowerContext services and their review or authorization contracts. @@ -423,8 +423,9 @@ rewrite Memory. ### RFC 0050 Review Inbox remains the place for review-owned families and explicit review proposals. A gate may create a pending -Candidate only where the owning family already supports that flow, and the model cannot approve the Candidate it -creates. +Candidate only where the owning family already supports that flow. Gate evaluation creates no approval; approval is +a separate Review operation. An Agent authorized to call that operation can approve its own Candidate, so independent +review requires distinct authenticated identities and suitable permission assignments. ## Compatibility, persistence, and API impact @@ -480,7 +481,7 @@ PowerContext already has the building blocks: - RFC 1745 defines a decision-model rerank seam and the fail-closed distinction for reranking. - RFC 1560 defines a model-free recall sufficiency gate with bounded expansion. - RFC 1652 defines evidence-preserving Memory quality and lifecycle boundaries. -- RFC 0050 defines the Review Inbox and the rule that generated candidates cannot approve themselves. +- RFC 0050 defines the Review Inbox and the separate approval operation required before a Candidate becomes an Artifact. - RFC 0046 defines observability foundations that the observation stream should reuse rather than bypass. Adjacent systems provide cautionary examples. Hermes-style Jev plugins show the value of cheap code-invoked gates, diff --git a/docs/zh/docs/develop/interfaces.md b/docs/zh/docs/develop/interfaces.md index 8f55e48171..939f9f6f76 100644 --- a/docs/zh/docs/develop/interfaces.md +++ b/docs/zh/docs/develop/interfaces.md @@ -162,6 +162,10 @@ Candidate 的统一 HTTP 路径为 `/v1/candidates/list`、`get`、`history`、` 通过 `candidate_kind=artifact|tag` 区分类型;旧 Artifact Candidate 路径直接移除,调用方必须同步升级。 Artifact 批准返回真实版本引用,Tag 批准返回标签集和 ETag,不生成正文版本。 +MCP 继续暴露审核决策操作。启用权限控制时,Server 要求 `scope.review`,但不禁止有权限的提案人批准自己的 Candidate。 +MCP 工具注解供宿主执行确认策略,不强制人工确认。权限模式与审核者分离方式见 +[审核权限](../workflows/review-candidates.md#审核权限)。 + `candidate` 命令组提供面向人工的 Review Inbox。列出、检查、修订、批准和拒绝的操作步骤见 [审核 Candidate](../workflows/review-candidates.md)。 @@ -192,7 +196,7 @@ Skill generation 还必须声明 provenance 形态: - `usage`:引用精确 target Skill 和有界 usage Source。 这些 generation operation 需要配置 `POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL`。已经拥有完整类型化内容和 -精确证据的人或 integration 仍可使用低阶 `propose_*` operation。两条路径都不能自行批准 Candidate。 +精确证据的人或 integration 仍可使用低阶 `propose_*` operation。两条路径返回的都是 pending 内容,批准需要单独调用审核操作。 Experience 经 Review 批准后,确定性的 `searchable_text` 会写入现有通用 Artifact head 并进入 backend 可重建 FTS 索引,从而可在同一 scope 内被 `PreparedContext` 召回。pending/rejected Candidate、所有 managed Skill 和历史 Experience Revision 仍不会进入 PreparedContext。 diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 3893403051..443a1347e4 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -167,6 +167,9 @@ Cookie,最长八小时;HTTPS 下设置 Secure。反向代理应正确传递 注入 Authentication Provider 或 AccessControlService 的部署必须关闭 Dashboard;不兼容的启用配置会在启动时被拒绝。 关闭 Dashboard 不影响团队的 API 和 MCP。个人启用步骤见[安装和运行](../get-started/install-and-run.md)。 +共享静态 token 不会强制人工审核,也不会阻止 Agent 批准自己的 Candidate。需要分离提交和审批时,应使用不同的已认证 +Principal,并仅向审核者授予审核权限。参见[分离 Agent 提交与审核](../workflows/review-candidates.md#分离-agent-提交与审核)。 + ## 检查部署 使用 liveness 判断进程能否响应 HTTP 请求: diff --git a/docs/zh/docs/workflows/architecture.md b/docs/zh/docs/workflows/architecture.md index 4f8bd62fe4..8f4679d67a 100644 --- a/docs/zh/docs/workflows/architecture.md +++ b/docs/zh/docs/workflows/architecture.md @@ -63,7 +63,8 @@ SQLite 使用本地数据文件,OceanBase / SeekDB 使用配置的数据库连 | Review / Projection | 审核 Candidate,或把精确的 approved Skill Revision 导出到宿主 | 不修改历史 Revision,不自动安装或执行 Skill | | 数据库和持久化层 | 保存 Scope、Source、Artifact Revision 和处理状态 | 不替代部署层的备份与恢复策略 | -模型生成、人工审核和执行权限是三个独立边界:需要审核的提案在批准后提交,直接 Create/Replace 则按接口校验和调用方的治理策略提交正式 Revision。 +模型生成、Review 批准和执行权限是三个独立边界:需要审核的提案在批准后提交,谁可以批准由[审核权限](review-candidates.md#审核权限) +决定,不要求审核者一定是人或与提案人不同。直接 Create/Replace 则按接口校验和调用方的治理策略提交正式 Revision。 导出创建宿主本地副本,不授予执行权限。`PreparedContext` 是一次 Agent turn 的临时结果,不是新的 Artifact。 ## 当前支持的内容类型 diff --git a/docs/zh/docs/workflows/experience-and-skill-lifecycle.md b/docs/zh/docs/workflows/experience-and-skill-lifecycle.md index 493ee4121f..809f38af43 100644 --- a/docs/zh/docs/workflows/experience-and-skill-lifecycle.md +++ b/docs/zh/docs/workflows/experience-and-skill-lifecycle.md @@ -5,8 +5,9 @@ description: 了解证据如何变成经过审核的 Experience 和 managed Skil # Experience 与 Skill 生命周期 -Experience 和 managed Skill 使用同一个审核边界。证据支撑 proposal,人工审核一个精确 Candidate version,批准后 -创建不可变的 Artifact Revision。Generation 不会自行批准结果。 +Experience 和 managed Skill 使用同一个审核边界。证据支撑 proposal,审核者检查一个精确 Candidate version,批准后 +创建不可变的 Artifact Revision。Generation 返回 pending 内容,批准需要单独调用操作。审核者可以是有权限的 Agent, +也可以是提案人自己。需要人工审核时,应[分离提交与审核权限](review-candidates.md#分离-agent-提交与审核)。 ## 先有证据 diff --git a/docs/zh/docs/workflows/review-candidates.md b/docs/zh/docs/workflows/review-candidates.md index fe54277c4a..e1c531c394 100644 --- a/docs/zh/docs/workflows/review-candidates.md +++ b/docs/zh/docs/workflows/review-candidates.md @@ -32,6 +32,62 @@ powercontext ready 将 `POWERCONTEXT_SCOPE_ID` 设置为 Candidate 所在的已有 Scope ID。本指南从 Experience 或 Skill 操作已经创建 pending Candidate 后开始。 +## 审核权限 + +Review 是一次状态转换:pending 内容只有经过显式批准,才能成为不可变的 Artifact Revision。 +这一步本身不证明内容由人审核,也不要求审核者与提案人不同。 + +| Server 权限模式 | 谁可以批准 | +| --- | --- | +| `disabled`(默认) | 能访问审核操作的调用者;不强制审核者身份 | +| `enforced` | 拥有 `scope.review` 的已认证 Principal,同时满足 Candidate 的证据与目标权限要求 | + +HTTP、Python Client、CLI 和 MCP 使用同一套 Server 授权。一个 Principal 同时具有提交和审核权限时,可以批准自己的 +Candidate。内置静态 Bearer token 代表一个共享的管理员身份;共享该 token 无法区分 Agent 与审核者。 + +MCP 暴露 `approve_candidate`、`reject_candidate` 和 `revise_candidate`。这些工具的注解描述操作副作用,供宿主执行 +自己的确认策略。注解不会强制弹出确认,也不证明已经经过人工确认。批准 Skill 同样不等于安装、发布或授予执行权限。 + +## 分离 Agent 提交与审核 + +如果希望由 Agent 提交新的 Experience 或 Skill Candidate,再由人决定是否批准: + +1. 启用 `POWERCONTEXT_SERVER_ACCESS_MODE=enforced`,配置能够建立不同身份的 Authentication Provider。 + 参见 [Scope 与访问控制](scopes-and-access.md#启用访问控制)。共享静态 token 无法满足这一要求。 +2. 由管理员在同一个精确 Scope 上,为 Agent 分配 `scope.contributor`,为审核者分配 `scope.reviewer`。 + 不要通过其他角色、群组或额外凭据向 Agent 授予审核或角色管理权限。 +3. 不要把审核者凭据交给 Agent 的工具或进程环境。审核使用已认证的 Client 或集成;Dashboard 面向个人和演示部署, + 不支持团队 RBAC。 +4. 以 Agent 身份提交 Candidate,再分别以两个身份读取。Agent 通过 HTTP 或 MCP 尝试批准时都应被拒绝; + 审核者可以批准确切的 pending version。 +5. 需要修改时,由审核者填写原因并拒绝,再提交修正后的新 Candidate。拒绝是终态。修订已有 Candidate 同时要求 + `scope.review` 和原提案人身份,因此上述两个分离的角色都不能直接使用 `revise`;不要为了编辑提案给 Agent 增加审核权限。 + +使用内置可写 Access Provider 时,管理员可以向 `POST /v1/access/bindings/create` 分别发送以下请求。 +将身份替换为 Authentication Provider 确认的身份,将 Scope ID 替换为已有值;请求体只分配角色,不会认证其中的主体。 + +```json +{ + "subject": {"type": "service", "id": "coding-agent"}, + "resource": {"type": "scope", "scope_id": "scope-project"}, + "role": "scope.contributor", + "idempotency_key": "project-agent-contributor" +} +``` + +```json +{ + "subject": {"type": "user", "id": "reviewer"}, + "resource": {"type": "scope", "scope_id": "scope-project"}, + "role": "scope.reviewer", + "idempotency_key": "project-human-reviewer" +} +``` + +上述分配适用于创建新的 Artifact identity。修改已有 Artifact 的提案还需要相应的目标权限,两个 Scope 角色并不授权所有 +replacement。这个工作流通过角色分配隔离权限,Server 没有全面禁止自审的规则;若同一身份后来同时获得两种权限, +它仍然可以提交并批准自己的 Candidate。 + ## 从已有证据发起 Dream Dream 在后台将选中的 Atomic Memory 或 Experience Revision 提炼成一个待审核候选;也可以返回 `no_change` 或 diff --git a/docs/zh/docs/workflows/scopes-and-access.md b/docs/zh/docs/workflows/scopes-and-access.md index 3f576984e5..dc9069a5b9 100644 --- a/docs/zh/docs/workflows/scopes-and-access.md +++ b/docs/zh/docs/workflows/scopes-and-access.md @@ -37,6 +37,11 @@ Scope 选择项目上下文边界。其不透明 ID 用于标识数据,不负 创建 Scope 需要 `server.admin`;修改 Prompt 需要当前 `scope.admin` 权限。 能够读取候选材料不代表能够批准候选材料。 +需要 Agent 提交新的 Experience 或 Skill Candidate、再由独立审核者决定时,为不同的已认证身份分别分配 +`scope.contributor` 和 `scope.reviewer`,并将审核者凭据保留在 Agent 环境之外。同一 Principal 同时持有两种权限时, +仍可批准自己的 Candidate。角色分配示例、拒绝后重新提交的流程和已有 Artifact 的权限限制见 +[分离 Agent 提交与审核](review-candidates.md#分离-agent-提交与审核)。 + 定时处理需要有权限的后台 Principal。标签查询需要 `scope.read`,修改目标标签遵循目标自身的写权限。 相关流程见[标签](manage-artifact-tags.md)和 [Scope Profile](use-profiles.md)。 diff --git a/docs/zh/rfcs/0050_artifact_candidate_review_inbox.md b/docs/zh/rfcs/0050_artifact_candidate_review_inbox.md index 1269ea28f7..f66579f85c 100644 --- a/docs/zh/rfcs/0050_artifact_candidate_review_inbox.md +++ b/docs/zh/rfcs/0050_artifact_candidate_review_inbox.md @@ -210,7 +210,10 @@ exact `ArtifactRef` 读取已经批准的 Experience Revision。对应 HTTP 路 生成的 Python Client 暴露相同操作,CLI 提供 `candidate list/show/approve/reject/revise`,MCP 则把五个 Review operation 全部投影为 tool。transport 不改变 Candidate validation、`expected_version` 校验或原子 approval transaction。 -PowerContext 不把 MCP 可见性作为授权边界;需要 reviewer separation 的部署必须控制 MCP endpoint 的访问权限。 +PowerContext 不把 MCP 可见性作为授权边界。启用 RFC 1396 权限控制后,各 transport 的审核决策都需要 `scope.review`; +关闭权限控制时,能访问该操作的调用者都可以决策。有审核权限的提案人可以批准自己的 Candidate。需要分离提交与审核的 +部署必须使用不同的已认证 Principal,不向提交内容的 Agent 授予审核权限,并将审核者凭据保留在其环境之外。 +MCP 注解描述操作副作用,供宿主执行确认策略,不会强制人工确认。 本 RFC 不修改现有 Memory contract: diff --git a/docs/zh/rfcs/0051_experience_skill_artifact_families.md b/docs/zh/rfcs/0051_experience_skill_artifact_families.md index f39b7a7cb1..d644682dd2 100644 --- a/docs/zh/rfcs/0051_experience_skill_artifact_families.md +++ b/docs/zh/rfcs/0051_experience_skill_artifact_families.md @@ -24,8 +24,10 @@ Skill 都必须从 Experience 生成。当前 Agent 环境已经安装或提供 Experience 和 managed Skill 都复用现有 Artifact identity、不可变 Revision、lineage 和 CAS。它们的 create 和 revise 必须先进入 RFC 0031 定义的 Review Inbox。外部 Skill 的发现不产生 managed Artifact;只有显式 import 或 fork 才会提出新的 managed Skill Candidate。 Experience 和 managed Skill 的生成与演化是需要配置生成模型的高级能力。未配置模型时,Runtime 不生成 Candidate,也不使用 -规则降级;Artifact 的存储、Review、exact read 和本地 External Skill registration 仍可独立工作。模型不能批准自己的 -Candidate、分配最终 Artifact identity 或获得执行权限。 +规则降级;Artifact 的存储、Review、exact read 和本地 External Skill registration 仍可独立工作。生成只创建 pending 内容, +不分配最终 Artifact identity,也不授予执行权限。批准是独立的 Review 操作,启用权限控制后需要 `scope.review`; +拥有该权限的 Agent 可以批准自己的 Candidate。需要由独立审核者决定时,应向不同的已认证身份分配提交与审核权限,参见 +[审核 Candidate](../docs/workflows/review-candidates.md#分离-agent-提交与审核)。 # Motivation @@ -435,8 +437,9 @@ Skill Registry 不依赖 LLM: - 已有 Candidate 的 Review 以及已批准 Artifact 的 exact read 正常工作; - Runtime 不用确定性规则或原始摘要强行包装 Experience/managed Skill。 -规则仍负责输入上限、scope、exact reference、typed shape 和 lineage 校验,但这些校验不能代替语义生成。LLM 可用时也不获得 -额外权限:模型不能批准 Candidate、选择执行授权、伪造 evidence、自动安装 Skill 或提交最终 Revision。 +规则仍负责输入上限、scope、exact reference、typed shape 和 lineage 校验,但这些校验不能代替语义生成。生成模型只提供 +proposal 内容,不执行审批或最终 Revision 提交,也不得伪造 evidence。Review 通过独立操作提交最终 Revision; +有权限的 Agent 可以调用该操作。生成与批准都不授予安装或执行权限。 ## Identity, Revision, persistence, and projections diff --git a/docs/zh/rfcs/1304_experience_skill_review_page.md b/docs/zh/rfcs/1304_experience_skill_review_page.md index a8ce3496bc..07d7a6b4a1 100644 --- a/docs/zh/rfcs/1304_experience_skill_review_page.md +++ b/docs/zh/rfcs/1304_experience_skill_review_page.md @@ -32,7 +32,9 @@ approval 之后的独立显式操作,只能写入配置中明确允许的 host # Motivation Experience 和 managed Skill 被有意置于 Review gate 后。生成的 proposal 不受信任,不会获得最终 Artifact identity,也不能 -在审核者批准前进入检索或 PreparedContext。该边界已经在 HTTP、Python Client、CLI 和 MCP 中实现。 +在审核者批准前进入检索或 PreparedContext。该生命周期与检索边界已经在 HTTP、Python Client、CLI 和 MCP 中实现, +但不证明经过人工或独立审核。启用权限控制时由 RFC 1396 管理授权;有审核权限的 Agent 可以批准自己的 Candidate。 +页面不增加禁止自审的规则,也不增加可信的人工确认步骤。 Server Dashboard 目前会显示 pending Candidate 的数量,却不能让用户查看或处理它们。完成审核需要通过命令行或 MCP 调用,并准确提供 ID、版本、proposal shape 和证据引用。这适合自动化和调试,但日常人工治理难以发现,也容易被搁置。 diff --git a/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md b/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md index 5cb994f593..fc80560a7f 100644 --- a/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md +++ b/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md @@ -164,8 +164,9 @@ Revision 完整包含捕获的包。外部包和导入后的受管 Skill 成为 ### 创作或生成受管 Skill 用户可以上传完整包,也可以由已配置的生成模型根据精确 SourceRef 和 ArtifactRef 证据提出一个包。生成在批准 -事务之外完成。PowerContext 先校验和保存包,再创建 pending Candidate。模型不能批准自己的 Candidate、分配最终 -Artifact identity、发布包或获得脚本执行权限。 +事务之外完成。PowerContext 先校验和保存包,再创建 pending Candidate。生成不批准 Candidate,也不分配最终 +Artifact identity。批准是面向有权限的调用者(包括 Agent)的独立操作,不禁止自审。发布需要另一个显式操作, +这些步骤都不授予脚本执行权限。 ## Review 最终会被发布的包 diff --git a/docs/zh/rfcs/1770-decision-policy-governance.md b/docs/zh/rfcs/1770-decision-policy-governance.md index 4906b8d7e8..59c5fb7060 100644 --- a/docs/zh/rfcs/1770-decision-policy-governance.md +++ b/docs/zh/rfcs/1770-decision-policy-governance.md @@ -127,7 +127,7 @@ promotion 基于 replay 和带标签证据,而不是 provider 宣称。 ## 用户不应该期待的边界 -本 RFC 不新增通用决策工作流引擎。它不允许模型 approve Review candidate、accept Handoff、验证任务完成、deactivate Memory、install Skill,或替 agent 选 tool。这些仍然是现有 PowerContext 服务及其 review/authorization 契约拥有的领域动作。 +本 RFC 不新增通用决策工作流引擎。Gate evaluation 不执行 Review candidate 批准、Handoff 接受、任务完成验证、Memory 停用、Skill 安装或 agent 工具选择。这些仍然是现有 PowerContext 服务及其 review/authorization 契约拥有的领域动作。 本 RFC 也不改变 RFC 1745。Decision reranking 是一个特殊的非 advisory consumer,其失败策略在 RFC 0080 下是 fail-closed。共享 policy 层可以描述该角色,但不能把 reranking 变成 fail-open advisory gate。 @@ -345,7 +345,7 @@ Memory quality 与 lifecycle effects 仍然是 review-oriented 且保留证据 ### RFC 0050 -Review Inbox 仍然服务于 review-owned families 和明确的 review proposal。只有所属 family 已支持这种流程时,gate 才可以创建 pending Candidate,而且模型不能 approve 它自己创建的 Candidate。 +Review Inbox 仍然服务于 review-owned families 和明确的 review proposal。只有所属 family 已支持这种流程时,gate 才可以创建 pending Candidate。Gate evaluation 不执行批准;批准是独立的 Review 操作。有权调用该操作的 Agent 可以批准自己的 Candidate,因此独立审核需要不同的已认证身份和适当的权限分配。 ## 兼容性、持久化和 API 影响 @@ -393,7 +393,7 @@ PowerContext 已经有相关基础: - RFC 1745 定义了 decision-model rerank seam,以及 reranking 的 fail-closed 区分。 - RFC 1560 定义了 model-free recall sufficiency gate 与 bounded expansion。 - RFC 1652 定义了 evidence-preserving 的 Memory quality 与 lifecycle 边界。 -- RFC 0050 定义了 Review Inbox,以及 generated candidate 不能 approve 自己的规则。 +- RFC 0050 定义了 Review Inbox,以及 Candidate 成为 Artifact 前所需的独立审批操作。 - RFC 0046 定义了 observability foundations,observation stream 应复用它,而不是绕开它。 相邻系统提供了反例和经验。Hermes 风格的 Jev 插件展示了 cheap code-invoked gates、shadow mode 和 local rules 的价值,但它们的阈值与宿主特定 hook 不是 PowerContext 默认值。OpenClaw 风格的 decision role 说明,decision model 更适合作为 runtime collaborator,而不是 model-callable tool。Jev 与 Laya 是候选后端或参考点,不拥有 policy。 diff --git a/tests/e2e/test_candidate_review_authority.py b/tests/e2e/test_candidate_review_authority.py new file mode 100644 index 0000000000..16183ede82 --- /dev/null +++ b/tests/e2e/test_candidate_review_authority.py @@ -0,0 +1,312 @@ +# 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. + +"""Submission and review authority through the Server's HTTP and MCP transports.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import RuntimeConfig +from powercontext.server.authentication import ( + AuthenticationRejectedError, + AuthenticationRequest, + AuthenticationResult, + ProviderReadiness, +) +from powercontext.server.authz import PrincipalRef +from powercontext.server.authz.composition import open_builtin_access_control +from powercontext.server.factory import create_server_app +from powercontext.server.settings import AccessControlConfig, McpConfig, MetricsConfig, ServerSettings + +ADMIN = PrincipalRef(type="service", id="admin") +AGENT = PrincipalRef(type="service", id="coding-agent") +REVIEWER = PrincipalRef(type="user", id="reviewer") + + +class _Authentication: + """Test credentials for three distinct identities; no external identity service.""" + + async def authenticate(self, request: AuthenticationRequest, /) -> AuthenticationResult: + principals = {"Bearer admin": ADMIN, "Bearer agent": AGENT, "Bearer reviewer": REVIEWER} + principal = principals.get(request.headers.get("authorization", "")) + if principal is None: + raise AuthenticationRejectedError + return AuthenticationResult(subject=principal) + + async def readiness(self) -> ProviderReadiness: + return ProviderReadiness(ready=True) + + +def _http_client(app: FastAPI, token: str) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers={"Authorization": f"Bearer {token}"}, + trust_env=False, + ) + + +def _mcp_client(app: FastAPI, token: str) -> Client[StreamableHttpTransport]: + def create_http_client( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + **_: object, + ) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers={"Authorization": f"Bearer {token}", **(headers or {})}, + timeout=timeout, + auth=auth, + follow_redirects=True, + trust_env=False, + ) + + return Client(StreamableHttpTransport("http://testserver/mcp/", httpx_client_factory=create_http_client)) + + +@asynccontextmanager +async def _review_server(tmp_path: Path) -> AsyncIterator[tuple[FastAPI, str]]: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'review.db'}") + async with open_builtin_access_control(database, bootstrap_administrators=(ADMIN,)) as access: + app = create_server_app( + settings=ServerSettings( + database=database, + runtime=RuntimeConfig(artifact_processing_families=()), + access=AccessControlConfig(mode="enforced"), + mcp=McpConfig(enabled=True), + metrics=MetricsConfig(enabled=False), + ), + authentication_provider=_Authentication(), + access_control=access, + scheduler_path=tmp_path / "scheduler.db", + ) + async with app.router.lifespan_context(app), _http_client(app, "admin") as admin: + scope = await admin.post( + "/v1/scopes", + json={ + "title": "Review authority", + "summary": "Separate submission and review", + "idempotency_key": "scope", + }, + ) + assert scope.status_code == 201, scope.text + scope_id = scope.json()["scope_id"] + for principal, role in ((AGENT, "scope.contributor"), (REVIEWER, "scope.reviewer")): + binding = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": principal.type, "id": principal.id}, + "resource": {"type": "scope", "scope_id": scope_id}, + "role": role, + "idempotency_key": f"{principal.id}-{role}", + }, + ) + assert binding.is_success, binding.text + yield app, scope_id + + +async def _submit_candidate(client: httpx.AsyncClient, scope_id: str, family: str) -> dict[str, Any]: + captured = await client.post( + "/v1/sources/content", + json={"scope_id": scope_id, "source_id": "review-evidence", "content": "API contract checks passed."}, + ) + assert captured.status_code == 202, captured.text + proposal = ( + { + "situation": "The public API contract changes.", + "action": "Regenerate the client and run contract tests.", + "outcome": "API contract checks passed.", + "lesson": "Regenerate the client before running contract tests.", + } + if family == "experience" + else { + "name": "review-api-contract", + "description": "Use when changing the public API contract.", + "instructions": "Regenerate the client and run contract tests.", + "validation": ["API contract checks pass."], + } + ) + response = await client.post( + f"/v1/{family}/propose", + json={ + "scope_id": scope_id, + "proposal": proposal, + "source_refs": [captured.json()["source"]], + "artifact_refs": [], + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.parametrize("family", ["experience", "skill"]) +@pytest.mark.parametrize("review_transport", ["http", "mcp"]) +def test_contributor_submits_but_only_reviewer_can_approve(tmp_path: Path, family: str, review_transport: str) -> None: + async def scenario() -> None: + async with ( + _review_server(tmp_path) as (app, scope_id), + _http_client(app, "agent") as agent, + _http_client(app, "reviewer") as reviewer, + ): + candidate = await _submit_candidate(agent, scope_id, family) + selection = {"scope_id": scope_id, "candidate_id": candidate["candidate_id"]} + decision = {**selection, "expected_version": candidate["version"]} + denied = await agent.post("/v1/candidates/approve", json=decision) + assert denied.status_code == 403, denied.text + async with _mcp_client(app, "agent") as mcp: + denied_tool = await mcp.call_tool("approve_candidate", decision, raise_on_error=False) + assert denied_tool.is_error + assert "403" in denied_tool.content[0].text + + pending = await agent.post("/v1/candidates/get", json=selection) + assert pending.status_code == 200, pending.text + assert pending.json()["status"] == "pending" + assert pending.json()["result_artifact"] is None + assert pending.json()["permissions"] == {"can_revise": False, "can_approve": False, "can_reject": False} + visible = await reviewer.post("/v1/candidates/get", json=selection) + assert visible.status_code == 200, visible.text + assert visible.json()["permissions"] == {"can_revise": False, "can_approve": True, "can_reject": True} + context_request = {"scope_id": scope_id, "query": "Regenerate the client before running contract tests."} + before = await agent.post("/v1/context/prepare", json=context_request) + assert before.status_code == 200, before.text + assert before.json()["status"] == "empty" + + if review_transport == "http": + response = await reviewer.post("/v1/candidates/approve", json=decision) + assert response.status_code == 200, response.text + approved = response.json() + else: + async with _mcp_client(app, "reviewer") as mcp: + result = await mcp.call_tool("approve_candidate", decision) + assert not result.is_error + approved = result.structured_content + assert approved is not None + assert approved["status"] == "approved" + artifact = approved["result_artifact"] + assert artifact["family"] == family + readback = await agent.post(f"/v1/{family}/get", json={"scope_id": scope_id, "artifact": artifact}) + assert readback.status_code == 200, readback.text + after = await agent.post("/v1/context/prepare", json=context_request) + assert after.status_code == 200, after.text + if family == "experience": + assert after.json()["status"] == "ready" + assert candidate["proposal"]["lesson"] in after.json()["content"] + else: + assert after.json()["status"] == "empty" + + asyncio.run(scenario()) + + +def test_reviewer_rejects_and_contributor_resubmits_without_review_authority(tmp_path: Path) -> None: + async def scenario() -> None: + async with ( + _review_server(tmp_path) as (app, scope_id), + _http_client(app, "agent") as agent, + _http_client(app, "reviewer") as reviewer, + ): + candidate = await _submit_candidate(agent, scope_id, "experience") + selection = {"scope_id": scope_id, "candidate_id": candidate["candidate_id"]} + decision = {**selection, "expected_version": candidate["version"]} + replacement = {**candidate["proposal"], "lesson": "Inspect the generated client before contract tests."} + for client in (agent, reviewer): + denied = await client.post("/v1/candidates/revise", json={**decision, "proposal": replacement}) + assert denied.status_code == 403, denied.text + + rejected = await reviewer.post( + "/v1/candidates/reject", json={**decision, "reason": "Include inspection of the generated client."} + ) + assert rejected.status_code == 200, rejected.text + assert rejected.json()["status"] == "rejected" + assert rejected.json()["result_artifact"] is None + terminal = await reviewer.post("/v1/candidates/approve", json=decision) + assert terminal.status_code == 409, terminal.text + + resubmitted = await agent.post( + "/v1/experience/propose", + json={ + "scope_id": scope_id, + "proposal": replacement, + "source_refs": candidate["source_refs"], + "artifact_refs": candidate["artifact_refs"], + }, + ) + assert resubmitted.status_code == 201, resubmitted.text + corrected = resubmitted.json() + assert corrected["candidate_id"] != candidate["candidate_id"] + assert corrected["status"] == "pending" + assert corrected["permissions"]["can_approve"] is False + approved = await reviewer.post( + "/v1/candidates/approve", + json={ + "scope_id": scope_id, + "candidate_id": corrected["candidate_id"], + "expected_version": corrected["version"], + }, + ) + assert approved.status_code == 200, approved.text + exact = await agent.post( + "/v1/experience/get", json={"scope_id": scope_id, "artifact": approved.json()["result_artifact"]} + ) + assert exact.status_code == 200, exact.text + assert exact.json()["content"]["lesson"] == replacement["lesson"] + + asyncio.run(scenario()) + + +def test_proposer_with_review_permission_can_approve_its_own_candidate(tmp_path: Path) -> None: + async def scenario() -> None: + async with ( + _review_server(tmp_path) as (app, scope_id), + _http_client(app, "agent") as agent, + _http_client(app, "admin") as admin, + ): + candidate = await _submit_candidate(agent, scope_id, "experience") + granted = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": AGENT.type, "id": AGENT.id}, + "resource": {"type": "scope", "scope_id": scope_id}, + "role": "scope.reviewer", + "idempotency_key": "agent-reviewer", + }, + ) + assert granted.is_success, granted.text + async with _mcp_client(app, "agent") as mcp: + approved = await mcp.call_tool( + "approve_candidate", + { + "scope_id": scope_id, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ) + assert not approved.is_error + assert approved.structured_content is not None + assert approved.structured_content["status"] == "approved" + + asyncio.run(scenario())