bindable 0.0.1__tar.gz → 0.1.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 (37) hide show
  1. bindable-0.1.0/.gitignore +8 -0
  2. {bindable-0.0.1 → bindable-0.1.0}/LICENSE +2 -2
  3. bindable-0.1.0/NOTICE +3 -0
  4. bindable-0.1.0/PKG-INFO +38 -0
  5. bindable-0.1.0/README.md +20 -0
  6. bindable-0.1.0/bindable/__init__.py +22 -0
  7. bindable-0.1.0/bindable/_canonical_record.py +78 -0
  8. bindable-0.1.0/bindable/_version.py +1 -0
  9. bindable-0.1.0/bindable/client.py +130 -0
  10. bindable-0.1.0/bindable/crypto/__init__.py +57 -0
  11. bindable-0.1.0/bindable/crypto/canonical.py +250 -0
  12. bindable-0.1.0/bindable/crypto/hash.py +42 -0
  13. bindable-0.1.0/bindable/crypto/keys.py +90 -0
  14. bindable-0.1.0/bindable/crypto/sign.py +90 -0
  15. bindable-0.1.0/bindable/errors.py +18 -0
  16. bindable-0.1.0/bindable/py.typed +0 -0
  17. bindable-0.1.0/bindable/verify.py +251 -0
  18. bindable-0.1.0/pyproject.toml +23 -0
  19. bindable-0.1.0/tests/__init__.py +0 -0
  20. bindable-0.1.0/tests/crypto/sign_vectors.json +35 -0
  21. bindable-0.1.0/tests/crypto/vectors.json +94 -0
  22. bindable-0.1.0/tests/fixtures/receipt_public_key.pem +4 -0
  23. bindable-0.1.0/tests/fixtures/receipt_released.json +30 -0
  24. bindable-0.1.0/tests/test_canonical_vectors.py +133 -0
  25. bindable-0.1.0/tests/test_client.py +193 -0
  26. bindable-0.1.0/tests/test_signature_verify.py +79 -0
  27. bindable-0.1.0/tests/test_verify_receipt.py +165 -0
  28. bindable-0.1.0/tests/test_version.py +5 -0
  29. bindable-0.0.1/.github/workflows/release.yml +0 -51
  30. bindable-0.0.1/.gitignore +0 -218
  31. bindable-0.0.1/PKG-INFO +0 -45
  32. bindable-0.0.1/README.md +0 -23
  33. bindable-0.0.1/bindable/__init__.py +0 -6
  34. bindable-0.0.1/bindable/_version.py +0 -3
  35. bindable-0.0.1/bindable/core.py +0 -43
  36. bindable-0.0.1/pyproject.toml +0 -28
  37. bindable-0.0.1/tests/test_import.py +0 -11
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .pytest_cache/
@@ -1,4 +1,4 @@
1
- Apache License
1
+ Apache License
2
2
  Version 2.0, January 2004
3
3
  http://www.apache.org/licenses/
4
4
 
@@ -186,7 +186,7 @@ Apache License
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright [yyyy] [name of copyright owner]
189
+ Copyright 2026 Bindable AI LLC
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
bindable-0.1.0/NOTICE ADDED
@@ -0,0 +1,3 @@
1
+ Bindable
2
+ Copyright 2026 Bindable AI LLC
3
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: bindable
3
+ Version: 0.1.0
4
+ Summary: Python client for the hosted Bindable Action Gate, with offline Receipt verification
5
+ Author: Bindable AI LLC
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ License-File: NOTICE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: cryptography>=42
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: dev
13
+ Requires-Dist: build>=1.2; extra == 'dev'
14
+ Requires-Dist: mypy>=1.10; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: ruff>=0.5; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # bindable
20
+
21
+ Bindable Python client — propose, decide, and verify accountable agent actions against the hosted Bindable Action Gate. Offline receipt verification included.
22
+
23
+ ## Install
24
+
25
+ ```
26
+ pip install bindable
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import bindable
33
+
34
+ # Submit a Proposal to the Bindable Action Gate, receive a Decision,
35
+ # record the Outcome, then verify the returned Receipt offline.
36
+ ```
37
+
38
+ Status: pre-release (0.1.0.dev0)
@@ -0,0 +1,20 @@
1
+ # bindable
2
+
3
+ Bindable Python client — propose, decide, and verify accountable agent actions against the hosted Bindable Action Gate. Offline receipt verification included.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install bindable
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import bindable
15
+
16
+ # Submit a Proposal to the Bindable Action Gate, receive a Decision,
17
+ # record the Outcome, then verify the returned Receipt offline.
18
+ ```
19
+
20
+ Status: pre-release (0.1.0.dev0)
@@ -0,0 +1,22 @@
1
+ """Bindable — Python client for the hosted Bindable Action Gate.
2
+
3
+ Submit a Proposal to the Bindable Action Gate via ``ActionGate.decide()``,
4
+ receive a Decision (the Outcome), and verify the returned Receipt offline with
5
+ ``verify_receipt``. The client is pure transport; all proof-rule evaluation
6
+ happens on the hosted gate, and receipt verification is fully offline.
7
+ """
8
+
9
+ from ._version import __version__
10
+ from .client import ActionGate
11
+ from .crypto import load_public_key
12
+ from .errors import BindableAPIError
13
+ from .verify import ReceiptVerification, verify_receipt
14
+
15
+ __all__ = [
16
+ "ActionGate",
17
+ "BindableAPIError",
18
+ "ReceiptVerification",
19
+ "__version__",
20
+ "load_public_key",
21
+ "verify_receipt",
22
+ ]
@@ -0,0 +1,78 @@
1
+ """Canonical hash-input serialization for receipts (thin client).
2
+
3
+ Byte-faithful port of the engine's ``_canonical_hash_input``
4
+ (bindable-interlock-engine @ 5f129a6), adapted to read a receipt
5
+ delivered as a plain JSON dict from the hosted Bindable Action Gate
6
+ rather than as a pydantic ``Receipt``. The 15 included fields, the
7
+ §12.3 exclusions, and the field set match the engine EXACTLY so that
8
+ ``canonical_sha256`` of this output is byte-identical across the
9
+ hosted gate and any offline verifier.
10
+
11
+ This module is NOT part of the public API.
12
+
13
+ verified_at handling (de-pydantic departure, behaviour-preserving):
14
+ In the engine, ``Receipt.verified_at`` is a ``datetime`` and the
15
+ builder calls ``_serialize_wire_datetime`` to produce the wire
16
+ string (YYYY-MM-DDTHH:MM:SS.sssZ). In the thin client the receipt
17
+ arrives from /v1/decide as JSON, so ``verified_at`` is ALREADY the
18
+ wire string. It is used verbatim — passing it back through a
19
+ datetime serializer would be a double-serialization bug. No
20
+ ``datetime`` object can reach this path, so ``_serialize_wire_datetime``
21
+ is not ported here. The vectors.json production-shape record
22
+ (verified_at = "2026-05-19T14:00:00.000Z" hashed verbatim) confirms
23
+ the wire string is what the hash commits to.
24
+
25
+ Pure and deterministic: given a receipt mapping, returns a new dict
26
+ of wire-form fields with no I/O and no mutation. Canonical ordering
27
+ is applied downstream by ``canonical_json`` (alphabetical key sort),
28
+ so the literal order here is illustrative only.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Mapping
34
+ from typing import Any
35
+
36
+
37
+ def _canonical_hash_input(receipt: Mapping[str, Any]) -> dict[str, Any]:
38
+ """Return the canonical hash input dict per MVP spec §12.3.
39
+
40
+ Included fields (canonical_json sorts alphabetically):
41
+ action_type, claim_payload, contract_id, contract_version,
42
+ counterparty_identity, failed_rule, nonce,
43
+ persistent_agent_id, previous_hash, proposed_action_id,
44
+ referenced_evidence, sponsor_identity,
45
+ sponsor_identity_context, verification_outcome, verified_at.
46
+
47
+ Excluded per §12.3:
48
+ receipt_id, signature, signing_key_id, signed_at,
49
+ chain_hash (self-reference), reason_detail, claim_digest
50
+ (derivable from canonical claim_payload).
51
+
52
+ Args:
53
+ receipt: A receipt as a plain mapping (e.g. parsed JSON from
54
+ /v1/decide). All 15 included fields must be present;
55
+ nullable fields carry ``None``. ``verified_at`` must be
56
+ the wire-format string already.
57
+
58
+ Returns:
59
+ A dict of exactly the 15 canonical-hash-input fields, ready
60
+ to feed into ``canonical_sha256``.
61
+ """
62
+ return {
63
+ "action_type": receipt["action_type"],
64
+ "claim_payload": receipt["claim_payload"],
65
+ "contract_id": receipt["contract_id"],
66
+ "contract_version": receipt["contract_version"],
67
+ "counterparty_identity": receipt["counterparty_identity"],
68
+ "failed_rule": receipt["failed_rule"],
69
+ "nonce": receipt["nonce"],
70
+ "persistent_agent_id": receipt["persistent_agent_id"],
71
+ "previous_hash": receipt["previous_hash"],
72
+ "proposed_action_id": receipt["proposed_action_id"],
73
+ "referenced_evidence": receipt["referenced_evidence"],
74
+ "sponsor_identity": receipt["sponsor_identity"],
75
+ "sponsor_identity_context": receipt["sponsor_identity_context"],
76
+ "verification_outcome": receipt["verification_outcome"],
77
+ "verified_at": receipt["verified_at"],
78
+ }
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """Transport client for the hosted Bindable Action Gate.
2
+
3
+ ``ActionGate.decide()`` submits a Proposal to ``POST /v1/decide`` and returns
4
+ the hosted Decision (the Outcome dict) verbatim. The client is pure transport:
5
+ it does NOT evaluate, canonicalize, or decide anything locally — all
6
+ proof-rule evaluation happens on the hosted gate. To verify the returned
7
+ receipt offline, hand ``outcome["receipt"]`` to ``bindable.verify_receipt``.
8
+
9
+ Runtime dependency: httpx only (plus cryptography via the verify path).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import secrets
15
+ from collections.abc import Mapping
16
+ from types import TracebackType
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from bindable.errors import BindableAPIError
22
+
23
+
24
+ class ActionGate:
25
+ """Thin, token-authenticated httpx client for the hosted Bindable Action Gate.
26
+
27
+ Transports a Proposal to the hosted ``/v1/decide`` endpoint and returns the
28
+ Decision verbatim as a dict. The client performs no local evaluation,
29
+ canonicalization, or decision-making; offline receipt verification is a
30
+ separate concern handled by ``bindable.verify_receipt``.
31
+
32
+ Usage:
33
+ with ActionGate(base_url="https://gate.example.com", token="...") as gate:
34
+ outcome = gate.decide(
35
+ persistent_agent_id="agent_1",
36
+ action_type="send_invoice",
37
+ payload={"po_number": "PO-001", "amount": 1000},
38
+ proposed_action_id="claim_1",
39
+ )
40
+ # Offline verification of the returned receipt:
41
+ result = verify_receipt(outcome["receipt"])
42
+ """
43
+
44
+ def __init__(self, *, base_url: str, token: str, timeout: float = 30.0) -> None:
45
+ self._base_url = base_url.rstrip("/")
46
+ self._token = token
47
+ self._client = httpx.Client(timeout=timeout)
48
+
49
+ def decide(
50
+ self,
51
+ *,
52
+ persistent_agent_id: str,
53
+ action_type: str,
54
+ payload: Mapping[str, Any],
55
+ proposed_action_id: str,
56
+ nonce: str | None = None,
57
+ ) -> dict[str, Any]:
58
+ """Submit a Proposal to ``/v1/decide`` and return the hosted Outcome dict.
59
+
60
+ Sends exactly the five wire fields (persistent_agent_id, action_type,
61
+ payload, proposed_action_id, nonce) with bearer-token auth. The Outcome
62
+ is returned verbatim; its ``receipt`` field (when present) can be passed
63
+ to ``verify_receipt`` for offline verification.
64
+
65
+ Args:
66
+ persistent_agent_id: The stable agent identity.
67
+ action_type: The action being proposed.
68
+ payload: The action payload (sent as the ``payload`` field).
69
+ proposed_action_id: The caller's id for this proposed action.
70
+ nonce: Replay-protection nonce; a fresh ``secrets.token_hex(16)``
71
+ is generated when omitted.
72
+
73
+ Returns:
74
+ The parsed Outcome JSON object as a dict.
75
+
76
+ Raises:
77
+ BindableAPIError: If the gate returns a non-2xx response. The
78
+ error carries ``.status`` and the ``detail`` from the JSON body.
79
+ """
80
+ if nonce is None:
81
+ nonce = secrets.token_hex(16)
82
+ body = {
83
+ "persistent_agent_id": persistent_agent_id,
84
+ "action_type": action_type,
85
+ "payload": dict(payload),
86
+ "proposed_action_id": proposed_action_id,
87
+ "nonce": nonce,
88
+ }
89
+ response = self._client.post(
90
+ f"{self._base_url}/v1/decide",
91
+ json=body,
92
+ headers={
93
+ "Authorization": f"Bearer {self._token}",
94
+ "Content-Type": "application/json",
95
+ },
96
+ )
97
+ if not response.is_success:
98
+ raise BindableAPIError(response.status_code, _extract_detail(response))
99
+ result: dict[str, Any] = response.json()
100
+ return result
101
+
102
+ def close(self) -> None:
103
+ """Close the underlying httpx.Client."""
104
+ self._client.close()
105
+
106
+ def __enter__(self) -> ActionGate:
107
+ return self
108
+
109
+ def __exit__(
110
+ self,
111
+ exc_type: type[BaseException] | None,
112
+ exc: BaseException | None,
113
+ tb: TracebackType | None,
114
+ ) -> None:
115
+ self.close()
116
+
117
+
118
+ def _extract_detail(response: httpx.Response) -> str:
119
+ """Pull the ``detail`` from an error JSON body, falling back to text.
120
+
121
+ The gate returns errors as ``{"detail": ...}``. ``detail`` may be a string
122
+ (401/403) or a structured value (422); it is stringified either way.
123
+ """
124
+ try:
125
+ body = response.json()
126
+ except Exception: # noqa: BLE001 - non-JSON error body; fall back to text
127
+ return response.text or response.reason_phrase
128
+ if isinstance(body, dict) and "detail" in body:
129
+ return str(body["detail"])
130
+ return str(body)
@@ -0,0 +1,57 @@
1
+ """Cryptographic primitives for the Bindable thin client.
2
+
3
+ This subpackage provides the deterministic, offline-verifiable
4
+ primitives used to recompute a receipt's chain_hash and to verify its
5
+ ECDSA P-256 signature. Per D6 these live under ``bindable.crypto`` and
6
+ are NOT re-exported at the top-level ``bindable`` namespace (except the
7
+ high-level ``verify_receipt`` / ``ReceiptVerification`` / ``load_public_key``
8
+ surface exposed by ``bindable`` directly).
9
+
10
+ Surface (stdlib + cryptography — no pydantic):
11
+
12
+ Type aliases:
13
+ PublicKey # alias for cryptography EllipticCurvePublicKey
14
+
15
+ Hashing (pure, stdlib only):
16
+ sha256_hex(data: bytes) -> str
17
+ canonicalize(value: Any) -> Any
18
+ canonical_json(record: Mapping[str, Any]) -> bytes
19
+ canonical_sha256(record: Mapping[str, Any]) -> str
20
+
21
+ Key loading (cryptography library):
22
+ load_public_key(pem_bytes: bytes) -> PublicKey
23
+
24
+ Signature verification (cryptography library):
25
+ verify(public_key, record, signature) -> bool
26
+
27
+ This is a VERIFY-ONLY surface: there is no signing, key generation, or
28
+ private-key handling client-side.
29
+
30
+ PATENT-CRITICAL: these primitives produce the canonical bytes a
31
+ receipt's chain_hash commits to and verify the signature over them.
32
+ The hashing primitives use only the Python standard library; key
33
+ loading and signature verification depend on the cryptography library,
34
+ which is pinned in [project] dependencies. All primitives operate
35
+ without network access.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from bindable.crypto.canonical import (
41
+ canonical_json,
42
+ canonical_sha256,
43
+ canonicalize,
44
+ )
45
+ from bindable.crypto.hash import sha256_hex
46
+ from bindable.crypto.keys import PublicKey, load_public_key
47
+ from bindable.crypto.sign import verify
48
+
49
+ __all__ = [
50
+ "PublicKey",
51
+ "canonical_json",
52
+ "canonical_sha256",
53
+ "canonicalize",
54
+ "load_public_key",
55
+ "sha256_hex",
56
+ "verify",
57
+ ]
@@ -0,0 +1,250 @@
1
+ """Canonical JSON serialization and hashing per RFC 8785 (JCS).
2
+
3
+ RFC 8785 (JSON Canonicalization Scheme) defines a deterministic
4
+ byte-level representation of a JSON value. Two equivalent JSON
5
+ values always serialize to identical bytes, which makes them
6
+ suitable for cryptographic hashing.
7
+
8
+ This module implements the subset of RFC 8785 that the Bindable
9
+ SDK requires for v0.1.0 receipts:
10
+
11
+ * Object keys are sorted lexicographically by UTF-16 code units.
12
+ * No whitespace between tokens.
13
+ * Strings are UTF-8 encoded with the RFC 8785 escape rules.
14
+ * Booleans and null serialize as ``true``, ``false``, ``null``.
15
+ * Integers (including booleans coerced via int subclassing) serialize
16
+ as their decimal representation.
17
+
18
+ DEPARTURE FROM RFC 8785 — by design, locked decision D3:
19
+ * Python floats are REJECTED with TypeError. RFC 8785 specifies
20
+ IEEE-754 shortest-form serialization for non-integer numbers;
21
+ we forbid them entirely at the canonicalization boundary
22
+ because float drift across implementations is the dominant
23
+ source of cross-language hash divergence in financial systems.
24
+ Callers MUST convert decimal quantities to integers (e.g.,
25
+ dollars * 100 = cents) or strings before hashing.
26
+
27
+ PATENT-CRITICAL: this module produces the canonical bytes that feed
28
+ into every sponsor-scoped verification receipt's chain_hash. Any
29
+ change to canonicalization behavior is a wire-format break and
30
+ MUST be accompanied by a major version bump.
31
+
32
+ Per Interlock MVP Spec v0.1.2 §6.2, canonical_sha256(record) is the
33
+ function used to derive a receipt's chain_hash.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Mapping
39
+ from typing import Any
40
+
41
+ from bindable.crypto.hash import sha256_hex
42
+
43
+ # Characters that must be escaped in JSON strings per RFC 8259 §7
44
+ # and RFC 8785 §3.2.2.2. Control characters (U+0000 through U+001F)
45
+ # are escaped as \uXXXX. Quote and backslash get short-form escapes.
46
+ _SHORT_ESCAPES = {
47
+ 0x08: "\\b",
48
+ 0x09: "\\t",
49
+ 0x0A: "\\n",
50
+ 0x0C: "\\f",
51
+ 0x0D: "\\r",
52
+ 0x22: '\\"',
53
+ 0x5C: "\\\\",
54
+ }
55
+
56
+
57
+ def canonicalize(value: Any) -> Any:
58
+ """Return a structurally-canonical copy of ``value``.
59
+
60
+ Recursively sorts object (dict) keys. Leaves arrays in their
61
+ original order (RFC 8785 §3.2.3 — array element order is
62
+ semantically significant in JSON and is preserved).
63
+
64
+ This is a structural pass only. The byte-level canonicalization
65
+ happens in ``canonical_json``.
66
+
67
+ Args:
68
+ value: Any JSON-serializable value (dict, list, str, int,
69
+ bool, None). Float values raise in canonical_json, not here.
70
+
71
+ Returns:
72
+ A structurally-canonical copy of ``value`` with all
73
+ dict keys sorted lexicographically (by UTF-16 code units,
74
+ which for ASCII keys is the same as Python's default
75
+ string sort).
76
+
77
+ Example:
78
+ >>> canonicalize({"b": 1, "a": 2})
79
+ {'a': 2, 'b': 1}
80
+
81
+ >>> canonicalize({"z": {"y": 1, "x": 2}})
82
+ {'z': {'x': 2, 'y': 1}}
83
+ """
84
+ if isinstance(value, dict):
85
+ # Validate keys before sorting. _utf16_sort_key encodes via UTF-16-BE,
86
+ # which requires string keys. Validation is duplicated in _encode as
87
+ # defense-in-depth for direct internal callers.
88
+ for key in value:
89
+ if not isinstance(key, str):
90
+ raise TypeError(
91
+ f"Object keys must be strings in canonical JSON, "
92
+ f"got {type(key).__name__} (value: {key!r})."
93
+ )
94
+ # Sort keys per RFC 8785 §3.2.3: UTF-16 code-unit order.
95
+ # For pure ASCII keys this matches Python's default string
96
+ # ordering. For non-ASCII keys, see _utf16_sort_key.
97
+ return {k: canonicalize(value[k]) for k in sorted(value, key=_utf16_sort_key)}
98
+ if isinstance(value, list):
99
+ return [canonicalize(item) for item in value]
100
+ return value
101
+
102
+
103
+ def canonical_json(record: Mapping[str, Any]) -> bytes:
104
+ """Return the canonical JSON bytes of ``record`` per RFC 8785.
105
+
106
+ Args:
107
+ record: A JSON-serializable mapping. Nested dicts, lists,
108
+ strings, integers, booleans, and None are supported.
109
+ Floats are rejected (see TypeError below).
110
+
111
+ Returns:
112
+ The UTF-8 encoded canonical JSON bytes. Suitable for hashing
113
+ with ``sha256_hex`` or for comparing two records for byte
114
+ equality.
115
+
116
+ Raises:
117
+ TypeError: If the record (or any nested value) contains a
118
+ float, a non-string dict key, or an unsupported type.
119
+
120
+ Example:
121
+ >>> canonical_json({"b": 1, "a": 2})
122
+ b'{"a":2,"b":1}'
123
+
124
+ >>> canonical_json({"hello": "world"})
125
+ b'{"hello":"world"}'
126
+ """
127
+ return _encode(canonicalize(record)).encode("utf-8")
128
+
129
+
130
+ def canonical_sha256(record: Mapping[str, Any]) -> str:
131
+ """Return the SHA-256 hex digest of the canonical JSON form of ``record``.
132
+
133
+ This is the primary function used by the Interlock to derive a
134
+ receipt's chain_hash. Two records that are semantically equal
135
+ (same keys, same values, regardless of input ordering) always
136
+ produce the same hash.
137
+
138
+ Args:
139
+ record: A JSON-serializable mapping (same constraints as
140
+ ``canonical_json``).
141
+
142
+ Returns:
143
+ The 64-character lowercase hexadecimal SHA-256 digest of
144
+ the canonical JSON bytes.
145
+
146
+ Raises:
147
+ TypeError: If the record contains a disallowed value
148
+ (see ``canonical_json``).
149
+
150
+ Example:
151
+ >>> canonical_sha256({"b": 1, "a": 2})
152
+ '43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777'
153
+
154
+ >>> # Key order does not affect the hash:
155
+ >>> canonical_sha256({"a": 2, "b": 1}) == canonical_sha256({"b": 1, "a": 2})
156
+ True
157
+ """
158
+ return sha256_hex(canonical_json(record))
159
+
160
+
161
+ # ─── Internal helpers ──────────────────────────────────────────────
162
+
163
+
164
+ def _utf16_sort_key(s: str) -> bytes:
165
+ """Return a sort key approximating UTF-16 code-unit order for ``s``.
166
+
167
+ RFC 8785 §3.2.3 mandates sorting object keys by their UTF-16
168
+ code-unit sequences. For ASCII keys this is identical to Python's
169
+ default Unicode codepoint order. For non-BMP characters (above
170
+ U+FFFF), Python's codepoint order and UTF-16 code-unit order
171
+ diverge because non-BMP characters are represented in UTF-16 as
172
+ surrogate pairs.
173
+
174
+ Encoding the string to UTF-16-BE and treating the bytes as the
175
+ sort key gives RFC-8785-correct ordering for both BMP and non-BMP
176
+ characters.
177
+ """
178
+ return s.encode("utf-16-be")
179
+
180
+
181
+ def _encode(value: Any) -> str:
182
+ """Serialize a canonical value to a JSON string per RFC 8785.
183
+
184
+ Pre-condition: ``value`` has already been through ``canonicalize``,
185
+ so dicts have sorted keys.
186
+ """
187
+ if value is None:
188
+ return "null"
189
+ if value is True:
190
+ return "true"
191
+ if value is False:
192
+ return "false"
193
+ if isinstance(value, bool):
194
+ # Defensive: covered by the True/False checks above, but
195
+ # guard against bool subclasses.
196
+ return "true" if value else "false"
197
+ if isinstance(value, int):
198
+ # bool is a subclass of int in Python; the bool checks above
199
+ # short-circuit before we reach this branch.
200
+ return str(value)
201
+ if isinstance(value, float):
202
+ raise TypeError(
203
+ "Float values are not allowed in canonical JSON. "
204
+ "RFC 8785 specifies IEEE-754 shortest-form serialization, "
205
+ "but float drift across implementations is the dominant "
206
+ "source of cross-language hash divergence. Convert decimal "
207
+ "quantities to integers (e.g., dollars * 100 = cents) or "
208
+ "to strings before hashing. See docs/VOCABULARY.md §8 and "
209
+ "the canonical.py module docstring for rationale."
210
+ )
211
+ if isinstance(value, str):
212
+ return _encode_string(value)
213
+ if isinstance(value, list):
214
+ return "[" + ",".join(_encode(item) for item in value) + "]"
215
+ if isinstance(value, dict):
216
+ parts = []
217
+ for key in value:
218
+ if not isinstance(key, str):
219
+ raise TypeError(
220
+ f"Object keys must be strings in canonical JSON, "
221
+ f"got {type(key).__name__} (value: {key!r})."
222
+ )
223
+ parts.append(_encode_string(key) + ":" + _encode(value[key]))
224
+ return "{" + ",".join(parts) + "}"
225
+ raise TypeError(
226
+ f"Unsupported type in canonical JSON: {type(value).__name__}. "
227
+ f"Allowed: dict (str keys), list, str, int, bool, None."
228
+ )
229
+
230
+
231
+ def _encode_string(s: str) -> str:
232
+ """Encode a string per RFC 8785 §3.2.2.2.
233
+
234
+ Wraps the string in double quotes and escapes characters per the
235
+ JSON serialization rules. Non-ASCII characters are passed through
236
+ as UTF-8 (no \\uXXXX escapes for codepoints above 0x1F).
237
+ """
238
+ out = ['"']
239
+ for ch in s:
240
+ cp = ord(ch)
241
+ if cp in _SHORT_ESCAPES:
242
+ out.append(_SHORT_ESCAPES[cp])
243
+ elif cp < 0x20:
244
+ # Control characters not in _SHORT_ESCAPES → \uXXXX.
245
+ out.append(f"\\u{cp:04x}")
246
+ else:
247
+ # Printable characters (including non-ASCII) pass through.
248
+ out.append(ch)
249
+ out.append('"')
250
+ return "".join(out)
@@ -0,0 +1,42 @@
1
+ """SHA-256 hashing primitive.
2
+
3
+ Thin wrapper over hashlib.sha256 that returns a lowercase hexadecimal
4
+ digest string. Used as the terminal step of canonical_sha256 and as
5
+ a building block for receipt verification.
6
+
7
+ PATENT-CRITICAL: this function is part of the offline-verifiable
8
+ receipt path. Per Interlock MVP Spec v0.1.2 §5.2 invariant I7,
9
+ verify_receipt(...) must work offline using only stdlib hashlib.
10
+ This module satisfies that requirement.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+
17
+
18
+ def sha256_hex(data: bytes) -> str:
19
+ """Return the SHA-256 digest of ``data`` as a lowercase hex string.
20
+
21
+ Args:
22
+ data: The bytes to hash. Must be of type ``bytes``.
23
+
24
+ Returns:
25
+ The 64-character lowercase hexadecimal SHA-256 digest.
26
+
27
+ Raises:
28
+ TypeError: If ``data`` is not of type ``bytes``.
29
+
30
+ Example:
31
+ >>> sha256_hex(b"hello")
32
+ '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
33
+
34
+ >>> sha256_hex(b"")
35
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
36
+ """
37
+ if not isinstance(data, bytes):
38
+ raise TypeError(
39
+ f"sha256_hex requires bytes input, got {type(data).__name__}. "
40
+ f"Encode strings to bytes (e.g., text.encode('utf-8')) before hashing."
41
+ )
42
+ return hashlib.sha256(data).hexdigest()