diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 4916968fd7..f4a44ccf99 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -78,7 +78,7 @@ P0 blocks correctness or continuity in the current user journey. P1 enables repe | **S3 Goal planning and multi-Agent collaboration · P0/P1** | Vision/replan, peer frontiers, claim/lease, directory, manager_context and explicit continuation exist; general handoff/shared amendment remain incomplete | R2 proves peer dependency; R3 closes parallel joins, pipelines, help/review, continuation and automatic return; R4 delivers one intent-preserving amendment class. Cover cycles, invalidated inputs, rejection/deferral, lease transfer, competing bases and aggregate acceptance | | **S4 Runtime/host/daemon · P0/P1** | Attached/managed, Turn, broker, runtime connectors and Desktop repairs exist; registration does not establish executable capacity | Qualify multi-Turn supervision for one real supported combination; restart/cancel/drain/stop retain work and fence old executors. Then expand host parity, unique service-profile ownership, clean installation and upgrades; show unsupported adapter capabilities | | **S5 Frontend, Lark and human interaction · P0/P1** | Local chat, settings, proposals and partial Goal Channel verticals exist; shared audience/session/work readback needs qualification | One journey spans settings, work graph, handoff, blockers, cost, corrections, artifacts and return. Shared typed projections; reconnect/repeated-click/stale/original-route cases. Realtime IM reuses Chat/Turn: isolate independent conversations on one listener, then qualify ordinary DM onboarding, explicit role selection, busy-session admission, provisional progress, media and exact permission callbacks through [the shared operational contract](capable-manager-semantic-handoff-v0.md#10-operational-contract). [Live team workspace](live-team-workspace-v0.md) makes exchange, revision and original-coordinator continuation visible. Its [Work-scale map track](live-team-workspace-v0.md#11-delivery-order-and-relationship-to-aggressive-r2-progress) draws each Goal's typed Todo relations first (W1), then live state and outputs on the same nodes. Then intelligent review, keyboard accessibility, bilingual terminology, actionable errors and offline degradation; interrupt only for actual decisions | -| **S6 Materials, evidence, memory and learning · P1** | Authority registry, material lifecycle/frontier, decision context, reward memory and turn recall exist; direction baseline and parts of attribution remain proposed | Connect material revision→same-Agent read→decision reference→artifact/outcome. Expose expiry/revocation/source loss and forgetting policy. Handoff preserves decision-relevant summaries and authorized artifacts; qualify OpenViking/Obelisk as optional providers. Prove causal utility with controls, not relevance alone | +| **S6 Materials, evidence, memory and learning · P1** | Authority registry, material lifecycle/frontier, decision context, reward memory and turn recall exist; explicit project material packets and source-verifier gates are implemented, while private source initialization/Core binding and ordinary-conversation adoption remain unqualified; direction baseline and parts of attribution remain proposed | Connect material revision→same-Agent read→decision reference→artifact/outcome. Expose expiry/revocation/source loss and forgetting policy. Handoff preserves decision-relevant summaries and authorized artifacts; qualify OpenViking/Obelisk as optional providers. Prove causal utility with controls, not relevance alone | | **S7 Budget, scheduling and fleet scale · P0 observation/P1–P2 expansion** | Quota/scheduler and partial usage aggregates exist; full provider cost, distributed reservations and hundred-Agent concurrency need evidence | Separate configured budget, admission, consumption and estimates; unknown is not zero and replay cannot double-charge. R7 pagination/bounded summaries and [complete-history transport](typescript-control-plane-migration-v0.md), including refresh/replay/single-debit evidence beyond the RPC limit; provider/host limits, fairness, backpressure, event wake and isolation; report registration/activity/throughput and cost per accepted outcome separately | | **S8 Capabilities, extensions and domain integration · P1/P2** | Capability catalog, extension lifecycle, hooks, engineering/research/content/office capabilities and computer-use contracts exist | First exercise the shared control plane with existing issue-fix/PR-review and material/research callers. Every provider has readiness/version/permissions/default-off/uninstall/rollback/isolation and real-entry evidence. New domain effects start with one simulated operation, not a marketplace or workflow DSL | | **S9 Identity, authority, privacy and trust · continuous P0/P1–P2 remote** | Public/private scope, capability gates, fencing and confirmation contracts belong to existing owners | R1/R3 cover sender/audience/artifact scope and stale authority; R6 authenticates tenant/Goal/actor/host, rotation/revocation and least privilege. Qualify credential custody, untrusted tool/document inputs, dependency supply chain, audit retention/deletion and vulnerability response through real paths; roles/messages/memory mint no write authority | diff --git a/docs/reference/protocols/material-lifecycle-architecture-v0.md b/docs/reference/protocols/material-lifecycle-architecture-v0.md index 148d5e5012..b64c01c85c 100644 --- a/docs/reference/protocols/material-lifecycle-architecture-v0.md +++ b/docs/reference/protocols/material-lifecycle-architecture-v0.md @@ -8,6 +8,14 @@ migration, candidate/archive transitions, and bounded rerank proposals. It does not own raw documents, private source locations, provider credentials, or Core goal authority. +Ordinary project conversations can use an explicitly activated source profile +and an existing Core workspace write grant for inventory, candidate +intake/rollback, rerank packets, readable projection and ranking settlement. +These SDK packets select exactly one Goal or project owner; references never +grant access. The source verifier resolves current Core authority and retains +its staging/publication fence. Migration, rebuild and Explore remain Goal-only; +installing the project skill or importing the SDK does not activate a source. + ```mermaid flowchart LR RAW["Private raw material store
files, messages, web captures"] diff --git a/loopx/capabilities/material_lifecycle/README.md b/loopx/capabilities/material_lifecycle/README.md index ec10237553..1f4a6954b7 100644 --- a/loopx/capabilities/material_lifecycle/README.md +++ b/loopx/capabilities/material_lifecycle/README.md @@ -212,6 +212,21 @@ rebuild, bounded rerank, readable projection, Explore intent, apply, and rollback. Concrete legacy parsers, private storage adapters, source profiles, and provider credentials remain project owned. +## Project Conversation Intake + +An explicitly activated project source can pass `MaterialProjectScope` instead +of `goal_id` to the existing inventory, intake/rollback, ranking, projection and +settlement builders. Exactly one owner is required; the project path creates +no Goal. Its project/profile/grant references select existing Core context and +source ownership, and never grant access by themselves. + +Project intake/rollback require the source provider's `verify_project_scope` +to resolve the current Core caller, audience, exact profile/store, workspace +write grant and expiring owner gate. Verification runs before source access +and publication; the source must retain its transaction authorization fence. +Source initialization stays project owned. Migration, rebuild and Explore keep +their existing Goal route. No new CLI or transport configuration path is added. + ## Relationship To Other Capabilities | Capability | Primary question | Relationship | diff --git a/loopx/capabilities/material_lifecycle/README.zh-CN.md b/loopx/capabilities/material_lifecycle/README.zh-CN.md index 2d3c091bbd..217bdbd374 100644 --- a/loopx/capabilities/material_lifecycle/README.zh-CN.md +++ b/loopx/capabilities/material_lifecycle/README.zh-CN.md @@ -176,6 +176,14 @@ migration preparation、lifecycle receipt、ranked-entry rebuild、bounded reran readable projection、Explore intent、apply 与 rollback。具体 legacy parser、私有 storage adapter、source profile 和 provider credential 仍由项目拥有。 +## 明确的项目来源 scope + +普通项目对话可为已显式启用的素材来源使用 `MaterialProjectScope`,无需创建 Goal。inventory、candidate intake/rollback、rerank packet、readable projection 和 intake-ranking settlement 支持以 `project_scope` 替代 `goal_id`;两者必须且只能选一个。项目 packet 不含 Goal 标识,也不带入 manager 上下文。 + +scope 的 `project_ref`、`source_profile_ref`、`workspace_grant_ref` 是既有 Core 授权与来源 profile 的选择器,不授予权限。project candidate provider 必须实现 `MaterialProjectScopeVerifier`,核对当前调用者/受众、精确 store/profile、工作区写入边界、owner gate 的有效期与撤销状态。SDK 在访问 provider 前及发布 authority 前调用该 verifier,授权事实仍由既有 Core owner 提供;adapter 还须在自身 staging/publication 事务内执行授权检查,不能把预检查的 Boolean 当作事务 fence。 + +默认关闭不变;本路径不注册 Goal、不创建 source authority,不扩大 Goal-only migration/rebuild/Explore。新来源初始化、私有存储与 transport 接入仍由 source owner 提供,不能仅凭这些字段或安装 skill 开始写入。 + ## 与其他能力的关系 | 能力 | 核心问题 | 与 Material Lifecycle 的关系 | diff --git a/loopx/capabilities/material_lifecycle/__init__.py b/loopx/capabilities/material_lifecycle/__init__.py index 7d885ac1ff..ecba127904 100644 --- a/loopx/capabilities/material_lifecycle/__init__.py +++ b/loopx/capabilities/material_lifecycle/__init__.py @@ -1,4 +1,6 @@ -"""Goal-scoped Material Lifecycle capability contracts.""" +"""Material Lifecycle contracts for explicit Goal or project source owners.""" + +from .ownership import MaterialProjectScope, MaterialProjectScopeVerifier from .apply import ( MATERIAL_MIGRATION_APPLY_RECEIPT_SCHEMA_VERSION, @@ -95,6 +97,8 @@ ) __all__ = [ + "MaterialProjectScope", + "MaterialProjectScopeVerifier", "MATERIAL_CANDIDATE_INTAKE_APPLY_RECEIPT_SCHEMA_VERSION", "MATERIAL_CANDIDATE_INTAKE_PROPOSAL_SCHEMA_VERSION", "MATERIAL_CANDIDATE_INTAKE_ROLLBACK_RECEIPT_SCHEMA_VERSION", diff --git a/loopx/capabilities/material_lifecycle/_validation.py b/loopx/capabilities/material_lifecycle/_validation.py index 3c84295479..bcf6da1d13 100644 --- a/loopx/capabilities/material_lifecycle/_validation.py +++ b/loopx/capabilities/material_lifecycle/_validation.py @@ -182,10 +182,10 @@ def packet_ref(prefix: str, packet: Mapping[str, Any]) -> str: return f"{prefix}-{digest}" -def capability_contract(*, packet_role: str) -> dict[str, Any]: +def capability_contract(*, packet_role: str, project_scoped: bool = False) -> dict[str, Any]: return { "capability_id": "material_lifecycle", - "scope": "goal", + "scope": "project" if project_scoped else "goal", "default_enabled": False, "packet_role": packet_role, "creates_authority": False, diff --git a/loopx/capabilities/material_lifecycle/intake.py b/loopx/capabilities/material_lifecycle/intake.py index 451ee4e651..c655f55071 100644 --- a/loopx/capabilities/material_lifecycle/intake.py +++ b/loopx/capabilities/material_lifecycle/intake.py @@ -7,6 +7,10 @@ from dataclasses import dataclass from typing import Any, Protocol +from .ownership import ( + MaterialProjectScope, material_owner_fields, verify_project_material_write, +) + from ._validation import ( capability_contract, check_record_keys, @@ -252,7 +256,8 @@ def _readback( def build_material_candidate_intake_proposal( *, - goal_id: str, + goal_id: str | None = None, + project_scope: MaterialProjectScope | None = None, proposal_id: str, store_id: str, source_authority_revision: str, @@ -268,7 +273,7 @@ def build_material_candidate_intake_proposal( proposal: dict[str, Any] = { "schema_version": MATERIAL_CANDIDATE_INTAKE_PROPOSAL_SCHEMA_VERSION, - "goal_id": compact_token(goal_id, field="goal_id"), + **material_owner_fields(goal_id=goal_id, project_scope=project_scope), "proposal_id": compact_token(proposal_id, field="proposal_id"), "store_id": compact_token(store_id, field="store_id"), "source_authority_revision": compact_token( @@ -299,12 +304,23 @@ def build_material_candidate_intake_proposal( "raw_content_captured": False, "private_locations_captured": False, "visibility": "public_safe", - "capability": capability_contract(packet_role="candidate_intake_proposal"), + "capability": capability_contract(packet_role="candidate_intake_proposal", project_scoped=project_scope is not None), } proposal["proposal_ref"] = packet_ref("material-candidate-intake", proposal) return proposal +def _packet_owner(value: Mapping[str, Any]) -> dict[str, Any]: + owner = material_owner_fields( + goal_id=value.get("goal_id"), project_scope=value.get("project_scope"), + ) + if "project_scope" in owner: + contract = value.get("capability") + if not isinstance(contract, Mapping) or contract.get("scope") != "project": + raise ValueError("project material packet must declare project capability scope") + return owner + + def _proposal(value: Mapping[str, Any]) -> dict[str, Any]: check_record_keys( value, @@ -317,6 +333,7 @@ def _proposal(value: Mapping[str, Any]) -> dict[str, Any]: "exact_read_ref", "exact_read_verified", "goal_id", + "project_scope", "lifecycle_state", "material_ref", "observed_at", @@ -337,7 +354,6 @@ def _proposal(value: Mapping[str, Any]) -> dict[str, Any]: "content_digest", "content_size_bytes", "exact_read_ref", - "goal_id", "material_ref", "proposal_ref", "schema_version", @@ -349,6 +365,8 @@ def _proposal(value: Mapping[str, Any]) -> dict[str, Any]: ) if value.get("schema_version") != MATERIAL_CANDIDATE_INTAKE_PROPOSAL_SCHEMA_VERSION: raise ValueError("candidate intake proposal has an unsupported schema_version") + owner = _packet_owner(value) + required_truth = { "owner_gate_required": True, "exact_read_verified": True, @@ -362,7 +380,7 @@ def _proposal(value: Mapping[str, Any]) -> dict[str, Any]: if value.get(field) != expected: raise ValueError(f"candidate intake proposal has invalid {field}") return { - "goal_id": compact_token(value["goal_id"], field="proposal.goal_id"), + **owner, "proposal_ref": compact_token( value["proposal_ref"], field="proposal.proposal_ref", @@ -471,6 +489,11 @@ def apply_material_candidate_intake( timestamp = iso_timestamp(observed_at, field="observed_at") owner_gate = compact_token(owner_gate_ref, field="owner_gate_ref") + verify_project_material_write( + provider, owner=proposal, store_id=store_id, owner_gate_ref=owner_gate, + observed_at=timestamp, + ) + before = _snapshot( provider, provider_id=expected_provider, @@ -624,6 +647,11 @@ def apply_material_candidate_intake( field="append_reconciliation.validation_ref", ) + verify_project_material_write( + provider, owner=proposal, store_id=store_id, owner_gate_ref=owner_gate, + observed_at=timestamp, + ) + transition = provider.switch_authority( store_id=store_id, expected_revision=source_revision, @@ -680,7 +708,9 @@ def apply_material_candidate_intake( receipt: dict[str, Any] = { "schema_version": MATERIAL_CANDIDATE_INTAKE_APPLY_RECEIPT_SCHEMA_VERSION, - "goal_id": proposal["goal_id"], + **material_owner_fields( + goal_id=proposal.get("goal_id"), project_scope=proposal.get("project_scope"), + ), "receipt_id": compact_token(receipt_id, field="receipt_id"), "proposal_ref": proposal["proposal_ref"], "provider_id": expected_provider, @@ -718,7 +748,7 @@ def apply_material_candidate_intake( "private_locations_captured": False, "visibility": "public_safe", "observed_at": timestamp, - "capability": capability_contract(packet_role="candidate_intake_apply_receipt"), + "capability": capability_contract(packet_role="candidate_intake_apply_receipt", project_scoped="project_scope" in proposal), } receipt["receipt_ref"] = packet_ref("material-candidate-intake-apply", receipt) return receipt @@ -766,6 +796,12 @@ def rollback_material_candidate_intake( timestamp = iso_timestamp(observed_at, field="observed_at") owner_gate = compact_token(owner_gate_ref, field="owner_gate_ref") + owner = _packet_owner(apply_receipt) + verify_project_material_write( + provider, owner=owner, store_id=store_id, owner_gate_ref=owner_gate, + observed_at=timestamp, + ) + current = _snapshot( provider, provider_id=expected_provider, @@ -775,6 +811,11 @@ def rollback_material_candidate_intake( if current.authority_revision != current_revision: raise ValueError("candidate intake rollback authority revision CAS failed") + verify_project_material_write( + provider, owner=owner, store_id=store_id, owner_gate_ref=owner_gate, + observed_at=timestamp, + ) + transition = provider.switch_authority( store_id=store_id, expected_revision=current_revision, @@ -828,7 +869,7 @@ def rollback_material_candidate_intake( receipt: dict[str, Any] = { "schema_version": MATERIAL_CANDIDATE_INTAKE_ROLLBACK_RECEIPT_SCHEMA_VERSION, - "goal_id": compact_token(apply_receipt.get("goal_id"), field="receipt.goal_id"), + **owner, "receipt_id": compact_token(receipt_id, field="receipt_id"), "apply_receipt_ref": compact_token( apply_receipt.get("receipt_ref"), @@ -859,7 +900,8 @@ def rollback_material_candidate_intake( "visibility": "public_safe", "observed_at": timestamp, "capability": capability_contract( - packet_role="candidate_intake_rollback_receipt" + packet_role="candidate_intake_rollback_receipt", + project_scoped="project_scope" in owner, ), } receipt["receipt_ref"] = packet_ref( diff --git a/loopx/capabilities/material_lifecycle/inventory.py b/loopx/capabilities/material_lifecycle/inventory.py index 5b99fc567a..8b587e2303 100644 --- a/loopx/capabilities/material_lifecycle/inventory.py +++ b/loopx/capabilities/material_lifecycle/inventory.py @@ -5,6 +5,8 @@ from collections.abc import Mapping, Sequence from typing import Any +from .ownership import MaterialProjectScope, material_owner_fields + from ._validation import ( capability_contract, compact_token, @@ -28,7 +30,8 @@ def build_material_store_inventory( *, - goal_id: str, + goal_id: str | None = None, + project_scope: MaterialProjectScope | None = None, store_id: str, store_revision: str, observed_at: str, @@ -61,7 +64,7 @@ def build_material_store_inventory( inventory: dict[str, Any] = { "schema_version": MATERIAL_STORE_INVENTORY_SCHEMA_VERSION, - "goal_id": compact_token(goal_id, field="goal_id"), + **material_owner_fields(goal_id=goal_id, project_scope=project_scope), "store_id": compact_token(store_id, field="store_id"), "store_revision": compact_token(store_revision, field="store_revision"), "observed_at": iso_timestamp(observed_at, field="observed_at"), @@ -72,7 +75,7 @@ def build_material_store_inventory( "backup_ref": compact_token(backup_ref, field="backup_ref"), "source_digest": compact_token(source_digest, field="source_digest"), "visibility": "public_safe", - "capability": capability_contract(packet_role="inventory"), + "capability": capability_contract(packet_role="inventory", project_scoped=project_scope is not None), "lifecycle_counts": counts, "item_count": sum(counts.values()), "parse_error_refs": token_list( diff --git a/loopx/capabilities/material_lifecycle/ownership.py b/loopx/capabilities/material_lifecycle/ownership.py new file mode 100644 index 0000000000..e6b5fe04e7 --- /dev/null +++ b/loopx/capabilities/material_lifecycle/ownership.py @@ -0,0 +1,88 @@ +"""Material ownership metadata; references never grant write authority.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Protocol, cast + +from ._validation import check_record_keys, compact_token + + +@dataclass(frozen=True) +class MaterialProjectScope: + """An explicitly activated source profile within one authorized workspace.""" + + project_ref: str + source_profile_ref: str + workspace_grant_ref: str + + +def material_owner_fields( + *, + goal_id: str | None = None, + project_scope: MaterialProjectScope | Mapping[str, Any] | None = None, +) -> dict[str, Any]: + if (goal_id is None) == (project_scope is None): + raise ValueError("exactly one goal_id or project_scope is required") + if goal_id is not None: + return {"goal_id": compact_token(goal_id, field="goal_id")} + scope: Mapping[str, Any] + if isinstance(project_scope, MaterialProjectScope): + scope = { + "project_ref": project_scope.project_ref, + "source_profile_ref": project_scope.source_profile_ref, + "workspace_grant_ref": project_scope.workspace_grant_ref, + } + elif isinstance(project_scope, Mapping): + scope = project_scope + else: + raise TypeError("project_scope must be MaterialProjectScope or an object") + fields = {"project_ref", "source_profile_ref", "workspace_grant_ref"} + check_record_keys(scope, field="project_scope", allowed=fields, required=fields) + return { + "project_scope": { + key: compact_token(scope[key], field=f"project_scope.{key}") + for key in sorted(fields) + } + } + + +class MaterialProjectScopeVerifier(Protocol): + """Source adapter resolves authorization through its existing Core owner.""" + + def verify_project_scope( + self, *, project_scope: Mapping[str, str], store_id: str, + owner_gate_ref: str, observed_at: str, + ) -> bool: ... + + +def verify_project_material_write( + provider: object, + *, + owner: Mapping[str, Any], + store_id: str, + owner_gate_ref: str, + observed_at: str, +) -> None: + """Require the source owner to resolve current authorization before writes. + + Project refs, profile refs and grant refs are selectors, not permission. + The adapter must resolve the exact store/profile/workspace and current + caller, audience, gate expiry, write scope and revocation. No Goal is + created or used to borrow manager authority. + """ + scope = owner.get("project_scope") + if scope is None: + return + verifier = getattr(provider, "verify_project_scope", None) + if not callable(verifier): + raise ValueError("project material writes require a source authorization verifier") + verified = cast(MaterialProjectScopeVerifier, provider).verify_project_scope( + project_scope=dict(scope), + store_id=store_id, + owner_gate_ref=owner_gate_ref, + observed_at=observed_at, + ) + if verified is not True: + raise ValueError("project material source authorization was not verified") diff --git a/loopx/capabilities/material_lifecycle/ranking.py b/loopx/capabilities/material_lifecycle/ranking.py index a9042d6edb..39f1a8efa5 100644 --- a/loopx/capabilities/material_lifecycle/ranking.py +++ b/loopx/capabilities/material_lifecycle/ranking.py @@ -6,6 +6,8 @@ from collections.abc import Mapping, Sequence from typing import Any +from .ownership import MaterialProjectScope, material_owner_fields + from ._validation import ( capability_contract, check_record_keys, @@ -193,7 +195,8 @@ def _normalize_moves( def build_material_rerank_proposal( *, - goal_id: str, + goal_id: str | None = None, + project_scope: MaterialProjectScope | None = None, proposal_id: str, inventory_ref: str, decision_evidence_ref: str, @@ -231,7 +234,7 @@ def build_material_rerank_proposal( proposal: dict[str, Any] = { "schema_version": MATERIAL_RERANK_PROPOSAL_SCHEMA_VERSION, - "goal_id": compact_token(goal_id, field="goal_id"), + **material_owner_fields(goal_id=goal_id, project_scope=project_scope), "proposal_id": compact_token(proposal_id, field="proposal_id"), "inventory_ref": compact_token(inventory_ref, field="inventory_ref"), "decision_evidence_ref": compact_token( @@ -240,7 +243,7 @@ def build_material_rerank_proposal( ), "observed_at": iso_timestamp(observed_at, field="observed_at"), "visibility": "public_safe", - "capability": capability_contract(packet_role="rerank_proposal"), + "capability": capability_contract(packet_role="rerank_proposal", project_scoped=project_scope is not None), "constraints": { "target_window_size": window, "max_moved_items": max_moved, @@ -263,7 +266,8 @@ def build_material_rerank_proposal( def build_material_rerank_apply_receipt( *, - goal_id: str, + goal_id: str | None = None, + project_scope: MaterialProjectScope | None = None, receipt_id: str, proposal_ref: str, observed_at: str, @@ -317,7 +321,7 @@ def build_material_rerank_apply_receipt( receipt: dict[str, Any] = { "schema_version": MATERIAL_RERANK_APPLY_RECEIPT_SCHEMA_VERSION, - "goal_id": compact_token(goal_id, field="goal_id"), + **material_owner_fields(goal_id=goal_id, project_scope=project_scope), "receipt_id": compact_token(receipt_id, field="receipt_id"), "proposal_ref": compact_token(proposal_ref, field="proposal_ref"), "observed_at": iso_timestamp(observed_at, field="observed_at"), @@ -328,7 +332,7 @@ def build_material_rerank_apply_receipt( "validation_ref": compact_token(validation_ref, field="validation_ref"), "applied_material_refs": applied_refs, "visibility": "public_safe", - "capability": capability_contract(packet_role="rerank_apply_receipt"), + "capability": capability_contract(packet_role="rerank_apply_receipt", project_scoped=project_scope is not None), "raw_content_captured": False, "private_locations_captured": False, } diff --git a/loopx/capabilities/material_lifecycle/readable_projection.py b/loopx/capabilities/material_lifecycle/readable_projection.py index 01806dfb61..cfd50dab77 100644 --- a/loopx/capabilities/material_lifecycle/readable_projection.py +++ b/loopx/capabilities/material_lifecycle/readable_projection.py @@ -7,6 +7,8 @@ from typing import Any from urllib.parse import urlsplit +from .ownership import MaterialProjectScope, material_owner_fields + from ._validation import ( capability_contract, check_record_keys, @@ -337,7 +339,8 @@ def _markdown_lines( def build_material_readable_projection( *, - goal_id: str, + goal_id: str | None = None, + project_scope: MaterialProjectScope | None = None, projection_id: str, authority_revision: str, observed_at: str, @@ -463,7 +466,7 @@ def build_material_readable_projection( projection_ref = f"material-readable-projection-{projection_digest[:20]}" receipt: dict[str, Any] = { "schema_version": MATERIAL_READABLE_PROJECTION_RECEIPT_SCHEMA_VERSION, - "goal_id": compact_token(goal_id, field="goal_id"), + **material_owner_fields(goal_id=goal_id, project_scope=project_scope), "projection_id": compact_token(projection_id, field="projection_id"), "authority_revision": compact_token( authority_revision, @@ -492,7 +495,7 @@ def build_material_readable_projection( }, "visibility": "public_safe", "projection_visibility": "local_private_allowed", - "capability": capability_contract(packet_role="readable_projection_receipt"), + "capability": capability_contract(packet_role="readable_projection_receipt", project_scoped=project_scope is not None), "raw_content_captured": False, "private_locations_captured": False, } diff --git a/loopx/capabilities/material_lifecycle/settlement.py b/loopx/capabilities/material_lifecycle/settlement.py index 1e5a745169..703bc6488b 100644 --- a/loopx/capabilities/material_lifecycle/settlement.py +++ b/loopx/capabilities/material_lifecycle/settlement.py @@ -5,6 +5,8 @@ from collections.abc import Sequence from typing import Any +from .ownership import MaterialProjectScope, material_owner_fields + from ._validation import ( capability_contract, compact_text, @@ -31,7 +33,8 @@ def _required_bool(value: Any, *, field: str) -> bool: def build_material_intake_ranking_settlement( *, - goal_id: str, + goal_id: str | None = None, + project_scope: MaterialProjectScope | None = None, settlement_id: str, material_ref: str, observed_at: str, @@ -176,7 +179,7 @@ def build_material_intake_ranking_settlement( settlement: dict[str, Any] = { "schema_version": MATERIAL_INTAKE_RANKING_SETTLEMENT_SCHEMA_VERSION, - "goal_id": compact_token(goal_id, field="goal_id"), + **material_owner_fields(goal_id=goal_id, project_scope=project_scope), "settlement_id": compact_token(settlement_id, field="settlement_id"), "material_ref": normalized_material_ref, "observed_at": iso_timestamp(observed_at, field="observed_at"), @@ -212,7 +215,7 @@ def build_material_intake_ranking_settlement( "ranked_membership_verified": membership_verified, }, "visibility": "public_safe", - "capability": capability_contract(packet_role="intake_ranking_settlement"), + "capability": capability_contract(packet_role="intake_ranking_settlement", project_scoped=project_scope is not None), "raw_content_captured": False, "private_locations_captured": False, } diff --git a/skills/loopx-material/SKILL.md b/skills/loopx-material/SKILL.md index 60d68ee595..8d60532385 100644 --- a/skills/loopx-material/SKILL.md +++ b/skills/loopx-material/SKILL.md @@ -25,24 +25,34 @@ Use `--surface claude-code` or `--surface opencode` for those hosts; repeat the flag to install multiple host-native copies in one transaction. Managed copies live under `.agents/skills/`, `.claude/skills/`, or `.opencode/skills/` and are upgraded or removed through the same CLI. Project-local discovery does not -itself activate material-store writes; the selected goal still needs explicit -Material Lifecycle authority. +itself activate material-store writes; the selected execution owner still +needs explicit Material Lifecycle source authority. ## Activation Gate Before changing a material store: -1. Resolve the current project, `goal_id`, registered agent, and active todo - through `loopx start-goal --guided`, `loopx status`, or `loopx diagnose`. +1. Resolve the current project and execution owner. For a Goal route, use the + existing registered agent/active Todo. For an ordinary project route, + resolve the current Core project context and accepted request; do not run + `start-goal` or create a dummy Goal merely to obtain material metadata. 2. Run `loopx project-skill status --project . --skill loopx-material` and confirm the required host surfaces are current. -3. Confirm the selected todo explicitly targets `material_lifecycle`, or that - the goal authority declares an active Material Lifecycle profile and its - source store. A catalog entry or project-local skill is not activation. -4. Confirm the goal boundary covers the exact private adapter and authority - paths. Public LoopX contracts never grant access to private source content. +3. Require explicit activation: either the selected Goal Todo targets + `material_lifecycle`/the Goal declares its source profile, or the project + source owner declares an active material profile and an existing Core + workspace write grant. A catalog entry, scope reference or skill copy is + not activation. +4. Confirm the Goal boundary or Core project write grant covers the exact + private adapter/authority paths. The project source verifier must bind the + current caller, audience, store/profile and owner gate, including expiry + and revocation. Ref names never grant access; enforce the authorization + fence within staging/publication as well as preflight checks. 5. Run `loopx material-lifecycle architecture --format json` and preserve its - default-off, owner-gated, provider-neutral boundaries. + default-off, owner-gated, provider-neutral boundaries. The project source + route supports inventory, candidate intake/rollback, rerank packets, + readable projection and settlement; it does not widen Goal-only migration, + rebuild or Explore APIs. If the project-local skill is missing, preview an explicit project install; do not fall back to a global copy. If activation or authority is missing, stop @@ -238,8 +248,10 @@ A complete material operation reports: Stop without mutating the source when: -- the project or goal is ambiguous; -- Material Lifecycle is not explicitly active for the selected goal; +- the project or current execution owner is ambiguous; +- Material Lifecycle is not explicitly active for the selected Goal or the + ordinary project's source profile with its existing Core write grant; +- a project source route attempts Goal-only migration, rebuild or Explore; - source authority, backup, revision, or stable ids cannot be verified; - exact-read evidence conflicts with the proposed change; - overflow would be hidden rather than independently ranked; diff --git a/tests/capabilities/test_material_candidate_intake.py b/tests/capabilities/test_material_candidate_intake.py index e04737e11b..4be7edaadf 100644 --- a/tests/capabilities/test_material_candidate_intake.py +++ b/tests/capabilities/test_material_candidate_intake.py @@ -384,3 +384,179 @@ def stage_candidate(self, **kwargs: object) -> MaterialStagedCandidate: with pytest.raises(ValueError, match="local path"): apply(UnsafeReadbackProvider()) + + +class AuthorizedProjectProvider(FakeCandidateProvider): + """Source-owner fixture resolves refs; packet metadata is not permission.""" + + def __init__(self): + super().__init__() + self.write_allowed = True + self.revoke_after_stage = False + + def verify_project_scope(self, *, project_scope, store_id, owner_gate_ref, observed_at): + self.calls.append('verify-project') + return ( + self.write_allowed + and project_scope == { + 'project_ref': 'project:alpha', + 'source_profile_ref': 'profile:materials', + 'workspace_grant_ref': 'grant:workspace-write', + } + and store_id == 'store:materials' + and owner_gate_ref == 'gate:material-input' + ) + + def stage_candidate(self, **kwargs): + result = super().stage_candidate(**kwargs) + if self.revoke_after_stage: + self.write_allowed = False + return result + + +def project_proposal(**scope_changes): + from loopx.capabilities.material_lifecycle import MaterialProjectScope + scope = { + 'project_ref': 'project:alpha', + 'source_profile_ref': 'profile:materials', + 'workspace_grant_ref': 'grant:workspace-write', + **scope_changes, + } + return build_material_candidate_intake_proposal( + project_scope=MaterialProjectScope(**scope), + proposal_id='proposal:project-input', store_id='store:materials', + source_authority_revision='revision:42', material_ref='material:A43', + source_ref='source:paper-43', source_revision='source-revision:7', + exact_read_ref='exact-read:paper-43', content_digest=CONTENT_DIGEST, + content_size_bytes=len(CONTENT), observed_at=OBSERVED_AT, + ) + + +def apply_project(provider, intake_proposal=None, gate='gate:material-input'): + return apply_material_candidate_intake( + provider=provider, provider_id=provider.provider_id, + intake_proposal=intake_proposal or project_proposal(), content=CONTENT, + owner_gate_ref=gate, receipt_id='receipt:project-intake', observed_at=OBSERVED_AT, + ) + + +def test_project_intake_and_rollback_keep_scope_without_a_goal(): + provider = AuthorizedProjectProvider() + candidate = project_proposal() + receipt = apply_project(provider, candidate) + assert 'goal_id' not in candidate and 'goal_id' not in receipt + assert receipt['project_scope'] == candidate['project_scope'] + assert receipt['capability']['scope'] == 'project' + assert provider.calls.count('verify-project') == 2 + restored = rollback_material_candidate_intake( + provider=provider, provider_id=provider.provider_id, apply_receipt=receipt, + owner_gate_ref='gate:material-input', receipt_id='receipt:project-rollback', + observed_at=OBSERVED_AT, + ) + assert restored['project_scope'] == candidate['project_scope'] + assert 'goal_id' not in restored + assert restored['capability']['scope'] == 'project' + assert provider.authority_revision == 'revision:42' + + +def test_project_intake_requires_a_source_verifier_before_provider_access(): + provider = FakeCandidateProvider() + with pytest.raises(ValueError, match='source authorization verifier'): + apply_project(provider) + assert provider.calls == [] + + +@pytest.mark.parametrize('field,value', [ + ('project_ref', 'project:other'), + ('source_profile_ref', 'profile:other'), + ('workspace_grant_ref', 'grant:read-only'), +]) +def test_project_scope_selectors_cannot_borrow_other_authority(field, value): + provider = AuthorizedProjectProvider() + with pytest.raises(ValueError, match='authorization was not verified'): + apply_project(provider, project_proposal(**{field: value})) + assert provider.calls == ['verify-project'] + assert provider.authority_revision == 'revision:42' + + +def test_project_gate_must_match_the_exact_source_operation(): + provider = AuthorizedProjectProvider() + with pytest.raises(ValueError, match='authorization was not verified'): + apply_project(provider, gate='gate:unrelated') + assert provider.calls == ['verify-project'] + + +def test_project_revocation_after_staging_blocks_authority_cutover(): + provider = AuthorizedProjectProvider() + provider.revoke_after_stage = True + with pytest.raises(ValueError, match='authorization was not verified'): + apply_project(provider) + assert provider.authority_revision == 'revision:42' + assert provider.calls.count('verify-project') == 2 + assert 'switch' not in provider.calls + + +def test_revoked_project_scope_blocks_rollback_before_provider_access(): + provider = AuthorizedProjectProvider() + receipt = apply_project(provider) + provider.write_allowed = False + provider.calls.clear() + with pytest.raises(ValueError, match='authorization was not verified'): + rollback_material_candidate_intake( + provider=provider, provider_id=provider.provider_id, apply_receipt=receipt, + owner_gate_ref='gate:material-input', receipt_id='receipt:project-rollback', + observed_at=OBSERVED_AT, + ) + assert provider.calls == ['verify-project'] + assert provider.authority_revision == 'revision:43' + + +@pytest.mark.parametrize('goal_id,scope', [ + (None, None), + ('goal:manager', {'project_ref': 'project:alpha', 'source_profile_ref': 'profile:materials', + 'workspace_grant_ref': 'grant:workspace-write'}), + (None, {'project_ref': 'project:alpha'}), + (None, {'project_ref': 'project:alpha', 'source_profile_ref': 'https://example.test/private', + 'workspace_grant_ref': 'grant:workspace-write'}), +]) +def test_material_owner_requires_one_complete_public_safe_scope(goal_id, scope): + from loopx.capabilities.material_lifecycle.ownership import material_owner_fields + with pytest.raises((ValueError, TypeError)): + material_owner_fields(goal_id=goal_id, project_scope=scope) + + +def test_project_metadata_cannot_reuse_a_goal_capability_declaration(): + provider = AuthorizedProjectProvider() + candidate = project_proposal() + candidate['capability']['scope'] = 'goal' + with pytest.raises(ValueError, match='project capability scope'): + apply_project(provider, candidate) + assert provider.calls == [] + + + +def test_project_rollback_rejects_a_mismatched_capability_before_provider_access(): + provider = AuthorizedProjectProvider() + receipt = apply_project(provider) + receipt['capability']['scope'] = 'goal' + provider.calls.clear() + with pytest.raises(ValueError, match='project capability scope'): + rollback_material_candidate_intake( + provider=provider, provider_id=provider.provider_id, apply_receipt=receipt, + owner_gate_ref='gate:material-input', receipt_id='receipt:project-rollback', + observed_at=OBSERVED_AT, + ) + assert provider.calls == [] + assert provider.authority_revision == 'revision:43' + + +def test_project_verifier_must_return_true_rather_than_a_truthy_value(): + class TruthyProvider(AuthorizedProjectProvider): + def verify_project_scope(self, **kwargs): + super().verify_project_scope(**kwargs) + return 1 + + provider = TruthyProvider() + with pytest.raises(ValueError, match='authorization was not verified'): + apply_project(provider) + assert provider.calls == ['verify-project'] diff --git a/tests/capabilities/test_material_project_scope.py b/tests/capabilities/test_material_project_scope.py new file mode 100644 index 0000000000..9c5cd784a8 --- /dev/null +++ b/tests/capabilities/test_material_project_scope.py @@ -0,0 +1,65 @@ +"""Project material packets keep source ownership without a synthetic Goal.""" +from loopx.capabilities.material_lifecycle import ( + MaterialProjectScope, + build_material_intake_ranking_settlement, + build_material_readable_projection, + build_material_rerank_apply_receipt, + build_material_rerank_proposal, + build_material_store_inventory, +) + +SCOPE = MaterialProjectScope('project:alpha', 'profile:materials', 'grant:workspace-write') +NOW = '2026-10-04T08:30:00+00:00' + + +def test_project_inventory_ranking_projection_and_settlement_share_owner(): + inventory = build_material_store_inventory( + project_scope=SCOPE, store_id='store:materials', store_revision='revision:42', + observed_at=NOW, source_snapshot_ref='snapshot:42', backup_ref='backup:42', + source_digest='digest:42', lifecycle_counts={}, stable_ids_verified=True, + backup_verified=True, + ) + assert inventory['item_count'] == 0 + proposal = build_material_rerank_proposal( + project_scope=SCOPE, proposal_id='proposal:rank', inventory_ref=inventory['inventory_ref'], + decision_evidence_ref='decision:project-priorities', observed_at=NOW, + target_window_size=30, max_moved_items=3, max_rank_displacement=3, + no_change_reason='initial project policy is unchanged', + ) + ranking = build_material_rerank_apply_receipt( + project_scope=SCOPE, receipt_id='receipt:ranking', proposal_ref=proposal['proposal_ref'], + observed_at=NOW, status='applied', before_revision='revision:43', after_revision='revision:44', + owner_gate_ref='gate:material-input', validation_ref='validation:ranking', + applied_material_refs=['material:one'], rollback_ref='rollback:ranking', + ) + rendered, projection = build_material_readable_projection( + project_scope=SCOPE, projection_id='projection:current', authority_revision='revision:44', + observed_at=NOW, entries=[{ + 'entry_ref': 'entry:one', 'rank': 1, 'title': 'One source', 'stage': 'candidate', + 'judgment': 'Current project relevance', 'action': 'Read the source', + 'materials': [{'material_ref': 'material:one', 'title': 'One source', + 'summary': 'Synthetic source text', 'judgment': 'Project relevance', + 'action': 'Read the source', 'audit_ref': 'audit:one', + 'source_status': 'Exact read completed'}], + }], expected_material_refs=['material:one'], canonical_item_count=1, top_window_size=1, + ) + settlement = build_material_intake_ranking_settlement( + project_scope=SCOPE, settlement_id='settlement:one', material_ref='material:one', + observed_at=NOW, decision_evidence_ref='decision:project-priorities', + value_classification='high_value', ranking_disposition='top_window', + intake_before_revision='revision:42', intake_after_revision='revision:43', + ranking_before_revision='revision:43', ranking_after_revision='revision:44', + intake_receipt_ref='receipt:intake', ranking_receipt_ref=ranking['receipt_ref'], + owner_gate_ref='gate:material-input', validation_ref='validation:settlement', + top_window_size=30, target_rank=1, authority_readback_verified=True, + ranking_source_contains_material_verified=True, ranked_membership_verified=True, + projection_receipt_ref=projection['receipt_ref'], rollback_ref='rollback:ranking', + ) + assert b'Synthetic source text' in rendered + for packet in [inventory, proposal, ranking, projection, settlement]: + assert 'goal_id' not in packet + assert packet['project_scope'] == inventory['project_scope'] + assert packet['capability']['scope'] == 'project' + assert packet['capability']['default_enabled'] is False + assert packet['capability']['creates_authority'] is False + assert packet['capability']['mutates_core_state'] is False