From 45ea866b61656acd3023db8b986d9e1d49deba7b Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 19 Jun 2026 04:33:11 -0500 Subject: [PATCH] feat: platform delegation - on_behalf_of on seal + delegated badge on verify (1.5.0) Platforms sealing on behalf of the businesses they serve can name the business per seal: on_behalf_of {ref, name} on create_intent / create_file_intent / create_selective_intent / create_intent_batch. verify_intent now surfaces verification_level and delegated_by. README + CHANGELOG updated. Tests added. Pairs with ValidPay-API delegation (live 2026-06-19); requires it deployed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_client.py | 40 ++++++++++++++++++++++++++++++++++++++++ validpay/__init__.py | 2 +- validpay/client.py | 18 ++++++++++++++++++ validpay/types.py | 6 ++++++ 7 files changed, 112 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd7a02..7bba968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the ValidPay Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-06-19 + +### Added + +- **Platform delegation — `on_behalf_of`** (Fork B). Platforms that seal on + behalf of the businesses they serve can declare which business each seal is + for: pass `on_behalf_of={"ref": ..., "name": ...}` to `create_intent`, + `create_file_intent`, `create_selective_intent`, or per-item in + `create_intent_batch`. `ref` is your own id for the business (the dedupe key — + same `ref` rolls up); `name` is who the verifier sees. The verifier sees that + business as the issuer, attributed *through* your platform, at the `delegated` + trust rung. ValidPay stays blind to the document contents — identity only. +- `verify_intent` now surfaces `verification_level` (`none` < `delegated` < + `domain` < `business`) and `delegated_by` (`{"platform", "platform_level"}` or + `None`) on `VerifyIntentResult`. + +> Requires the ValidPay API with platform-delegation support (live 2026-06-19). + ## [1.4.0] - 2026-06-17 ### Added diff --git a/README.md b/README.md index 5370274..da9a6d7 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,34 @@ print(verified.payload) response, so it verifies both; `verify_split_key_intent()` also still works. +### Platform delegation — `on_behalf_of` + +If you integrate as a **platform** and seal on behalf of the businesses you +serve, name the business on each seal. The verifier sees that business as the +issuer ("who"), attributed *through* your platform ("through whom"), at the +`delegated` trust rung. Those businesses never touch ValidPay — no account, no +login — and ValidPay stays blind to the document contents. + +```python +result = client.create_intent( + document_type="lease", + payload={"unit": "4B", "term": "12mo"}, + on_behalf_of={ + "ref": "landlord_8675309", # YOUR id for this business (dedupe key) + "name": "Smith Properties LLC", # who the verifier sees + }, +) + +verified = client.verify_intent(result.retrieval_id, result.key) +verified.issuer # "Smith Properties LLC" +verified.verification_level # "delegated" +verified.delegated_by # {"platform": "Your Platform", "platform_level": "domain"} +``` + +Same `ref` ⇒ same tracked business (its documents and verification counts roll +up). A sub-issuer surfaces as `delegated` only once **your** platform account is +domain-verified; until then its documents show as unverified. + ### Selective disclosure (Patent E) Each field is encrypted with its own per-field key. A disclosure policy maps diff --git a/pyproject.toml b/pyproject.toml index 6f9c69b..b1e06a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "validpay" -version = "1.4.0" +version = "1.5.0" description = "Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_client.py b/tests/test_client.py index f143697..03992c0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -104,6 +104,46 @@ def test_create_intent_encrypts_locally_and_never_sends_key(): assert decrypted == payload +def test_create_intent_sends_on_behalf_of_for_delegation(): + session = _FakeSession([ + _FakeResponse(201, {"retrieval_id": "vp_d", "status": "active"}), + ]) + client = ValidPayClient( + api_key="test_key", base_url="https://api.example.test", session=session + ) + client.create_intent( + document_type="lease", + payload={"a": 1}, + on_behalf_of={"ref": "cust_42", "name": "Smith Properties"}, + ) + sent_body = json.loads(session.calls[0]["data"]) + assert sent_body["on_behalf_of"] == {"ref": "cust_42", "name": "Smith Properties"} + + +def test_verify_surfaces_verification_level_and_delegated_by(): + key = generate_key() + blob = encrypt(json.dumps({"a": 1}), key) + session = _FakeSession([ + _FakeResponse(200, { + "intent_id": "vp_d", + "encrypted_payload": blob, + "issuer": "Smith Properties", + "issuer_verified": False, + "registered_at": "2026-04-29T12:00:00.000Z", + "status": "active", + "document_type": "lease", + "verification_level": "delegated", + "delegated_by": {"platform": "Acme Platform", "platform_level": "domain"}, + }), + ]) + client = ValidPayClient( + api_key="test_key", base_url="https://api.example.test", session=session + ) + result = client.verify_intent("vp_d", key) + assert result.verification_level == "delegated" + assert result.delegated_by == {"platform": "Acme Platform", "platform_level": "domain"} + + def test_create_intent_split_key_false_is_legacy_full_key(): """split_key=False preserves the 1.0.x behavior: no fragment fields, and the returned key decrypts the payload directly.""" diff --git a/validpay/__init__.py b/validpay/__init__.py index d4eb266..40078f2 100644 --- a/validpay/__init__.py +++ b/validpay/__init__.py @@ -69,4 +69,4 @@ "embed_qr", ] -__version__ = "1.4.0" +__version__ = "1.5.0" diff --git a/validpay/client.py b/validpay/client.py index d1ed793..0386fc6 100644 --- a/validpay/client.py +++ b/validpay/client.py @@ -97,6 +97,7 @@ def create_intent( valid_from: Optional[str] = None, valid_until: Optional[str] = None, split_key: bool = True, + on_behalf_of: Optional[Dict[str, str]] = None, ) -> CreateIntentResult: """Encrypt ``payload`` locally and register it with the ValidPay API. @@ -157,6 +158,8 @@ def create_intent( body["valid_from"] = valid_from if valid_until is not None: body["valid_until"] = valid_until + if on_behalf_of is not None: + body["on_behalf_of"] = on_behalf_of data = self._request( "POST", @@ -185,6 +188,7 @@ def create_file_intent( valid_from: Optional[str] = None, valid_until: Optional[str] = None, split_key: bool = True, + on_behalf_of: Optional[Dict[str, str]] = None, ) -> CreateIntentResult: """Seal a full document file (PDF, image, DOCX, …) end-to-end. @@ -252,6 +256,8 @@ def create_file_intent( body["valid_from"] = valid_from if valid_until is not None: body["valid_until"] = valid_until + if on_behalf_of is not None: + body["on_behalf_of"] = on_behalf_of data = self._request("POST", "/v1/intent", body=body, auth=True) @@ -329,6 +335,9 @@ def create_intent_batch( req_item["valid_from"] = valid_from if valid_until is not None: req_item["valid_until"] = valid_until + on_behalf_of = item.get("on_behalf_of") + if on_behalf_of is not None: + req_item["on_behalf_of"] = on_behalf_of request_items.append(req_item) data = self._request( @@ -478,6 +487,8 @@ def verify_intent( registered_at=data.get("registered_at", ""), status=data.get("status", ""), integrity_verified=integrity_verified, + verification_level=data.get("verification_level"), + delegated_by=data.get("delegated_by"), valid_from=valid_from_str, valid_until=valid_until_str, time_lock_status=time_lock_status, @@ -589,6 +600,8 @@ def verify_split_key_intent( registered_at=data.get("registered_at", ""), status=data.get("status", ""), integrity_verified=integrity_verified, + verification_level=data.get("verification_level"), + delegated_by=data.get("delegated_by"), valid_from=valid_from_str, valid_until=valid_until_str, time_lock_status=time_lock_status, @@ -603,6 +616,7 @@ def create_selective_intent( split_key: bool = False, valid_from: Optional[str] = None, valid_until: Optional[str] = None, + on_behalf_of: Optional[Dict[str, str]] = None, ) -> CreateIntentResult: """Create an intent with per-field encryption and a disclosure policy (Patent E). @@ -677,6 +691,8 @@ def create_selective_intent( body["valid_from"] = valid_from if valid_until is not None: body["valid_until"] = valid_until + if on_behalf_of is not None: + body["on_behalf_of"] = on_behalf_of data = self._request("POST", "/v1/intent", body=body, auth=True) @@ -817,6 +833,8 @@ def verify_selective_intent( registered_at=data.get("registered_at", ""), status=data.get("status", ""), integrity_verified=integrity_verified, + verification_level=data.get("verification_level"), + delegated_by=data.get("delegated_by"), valid_from=valid_from_str, valid_until=valid_until_str, time_lock_status=time_lock_status, diff --git a/validpay/types.py b/validpay/types.py index f2dd4fb..2dd2f42 100644 --- a/validpay/types.py +++ b/validpay/types.py @@ -54,3 +54,9 @@ class VerifyIntentResult: valid_from: Optional[str] = None valid_until: Optional[str] = None time_lock_status: Optional[str] = None + # Platform delegation (Fork B). ``verification_level`` is the issuer's graded + # trust rung: "none" < "delegated" < "domain" < "business". ``delegated_by`` + # is set when this was sealed on behalf of, via a platform — a dict + # ``{"platform": str, "platform_level": "domain"|"business"}`` — else None. + verification_level: Optional[str] = None + delegated_by: Optional[dict] = None