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.
- {macp_sdk_python-0.6.0/src/macp_sdk_python.egg-info → macp_sdk_python-0.8.0}/PKG-INFO +5 -4
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/README.md +2 -2
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/pyproject.toml +3 -2
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/__init__.py +14 -1
- macp_sdk_python-0.8.0/src/macp_sdk/base_projection.py +314 -0
- macp_sdk_python-0.8.0/src/macp_sdk/commitment_hash.py +317 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/envelope.py +14 -2
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/projections.py +19 -1
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/quorum.py +33 -9
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/validation.py +17 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0/src/macp_sdk_python.egg-info}/PKG-INFO +5 -4
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/requires.txt +2 -1
- macp_sdk_python-0.6.0/src/macp_sdk/base_projection.py +0 -52
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/LICENSE +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/setup.cfg +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/_logging.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/__init__.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/dispatcher.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/participant.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/runner.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/strategies.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/transports.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/types.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/auth.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/base_session.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/client.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/constants.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/decision.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/errors.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/handoff.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/policy.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proposal.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proto_registry.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/py.typed +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/retry.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/task.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk/watchers.py +0 -0
- {macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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.
|
|
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
|
+
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.
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
89
|
-
|
|
90
|
-
def _set_ballot(
|
|
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.
|
|
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.
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{macp_sdk_python-0.6.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|