macp-sdk-python 0.7.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.7.0/src/macp_sdk_python.egg-info → macp_sdk_python-0.8.0}/PKG-INFO +5 -4
  2. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/README.md +2 -2
  3. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/pyproject.toml +3 -2
  4. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/__init__.py +9 -1
  5. macp_sdk_python-0.8.0/src/macp_sdk/base_projection.py +314 -0
  6. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/projections.py +19 -1
  7. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/quorum.py +33 -9
  8. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0/src/macp_sdk_python.egg-info}/PKG-INFO +5 -4
  9. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/requires.txt +2 -1
  10. macp_sdk_python-0.7.0/src/macp_sdk/base_projection.py +0 -52
  11. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/LICENSE +0 -0
  12. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/setup.cfg +0 -0
  13. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/_logging.py +0 -0
  14. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/__init__.py +0 -0
  15. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
  16. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  17. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/participant.py +0 -0
  18. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/runner.py +0 -0
  19. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/strategies.py +0 -0
  20. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/transports.py +0 -0
  21. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/types.py +0 -0
  22. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/auth.py +0 -0
  23. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/base_session.py +0 -0
  24. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/client.py +0 -0
  25. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/commitment_hash.py +0 -0
  26. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/constants.py +0 -0
  27. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/decision.py +0 -0
  28. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/envelope.py +0 -0
  29. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/errors.py +0 -0
  30. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/handoff.py +0 -0
  31. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/policy.py +0 -0
  32. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proposal.py +0 -0
  33. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proto_registry.py +0 -0
  34. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/py.typed +0 -0
  35. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/retry.py +0 -0
  36. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/task.py +0 -0
  37. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/validation.py +0 -0
  38. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/watchers.py +0 -0
  39. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
  40. {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  41. {macp_sdk_python-0.7.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.7.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.7.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,6 +1,11 @@
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
6
11
  from .commitment_hash import commitment_hash, is_canonical_commitment_hash
@@ -110,6 +115,8 @@ from .watchers import (
110
115
  )
111
116
 
112
117
  __all__ = [
118
+ "ANOMALY_DUPLICATE_BALLOT",
119
+ "ANOMALY_DUPLICATE_VOTE",
113
120
  "DEFAULT_CONFIGURATION_VERSION",
114
121
  "DEFAULT_MODE_VERSION",
115
122
  "DEFAULT_POLICY_VERSION",
@@ -165,6 +172,7 @@ __all__ = [
165
172
  "ObjectionHandlingRules",
166
173
  "PolicyChange",
167
174
  "PolicyWatcher",
175
+ "ProjectionAnomaly",
168
176
  "ProposalAcceptanceRules",
169
177
  "ProposalProjection",
170
178
  "ProposalSession",
@@ -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
+ )
@@ -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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.7.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
@@ -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