diff --git a/README.md b/README.md index beb3a36..e20ac7a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ schema = get_contribution_schema() `validate_record`, `record_errors` and `get_record_schema` handle expanded Registry records. Pass `--record` to the CLI for the corresponding record format. -The package bundles the MAP 0.1 and Registry schemas, the Content Review 0.1 request schema and its canonical type contract. It uses the established `jsonschema` and `referencing` libraries for validation and performs no network requests. +The package bundles the MAP 0.1 and Registry schemas plus the Content Review 0.1 and 0.2 request schemas and canonical type contracts. Unversioned Content Review helpers use 0.2; `*_content_review_01_*` helpers remain available for implementations pinned to 0.1. It uses the established `jsonschema` and `referencing` libraries for validation and performs no network requests. ## Trust boundary diff --git a/UPSTREAM.md b/UPSTREAM.md index 241566d..dd44223 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -1,7 +1,14 @@ # Release provenance -Version `0.1.2` was prepared from [`mailschema/mailschema@eddc745`](https://github.com/mailschema/mailschema/commit/eddc7456910bf6a3c33de51c46ebf50f61ee7342). +Version `0.1.3` derives from [`mailschema/mailschema@f630ec5`](https://github.com/mailschema/mailschema/commit/f630ec5569e7167114fb2a39a10548febfa6b979). -The included contribution schema has SHA-256 digest `a8a241b4837971a6251bcc135c704283536f4fb825fbbaedaf1598c6e688d564`. The canonical schemas live in the main MailSchema repository; this repository owns the Python package surface and its release history. +The bundled files are exact projections of that source commit and do not independently define MAP: -The unreleased MAP contract projection is derived from [`mailschema/mailschema@e2f2cc6`](https://github.com/mailschema/mailschema/commit/e2f2cc6983173e5aba1da27f1f8bff37336efd77). Its MAP schema digest is `c39befeecf8643e8beda1f53a8d1411482b68ddf14974f3806cfa95776103f85`; its Content Review schema digest is `10e2263c820ba3a6ef0381c377078e2c1f324c3b142a6f8caf92b298cc66c692`; and its Content Review contract byte digest is `754ab7245ddb09ff4da000fccc60d746724379d4146ea227973e5d87f105f800`. These files are exact projections of that source commit and do not independently define MAP. +- MAP 0.1 schema: `c39befeecf8643e8beda1f53a8d1411482b68ddf14974f3806cfa95776103f85` +- Content Review 0.1 schema: `10e2263c820ba3a6ef0381c377078e2c1f324c3b142a6f8caf92b298cc66c692` +- Content Review 0.1 contract: `754ab7245ddb09ff4da000fccc60d746724379d4146ea227973e5d87f105f800` +- Content Review 0.2 schema: `d2286ffe410cceae56f9caa145634fd9613f5ce51e11ba089cad67b9f62259b7` +- Content Review 0.2 contract: `3bd1cd4a86e00d00590eeb150b7b29cebece589058d6094a40aa9d497dc8efe0` +- Contribution schema: `a8a241b4837971a6251bcc135c704283536f4fb825fbbaedaf1598c6e688d564` + +The canonical contracts live in the main MailSchema repository. This repository owns the Python package surface and its release history. diff --git a/pyproject.toml b/pyproject.toml index 99f7909..abec2d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mailschema" -version = "0.1.2" +version = "0.1.3" description = "Schemas and validation tools for Mail Action Protocol and the MailSchema Registry" readme = "README.md" license = "MIT" diff --git a/src/mailschema/__init__.py b/src/mailschema/__init__.py index 4a04190..3219808 100644 --- a/src/mailschema/__init__.py +++ b/src/mailschema/__init__.py @@ -7,7 +7,7 @@ from jsonschema import Draft202012Validator, FormatChecker from referencing import Registry, Resource -__version__ = "0.1.2" +__version__ = "0.1.3" def _bundled(name: str) -> dict[str, Any]: @@ -25,12 +25,22 @@ def get_map_schema() -> dict[str, Any]: def get_content_review_schema() -> dict[str, Any]: - """Return a fresh copy of the Content Review 0.1 request schema.""" - return _bundled("content-review-0.1.schema.json") + """Return a fresh copy of the current Content Review 0.2 request schema.""" + return _bundled("content-review-0.2.schema.json") def get_content_review_contract() -> dict[str, Any]: - """Return a fresh copy of the canonical Content Review 0.1 type contract.""" + """Return a fresh copy of the canonical Content Review 0.2 type contract.""" + return _bundled("content-review-0.2.contract.json") + + +def get_content_review_01_schema() -> dict[str, Any]: + """Return a fresh copy of the immutable Content Review 0.1 request schema.""" + return _bundled("content-review-0.1.schema.json") + + +def get_content_review_01_contract() -> dict[str, Any]: + """Return a fresh copy of the immutable Content Review 0.1 type contract.""" return _bundled("content-review-0.1.contract.json") @@ -57,6 +67,11 @@ def get_record_schema() -> dict[str, Any]: registry=Registry().with_resource(_map_schema["$id"], Resource.from_contents(_map_schema)), format_checker=FormatChecker(), ) +_content_review_01_validator = Draft202012Validator( + get_content_review_01_schema(), + registry=Registry().with_resource(_map_schema["$id"], Resource.from_contents(_map_schema)), + format_checker=FormatChecker(), +) def _errors(validator: Draft202012Validator, value: Any) -> list[str]: @@ -85,10 +100,15 @@ def map_errors(value: Any) -> list[str]: def content_review_request_errors(value: Any) -> list[str]: - """Return errors for a Content Review 0.1 MAP request.""" + """Return errors for a Content Review 0.2 MAP request.""" return _errors(_content_review_validator, value) +def content_review_01_request_errors(value: Any) -> list[str]: + """Return errors for an immutable Content Review 0.1 MAP request.""" + return _errors(_content_review_01_validator, value) + + def validate_contribution(value: Any) -> None: """Raise ValueError if a contribution does not match the schema.""" errors = contribution_errors(value) @@ -111,7 +131,14 @@ def validate_map_document(value: Any) -> None: def validate_content_review_request(value: Any) -> None: - """Raise ValueError if a value is not a Content Review 0.1 request.""" + """Raise ValueError if a value is not a Content Review 0.2 request.""" errors = content_review_request_errors(value) + if errors: + raise ValueError("Invalid Content Review 0.2 request:\n" + "\n".join(errors)) + + +def validate_content_review_01_request(value: Any) -> None: + """Raise ValueError if a value is not an immutable Content Review 0.1 request.""" + errors = content_review_01_request_errors(value) if errors: raise ValueError("Invalid Content Review 0.1 request:\n" + "\n".join(errors)) diff --git a/src/mailschema/content-review-0.2.contract.json b/src/mailschema/content-review-0.2.contract.json new file mode 100644 index 0000000..7dec56a --- /dev/null +++ b/src/mailschema/content-review-0.2.contract.json @@ -0,0 +1,91 @@ +{ + "kind": "MapTypeContract", + "id": "https://mailschema.org/types/content-review", + "version": "0.2", + "profile": "https://mailschema.org/profiles/map/0.1", + "target": "Service-owned content at the exact revision and digest named in the request.", + "requestSchema": { + "url": "https://mailschema.org/schemas/content-review-0.2.schema.json", + "canonicalDigest": "sha-256:43a01e620e0730e47755694bdf5d086f8d0a1d3d8328fb0c7d2a4b70c6e62e25" + }, + "operations": [ + { + "id": "request-changes", + "effect": "Record review feedback on the named revision.", + "results": [ + { + "state": "accepted", + "outputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "feedbackRecorded": { + "const": true + } + }, + "required": [ + "feedbackRecorded" + ] + } + } + ] + }, + { + "id": "approve", + "effect": "Record approval of the named revision, or record why a proposed approval ended without approval.", + "results": [ + { + "state": "approval-required", + "outputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "approvalUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + } + }, + "required": [ + "approvalUrl" + ] + } + }, + { + "state": "completed", + "outputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "decision": { + "const": "approved" + } + }, + "required": [ + "decision" + ] + } + }, + { + "state": "failed", + "outputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "enum": [ + "declined", + "stale-target", + "expired" + ] + } + }, + "required": [ + "reason" + ] + } + } + ] + } + ] +} diff --git a/src/mailschema/content-review-0.2.schema.json b/src/mailschema/content-review-0.2.schema.json new file mode 100644 index 0000000..88b5be7 --- /dev/null +++ b/src/mailschema/content-review-0.2.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mailschema.org/schemas/content-review-0.2.schema.json", + "title": "Content Review 0.2 MAP request binding", + "allOf": [ + { "$ref": "https://mailschema.org/schemas/map-0.1.schema.json#/$defs/request" }, + { + "type": "object", + "properties": { + "type": { + "type": "object", + "properties": { + "id": { "const": "https://mailschema.org/types/content-review" }, + "version": { "const": "0.2" } + } + } + } + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "operation": { "const": "request-changes" }, + "input": { + "type": "object", + "additionalProperties": false, + "properties": { + "feedback": { + "type": "string", + "minLength": 1, + "maxLength": 12000, + "pattern": "\\S" + } + }, + "required": ["feedback"] + } + } + }, + { + "type": "object", + "properties": { + "operation": { "const": "approve" }, + "input": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + } + } + } + ] + } + ] +} diff --git a/tests/content-review.json b/tests/content-review.json index d05002d..f01a863 100644 --- a/tests/content-review.json +++ b/tests/content-review.json @@ -1,13 +1,13 @@ { "slug": "content-review", "name": "Content Review", - "summary": "Request changes or approve a specific revision of content.", + "summary": "Request changes or decide approval for a specific revision of content.", "category": "Review", "status": "Draft", - "version": "0.1", + "version": "0.2", "origin": "MailSchema draft", "profile": "https://mailschema.org/profiles/map/0.1", - "overview": "A service sends content for review. The recipient can submit feedback or record approval of the revision they received. Editing the draft and sending or publishing it remain separate service operations.", + "overview": "A service sends content for review. The recipient can submit feedback or decide whether to approve the revision they received. A proposed approval may require an authorized human decision and ends as approved, declined, stale or expired. Editing the draft and sending or publishing it remain separate service operations.", "target": "Service-owned content identified by its exact revision.", "operations": [ { @@ -18,12 +18,12 @@ { "id": "approve", "name": "Approve", - "description": "Record approval of that revision." + "description": "Propose or record approval of that revision." } ], "inputs": "The target revision and, for a change request, review feedback.", - "results": "Feedback recorded or approval recorded, with the revision identified. Requests without permission or against stale revisions leave the current review decision unchanged.", - "permissions": "The service checks the caller’s review permission. Editing and any later sending or publication require their own permissions.", + "results": "Feedback recorded; approval recorded; or a proposed approval closed as declined, stale or expired. Every result identifies the request and exact revision.", + "permissions": "The service checks permission when the request is proposed and again when a human decides it. An authorized teammate may decide for the same service tenant. An unauthorized attempt does not change the proposed request. Editing and any later sending or publication require their own permissions.", "humanRoute": "A review page showing the same content and revision.", "example": { "title": "Review a campaign draft", @@ -37,8 +37,8 @@ }, "references": [ { - "title": "Content Review 0.1 contract", - "href": "https://mailschema.org/contracts/content-review-0.1.json", + "title": "Content Review 0.2 contract", + "href": "https://mailschema.org/contracts/content-review-0.2.json", "description": "Normative canonical type contract used by MAP wire messages." }, { @@ -53,10 +53,14 @@ } ], "openQuestions": [ - "Which independent services and clients can reproduce the 0.1 conformance cases?", + "Which independent services and clients can reproduce the 0.2 conformance cases?", "Which content digest representations need bindings beyond SHA-256?" ], "history": [ + { + "label": "0.2 · Working draft", + "description": "Declares completed and terminal failed outcomes for supervised approval, including declined, stale and expired decisions. Human decisions are authorized independently of the proposing client." + }, { "label": "0.1 · Working draft", "description": "The MAP 0.1 profile defines review feedback, revision approval, exact target binding, retries and recovery." diff --git a/tests/map-description.json b/tests/map-description.json index 0f00029..3bb8c3d 100644 --- a/tests/map-description.json +++ b/tests/map-description.json @@ -5,8 +5,8 @@ "profile": "https://mailschema.org/profiles/map/0.1", "type": { "id": "https://mailschema.org/types/content-review", - "version": "0.1", - "contractDigest": "sha-256:deba8fc588c83c7cdf9d4e47d23b114861aea35c3daaf71854dc6c626c8a1d05" + "version": "0.2", + "contractDigest": "sha-256:6ee5b086138799f70f2489e851e1db6e11ff91cd1141bd36fe202a47d8c1e637" }, "describedAt": "2026-09-23T01:00:00Z", "expiresAt": "2026-09-30T01:00:00Z", @@ -24,7 +24,9 @@ "humanUrl": "https://reviews.example/reviews/campaign-42/revision/4", "authorization": { "kind": "service-configured", - "schemes": ["oauth2"], + "schemes": [ + "oauth2" + ], "audience": "https://reviews.example/" } }, @@ -39,13 +41,13 @@ "id": "request-changes", "name": "Request changes", "description": "Record feedback on revision 4.", - "inputSchema": "https://mailschema.org/schemas/content-review-0.1.schema.json" + "inputSchema": "https://mailschema.org/schemas/content-review-0.2.schema.json" }, { "id": "approve", "name": "Approve", "description": "Record approval of revision 4.", - "inputSchema": "https://mailschema.org/schemas/content-review-0.1.schema.json" + "inputSchema": "https://mailschema.org/schemas/content-review-0.2.schema.json" } ] } diff --git a/tests/map-request.json b/tests/map-request.json index 26c367a..47e5dfe 100644 --- a/tests/map-request.json +++ b/tests/map-request.json @@ -5,8 +5,8 @@ "interactionId": "urn:uuid:018f47a2-5d7c-7b11-9a3d-4d2160b85b10", "type": { "id": "https://mailschema.org/types/content-review", - "version": "0.1", - "contractDigest": "sha-256:deba8fc588c83c7cdf9d4e47d23b114861aea35c3daaf71854dc6c626c8a1d05" + "version": "0.2", + "contractDigest": "sha-256:6ee5b086138799f70f2489e851e1db6e11ff91cd1141bd36fe202a47d8c1e637" }, "operation": "approve", "target": { diff --git a/tests/map-result.json b/tests/map-result.json index f8721c9..abd730a 100644 --- a/tests/map-result.json +++ b/tests/map-result.json @@ -5,8 +5,8 @@ "interactionId": "urn:uuid:018f47a2-5d7c-7b11-9a3d-4d2160b85b10", "type": { "id": "https://mailschema.org/types/content-review", - "version": "0.1", - "contractDigest": "sha-256:deba8fc588c83c7cdf9d4e47d23b114861aea35c3daaf71854dc6c626c8a1d05" + "version": "0.2", + "contractDigest": "sha-256:6ee5b086138799f70f2489e851e1db6e11ff91cd1141bd36fe202a47d8c1e637" }, "operation": "approve", "state": "completed", diff --git a/tests/test_package.py b/tests/test_package.py index b43ff4b..6073695 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -7,6 +7,7 @@ contribution_errors, get_contribution_schema, get_content_review_contract, + get_content_review_01_contract, get_record_schema, get_map_schema, validate_contribution, @@ -46,6 +47,8 @@ def test_schema_copies_and_record_reference(self): self.assertEqual(get_record_schema()["$ref"], "#/$defs/record") self.assertEqual(get_map_schema()["$id"], "https://mailschema.org/schemas/map-0.1.schema.json") self.assertEqual(get_content_review_contract()["id"], "https://mailschema.org/types/content-review") + self.assertEqual(get_content_review_contract()["version"], "0.2") + self.assertEqual(get_content_review_01_contract()["version"], "0.1") def test_map_documents(self): validate_map_document(self.description)