bindable 0.1.3__tar.gz → 0.1.4__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 (34) hide show
  1. {bindable-0.1.3 → bindable-0.1.4}/PKG-INFO +1 -1
  2. {bindable-0.1.3 → bindable-0.1.4}/bindable/__init__.py +6 -1
  3. bindable-0.1.4/bindable/_version.py +1 -0
  4. {bindable-0.1.3 → bindable-0.1.4}/bindable/client.py +43 -5
  5. {bindable-0.1.3 → bindable-0.1.4}/pyproject.toml +1 -1
  6. bindable-0.1.4/tests/test_stage.py +152 -0
  7. {bindable-0.1.3 → bindable-0.1.4}/tests/test_version.py +1 -1
  8. bindable-0.1.3/bindable/_version.py +0 -1
  9. {bindable-0.1.3 → bindable-0.1.4}/.gitignore +0 -0
  10. {bindable-0.1.3 → bindable-0.1.4}/LICENSE +0 -0
  11. {bindable-0.1.3 → bindable-0.1.4}/NOTICE +0 -0
  12. {bindable-0.1.3 → bindable-0.1.4}/README.md +0 -0
  13. {bindable-0.1.3 → bindable-0.1.4}/bindable/_canonical_record.py +0 -0
  14. {bindable-0.1.3 → bindable-0.1.4}/bindable/crypto/__init__.py +0 -0
  15. {bindable-0.1.3 → bindable-0.1.4}/bindable/crypto/canonical.py +0 -0
  16. {bindable-0.1.3 → bindable-0.1.4}/bindable/crypto/hash.py +0 -0
  17. {bindable-0.1.3 → bindable-0.1.4}/bindable/crypto/keys.py +0 -0
  18. {bindable-0.1.3 → bindable-0.1.4}/bindable/crypto/sign.py +0 -0
  19. {bindable-0.1.3 → bindable-0.1.4}/bindable/decision.py +0 -0
  20. {bindable-0.1.3 → bindable-0.1.4}/bindable/errors.py +0 -0
  21. {bindable-0.1.3 → bindable-0.1.4}/bindable/py.typed +0 -0
  22. {bindable-0.1.3 → bindable-0.1.4}/bindable/verify.py +0 -0
  23. {bindable-0.1.3 → bindable-0.1.4}/tests/__init__.py +0 -0
  24. {bindable-0.1.3 → bindable-0.1.4}/tests/crypto/sign_vectors.json +0 -0
  25. {bindable-0.1.3 → bindable-0.1.4}/tests/crypto/vectors.json +0 -0
  26. {bindable-0.1.3 → bindable-0.1.4}/tests/fixtures/KEY_PROVENANCE.md +0 -0
  27. {bindable-0.1.3 → bindable-0.1.4}/tests/fixtures/bindable-test-rc4-2026-08.pem +0 -0
  28. {bindable-0.1.3 → bindable-0.1.4}/tests/fixtures/receipt_public_blocked_rc4.json +0 -0
  29. {bindable-0.1.3 → bindable-0.1.4}/tests/fixtures/receipt_public_released_rc4.json +0 -0
  30. {bindable-0.1.3 → bindable-0.1.4}/tests/test_canonical_vectors.py +0 -0
  31. {bindable-0.1.3 → bindable-0.1.4}/tests/test_client.py +0 -0
  32. {bindable-0.1.3 → bindable-0.1.4}/tests/test_decision_from.py +0 -0
  33. {bindable-0.1.3 → bindable-0.1.4}/tests/test_signature_verify.py +0 -0
  34. {bindable-0.1.3 → bindable-0.1.4}/tests/test_verify_receipt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindable
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Python client for the hosted Bindable Action Gate, with offline Receipt verification
5
5
  Author: Bindable AI LLC
6
6
  License-Expression: Apache-2.0
@@ -6,10 +6,14 @@ receive an Outcome, and verify the returned Receipt offline with
6
6
  signed Receipt with ``decision_from``. The client is pure transport; all
7
7
  proof-rule evaluation happens on the hosted gate, and receipt verification
8
8
  is fully offline.
9
+
10
+ Review resolves the governing Contract by stage. ``decide()`` takes an optional
11
+ ``stage`` — one of ``STAGES`` — and omits the field entirely when it is not
12
+ given, leaving the platform's ``development`` default in force.
9
13
  """
10
14
 
11
15
  from ._version import __version__
12
- from .client import ActionGate
16
+ from .client import STAGES, ActionGate
13
17
  from .crypto import load_public_key
14
18
  from .decision import BLOCKED, RELEASED, decision_from
15
19
  from .errors import BindableAPIError
@@ -18,6 +22,7 @@ from .verify import ReceiptVerification, verify_receipt
18
22
  __all__ = [
19
23
  "BLOCKED",
20
24
  "RELEASED",
25
+ "STAGES",
21
26
  "ActionGate",
22
27
  "BindableAPIError",
23
28
  "ReceiptVerification",
@@ -0,0 +1 @@
1
+ __version__ = "0.1.4"
@@ -14,12 +14,38 @@ from __future__ import annotations
14
14
  import secrets
15
15
  from collections.abc import Mapping
16
16
  from types import TracebackType
17
- from typing import Any
17
+ from typing import Any, Final
18
18
 
19
19
  import httpx
20
20
 
21
21
  from bindable.errors import BindableAPIError
22
22
 
23
+ #: The stages a Contract can be resolved at, as the platform names them.
24
+ #:
25
+ #: Review resolves the governing Contract BY STAGE. Omitting the field lets the
26
+ #: platform apply its own default, which is ``development`` — correct for
27
+ #: Sandbox work and wrong for a promoted Contract, which exists only at
28
+ #: ``production``. Callers targeting a promoted Contract must say so.
29
+ #:
30
+ #: Single-sourced here so that callers validating their own configuration —
31
+ #: an environment variable, a config file — check against the same tuple
32
+ #: rather than keeping a second copy that can drift.
33
+ STAGES: Final[tuple[str, ...]] = ("development", "test", "production")
34
+
35
+
36
+ def _validated_stage(stage: str) -> str:
37
+ """Return ``stage`` if the platform accepts it, else raise locally.
38
+
39
+ Checked here rather than sent hopefully: a bad stage is a caller's
40
+ configuration mistake, and a named ValueError at the call site is a better
41
+ answer than a 422 that has already cost a round trip.
42
+ """
43
+ if stage not in STAGES:
44
+ raise ValueError(
45
+ f"stage must be one of {', '.join(STAGES)}; got {stage!r}"
46
+ )
47
+ return stage
48
+
23
49
 
24
50
  class ActionGate:
25
51
  """Thin, token-authenticated httpx client for the hosted Bindable Action Gate.
@@ -54,13 +80,15 @@ class ActionGate:
54
80
  payload: Mapping[str, Any],
55
81
  proposed_action_id: str,
56
82
  nonce: str | None = None,
83
+ stage: str | None = None,
57
84
  ) -> dict[str, Any]:
58
85
  """Submit a Proposal to ``/v1/review`` and return the hosted Outcome dict.
59
86
 
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.
87
+ Sends the five frozen wire fields (persistent_agent_id, action_type,
88
+ payload, proposed_action_id, nonce) with bearer-token auth, plus
89
+ ``stage`` when — and only when — one is given. The Outcome is returned
90
+ verbatim; its ``receipt`` field (when present) can be passed to
91
+ ``verify_receipt`` for offline verification.
64
92
 
65
93
  The endpoint moved from ``/v1/decide`` to ``/v1/review`` in 0.1.2. The
66
94
  platform keeps BOTH paths live for a deprecation window, so an older
@@ -74,11 +102,19 @@ class ActionGate:
74
102
  proposed_action_id: The caller's id for this proposed action.
75
103
  nonce: Replay-protection nonce; a fresh ``secrets.token_hex(16)``
76
104
  is generated when omitted.
105
+ stage: Which stage to resolve the governing Contract at — one of
106
+ :data:`STAGES`. Omitted from the request entirely when None,
107
+ which leaves the platform's own default (``development``) in
108
+ force and keeps the body byte-identical to a pre-0.1.4 call.
109
+ A promoted Contract exists only at ``production``, so a caller
110
+ targeting one must pass it.
77
111
 
78
112
  Returns:
79
113
  The parsed Outcome JSON object as a dict.
80
114
 
81
115
  Raises:
116
+ ValueError: If ``stage`` is given and is not one of :data:`STAGES`.
117
+ Raised before any request is sent.
82
118
  BindableAPIError: If the gate returns a non-2xx response. The
83
119
  error carries ``.status`` and the ``detail`` from the JSON body.
84
120
  """
@@ -91,6 +127,8 @@ class ActionGate:
91
127
  "proposed_action_id": proposed_action_id,
92
128
  "nonce": nonce,
93
129
  }
130
+ if stage is not None:
131
+ body["stage"] = _validated_stage(stage)
94
132
  response = self._client.post(
95
133
  f"{self._base_url}/v1/review",
96
134
  json=body,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bindable"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  requires-python = ">=3.10"
5
5
  description = "Python client for the hosted Bindable Action Gate, with offline Receipt verification"
6
6
  readme = "README.md"
@@ -0,0 +1,152 @@
1
+ """The stage parameter: sent when given, absent when not, refused when wrong.
2
+
3
+ Review resolves the governing Contract BY STAGE, and a promoted Contract exists
4
+ only at ``production``. A client that cannot say which stage it means can only
5
+ ever reach the platform's default, so these tests pin three things: that a
6
+ given stage reaches the wire, that an omitted one leaves the request
7
+ byte-identical to a pre-0.1.4 call, and that a wrong one is refused here rather
8
+ than spent on a round trip.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ import httpx
17
+ import pytest
18
+
19
+ from bindable import STAGES, ActionGate
20
+
21
+ _BASE_URL = "https://gate.example.com"
22
+ _TOKEN = "test-token"
23
+
24
+ _OUTCOME: dict[str, Any] = {
25
+ "status": "released",
26
+ "verdict": "passed",
27
+ "reason": None,
28
+ "reason_code": None,
29
+ "receipt": None,
30
+ "action_result": None,
31
+ }
32
+
33
+ _FROZEN_FIVE = {
34
+ "persistent_agent_id",
35
+ "action_type",
36
+ "payload",
37
+ "proposed_action_id",
38
+ "nonce",
39
+ }
40
+
41
+
42
+ def _capture(captured: dict[str, Any]) -> Any:
43
+ def handler(request: httpx.Request) -> httpx.Response:
44
+ captured["body"] = json.loads(request.content)
45
+ captured["raw"] = request.content
46
+ return httpx.Response(200, json=_OUTCOME)
47
+
48
+ return handler
49
+
50
+
51
+ def _gate_with(handler: Any) -> ActionGate:
52
+ gate = ActionGate(base_url=_BASE_URL, token=_TOKEN)
53
+ gate._client.close()
54
+ gate._client = httpx.Client(transport=httpx.MockTransport(handler), timeout=5.0)
55
+ return gate
56
+
57
+
58
+ def _decide(gate: ActionGate, **extra: Any) -> dict[str, Any]:
59
+ return gate.decide(
60
+ persistent_agent_id="agent_1",
61
+ action_type="paid_data_call",
62
+ payload={"matter_id": "MER-2026-014", "call_cost_usd": 12},
63
+ proposed_action_id="claim_1",
64
+ nonce="fixednonce",
65
+ **extra,
66
+ )
67
+
68
+
69
+ # ─── the vocabulary ────────────────────────────────────────────────────
70
+
71
+
72
+ def test_the_stages_are_the_three_the_platform_resolves_against() -> None:
73
+ assert STAGES == ("development", "test", "production")
74
+
75
+
76
+ # ─── sent when given ───────────────────────────────────────────────────
77
+
78
+
79
+ @pytest.mark.parametrize("stage", STAGES)
80
+ def test_a_given_stage_reaches_the_wire(stage: str) -> None:
81
+ captured: dict[str, Any] = {}
82
+ with _gate_with(_capture(captured)) as gate:
83
+ _decide(gate, stage=stage)
84
+
85
+ assert captured["body"]["stage"] == stage
86
+ assert set(captured["body"]) == _FROZEN_FIVE | {"stage"}
87
+
88
+
89
+ def test_production_is_reachable_because_a_promoted_contract_lives_only_there() -> None:
90
+ """The reason this parameter exists, stated as a test."""
91
+ captured: dict[str, Any] = {}
92
+ with _gate_with(_capture(captured)) as gate:
93
+ _decide(gate, stage="production")
94
+
95
+ assert captured["body"]["stage"] == "production"
96
+
97
+
98
+ # ─── omitted when None ─────────────────────────────────────────────────
99
+
100
+
101
+ def test_an_omitted_stage_is_absent_from_the_wire_entirely() -> None:
102
+ """Not null, not empty — absent. The platform's default stays in force."""
103
+ captured: dict[str, Any] = {}
104
+ with _gate_with(_capture(captured)) as gate:
105
+ _decide(gate)
106
+
107
+ assert "stage" not in captured["body"]
108
+ assert set(captured["body"]) == _FROZEN_FIVE
109
+
110
+
111
+ def test_omitting_the_stage_is_byte_identical_to_passing_none() -> None:
112
+ """Every pre-0.1.4 caller is untouched: same bytes, not merely same fields."""
113
+ without: dict[str, Any] = {}
114
+ explicit_none: dict[str, Any] = {}
115
+
116
+ with _gate_with(_capture(without)) as gate:
117
+ _decide(gate)
118
+ with _gate_with(_capture(explicit_none)) as gate:
119
+ _decide(gate, stage=None)
120
+
121
+ assert without["raw"] == explicit_none["raw"]
122
+ assert b"stage" not in without["raw"]
123
+
124
+
125
+ # ─── refused when invalid ──────────────────────────────────────────────
126
+
127
+
128
+ @pytest.mark.parametrize(
129
+ "bad", ["prod", "Production", "PRODUCTION", "staging", "dev", "", " development"]
130
+ )
131
+ def test_an_invalid_stage_is_refused_locally(bad: str) -> None:
132
+ """A configuration mistake, answered here — not spent on a 422."""
133
+ sent: list[Any] = []
134
+
135
+ def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover
136
+ sent.append(request)
137
+ return httpx.Response(200, json=_OUTCOME)
138
+
139
+ with _gate_with(handler) as gate, pytest.raises(ValueError) as excinfo:
140
+ _decide(gate, stage=bad)
141
+
142
+ assert sent == [] # nothing left the process
143
+ message = str(excinfo.value)
144
+ assert repr(bad) in message
145
+ for allowed in STAGES:
146
+ assert allowed in message # the error names what IS allowed
147
+
148
+
149
+ def test_the_refusal_happens_before_the_request_is_built() -> None:
150
+ """No partial send, no retry ambiguity: the call never starts."""
151
+ with _gate_with(_capture({})) as gate, pytest.raises(ValueError, match="stage must be one of"):
152
+ _decide(gate, stage="nope")
@@ -2,4 +2,4 @@ import bindable
2
2
 
3
3
 
4
4
  def test_version() -> None:
5
- assert bindable.__version__ == "0.1.3"
5
+ assert bindable.__version__ == "0.1.4"
@@ -1 +0,0 @@
1
- __version__ = "0.1.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes