nutria-plugin 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/CHANGELOG.md +6 -0
  2. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/PKG-INFO +13 -7
  3. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/README.md +12 -6
  4. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/index.md +3 -3
  5. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/manifest.md +4 -4
  6. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/python-api.md +2 -2
  7. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/quickstart.md +9 -4
  8. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/reviewable-actions.md +4 -4
  9. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/pyproject.toml +2 -1
  10. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/__init__.py +1 -1
  11. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/capabilities.py +25 -7
  12. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/manifest.py +25 -1
  13. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/packaging.py +8 -1
  14. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_bundle.py +9 -2
  15. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_capability_contracts.py +70 -8
  16. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_manifest.py +28 -8
  17. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_reviewable_actions.py +24 -3
  18. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_signing.py +1 -1
  19. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/uv.lock +3 -1
  20. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/.github/workflows/publish.yml +0 -0
  21. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/.gitignore +0 -0
  22. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/admin-extensions.md +0 -0
  23. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/admin-flows.md +0 -0
  24. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/cli.md +0 -0
  25. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/connection-types.md +0 -0
  26. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/security.md +0 -0
  27. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/docs/skill-format.md +0 -0
  28. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/README.md +0 -0
  29. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/hooks/hooks.json +0 -0
  30. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/plugin.json +0 -0
  31. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/examples/my-first-plugin/settings.schema.json +0 -0
  32. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/bundle.py +0 -0
  33. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/cli.py +0 -0
  34. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/src/nutria_plugin/signing.py +0 -0
  35. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_cli.py +0 -0
  36. {nutria_plugin-0.2.3 → nutria_plugin-0.3.0}/tests/test_packaging.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Replace adapter-specific prepared actions with one portable exact-preview protocol.
6
+ - Require typed value/resource inputs with explicit origin and sensitivity.
7
+ - Require JSON result paths for model-visible read outputs and reject schema 2.2.
8
+
3
9
  ## 0.2.3
4
10
 
5
11
  - Make manifest schema 2.2 the only accepted plugin contract.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nutria-plugin
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: SDK for building, validating, signing, and packaging Nutria plugins
5
5
  Project-URL: Homepage, https://github.com/AlRos14/nutria-plugin-sdk
6
6
  Project-URL: Repository, https://github.com/AlRos14/nutria-plugin-sdk
@@ -21,8 +21,8 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  SDK for building, validating, signing, and packaging Nutria plugins.
23
23
 
24
- Release `0.2.3` is intentionally breaking. It accepts only manifest schema
25
- `2.2`, requires typed capabilities and world providers, and makes authority,
24
+ Release `0.3.0` is intentionally breaking. It accepts only manifest schema
25
+ `3.0`, requires typed capabilities and world providers, and makes authority,
26
26
  audience, task-context requirements, and exposure explicit. There is no runtime
27
27
  migration for schema 2.0/2.1 manifests and no compatibility/model-callability
28
28
  field.
@@ -34,7 +34,7 @@ and exact delivery of an approved snapshot.
34
34
  ## Install
35
35
 
36
36
  ```bash
37
- uv add nutria-plugin==0.2.3
37
+ uv add nutria-plugin==0.3.0
38
38
  ```
39
39
 
40
40
  ## Scaffold and validate
@@ -49,11 +49,11 @@ The generated directory contains `plugin.json`, component directories for
49
49
  connections, skills, context documents, specs and hooks, plus an optional
50
50
  settings schema and assets.
51
51
 
52
- ## Minimal schema 2.2 manifest
52
+ ## Minimal schema 3.0 manifest
53
53
 
54
54
  ```json
55
55
  {
56
- "schema_version": "2.2",
56
+ "schema_version": "3.0",
57
57
  "id": "my-workspace-plugin",
58
58
  "name": "My Workspace Plugin",
59
59
  "version": "0.1.0",
@@ -74,7 +74,13 @@ settings schema and assets.
74
74
  "audience": ["team_internal"],
75
75
  "task_context": "optional"
76
76
  },
77
- "exposure": "model"
77
+ "exposure": "model",
78
+ "produces": [{
79
+ "result_path": ".items",
80
+ "resource_type": "workspace.item",
81
+ "output_name": "items",
82
+ "many": true
83
+ }]
78
84
  }],
79
85
  "world_providers": [{
80
86
  "id": "workspace",
@@ -2,8 +2,8 @@
2
2
 
3
3
  SDK for building, validating, signing, and packaging Nutria plugins.
4
4
 
5
- Release `0.2.3` is intentionally breaking. It accepts only manifest schema
6
- `2.2`, requires typed capabilities and world providers, and makes authority,
5
+ Release `0.3.0` is intentionally breaking. It accepts only manifest schema
6
+ `3.0`, requires typed capabilities and world providers, and makes authority,
7
7
  audience, task-context requirements, and exposure explicit. There is no runtime
8
8
  migration for schema 2.0/2.1 manifests and no compatibility/model-callability
9
9
  field.
@@ -15,7 +15,7 @@ and exact delivery of an approved snapshot.
15
15
  ## Install
16
16
 
17
17
  ```bash
18
- uv add nutria-plugin==0.2.3
18
+ uv add nutria-plugin==0.3.0
19
19
  ```
20
20
 
21
21
  ## Scaffold and validate
@@ -30,11 +30,11 @@ The generated directory contains `plugin.json`, component directories for
30
30
  connections, skills, context documents, specs and hooks, plus an optional
31
31
  settings schema and assets.
32
32
 
33
- ## Minimal schema 2.2 manifest
33
+ ## Minimal schema 3.0 manifest
34
34
 
35
35
  ```json
36
36
  {
37
- "schema_version": "2.2",
37
+ "schema_version": "3.0",
38
38
  "id": "my-workspace-plugin",
39
39
  "name": "My Workspace Plugin",
40
40
  "version": "0.1.0",
@@ -55,7 +55,13 @@ settings schema and assets.
55
55
  "audience": ["team_internal"],
56
56
  "task_context": "optional"
57
57
  },
58
- "exposure": "model"
58
+ "exposure": "model",
59
+ "produces": [{
60
+ "result_path": ".items",
61
+ "resource_type": "workspace.item",
62
+ "output_name": "items",
63
+ "many": true
64
+ }]
59
65
  }],
60
66
  "world_providers": [{
61
67
  "id": "workspace",
@@ -1,10 +1,10 @@
1
1
  # nutria-plugin SDK documentation
2
2
 
3
- Developer reference for SDK `0.2.3` and manifest schema `2.2`.
3
+ Developer reference for SDK `0.3.0` and manifest schema `3.0`.
4
4
 
5
5
  | Document | Purpose |
6
6
  |---|---|
7
- | [quickstart.md](quickstart.md) | Build a strict schema 2.2 plugin |
7
+ | [quickstart.md](quickstart.md) | Build a strict schema 3.0 plugin |
8
8
  | [manifest.md](manifest.md) | Manifest, capability, and provider contracts |
9
9
  | [reviewable-actions.md](reviewable-actions.md) | Host-owned drafts and external delivery |
10
10
  | [python-api.md](python-api.md) | Public Python API |
@@ -16,6 +16,6 @@ Developer reference for SDK `0.2.3` and manifest schema `2.2`.
16
16
  | [cli.md](cli.md) | CLI commands |
17
17
 
18
18
  A plugin bundle can contain connections, skills, context documents, hooks,
19
- settings, specs, and declarative admin assets. Its schema 2.2 manifest must also
19
+ settings, specs, and declarative admin assets. Its schema 3.0 manifest must also
20
20
  declare at least one typed capability and world provider. Older schemas are not
21
21
  loaded or migrated.
@@ -1,14 +1,14 @@
1
1
  # plugin.json manifest reference
2
2
 
3
3
  `plugin.json` is the single source of truth for plugin identity, runtime,
4
- capability authority, and provider topology. SDK 0.2.3 accepts exactly schema
5
- `2.2`; older schemas and unknown fields fail validation.
4
+ capability authority, and provider topology. SDK 0.3.0 accepts exactly schema
5
+ `3.0`; older schemas and unknown fields fail validation.
6
6
 
7
7
  ## Required top-level fields
8
8
 
9
9
  | Field | Contract |
10
10
  |---|---|
11
- | `schema_version` | Literal `"2.2"` |
11
+ | `schema_version` | Literal `"3.0"` |
12
12
  | `id` | Lowercase plugin slug |
13
13
  | `name`, `description`, `author` | Non-empty display metadata |
14
14
  | `version` | Semantic version |
@@ -19,7 +19,7 @@ capability authority, and provider topology. SDK 0.2.3 accepts exactly schema
19
19
  Optional fields include `default_scope`, `paths`, `required_secrets`,
20
20
  `optional_secrets`, `remote_endpoints`, `tags`, `reviewable_actions`,
21
21
  `admin_extensions`, `admin_flows`, `mcp_server_entry`, `homepage`, `license`,
22
- and `signature`. A `compatibility` field is not part of schema 2.2.
22
+ and `signature`. A `compatibility` field is not part of schema 3.0.
23
23
 
24
24
  ## Capability descriptor
25
25
 
@@ -1,6 +1,6 @@
1
1
  # Python API reference
2
2
 
3
- SDK 0.2.3 exposes the strict schema 2.2 API.
3
+ SDK 0.3.0 exposes the strict schema 3.0 API.
4
4
 
5
5
  ```python
6
6
  from nutria_plugin import (
@@ -39,7 +39,7 @@ manifest.to_file(Path("plugin.json"))
39
39
  payload = manifest.model_dump(mode="json", exclude_none=True)
40
40
  ```
41
41
 
42
- Validation is strict: schema version must be `2.2`, top-level extras are
42
+ Validation is strict: schema version must be `3.0`, top-level extras are
43
43
  rejected, and capability/provider/action references are checked together.
44
44
 
45
45
  ## Bundles and packaging
@@ -1,9 +1,9 @@
1
- # Quickstart: a schema 2.2 plugin
1
+ # Quickstart: a schema 3.0 plugin
2
2
 
3
3
  ## Install and scaffold
4
4
 
5
5
  ```bash
6
- uv add nutria-plugin==0.2.3
6
+ uv add nutria-plugin==0.3.0
7
7
  nutria-plugin new inventory-lookup --name "Inventory Lookup"
8
8
  ```
9
9
 
@@ -11,7 +11,7 @@ nutria-plugin new inventory-lookup --name "Inventory Lookup"
11
11
 
12
12
  ```json
13
13
  {
14
- "schema_version": "2.2",
14
+ "schema_version": "3.0",
15
15
  "id": "inventory-lookup",
16
16
  "name": "Inventory Lookup",
17
17
  "version": "0.1.0",
@@ -32,7 +32,12 @@ nutria-plugin new inventory-lookup --name "Inventory Lookup"
32
32
  "audience": ["team_internal", "public_customer"],
33
33
  "task_context": "optional"
34
34
  },
35
- "exposure": "model"
35
+ "exposure": "model",
36
+ "produces": [{
37
+ "result_path": ".stock",
38
+ "resource_type": "warehouse.stock",
39
+ "output_name": "stock"
40
+ }]
36
41
  }],
37
42
  "world_providers": [{
38
43
  "id": "warehouse",
@@ -1,6 +1,6 @@
1
1
  # Reviewable actions and external writes
2
2
 
3
- SDK 0.2.3 schema 2.2 defines one reviewable-action architecture:
3
+ SDK 0.3.0 schema 3.0 defines one reviewable-action architecture:
4
4
 
5
5
  ```text
6
6
  ChatBotNutralia -> task context, encrypted draft, revision, approval, idempotency, receipts
@@ -36,9 +36,9 @@ requirements, input bindings, and `reviewable_action_id`:
36
36
  },
37
37
  "reviewable_action_id": "whatsapp-text",
38
38
  "inputs": [
39
- {"semantic_field": "recipient", "argument_name": "recipient"},
40
- {"semantic_field": "body", "argument_name": "body"},
41
- {"semantic_field": "idempotency_key", "argument_name": "idempotency_key"}
39
+ {"kind": "value", "semantic_field": "recipient", "argument_name": "recipient", "sensitivity": "personal", "accepted_origins": ["current_user", "world_resource"]},
40
+ {"kind": "value", "semantic_field": "body", "argument_name": "body", "sensitivity": "personal", "accepted_origins": ["current_user"]},
41
+ {"kind": "value", "semantic_field": "idempotency_key", "argument_name": "idempotency_key", "sensitivity": "safe", "accepted_origins": ["current_user"]}
42
42
  ]
43
43
  }
44
44
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nutria-plugin"
7
- version = "0.2.3"
7
+ version = "0.3.0"
8
8
  description = "SDK for building, validating, signing, and packaging Nutria plugins"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -48,4 +48,5 @@ target-version = "py311"
48
48
  dev = [
49
49
  "pytest>=9.0.2",
50
50
  "pytest-asyncio>=1.3.0",
51
+ "ruff>=0.8",
51
52
  ]
@@ -15,7 +15,7 @@ Signing:
15
15
  generate_keypair, sign_manifest, verify_manifest, SignatureStatus
16
16
  """
17
17
 
18
- __version__ = "0.2.3"
18
+ __version__ = "0.3.0"
19
19
 
20
20
  from .capabilities import (
21
21
  CapabilityDescriptor,
@@ -36,16 +36,12 @@ class NonCallableReason(BaseModel):
36
36
 
37
37
 
38
38
  class PreparedActionDescriptor(BaseModel):
39
- """Exact-preview contract used by the host prepared-action boundary."""
39
+ """Portable exact-preview contract used by every Nutria host."""
40
40
 
41
41
  preview_argument: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
42
42
  preview_value: Any
43
43
  execute_value: Any
44
- adapter: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
45
44
  ttl_seconds: int = Field(..., ge=60, le=86_400)
46
- merge_previews: bool
47
- guard_mode: str = Field(..., pattern=r"^(always|pending_only)$")
48
- argument_default: Any
49
45
 
50
46
  model_config = {"extra": "forbid"}
51
47
 
@@ -152,12 +148,15 @@ class ResourceBinding(BaseModel):
152
148
 
153
149
 
154
150
  class CapabilityInputBinding(BaseModel):
155
- """Maps a semantic input to one plugin tool argument."""
151
+ """Maps a typed value or world resource to one plugin argument."""
156
152
 
153
+ kind: Literal["value", "resource"]
157
154
  semantic_field: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
158
155
  argument_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_-]{0,127}$")
159
156
  resource_type: ResourceType | str | None = None
160
157
  required: bool = True
158
+ sensitivity: Literal["safe", "personal"] = "safe"
159
+ accepted_origins: list[Literal["current_user", "world_resource"]] = Field(min_length=1)
161
160
 
162
161
  model_config = {"extra": "forbid"}
163
162
 
@@ -168,11 +167,21 @@ class CapabilityInputBinding(BaseModel):
168
167
  _validate_resource_type_id(value)
169
168
  return value
170
169
 
170
+ @model_validator(mode="after")
171
+ def _validate_kind(self) -> "CapabilityInputBinding":
172
+ if self.kind == "resource" and self.resource_type is None:
173
+ raise ValueError("resource inputs require resource_type")
174
+ if self.kind == "value" and self.resource_type is not None:
175
+ raise ValueError("value inputs must not declare resource_type")
176
+ if self.sensitivity == "personal" and "current_user" not in self.accepted_origins:
177
+ raise ValueError("personal inputs must accept current_user evidence")
178
+ return self
179
+
171
180
 
172
181
  class CapabilityOutputBinding(BaseModel):
173
182
  """Maps one plugin result field to a typed resource output."""
174
183
 
175
- field_name: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$")
184
+ result_path: str = Field(..., pattern=r"^\$?(?:\.[a-zA-Z][a-zA-Z0-9_-]*)+$")
176
185
  resource_type: ResourceType | str
177
186
  output_name: str = Field(..., pattern=r"^[a-z][a-z0-9_.-]{0,127}$")
178
187
  many: bool = False
@@ -335,6 +344,15 @@ class CapabilityDescriptor(BaseModel):
335
344
  raise ValueError(
336
345
  "model-selectable external writes require execution idempotency"
337
346
  )
347
+ if self.exposure == CapabilityExposure.MODEL and self.effect == CapabilityEffect.READ:
348
+ if not self.produces:
349
+ raise ValueError("model-selectable reads require declared outputs")
350
+ if self.exposure == CapabilityExposure.MODEL and self.effect in {
351
+ CapabilityEffect.WRITE,
352
+ CapabilityEffect.EXTERNAL_WRITE,
353
+ }:
354
+ if not self.inputs:
355
+ raise ValueError("model-selectable writes require declared inputs")
338
356
  return self
339
357
 
340
358
 
@@ -315,7 +315,7 @@ class PluginAdminFlow(BaseModel):
315
315
  class PluginManifest(BaseModel):
316
316
  """Manifest stored in plugin.json — the single source of truth for plugin metadata."""
317
317
 
318
- schema_version: Literal["2.2"]
318
+ schema_version: Literal["3.0"]
319
319
  id: str = Field(..., pattern=r"^[a-z][a-z0-9\-]*$", max_length=64)
320
320
  name: str = Field(..., min_length=1, max_length=128)
321
321
  version: str = Field(..., min_length=5, max_length=64)
@@ -383,6 +383,30 @@ class PluginManifest(BaseModel):
383
383
  "capability connections require a matching world provider: "
384
384
  + ", ".join(sorted(missing_provider_connections))
385
385
  )
386
+ provider_resource_types = {
387
+ provider.connection_id: {
388
+ resource.id.value if isinstance(resource.id, ResourceType) else str(resource.id)
389
+ for resource in provider.resource_types
390
+ }
391
+ for provider in self.world_providers
392
+ if provider.connection_id
393
+ }
394
+ for capability in self.capabilities:
395
+ if not capability.connection_id:
396
+ continue
397
+ available_types = provider_resource_types.get(capability.connection_id, set())
398
+ undeclared = {
399
+ output.resource_type.value
400
+ if isinstance(output.resource_type, ResourceType)
401
+ else str(output.resource_type)
402
+ for output in capability.produces
403
+ } - available_types
404
+ if undeclared:
405
+ raise ValueError(
406
+ f"capability {capability.id!r} produces resource types not declared by "
407
+ f"provider {capability.connection_id!r}: "
408
+ + ", ".join(sorted(undeclared))
409
+ )
386
410
  for provider in self.world_providers:
387
411
  for resource in provider.resource_types:
388
412
  resource_id = resource.id.value if isinstance(resource.id, ResourceType) else str(resource.id)
@@ -25,7 +25,7 @@ class PackagingError(Exception):
25
25
  SCAFFOLD_TEMPLATE = {
26
26
  "plugin.json": lambda plugin_id, name: json.dumps(
27
27
  {
28
- "schema_version": "2.2",
28
+ "schema_version": "3.0",
29
29
  "id": plugin_id,
30
30
  "name": name,
31
31
  "version": "0.1.0",
@@ -49,6 +49,13 @@ SCAFFOLD_TEMPLATE = {
49
49
  "task_context": "optional",
50
50
  },
51
51
  "exposure": "model",
52
+ "produces": [
53
+ {
54
+ "result_path": ".health",
55
+ "resource_type": f"{plugin_id}.resource",
56
+ "output_name": "health",
57
+ }
58
+ ],
52
59
  }
53
60
  ],
54
61
  "world_providers": [
@@ -28,7 +28,7 @@ def _make_zip(files: dict[str, str]) -> bytes:
28
28
 
29
29
  VALID_MANIFEST = json.dumps(
30
30
  {
31
- "schema_version": "2.2",
31
+ "schema_version": "3.0",
32
32
  "id": "test-plugin",
33
33
  "name": "Test",
34
34
  "version": "1.0.0",
@@ -48,7 +48,14 @@ VALID_MANIFEST = json.dumps(
48
48
  "audience": ["private_internal"],
49
49
  "task_context": "optional"
50
50
  },
51
- "exposure": "model"
51
+ "exposure": "model",
52
+ "produces": [
53
+ {
54
+ "result_path": ".health",
55
+ "resource_type": "test.resource",
56
+ "output_name": "health"
57
+ }
58
+ ]
52
59
  }
53
60
  ],
54
61
  "world_providers": [
@@ -1,4 +1,4 @@
1
- """Strict world-graph capability contracts introduced in SDK 0.2.3."""
1
+ """Strict world-graph capability contracts introduced in SDK 0.3.0."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -21,6 +21,15 @@ def _capability(**overrides):
21
21
  "task_context": "optional",
22
22
  },
23
23
  "exposure": "model",
24
+ "inputs": [
25
+ {
26
+ "kind": "value",
27
+ "semantic_field": "recipient",
28
+ "argument_name": "recipient",
29
+ "sensitivity": "personal",
30
+ "accepted_origins": ["current_user"],
31
+ }
32
+ ],
24
33
  }
25
34
  payload.update(overrides)
26
35
  return payload
@@ -32,11 +41,7 @@ def _safety_contracts():
32
41
  "preview_argument": "preview_only",
33
42
  "preview_value": True,
34
43
  "execute_value": False,
35
- "adapter": "exact_preview",
36
44
  "ttl_seconds": 3600,
37
- "merge_previews": True,
38
- "guard_mode": "pending_only",
39
- "argument_default": True,
40
45
  },
41
46
  "idempotency": {
42
47
  "argument_name": "idempotency_key",
@@ -47,13 +52,33 @@ def _safety_contracts():
47
52
 
48
53
 
49
54
  def test_model_capability_requires_explicit_model_exposure():
50
- descriptor = CapabilityDescriptor.model_validate(_capability(effect="read"))
55
+ descriptor = CapabilityDescriptor.model_validate(
56
+ _capability(
57
+ effect="read",
58
+ produces=[
59
+ {
60
+ "result_path": ".shipment",
61
+ "resource_type": "mrw.shipment",
62
+ "output_name": "shipment",
63
+ }
64
+ ],
65
+ )
66
+ )
51
67
  assert descriptor.exposure == CapabilityExposure.MODEL
52
68
  assert descriptor.non_callable_reason is None
53
69
 
54
70
 
55
71
  def test_missing_exposure_is_rejected():
56
- payload = _capability(effect="read")
72
+ payload = _capability(
73
+ effect="read",
74
+ produces=[
75
+ {
76
+ "result_path": ".shipment",
77
+ "resource_type": "mrw.shipment",
78
+ "output_name": "shipment",
79
+ }
80
+ ],
81
+ )
57
82
  payload.pop("exposure")
58
83
  with pytest.raises(ValidationError, match="exposure"):
59
84
  CapabilityDescriptor.model_validate(payload)
@@ -82,7 +107,7 @@ def test_model_selectable_external_write_requires_all_safety_contracts():
82
107
  descriptor = CapabilityDescriptor.model_validate(
83
108
  _capability(**_safety_contracts())
84
109
  )
85
- assert descriptor.prepared_action.adapter == "exact_preview"
110
+ assert descriptor.prepared_action.preview_argument == "preview_only"
86
111
  assert descriptor.idempotency.required_for_execution is True
87
112
  assert descriptor.completion.receipts == ["shipping.shipment.created"]
88
113
 
@@ -111,5 +136,42 @@ def test_task_owned_resources_require_task_context():
111
136
  _capability(
112
137
  effect="read",
113
138
  consumes=[{"name": "action", "resource_type": "prepared_action"}],
139
+ produces=[
140
+ {
141
+ "result_path": ".shipment",
142
+ "resource_type": "mrw.shipment",
143
+ "output_name": "shipment",
144
+ }
145
+ ],
146
+ )
147
+ )
148
+
149
+
150
+ def test_model_read_requires_typed_output():
151
+ with pytest.raises(ValidationError, match="declared outputs"):
152
+ CapabilityDescriptor.model_validate(_capability(effect="read", inputs=[]))
153
+
154
+
155
+ def test_unknown_prepared_action_adapter_is_rejected():
156
+ contracts = _safety_contracts()
157
+ contracts["prepared_action"]["adapter"] = "plugin.argument_toggle"
158
+ with pytest.raises(ValidationError, match="adapter"):
159
+ CapabilityDescriptor.model_validate(_capability(**contracts))
160
+
161
+
162
+ def test_personal_input_requires_current_user_origin():
163
+ with pytest.raises(ValidationError, match="current_user"):
164
+ CapabilityDescriptor.model_validate(
165
+ _capability(
166
+ inputs=[
167
+ {
168
+ "kind": "value",
169
+ "semantic_field": "recipient",
170
+ "argument_name": "recipient",
171
+ "sensitivity": "personal",
172
+ "accepted_origins": ["world_resource"],
173
+ }
174
+ ],
175
+ **_safety_contracts(),
114
176
  )
115
177
  )
@@ -18,7 +18,7 @@ from nutria_plugin.manifest import (
18
18
 
19
19
  def _minimal_manifest(**overrides) -> dict:
20
20
  base = {
21
- "schema_version": "2.2",
21
+ "schema_version": "3.0",
22
22
  "id": "test-plugin",
23
23
  "name": "Test Plugin",
24
24
  "version": "1.0.0",
@@ -39,6 +39,13 @@ def _minimal_manifest(**overrides) -> dict:
39
39
  "task_context": "optional",
40
40
  },
41
41
  "exposure": "model",
42
+ "produces": [
43
+ {
44
+ "result_path": ".health",
45
+ "resource_type": "test.resource",
46
+ "output_name": "health",
47
+ }
48
+ ],
42
49
  }
43
50
  ],
44
51
  "world_providers": [
@@ -97,21 +104,21 @@ def test_version_must_be_semver():
97
104
  PluginManifest.model_validate(_minimal_manifest(version="1.0"))
98
105
 
99
106
 
100
- def test_schema_version_must_be_2_2():
107
+ def test_schema_version_must_be_3_0():
101
108
  with pytest.raises(ValidationError):
102
109
  PluginManifest.model_validate(_minimal_manifest(schema_version="1.1"))
103
110
 
104
111
 
105
- @pytest.mark.parametrize("version", ["1.0", "2.0", "2.1", "2.3"])
112
+ @pytest.mark.parametrize("version", ["1.0", "2.0", "2.1", "2.2", "2.3"])
106
113
  def test_other_schema_versions_are_rejected(version):
107
114
  with pytest.raises(ValidationError):
108
115
  PluginManifest.model_validate(_minimal_manifest(schema_version=version))
109
116
 
110
117
 
111
- def test_schema_2_2_world_provider_accepts_custom_resource_type():
118
+ def test_schema_3_0_world_provider_accepts_custom_resource_type():
112
119
  manifest = PluginManifest.model_validate(
113
120
  _minimal_manifest(
114
- schema_version="2.2",
121
+ schema_version="3.0",
115
122
  capabilities=[
116
123
  {
117
124
  "id": "workspace.search",
@@ -128,7 +135,7 @@ def test_schema_2_2_world_provider_accepts_custom_resource_type():
128
135
  "exposure": "model",
129
136
  "produces": [
130
137
  {
131
- "field_name": "items",
138
+ "result_path": ".items",
132
139
  "resource_type": "workspace.item",
133
140
  "output_name": "items",
134
141
  "many": True,
@@ -159,7 +166,7 @@ def test_schema_2_2_world_provider_accepts_custom_resource_type():
159
166
  )
160
167
  )
161
168
 
162
- assert manifest.schema_version == "2.2"
169
+ assert manifest.schema_version == "3.0"
163
170
  assert manifest.world_providers[0].resource_types[0].id == "workspace.item"
164
171
 
165
172
 
@@ -167,7 +174,7 @@ def test_world_provider_rejects_unknown_capability_reference():
167
174
  with pytest.raises(ValidationError, match="unknown capability"):
168
175
  PluginManifest.model_validate(
169
176
  _minimal_manifest(
170
- schema_version="2.2",
177
+ schema_version="3.0",
171
178
  world_providers=[
172
179
  {
173
180
  "id": "workspace",
@@ -175,6 +182,12 @@ def test_world_provider_rejects_unknown_capability_reference():
175
182
  "description": "Authoritative workspace provider.",
176
183
  "connection_id": "test",
177
184
  "resource_types": [
185
+ {
186
+ "id": "test.resource",
187
+ "title": "Test resource",
188
+ "description": "One stable test resource.",
189
+ "identity_fields": ["id"],
190
+ },
178
191
  {
179
192
  "id": "workspace.item",
180
193
  "title": "Workspace item",
@@ -189,6 +202,13 @@ def test_world_provider_rejects_unknown_capability_reference():
189
202
  )
190
203
 
191
204
 
205
+ def test_capability_output_requires_provider_resource_declaration():
206
+ payload = _minimal_manifest()
207
+ payload["capabilities"][0]["produces"][0]["resource_type"] = "missing.resource"
208
+ with pytest.raises(ValidationError, match="not declared by provider"):
209
+ PluginManifest.model_validate(payload)
210
+
211
+
192
212
  def test_empty_runtime_types_rejected():
193
213
  with pytest.raises(ValidationError):
194
214
  PluginManifest.model_validate(_minimal_manifest(runtime_types=[]))
@@ -60,31 +60,52 @@ def _capabilities():
60
60
  {"name": "action", "resource_type": "prepared_action"}
61
61
  ],
62
62
  "inputs": [
63
- {"semantic_field": "recipient", "argument_name": "recipient"},
64
- {"semantic_field": "body", "argument_name": "body"},
65
63
  {
64
+ "kind": "value",
65
+ "semantic_field": "recipient",
66
+ "argument_name": "recipient",
67
+ "sensitivity": "personal",
68
+ "accepted_origins": ["current_user", "world_resource"],
69
+ },
70
+ {
71
+ "kind": "value",
72
+ "semantic_field": "body",
73
+ "argument_name": "body",
74
+ "accepted_origins": ["current_user"],
75
+ },
76
+ {
77
+ "kind": "value",
66
78
  "semantic_field": "subject",
67
79
  "argument_name": "subject",
68
80
  "required": False,
81
+ "accepted_origins": ["current_user", "world_resource"],
69
82
  },
70
83
  {
84
+ "kind": "value",
71
85
  "semantic_field": "reply_target",
72
86
  "argument_name": "reply_to_message_id",
73
87
  "required": False,
88
+ "accepted_origins": ["world_resource"],
74
89
  },
75
90
  {
91
+ "kind": "value",
76
92
  "semantic_field": "source_ref",
77
93
  "argument_name": "source_email_id",
78
94
  "required": False,
95
+ "accepted_origins": ["world_resource"],
79
96
  },
80
97
  {
98
+ "kind": "value",
81
99
  "semantic_field": "source_fingerprint",
82
100
  "argument_name": "source_fingerprint",
83
101
  "required": False,
102
+ "accepted_origins": ["world_resource"],
84
103
  },
85
104
  {
105
+ "kind": "value",
86
106
  "semantic_field": "idempotency_key",
87
107
  "argument_name": "idempotency_key",
108
+ "accepted_origins": ["current_user"],
88
109
  },
89
110
  ],
90
111
  "requirements": {
@@ -104,7 +125,7 @@ def _capabilities():
104
125
 
105
126
  def _manifest(**overrides):
106
127
  data = {
107
- "schema_version": "2.2",
128
+ "schema_version": "3.0",
108
129
  "id": "email-plugin",
109
130
  "name": "Email",
110
131
  "version": "1.0.0",
@@ -17,7 +17,7 @@ from nutria_plugin.signing import (
17
17
 
18
18
  def _minimal_dict(**overrides) -> dict:
19
19
  base = {
20
- "schema_version": "2.2",
20
+ "schema_version": "3.0",
21
21
  "id": "test-plugin",
22
22
  "name": "Test",
23
23
  "version": "1.0.0",
@@ -262,7 +262,7 @@ wheels = [
262
262
 
263
263
  [[package]]
264
264
  name = "nutria-plugin"
265
- version = "0.2.2"
265
+ version = "0.3.0"
266
266
  source = { editable = "." }
267
267
  dependencies = [
268
268
  { name = "cryptography" },
@@ -281,6 +281,7 @@ dev = [
281
281
  dev = [
282
282
  { name = "pytest" },
283
283
  { name = "pytest-asyncio" },
284
+ { name = "ruff" },
284
285
  ]
285
286
 
286
287
  [package.metadata]
@@ -298,6 +299,7 @@ provides-extras = ["dev"]
298
299
  dev = [
299
300
  { name = "pytest", specifier = ">=9.0.2" },
300
301
  { name = "pytest-asyncio", specifier = ">=1.3.0" },
302
+ { name = "ruff", specifier = ">=0.8" },
301
303
  ]
302
304
 
303
305
  [[package]]
File without changes
File without changes