macp-sdk-python 0.6.0__tar.gz → 0.7.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 (40) hide show
  1. {macp_sdk_python-0.6.0/src/macp_sdk_python.egg-info → macp_sdk_python-0.7.0}/PKG-INFO +1 -1
  2. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/pyproject.toml +1 -1
  3. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/__init__.py +5 -0
  4. macp_sdk_python-0.7.0/src/macp_sdk/commitment_hash.py +317 -0
  5. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/envelope.py +14 -2
  6. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/validation.py +17 -0
  7. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0/src/macp_sdk_python.egg-info}/PKG-INFO +1 -1
  8. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
  9. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/LICENSE +0 -0
  10. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/README.md +0 -0
  11. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/setup.cfg +0 -0
  12. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/_logging.py +0 -0
  13. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/__init__.py +0 -0
  14. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
  15. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  16. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/participant.py +0 -0
  17. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/runner.py +0 -0
  18. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/strategies.py +0 -0
  19. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/transports.py +0 -0
  20. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/agent/types.py +0 -0
  21. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/auth.py +0 -0
  22. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/base_projection.py +0 -0
  23. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/base_session.py +0 -0
  24. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/client.py +0 -0
  25. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/constants.py +0 -0
  26. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/decision.py +0 -0
  27. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/errors.py +0 -0
  28. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/handoff.py +0 -0
  29. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/policy.py +0 -0
  30. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/projections.py +0 -0
  31. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/proposal.py +0 -0
  32. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/proto_registry.py +0 -0
  33. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/py.typed +0 -0
  34. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/quorum.py +0 -0
  35. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/retry.py +0 -0
  36. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/task.py +0 -0
  37. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk/watchers.py +0 -0
  38. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  39. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk_python.egg-info/requires.txt +0 -0
  40. {macp_sdk_python-0.6.0 → macp_sdk_python-0.7.0}/src/macp_sdk_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: Multi-Agent Coordination Protocol <macp@multiagentcoordinationprotocol.org>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "macp-sdk-python"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Python SDK for the MACP Rust runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,6 +3,7 @@ from .auth import AuthConfig
3
3
  from .base_projection import BaseProjection
4
4
  from .base_session import BaseSession
5
5
  from .client import InlineErrorCallback, MacpClient, MacpStream
6
+ from .commitment_hash import commitment_hash, is_canonical_commitment_hash
6
7
  from .constants import (
7
8
  DEFAULT_CONFIGURATION_VERSION,
8
9
  DEFAULT_MODE_VERSION,
@@ -85,6 +86,7 @@ from .quorum import QuorumProjection, QuorumSession
85
86
  from .retry import RetryPolicy, retry_send
86
87
  from .task import TaskProjection, TaskSession
87
88
  from .validation import (
89
+ validate_commitment_hash,
88
90
  validate_confidence,
89
91
  validate_participant_count,
90
92
  validate_participants,
@@ -195,13 +197,16 @@ __all__ = [
195
197
  "build_session_start_payload",
196
198
  "build_signal_payload",
197
199
  "build_task_policy",
200
+ "commitment_hash",
198
201
  "configure_logging",
199
202
  "infer_outcome_positive",
203
+ "is_canonical_commitment_hash",
200
204
  "new_commitment_id",
201
205
  "new_message_id",
202
206
  "new_session_id",
203
207
  "retry_send",
204
208
  "serialize_message",
209
+ "validate_commitment_hash",
205
210
  "validate_confidence",
206
211
  "validate_participant_count",
207
212
  "validate_participants",
@@ -0,0 +1,317 @@
1
+ """Canonical commitment hash (RFC-MACP-0013).
2
+
3
+ Computes ``CommitmentRef.commitment_hash`` as a domain-separated SHA-256
4
+ digest over an RFC 8785 (JCS) canonicalized JSON projection of the frozen
5
+ nine-field ``CommitmentPayload`` set (RFC-MACP-0013 §5).
6
+
7
+ This module intentionally has **no third-party imports beyond
8
+ ``google.protobuf``/``macp.v1.core_pb2``** — the hashing algorithm is pure
9
+ stdlib (plus the protobuf runtime already required to hold ``payload``
10
+ itself) so that it can be reused (and re-verified) without pulling in the
11
+ rest of the SDK.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import re
18
+
19
+ from google.protobuf import unknown_fields # type: ignore[import-untyped]
20
+ from macp.v1 import core_pb2
21
+
22
+ from .errors import MacpSessionError
23
+
24
+ #: Identifies this projection/preimage construction (RFC-MACP-0013 §4, §7).
25
+ #: A change here is a MACP protocol MINOR change (new label, e.g. "/2").
26
+ LABEL = "macp-commitment-hash/1"
27
+
28
+ #: The frozen nine-field set ``CommitmentPayload`` MUST be limited to for this
29
+ #: hash label (RFC-MACP-0013 §5). A future ``macp-proto`` release that adds a
30
+ #: tenth field to the message is not hashable under this label -- see
31
+ #: `_check_frozen_field_set`.
32
+ _FROZEN_FIELD_NAMES = frozenset(
33
+ {
34
+ "commitment_id",
35
+ "action",
36
+ "authority_scope",
37
+ "reason",
38
+ "mode_version",
39
+ "policy_version",
40
+ "configuration_version",
41
+ "outcome_positive",
42
+ "supersedes",
43
+ }
44
+ )
45
+
46
+ #: The installed ``macp-proto``'s actual ``CommitmentPayload`` field names,
47
+ #: computed once at import time so `_check_frozen_field_set` never has to
48
+ #: walk the descriptor on every `commitment_hash()` call.
49
+ _ACTUAL_FIELD_NAMES: frozenset[str] = frozenset(
50
+ f.name for f in core_pb2.CommitmentPayload.DESCRIPTOR.fields
51
+ )
52
+
53
+ #: The frozen two-field set ``CommitmentRef`` (the type of `supersedes`) MUST
54
+ #: be limited to for this hash label (RFC-MACP-0013 §5): "``supersedes``,
55
+ #: when set, carries exactly two fields (``session_id``, ``commitment_hash``)".
56
+ #: A future ``macp-proto`` release that adds a third field to ``CommitmentRef``
57
+ #: itself is not hashable under this label -- see `_check_frozen_ref_field_set`.
58
+ _FROZEN_REF_FIELD_NAMES = frozenset({"session_id", "commitment_hash"})
59
+
60
+ #: The installed ``macp-proto``'s actual ``CommitmentRef`` field names,
61
+ #: computed once at import time, mirroring `_ACTUAL_FIELD_NAMES` above.
62
+ _ACTUAL_REF_FIELD_NAMES: frozenset[str] = frozenset(
63
+ f.name for f in core_pb2.CommitmentRef.DESCRIPTOR.fields
64
+ )
65
+
66
+ #: Matched with `re.fullmatch` (see `is_canonical_commitment_hash`), so the
67
+ #: anchors here are redundant but kept for readability.
68
+ _HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
69
+
70
+ # RFC 8785 §3.2.2.2 short-form escapes.
71
+ _SHORT_ESCAPES: dict[str, str] = {
72
+ "\\": "\\\\",
73
+ '"': '\\"',
74
+ "\b": "\\b",
75
+ "\f": "\\f",
76
+ "\n": "\\n",
77
+ "\r": "\\r",
78
+ "\t": "\\t",
79
+ }
80
+
81
+
82
+ def _escape_json_string(value: str) -> str:
83
+ """Escape ``value`` per RFC 8785 §3.2.2.2 and wrap it in double quotes.
84
+
85
+ Short-form escapes are used for backslash, quote, and the C0 controls
86
+ that have them (\\b \\t \\n \\f \\r); other C0 controls (U+0000-U+001F)
87
+ use \\u00XX; everything else -- including all non-ASCII and astral-plane
88
+ code points -- is emitted literally as the Unicode scalar value it is
89
+ (Python `str` already holds scalar values, not UTF-16 code units, so no
90
+ surrogate-pair handling is needed here).
91
+ """
92
+ out: list[str] = ['"']
93
+ for ch in value:
94
+ short = _SHORT_ESCAPES.get(ch)
95
+ if short is not None:
96
+ out.append(short)
97
+ elif ch < "\x20":
98
+ out.append(f"\\u{ord(ch):04x}")
99
+ else:
100
+ out.append(ch)
101
+ out.append('"')
102
+ return "".join(out)
103
+
104
+
105
+ def _json_bool(value: bool) -> str:
106
+ return "true" if value else "false"
107
+
108
+
109
+ def _supersedes_member(ref: core_pb2.CommitmentRef) -> str:
110
+ # Inside `supersedes`, key order is commitment_hash, session_id
111
+ # (plain lexicographic sort of the two ASCII member names).
112
+ members = [
113
+ f"{_escape_json_string('commitment_hash')}:{_escape_json_string(ref.commitment_hash)}",
114
+ f"{_escape_json_string('session_id')}:{_escape_json_string(ref.session_id)}",
115
+ ]
116
+ return "{" + ",".join(members) + "}"
117
+
118
+
119
+ def _check_frozen_field_set(field_names: frozenset[str] | None = None) -> None:
120
+ """Raise if ``field_names`` contains anything outside the RFC-MACP-0013 §5
121
+ frozen nine-field set for ``CommitmentPayload``.
122
+
123
+ RFC-MACP-0013 §5: "A ``CommitmentPayload`` carrying a field outside this
124
+ set is not hashable under this label. A verifier presented with such a
125
+ payload MUST return a cannot-verify result... never silently ignored."
126
+ §12 reinforces that skipping this check "produces a value that is not a
127
+ canonical commitment hash at all."
128
+
129
+ Defaults to the installed proto's actual field set (`_ACTUAL_FIELD_NAMES`,
130
+ computed once at import time and re-read from the module namespace here
131
+ -- not bound as a mutable default argument -- so that tests can
132
+ monkeypatch it); a caller may also pass an explicit set directly. Either
133
+ way this is how tests exercise the "extra field" branch without needing a
134
+ real 10-field proto descriptor.
135
+
136
+ This is *not* about an older proto missing a field (that's already
137
+ handled by the comment in `commitment_hash()`: accessing a genuinely
138
+ absent field raises ``AttributeError`` naturally) -- this is only about a
139
+ newer proto carrying a field this module does not know about.
140
+ """
141
+ if field_names is None:
142
+ field_names = _ACTUAL_FIELD_NAMES
143
+ extra = field_names - _FROZEN_FIELD_NAMES
144
+ if extra:
145
+ raise MacpSessionError(
146
+ "CommitmentPayload carries field(s) outside the RFC-MACP-0013 §5 "
147
+ f"frozen nine-field set and is not hashable under label {LABEL!r}: "
148
+ f"{sorted(extra)}"
149
+ )
150
+
151
+
152
+ def _check_frozen_ref_field_set(field_names: frozenset[str] | None = None) -> None:
153
+ """Raise if ``field_names`` contains anything outside the RFC-MACP-0013 §5
154
+ frozen two-field set for ``CommitmentRef`` (the type of ``supersedes``).
155
+
156
+ Mirrors `_check_frozen_field_set` exactly, one level down: that guard
157
+ catches a future ``macp-proto`` growing a 10th field on
158
+ ``CommitmentPayload`` itself; this one catches a future ``macp-proto``
159
+ growing a 3rd field on the nested ``CommitmentRef`` message, which
160
+ `_supersedes_member` would otherwise silently under-project without
161
+ anything raising.
162
+
163
+ Defaults to the installed proto's actual field set
164
+ (`_ACTUAL_REF_FIELD_NAMES`, computed once at import time and re-read from
165
+ the module namespace here -- not bound as a mutable default argument --
166
+ so that tests can monkeypatch it); a caller may also pass an explicit set
167
+ directly, exactly as `_check_frozen_field_set` does.
168
+ """
169
+ if field_names is None:
170
+ field_names = _ACTUAL_REF_FIELD_NAMES
171
+ extra = field_names - _FROZEN_REF_FIELD_NAMES
172
+ if extra:
173
+ raise MacpSessionError(
174
+ "CommitmentRef carries field(s) outside the RFC-MACP-0013 §5 "
175
+ f"frozen two-field set and is not hashable under label {LABEL!r}: "
176
+ f"{sorted(extra)}"
177
+ )
178
+
179
+
180
+ def _check_no_unknown_wire_fields(payload: core_pb2.CommitmentPayload) -> None:
181
+ """Raise if ``payload`` carries wire data for a field number the
182
+ installed schema does not recognize at all (an "unknown field").
183
+
184
+ `_check_frozen_field_set` only catches schema drift: a *newer* installed
185
+ ``macp-proto`` whose ``CommitmentPayload.DESCRIPTOR`` has grown a 10th
186
+ field. It cannot see this case: a peer sent bytes for a field number the
187
+ *local* schema has never heard of, so protobuf parses the message
188
+ successfully and stashes those bytes as an "unknown field" on the
189
+ instance, invisible to ``DESCRIPTOR.fields`` (RFC-MACP-0013 §5). Left
190
+ unchecked, `canonical_projection` would silently hash only the known
191
+ fields and drop the unknown field's contribution -- exactly the
192
+ "not hashable under this label ... never silently ignored" outcome the
193
+ RFC prohibits.
194
+
195
+ Classic ``payload.UnknownFields()`` raises ``NotImplementedError`` under
196
+ the ``upb`` backend (the default fast C-extension protobuf backend), so
197
+ this uses ``google.protobuf.unknown_fields.UnknownFieldSet``, the public
198
+ upb-safe replacement, instead.
199
+
200
+ This only inspects ``payload`` itself, not the nested ``supersedes``
201
+ submessage -- see `_check_no_unknown_ref_wire_fields` for the equivalent
202
+ check one level down.
203
+ """
204
+ unknown = unknown_fields.UnknownFieldSet(payload)
205
+ if len(unknown) > 0:
206
+ field_numbers = sorted({f.field_number for f in unknown})
207
+ raise MacpSessionError(
208
+ "CommitmentPayload carries wire data for unrecognized field "
209
+ "number(s) outside the RFC-MACP-0013 §5 frozen nine-field set "
210
+ f"and is not hashable under label {LABEL!r}: {field_numbers}"
211
+ )
212
+
213
+
214
+ def _check_no_unknown_ref_wire_fields(ref: core_pb2.CommitmentRef) -> None:
215
+ """Raise if ``ref`` (a ``supersedes`` submessage) carries wire data for a
216
+ field number the installed schema does not recognize at all.
217
+
218
+ Mirrors `_check_no_unknown_wire_fields` one level down: a peer may send a
219
+ ``CommitmentRef`` with wire data for a field number outside the
220
+ RFC-MACP-0013 §5 frozen two-field set (``session_id``=1,
221
+ ``commitment_hash``=2). `_check_frozen_ref_field_set` only catches schema
222
+ drift (a newer installed ``macp-proto``'s ``CommitmentRef.DESCRIPTOR``
223
+ growing a 3rd field); it cannot see wire data for a field number the
224
+ *local* schema has never heard of at all, which protobuf parses
225
+ successfully and stashes as an "unknown field", invisible to
226
+ ``DESCRIPTOR.fields``. Left unchecked, `_supersedes_member` would
227
+ silently hash only ``session_id``/``commitment_hash`` and drop the
228
+ unknown field's contribution -- the same "not hashable under this label
229
+ ... never silently ignored" outcome the RFC prohibits, just nested one
230
+ level deeper.
231
+ """
232
+ unknown = unknown_fields.UnknownFieldSet(ref)
233
+ if len(unknown) > 0:
234
+ field_numbers = sorted({f.field_number for f in unknown})
235
+ raise MacpSessionError(
236
+ "CommitmentPayload.supersedes carries wire data for unrecognized "
237
+ "field number(s) outside the RFC-MACP-0013 §5 frozen two-field "
238
+ f"set and is not hashable under label {LABEL!r}: {field_numbers}"
239
+ )
240
+
241
+
242
+ def canonical_projection(payload: core_pb2.CommitmentPayload) -> bytes:
243
+ """Return the JCS-canonicalized UTF-8 bytes of the Section 3 projection.
244
+
245
+ This is intermediate value ``C`` of RFC-MACP-0013 §4 step 2 -- the
246
+ canonicalized projection, not the full domain-separated preimage.
247
+
248
+ Raises `MacpSessionError` if the installed proto's `CommitmentPayload`
249
+ carries a field outside the RFC-MACP-0013 §5 frozen nine-field set (see
250
+ `_check_frozen_field_set`), or if this particular message instance
251
+ carries wire data for a field number the installed schema does not
252
+ recognize at all (see `_check_no_unknown_wire_fields`). When `supersedes`
253
+ is set, the same two checks are additionally applied one level down to
254
+ the nested `CommitmentRef` (see `_check_frozen_ref_field_set` and
255
+ `_check_no_unknown_ref_wire_fields`) -- an absent `supersedes` has no
256
+ `CommitmentRef` to check, so those two are skipped in that case.
257
+ """
258
+ _check_frozen_field_set()
259
+ _check_no_unknown_wire_fields(payload)
260
+ if payload.HasField("supersedes"):
261
+ _check_frozen_ref_field_set()
262
+ _check_no_unknown_ref_wire_fields(payload.supersedes)
263
+ members: list[tuple[str, str]] = [
264
+ ("action", _escape_json_string(payload.action)),
265
+ ("authority_scope", _escape_json_string(payload.authority_scope)),
266
+ ("commitment_id", _escape_json_string(payload.commitment_id)),
267
+ ("configuration_version", _escape_json_string(payload.configuration_version)),
268
+ ("mode_version", _escape_json_string(payload.mode_version)),
269
+ ("outcome_positive", _json_bool(payload.outcome_positive)),
270
+ ("policy_version", _escape_json_string(payload.policy_version)),
271
+ ("reason", _escape_json_string(payload.reason)),
272
+ ]
273
+ # Message field: omit entirely when unset (RFC-MACP-0013 §3 rule 3).
274
+ # HasField, not a truthiness check -- an empty CommitmentRef ({} with
275
+ # both sub-fields "") is falsy-looking but MUST still be projected when
276
+ # explicitly set, distinct from `supersedes` being absent altogether.
277
+ if payload.HasField("supersedes"):
278
+ members.append(("supersedes", _supersedes_member(payload.supersedes)))
279
+ # Top-level members are already in lexicographic key order except for
280
+ # "supersedes", which we appended last; re-sort to be explicit and
281
+ # robust regardless of append order above.
282
+ members.sort(key=lambda kv: kv[0])
283
+
284
+ body = ",".join(f"{_escape_json_string(k)}:{v}" for k, v in members)
285
+ text = "{" + body + "}"
286
+ return text.encode("utf-8")
287
+
288
+
289
+ def commitment_hash(payload: core_pb2.CommitmentPayload) -> str:
290
+ """Compute the canonical commitment hash of ``payload`` (RFC-MACP-0013 §4).
291
+
292
+ Accepts any ``CommitmentPayload``, including one that is not
293
+ well-formed under RFC-MACP-0001 §7.3.1 (e.g. all-empty fields, or a
294
+ ``supersedes`` with empty sub-fields) -- hashing is a pure function of
295
+ the projected field values and MUST NOT be gated on validity
296
+ (RFC-MACP-0013 §6, "hashability"). Do not add a validation call here.
297
+ """
298
+ # No guard against an older macp-proto lacking `outcome_positive` /
299
+ # `supersedes`: the repo's pin is macp-proto>=0.1.6 and both fields have
300
+ # existed since well before that, so the fields are always present on
301
+ # the installed descriptor. Accessing a genuinely absent field would
302
+ # raise AttributeError from the generated proto class itself rather
303
+ # than silently under-projecting, so no additional guard is needed here.
304
+ preimage = LABEL.encode("ascii") + b":" + canonical_projection(payload)
305
+ digest = hashlib.sha256(preimage).hexdigest()
306
+ return f"sha256:{digest}"
307
+
308
+
309
+ def is_canonical_commitment_hash(value: str) -> bool:
310
+ """Syntax predicate: ``^sha256:[0-9a-f]{64}$`` exactly (full match), no I/O.
311
+
312
+ Uses `re.fullmatch` rather than `match` + a trailing-``$`` pattern: `$`
313
+ alone matches immediately before a trailing ``\\n``, which would wrongly
314
+ accept ``"sha256:" + "a" * 64 + "\\n"``. `fullmatch` anchors both ends
315
+ with no such quirk, so no separate `strip()` check is needed.
316
+ """
317
+ return bool(_HASH_RE.fullmatch(value))
@@ -14,6 +14,7 @@ from .constants import (
14
14
  MACP_VERSION,
15
15
  )
16
16
  from .errors import MacpSessionError
17
+ from .validation import validate_commitment_hash
17
18
 
18
19
  # ── Outcome inference ────────────────────────────────────────────────
19
20
 
@@ -110,7 +111,14 @@ def _has_supersedes_field() -> bool:
110
111
 
111
112
  def build_commitment_ref(*, session_id: str, commitment_hash: str) -> core_pb2.CommitmentRef:
112
113
  """Build a ``CommitmentRef`` (macp-proto 0.1.3) pointing at a prior
113
- commitment, for use as ``build_commitment_payload(supersedes=...)``."""
114
+ commitment, for use as ``build_commitment_payload(supersedes=...)``.
115
+
116
+ ``commitment_hash`` must be a canonical RFC-MACP-0013 commitment hash
117
+ (``sha256:`` followed by 64 lowercase hex digits) — see
118
+ ``commitment_hash()`` in ``macp_sdk.commitment_hash`` for computing one
119
+ from a ``CommitmentPayload``. Raises ``MacpSessionError`` otherwise.
120
+ """
121
+ validate_commitment_hash(commitment_hash)
114
122
  return core_pb2.CommitmentRef(session_id=session_id, commitment_hash=commitment_hash)
115
123
 
116
124
 
@@ -131,7 +139,10 @@ def build_commitment_payload(
131
139
  ``supersedes`` (macp-proto 0.1.3) optionally references a prior
132
140
  commitment this one revises, as a ``CommitmentRef`` of
133
141
  ``(session_id, commitment_hash)``. It is absent by default and unrelated
134
- to proposal-mode ``supersedes_proposal_id``.
142
+ to proposal-mode ``supersedes_proposal_id``. ``supersedes.commitment_hash``
143
+ must be a canonical RFC-MACP-0013 hash (validated here even when the
144
+ ``CommitmentRef`` was constructed directly rather than via
145
+ ``build_commitment_ref``); raises ``MacpSessionError`` otherwise.
135
146
  """
136
147
  if outcome_positive is None:
137
148
  outcome_positive = infer_outcome_positive(action)
@@ -149,6 +160,7 @@ def build_commitment_payload(
149
160
  if supersedes is not None:
150
161
  if not _has_supersedes_field():
151
162
  raise MacpSessionError("CommitmentPayload.supersedes requires macp-proto >= 0.1.3")
163
+ validate_commitment_hash(supersedes.commitment_hash)
152
164
  kwargs["supersedes"] = supersedes
153
165
  return core_pb2.CommitmentPayload(**kwargs)
154
166
 
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import re
9
9
  from collections.abc import Sequence
10
10
 
11
+ from .commitment_hash import is_canonical_commitment_hash
11
12
  from .errors import MacpSessionError
12
13
 
13
14
  # A string with the structural shape of a UUID (36 chars, hyphens at
@@ -57,6 +58,22 @@ def validate_session_id(sid: str) -> None:
57
58
  )
58
59
 
59
60
 
61
+ def validate_commitment_hash(value: str) -> None:
62
+ """Validate that *value* has the shape of a canonical commitment hash.
63
+
64
+ Mirrors the runtime's RFC-MACP-0013 syntax check: a valid
65
+ ``commitment_hash`` MUST match ``^sha256:[0-9a-f]{64}$`` exactly (see
66
+ `macp_sdk.commitment_hash.is_canonical_commitment_hash`). This is a pure
67
+ shape check — it does not (and cannot) verify that the digest was
68
+ actually produced by `macp_sdk.commitment_hash.commitment_hash` over the
69
+ referenced payload; that requires the payload itself.
70
+ """
71
+ if not is_canonical_commitment_hash(value):
72
+ raise MacpSessionError(
73
+ f"commitment_hash must match ^sha256:[0-9a-f]{{64}}$ (RFC-MACP-0013), got: {value!r}"
74
+ )
75
+
76
+
60
77
  def validate_vote(value: str) -> str:
61
78
  """Normalize *value* to uppercase and validate as APPROVE/REJECT/ABSTAIN."""
62
79
  normalized = value.upper()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: Multi-Agent Coordination Protocol <macp@multiagentcoordinationprotocol.org>
6
6
  License: Apache-2.0
@@ -7,6 +7,7 @@ src/macp_sdk/auth.py
7
7
  src/macp_sdk/base_projection.py
8
8
  src/macp_sdk/base_session.py
9
9
  src/macp_sdk/client.py
10
+ src/macp_sdk/commitment_hash.py
10
11
  src/macp_sdk/constants.py
11
12
  src/macp_sdk/decision.py
12
13
  src/macp_sdk/envelope.py
File without changes