macp-sdk-python 0.6.0__tar.gz → 0.8.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 (41) hide show
  1. {macp_sdk_python-0.6.0/src/macp_sdk_python.egg-info → macp_sdk_python-0.8.0}/PKG-INFO +5 -4
  2. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/README.md +2 -2
  3. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/pyproject.toml +3 -2
  4. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/__init__.py +14 -1
  5. macp_sdk_python-0.8.0/src/macp_sdk/base_projection.py +314 -0
  6. macp_sdk_python-0.8.0/src/macp_sdk/commitment_hash.py +317 -0
  7. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/envelope.py +14 -2
  8. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/projections.py +19 -1
  9. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/quorum.py +33 -9
  10. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/validation.py +17 -0
  11. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0/src/macp_sdk_python.egg-info}/PKG-INFO +5 -4
  12. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
  13. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/requires.txt +2 -1
  14. macp_sdk_python-0.6.0/src/macp_sdk/base_projection.py +0 -52
  15. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/LICENSE +0 -0
  16. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/setup.cfg +0 -0
  17. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/_logging.py +0 -0
  18. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/__init__.py +0 -0
  19. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
  20. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  21. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/participant.py +0 -0
  22. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/runner.py +0 -0
  23. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/strategies.py +0 -0
  24. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/transports.py +0 -0
  25. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/types.py +0 -0
  26. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/auth.py +0 -0
  27. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/base_session.py +0 -0
  28. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/client.py +0 -0
  29. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/constants.py +0 -0
  30. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/decision.py +0 -0
  31. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/errors.py +0 -0
  32. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/handoff.py +0 -0
  33. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/policy.py +0 -0
  34. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proposal.py +0 -0
  35. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proto_registry.py +0 -0
  36. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/py.typed +0 -0
  37. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/retry.py +0 -0
  38. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/task.py +0 -0
  39. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/watchers.py +0 -0
  40. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  41. {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.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.8.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
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: grpcio>=1.82.0rc2
24
24
  Requires-Dist: protobuf>=7.35.0
25
- Requires-Dist: macp-proto<0.1.9,>=0.1.6
25
+ Requires-Dist: macp-proto<0.1.10,>=0.1.6
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.0; extra == "dev"
28
28
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
@@ -30,6 +30,7 @@ Requires-Dist: mypy>=1.10; extra == "dev"
30
30
  Requires-Dist: ruff>=0.5; extra == "dev"
31
31
  Requires-Dist: grpc-stubs>=1.53; extra == "dev"
32
32
  Requires-Dist: build>=1.0; extra == "dev"
33
+ Requires-Dist: twine>=5.0; extra == "dev"
33
34
  Requires-Dist: jsonschema>=4.0; extra == "dev"
34
35
  Provides-Extra: docs
35
36
  Requires-Dist: mkdocs-material>=9.5; extra == "docs"
@@ -127,7 +128,7 @@ if winner and not session.decision_projection.has_blocking_objection(winner):
127
128
  make setup # pip install -e ".[dev,docs]"
128
129
 
129
130
  # Quality
130
- make lint # ruff check
131
+ make lint # ruff check + ruff format --check
131
132
  make fmt # ruff format
132
133
  make typecheck # mypy strict
133
134
  make test # unit tests + coverage gate (85% branch, from pyproject)
@@ -136,7 +137,7 @@ make test-all # lint + typecheck + all tests
136
137
  make coverage # HTML + terminal coverage report
137
138
 
138
139
  # Build
139
- make build # sdist + wheel
140
+ make build # build sdist + wheel, then twine check
140
141
 
141
142
  # Proto definitions (provided by macp-proto package)
142
143
  make dev-link-protos # link local proto package for development
@@ -89,7 +89,7 @@ if winner and not session.decision_projection.has_blocking_objection(winner):
89
89
  make setup # pip install -e ".[dev,docs]"
90
90
 
91
91
  # Quality
92
- make lint # ruff check
92
+ make lint # ruff check + ruff format --check
93
93
  make fmt # ruff format
94
94
  make typecheck # mypy strict
95
95
  make test # unit tests + coverage gate (85% branch, from pyproject)
@@ -98,7 +98,7 @@ make test-all # lint + typecheck + all tests
98
98
  make coverage # HTML + terminal coverage report
99
99
 
100
100
  # Build
101
- make build # sdist + wheel
101
+ make build # build sdist + wheel, then twine check
102
102
 
103
103
  # Proto definitions (provided by macp-proto package)
104
104
  make dev-link-protos # link local proto package for development
@@ -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.8.0"
8
8
  description = "Python SDK for the MACP Rust runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -52,7 +52,7 @@ dependencies = [
52
52
  # grpcio comment above) — PEP 440 sorts 1.82.0rc2 below 1.82.0, so no
53
53
  # candidate exists. 0.1.6/0.1.7 declare "grpcio>=1.74.0" and resolve fine.
54
54
  # Re-widen to <0.2.0 once grpcio publishes a stable >= 1.82.1.
55
- "macp-proto>=0.1.6,<0.1.9",
55
+ "macp-proto>=0.1.6,<0.1.10",
56
56
  ]
57
57
 
58
58
  [project.urls]
@@ -74,6 +74,7 @@ dev = [
74
74
  "ruff>=0.5",
75
75
  "grpc-stubs>=1.53",
76
76
  "build>=1.0",
77
+ "twine>=5.0", # validates the built artifacts (`make build`), mirroring ci.yml's build job
77
78
  "jsonschema>=4.0", # validate vendored conformance fixtures against schema.json
78
79
  ]
79
80
  docs = [
@@ -1,8 +1,14 @@
1
1
  from ._logging import configure_logging
2
2
  from .auth import AuthConfig
3
- from .base_projection import BaseProjection
3
+ from .base_projection import (
4
+ ANOMALY_DUPLICATE_BALLOT,
5
+ ANOMALY_DUPLICATE_VOTE,
6
+ BaseProjection,
7
+ ProjectionAnomaly,
8
+ )
4
9
  from .base_session import BaseSession
5
10
  from .client import InlineErrorCallback, MacpClient, MacpStream
11
+ from .commitment_hash import commitment_hash, is_canonical_commitment_hash
6
12
  from .constants import (
7
13
  DEFAULT_CONFIGURATION_VERSION,
8
14
  DEFAULT_MODE_VERSION,
@@ -85,6 +91,7 @@ from .quorum import QuorumProjection, QuorumSession
85
91
  from .retry import RetryPolicy, retry_send
86
92
  from .task import TaskProjection, TaskSession
87
93
  from .validation import (
94
+ validate_commitment_hash,
88
95
  validate_confidence,
89
96
  validate_participant_count,
90
97
  validate_participants,
@@ -108,6 +115,8 @@ from .watchers import (
108
115
  )
109
116
 
110
117
  __all__ = [
118
+ "ANOMALY_DUPLICATE_BALLOT",
119
+ "ANOMALY_DUPLICATE_VOTE",
111
120
  "DEFAULT_CONFIGURATION_VERSION",
112
121
  "DEFAULT_MODE_VERSION",
113
122
  "DEFAULT_POLICY_VERSION",
@@ -163,6 +172,7 @@ __all__ = [
163
172
  "ObjectionHandlingRules",
164
173
  "PolicyChange",
165
174
  "PolicyWatcher",
175
+ "ProjectionAnomaly",
166
176
  "ProposalAcceptanceRules",
167
177
  "ProposalProjection",
168
178
  "ProposalSession",
@@ -195,13 +205,16 @@ __all__ = [
195
205
  "build_session_start_payload",
196
206
  "build_signal_payload",
197
207
  "build_task_policy",
208
+ "commitment_hash",
198
209
  "configure_logging",
199
210
  "infer_outcome_positive",
211
+ "is_canonical_commitment_hash",
200
212
  "new_commitment_id",
201
213
  "new_message_id",
202
214
  "new_session_id",
203
215
  "retry_send",
204
216
  "serialize_message",
217
+ "validate_commitment_hash",
205
218
  "validate_confidence",
206
219
  "validate_participant_count",
207
220
  "validate_participants",
@@ -0,0 +1,314 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import ClassVar
6
+
7
+ from macp.v1 import core_pb2, envelope_pb2
8
+
9
+ from ._logging import logger
10
+
11
+ # Cross-SDK contract with macp-sdk-typescript#55 — these two string values are
12
+ # part of the wire-adjacent public API and must match the TypeScript SDK
13
+ # byte-for-byte. Do not rename, and do not add a third without coordinating
14
+ # there first.
15
+ ANOMALY_DUPLICATE_VOTE = "duplicate_vote" # RFC-MACP-0007 §5.3
16
+ ANOMALY_DUPLICATE_BALLOT = "duplicate_ballot" # RFC-MACP-0011 §5
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ProjectionAnomaly:
21
+ """A discarded-message observation recorded by a projection.
22
+
23
+ Cross-SDK contract with macp-sdk-typescript#55: the field names, their
24
+ order, and both ``kind`` string values (``ANOMALY_DUPLICATE_VOTE``,
25
+ ``ANOMALY_DUPLICATE_BALLOT``) are a byte-for-byte contract shared with the
26
+ TypeScript SDK. Do not rename, reorder, or extend the field set without
27
+ coordinating there first.
28
+
29
+ Honesty clause: this records an **observation**, not a spec-violation
30
+ verdict. A projection cannot distinguish a genuinely non-conforming
31
+ source from a conforming source fed through an unfiltered loader --
32
+ acceptance is not a wire property (see ``apply_envelope``'s docstring).
33
+ Treat ``kind`` as "a second distinct message of this shape was observed
34
+ and discarded; the first stands," not as "this transcript violates the
35
+ spec."
36
+
37
+ Deliberately does not carry the full ``Envelope``: too heavy a public
38
+ commitment. The discarded envelope remains recoverable by correlating
39
+ ``message_id`` against a projection's ``transcript``.
40
+ """
41
+
42
+ kind: str
43
+ mode: str
44
+ message_type: str
45
+ message_id: str
46
+ sender: str
47
+ subject_id: str # proposal_id / request_id
48
+ detail: str = ""
49
+
50
+
51
+ class BaseProjection(ABC):
52
+ """Abstract base for in-process mode state tracking.
53
+
54
+ Maintains a local transcript and delegates mode-specific message handling
55
+ to subclasses. Needed because the runtime's ``GetSession`` RPC returns
56
+ metadata only, not mode state or transcript.
57
+ """
58
+
59
+ MODE: ClassVar[str]
60
+
61
+ def __init__(self) -> None:
62
+ self.transcript: list[envelope_pb2.Envelope] = []
63
+ self.phase: str = ""
64
+ self.commitment: core_pb2.CommitmentPayload | None = None
65
+ # Implementation detail, not public API: tracks message_id values already
66
+ # applied to this projection so a redelivered envelope is a no-op. See
67
+ # apply_envelope's docstring for the contract.
68
+ self._seen_message_ids: set[str] = set()
69
+ # Public: discarded-message observations recorded via _record_anomaly
70
+ # (Decision Vote, Quorum ballot -- see those projections' first-wins
71
+ # branches). See ProjectionAnomaly's docstring for the cross-SDK
72
+ # contract it carries.
73
+ self.anomalies: list[ProjectionAnomaly] = []
74
+
75
+ @property
76
+ def is_committed(self) -> bool:
77
+ return self.commitment is not None
78
+
79
+ @property
80
+ def has_anomalies(self) -> bool:
81
+ return bool(self.anomalies)
82
+
83
+ @property
84
+ def is_positive_outcome(self) -> bool | None:
85
+ """Return the outcome polarity, or ``None`` if not yet committed."""
86
+ if self.commitment is None:
87
+ return None
88
+ return getattr(self.commitment, "outcome_positive", True)
89
+
90
+ def apply_envelope(self, envelope: envelope_pb2.Envelope) -> None:
91
+ """Apply one envelope from **accepted history** to local state.
92
+
93
+ Precondition — accepted history only, not enforced here: callers MUST
94
+ feed only envelopes that a conforming runtime accepted. A subscribe
95
+ stream satisfies this automatically (RFC-MACP-0006 §3.2 obligation 4:
96
+ a runtime never replays rejected envelopes). Any other feed — hand-built
97
+ fixtures, captured logs, transcripts carrying non-accepted entries — MUST
98
+ be filtered by the caller before it reaches this method, the way
99
+ ``tests/conformance/test_conformance_projections.py:153`` filters out
100
+ non-``accept`` fixture entries. This method has no way to verify that
101
+ precondition, and derived state is **undefined** without it.
102
+
103
+ Idempotency: applying an envelope whose ``message_id`` has already been
104
+ seen by this projection is a total no-op — it is not appended to
105
+ ``transcript`` and is not dispatched to mode handling. This makes
106
+ redelivery (e.g. a stream resubscribe replaying accepted history from
107
+ the start) safe to re-apply without corrupting derived state. An empty
108
+ ``message_id`` (the proto3 default for hand-built envelopes) is never
109
+ deduplicated — every such envelope is applied. See the code comment on
110
+ the dedup gate below for the precise normative basis.
111
+
112
+ Honest limitation: this dedups **redelivery of the same envelope**, not
113
+ every way a feed can carry a non-conforming message. A *genuine*
114
+ duplicate -- e.g. a second, distinct ``Vote`` from a sender who already
115
+ voted on the same proposal, carrying its own new ``message_id`` -- is
116
+ not caught here: it passes this method's dedup gate, is appended to
117
+ ``transcript``, and is dispatched to mode handling like any other
118
+ envelope. Discarding it and recording the observation is the mode
119
+ projection's job, not this base method's -- see
120
+ ``DecisionProjection``'s Vote branch and ``QuorumProjection._set_ballot``,
121
+ which apply first-wins and append a ``ProjectionAnomaly`` (``anomalies``,
122
+ ``has_anomalies``) rather than silently overwriting. A rejected vote
123
+ from a sender who never votes again still corrupts derived state with
124
+ nothing here to detect it -- this method has no visibility into
125
+ cardinality rules, which are mode-specific. No mechanism in this
126
+ method closes that narrower gap; do not imply one exists.
127
+
128
+ Determinism holds in both directions: replaying ``transcript`` through a
129
+ fresh projection reproduces the same state, whether or not duplicates or
130
+ redeliveries were present in the original feed.
131
+
132
+ Acceptance is not a wire property: nothing on the envelope itself marks
133
+ it as "accepted" — it is accepted only by virtue of appearing in
134
+ accepted history at all, per the precondition above. If a caller sees a
135
+ surprising result, correlate the offending envelope's ``message_id``
136
+ against their own acceptance metadata (what they actually received back
137
+ from the runtime) to determine whether the surprise came from a
138
+ non-conforming source or from an unfiltered feed, rather than assuming
139
+ this method mis-processed it.
140
+
141
+ Not thread-safe: the ``message_id`` dedup check-then-add below is not
142
+ atomic across threads, and this class holds no lock. A single
143
+ projection instance must not be fed concurrently from multiple threads.
144
+
145
+ On failure: if applying the envelope raises, this method removes the
146
+ envelope from ``transcript`` and releases its ``message_id`` from the
147
+ dedup set before the exception propagates, so the caller may retry
148
+ the identical envelope and it will be applied rather than silently
149
+ swallowed as a redelivery. This rollback covers only ``transcript``
150
+ and the dedup set — subclass-derived state (e.g. ``self.phase``, or
151
+ any subclass collection) is not rolled back.
152
+ """
153
+ if envelope.mode != self.MODE:
154
+ return
155
+
156
+ # Empty-id guard — the real reason, not a test accommodation: message_id
157
+ # is a proto3 scalar, so its unset value is "" — not a real identity.
158
+ # Deduplicating on "" would collapse every envelope lacking an id into a
159
+ # single logical message. Hand-built envelopes without an id are a real,
160
+ # exercised path in this repo: tests/unit/test_client_helpers.py:142 and
161
+ # tests/unit/test_client_stream.py:160 both construct one. So the guard
162
+ # only dedupes non-empty ids.
163
+ #
164
+ # At-least-once basis for dedup, cited precisely:
165
+ # - Load-bearing: RFC-MACP-0001-core.md:306 — "MACP assumes
166
+ # at-least-once delivery semantics at the transport layer."
167
+ # Unqualified, about the transport, so it binds any consumer of that
168
+ # transport including client projections. From this alone it
169
+ # follows that a projection may be handed the same envelope twice.
170
+ # This is an INFERENCE from a transport-layer premise, not a MUST
171
+ # addressed to clients.
172
+ # - Corroborating only, not compelling on its own: RFC-MACP-0001 §8.2
173
+ # (:316) — "Runtimes MUST enforce idempotent handling of duplicates
174
+ # using message_id." That MUST is addressed to runtimes at their
175
+ # ingress boundary, not to clients; its contribution here is only
176
+ # the *identity* to key dedup on (message_id, not envelope bytes).
177
+ # - Now normative for clients directly: RFC-MACP-0006 §3.2 (spec PR
178
+ # #80, RFC-0006 1.4.0-draft) requires a client to tolerate an
179
+ # already-observed envelope and to key duplicate detection on
180
+ # message_id.
181
+ # Mirrors the runtime's own boundary idempotency: src/macp_sdk/client.py
182
+ # :419-422 already treats ack.duplicate as idempotent success.
183
+ message_id = envelope.message_id
184
+ seen_id_added = False
185
+ if message_id:
186
+ if message_id in self._seen_message_ids:
187
+ return
188
+ self._seen_message_ids.add(message_id)
189
+ seen_id_added = True
190
+
191
+ self.transcript.append(envelope)
192
+
193
+ # Rollback scope — narrower than "atomic" might suggest: the
194
+ # dedup-set add and transcript append above both happen BEFORE the
195
+ # actual effect (Commitment ParseFromString, or the subclass's
196
+ # _apply_mode_message dispatch), either of which can raise. On such
197
+ # a raise, the except block below undoes exactly those two
198
+ # mutations — transcript and _seen_message_ids — and re-raises, so
199
+ # a retry of the SAME envelope is not swallowed as a redelivery.
200
+ # Concretely: a supervisor that catches an exception out of
201
+ # Participant.run() and resubscribes (agent/transports.py:60
202
+ # replays accepted history from after_sequence=0) re-feeds the same
203
+ # envelope to the same projection (agent/participant.py:410,
204
+ # apply_envelope unguarded) — without this rollback the dedup gate
205
+ # would silently swallow that retry, permanently losing the
206
+ # envelope's effect while transcript still claims it is present.
207
+ #
208
+ # What this does NOT cover: self.phase (assigned directly on
209
+ # BaseProjection by subclasses — see projections.py:84,
210
+ # task.py:103, handoff.py:71) and any subclass-owned collection
211
+ # (evaluations, objections, accepts, rejections, updates,
212
+ # completions, failures) are never rolled back, because this
213
+ # method has no way to know what a subclass mutated. That is safe
214
+ # only because every _apply_mode_message implementation in this
215
+ # SDK today performs its one fallible operation (ParseFromString)
216
+ # strictly before any mutation, and every record type it
217
+ # constructs is a plain slotted dataclass whose construction
218
+ # cannot raise — so there is presently no reachable path that
219
+ # mutates subclass state and then raises. This is a
220
+ # raise-before-mutate invariant that _apply_mode_message
221
+ # implementations must preserve. BaseProjection is exported for
222
+ # third-party subclassing (see __init__.py:143), and upcoming
223
+ # first-wins/anomaly-tracking logic must keep fallible work ahead
224
+ # of mutations to keep this guarantee honest.
225
+ #
226
+ # Catches Exception, not BaseException: KeyboardInterrupt/SystemExit
227
+ # signal that the process is being torn down, not a recoverable
228
+ # per-envelope failure, and should propagate immediately rather than
229
+ # be treated as "this envelope's apply failed, try rolling back."
230
+ try:
231
+ if envelope.message_type == "Commitment":
232
+ payload = core_pb2.CommitmentPayload()
233
+ payload.ParseFromString(envelope.payload)
234
+ self.commitment = payload
235
+ self.phase = "Committed"
236
+ return
237
+
238
+ self._apply_mode_message(envelope)
239
+ except Exception:
240
+ # Roll back exactly what this call added, nothing more. In
241
+ # every path exercised by this SDK today, the append two lines
242
+ # above is the only mutation of transcript between there and
243
+ # here, so transcript[-1] is the entry this call just
244
+ # appended. But BaseProjection is a public ABC (exported at
245
+ # __init__.py:143) that third parties may subclass, so guard
246
+ # with an identity check rather than popping unconditionally.
247
+ # The two ways the guard could see something else at [-1] are:
248
+ # - a subclass's _apply_mode_message appending to
249
+ # self.transcript itself before raising (no subclass in
250
+ # this SDK does this — each mutates only its own
251
+ # collections, never self.transcript directly);
252
+ # - a re-entrant call to apply_envelope on the same instance
253
+ # while this call is still on the stack (nothing in this
254
+ # SDK calls apply_envelope from within apply_envelope or
255
+ # from a mode handler).
256
+ # Neither happens here, but a wrong-entry pop under either
257
+ # would silently corrupt an unrelated transcript entry — data
258
+ # corruption strictly worse than the bug this rollback fixes.
259
+ # `is` identity, not `==`: protobuf messages compare by value,
260
+ # so two distinct envelopes can be equal without being the
261
+ # same object.
262
+ if self.transcript and self.transcript[-1] is envelope:
263
+ self.transcript.pop()
264
+ if seen_id_added:
265
+ self._seen_message_ids.discard(message_id)
266
+ raise
267
+
268
+ @abstractmethod
269
+ def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
270
+ """Handle a mode-specific (non-Commitment) envelope."""
271
+
272
+ def _record_anomaly(
273
+ self,
274
+ *,
275
+ kind: str,
276
+ message_type: str,
277
+ message_id: str,
278
+ sender: str,
279
+ subject_id: str,
280
+ detail: str = "",
281
+ ) -> None:
282
+ """Append a `ProjectionAnomaly` and emit exactly one WARNING log line.
283
+
284
+ Protected and keyword-only: subclasses are the only callers, and
285
+ keyword-only arguments prevent a positional call from silently
286
+ transposing two same-typed fields (e.g. sender/subject_id). ``mode``
287
+ is filled in from ``self.MODE`` rather than accepted as an argument,
288
+ so call sites cannot drift from the projection's own mode.
289
+
290
+ Call sites: ``DecisionProjection``'s Vote branch and
291
+ ``QuorumProjection._set_ballot``, on first-wins discard of a genuine
292
+ duplicate Vote/ballot.
293
+ """
294
+ anomaly = ProjectionAnomaly(
295
+ kind=kind,
296
+ mode=self.MODE,
297
+ message_type=message_type,
298
+ message_id=message_id,
299
+ sender=sender,
300
+ subject_id=subject_id,
301
+ detail=detail,
302
+ )
303
+ self.anomalies.append(anomaly)
304
+ logger.warning(
305
+ "projection anomaly kind=%s mode=%s message_type=%s message_id=%s "
306
+ "sender=%s subject_id=%s detail=%s",
307
+ anomaly.kind,
308
+ anomaly.mode,
309
+ anomaly.message_type,
310
+ anomaly.message_id,
311
+ anomaly.sender,
312
+ anomaly.subject_id,
313
+ anomaly.detail,
314
+ )
@@ -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
 
@@ -5,7 +5,7 @@ from dataclasses import dataclass
5
5
  from macp.modes.decision.v1 import decision_pb2
6
6
  from macp.v1 import envelope_pb2
7
7
 
8
- from .base_projection import BaseProjection
8
+ from .base_projection import ANOMALY_DUPLICATE_VOTE, BaseProjection
9
9
  from .constants import MODE_DECISION
10
10
 
11
11
 
@@ -100,6 +100,24 @@ class DecisionProjection(BaseProjection):
100
100
  if message_type == "Vote":
101
101
  payload = decision_pb2.VotePayload()
102
102
  payload.ParseFromString(envelope.payload)
103
+ existing = self.votes.get(payload.proposal_id, {}).get(envelope.sender)
104
+ if existing is not None:
105
+ # First-wins (RFC-MACP-0007 §5.3: "the first accepted Vote
106
+ # stands"). A second, distinct Vote (its own message_id --
107
+ # redelivery of the same envelope never reaches here, see
108
+ # apply_envelope's dedup gate) from a sender who already
109
+ # voted on this proposal is discarded without mutating
110
+ # votes or phase. detail states facts only, no cause
111
+ # attribution.
112
+ self._record_anomaly(
113
+ kind=ANOMALY_DUPLICATE_VOTE,
114
+ message_type=envelope.message_type,
115
+ message_id=envelope.message_id,
116
+ sender=envelope.sender,
117
+ subject_id=payload.proposal_id,
118
+ detail=(f"kept first vote {existing.vote!r}; discarded {payload.vote!r}"),
119
+ )
120
+ return
103
121
  self.votes.setdefault(payload.proposal_id, {})[envelope.sender] = DecisionVoteRecord(
104
122
  proposal_id=payload.proposal_id,
105
123
  vote=payload.vote,
@@ -6,7 +6,7 @@ from macp.modes.quorum.v1 import quorum_pb2
6
6
  from macp.v1 import envelope_pb2
7
7
 
8
8
  from .auth import AuthConfig
9
- from .base_projection import BaseProjection
9
+ from .base_projection import ANOMALY_DUPLICATE_BALLOT, BaseProjection
10
10
  from .base_session import BaseSession
11
11
  from .constants import MODE_QUORUM
12
12
  from .envelope import build_envelope, serialize_message
@@ -41,9 +41,11 @@ class BallotRecord:
41
41
  class QuorumProjection(BaseProjection):
42
42
  """In-process state tracking for Quorum mode sessions.
43
43
 
44
- Supports multiple concurrent approval requests within a single session.
45
- Query methods accept a ``request_id`` parameter to target a specific
46
- request.
44
+ RFC-MACP-0011 §5 rule 1 caps a session at one ``ApprovalRequest`` in
45
+ base v1. The ``requests`` / ``ballots`` maps are keyed by ``request_id``
46
+ because that is the identifier the protocol uses, not because multiple
47
+ concurrent requests are supported. Query methods accept a ``request_id``
48
+ parameter to target that single request.
47
49
  """
48
50
 
49
51
  MODE = MODE_QUORUM
@@ -73,22 +75,44 @@ class QuorumProjection(BaseProjection):
73
75
  if mt == "Approve":
74
76
  p = quorum_pb2.ApprovePayload()
75
77
  p.ParseFromString(envelope.payload)
76
- self._set_ballot(p.request_id, envelope.sender, "approve", p.reason)
78
+ self._set_ballot(envelope, p.request_id, "approve", p.reason)
77
79
  return
78
80
 
79
81
  if mt == "Reject":
80
82
  p = quorum_pb2.RejectPayload()
81
83
  p.ParseFromString(envelope.payload)
82
- self._set_ballot(p.request_id, envelope.sender, "reject", p.reason)
84
+ self._set_ballot(envelope, p.request_id, "reject", p.reason)
83
85
  return
84
86
 
85
87
  if mt == "Abstain":
86
88
  p = quorum_pb2.AbstainPayload()
87
89
  p.ParseFromString(envelope.payload)
88
- self._set_ballot(p.request_id, envelope.sender, "abstain", p.reason)
89
-
90
- def _set_ballot(self, request_id: str, sender: str, vote: str, reason: str) -> None:
90
+ self._set_ballot(envelope, p.request_id, "abstain", p.reason)
91
+
92
+ def _set_ballot(
93
+ self, envelope: envelope_pb2.Envelope, request_id: str, vote: str, reason: str
94
+ ) -> None:
95
+ # Single funnel for Approve/Reject/Abstain -- this is exactly what
96
+ # enforces RFC-MACP-0011 §5 rule 3's *across-type* cardinality rule
97
+ # ("at most one ballot across Approve, Reject, or Abstain"). First
98
+ # ballot per sender stands; which-of-two-stands is inferred from
99
+ # RFC-MACP-0007 §5.3 parity plus what the only runtime enforces
100
+ # (quorum.rs:164/184/204) -- RFC-0011 itself is silent on that.
101
+ sender = envelope.sender
91
102
  sender_map = self.ballots.setdefault(request_id, {})
103
+ existing = sender_map.get(sender)
104
+ if existing is not None:
105
+ # detail states facts only, no cause attribution. message_type
106
+ # here is the DISCARDED message's type, not the first ballot's.
107
+ self._record_anomaly(
108
+ kind=ANOMALY_DUPLICATE_BALLOT,
109
+ message_type=envelope.message_type,
110
+ message_id=envelope.message_id,
111
+ sender=sender,
112
+ subject_id=request_id,
113
+ detail=f"kept first ballot {existing.vote!r}; discarded {vote!r}",
114
+ )
115
+ return
92
116
  sender_map[sender] = BallotRecord(
93
117
  request_id=request_id,
94
118
  vote=vote,
@@ -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.8.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
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: grpcio>=1.82.0rc2
24
24
  Requires-Dist: protobuf>=7.35.0
25
- Requires-Dist: macp-proto<0.1.9,>=0.1.6
25
+ Requires-Dist: macp-proto<0.1.10,>=0.1.6
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.0; extra == "dev"
28
28
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
@@ -30,6 +30,7 @@ Requires-Dist: mypy>=1.10; extra == "dev"
30
30
  Requires-Dist: ruff>=0.5; extra == "dev"
31
31
  Requires-Dist: grpc-stubs>=1.53; extra == "dev"
32
32
  Requires-Dist: build>=1.0; extra == "dev"
33
+ Requires-Dist: twine>=5.0; extra == "dev"
33
34
  Requires-Dist: jsonschema>=4.0; extra == "dev"
34
35
  Provides-Extra: docs
35
36
  Requires-Dist: mkdocs-material>=9.5; extra == "docs"
@@ -127,7 +128,7 @@ if winner and not session.decision_projection.has_blocking_objection(winner):
127
128
  make setup # pip install -e ".[dev,docs]"
128
129
 
129
130
  # Quality
130
- make lint # ruff check
131
+ make lint # ruff check + ruff format --check
131
132
  make fmt # ruff format
132
133
  make typecheck # mypy strict
133
134
  make test # unit tests + coverage gate (85% branch, from pyproject)
@@ -136,7 +137,7 @@ make test-all # lint + typecheck + all tests
136
137
  make coverage # HTML + terminal coverage report
137
138
 
138
139
  # Build
139
- make build # sdist + wheel
140
+ make build # build sdist + wheel, then twine check
140
141
 
141
142
  # Proto definitions (provided by macp-proto package)
142
143
  make dev-link-protos # link local proto package for development
@@ -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
@@ -1,6 +1,6 @@
1
1
  grpcio>=1.82.0rc2
2
2
  protobuf>=7.35.0
3
- macp-proto<0.1.9,>=0.1.6
3
+ macp-proto<0.1.10,>=0.1.6
4
4
 
5
5
  [dev]
6
6
  pytest>=8.0
@@ -9,6 +9,7 @@ mypy>=1.10
9
9
  ruff>=0.5
10
10
  grpc-stubs>=1.53
11
11
  build>=1.0
12
+ twine>=5.0
12
13
  jsonschema>=4.0
13
14
 
14
15
  [docs]
@@ -1,52 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
- from typing import ClassVar
5
-
6
- from macp.v1 import core_pb2, envelope_pb2
7
-
8
-
9
- class BaseProjection(ABC):
10
- """Abstract base for in-process mode state tracking.
11
-
12
- Maintains a local transcript and delegates mode-specific message handling
13
- to subclasses. Needed because the runtime's ``GetSession`` RPC returns
14
- metadata only, not mode state or transcript.
15
- """
16
-
17
- MODE: ClassVar[str]
18
-
19
- def __init__(self) -> None:
20
- self.transcript: list[envelope_pb2.Envelope] = []
21
- self.phase: str = ""
22
- self.commitment: core_pb2.CommitmentPayload | None = None
23
-
24
- @property
25
- def is_committed(self) -> bool:
26
- return self.commitment is not None
27
-
28
- @property
29
- def is_positive_outcome(self) -> bool | None:
30
- """Return the outcome polarity, or ``None`` if not yet committed."""
31
- if self.commitment is None:
32
- return None
33
- return getattr(self.commitment, "outcome_positive", True)
34
-
35
- def apply_envelope(self, envelope: envelope_pb2.Envelope) -> None:
36
- """Process an accepted envelope and update local state."""
37
- if envelope.mode != self.MODE:
38
- return
39
- self.transcript.append(envelope)
40
-
41
- if envelope.message_type == "Commitment":
42
- payload = core_pb2.CommitmentPayload()
43
- payload.ParseFromString(envelope.payload)
44
- self.commitment = payload
45
- self.phase = "Committed"
46
- return
47
-
48
- self._apply_mode_message(envelope)
49
-
50
- @abstractmethod
51
- def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
52
- """Handle a mode-specific (non-Commitment) envelope."""
File without changes