From 9724b424992a6a438e290f7a9abdac58f55ab903 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Mon, 6 Apr 2026 12:07:31 -0700 Subject: [PATCH 01/10] Add governance policy framework (RFC-MACP-0012) Implement policy registry, evaluation, and gRPC lifecycle RPCs for governance policies. Policies are resolved at SessionStart and evaluated at commitment time across all standard modes. Migrate proto management from vendored files to the macp-proto crate dependency. --- .github/workflows/ci.yml | 65 +--- Cargo.lock | 5 + Cargo.toml | 4 + Dockerfile | 1 - Makefile | 43 +-- README.md | 4 +- build.rs | 4 +- docs/architecture.md | 25 ++ docs/testing.md | 9 + integration_tests/Cargo.lock | 5 + integration_tests/tests/tier1_protocol/mod.rs | 1 + proto/buf.lock | 2 - proto/buf.yaml | 7 - proto/macp/modes/decision/v1/decision.proto | 31 -- proto/macp/modes/handoff/v1/handoff.proto | 30 -- proto/macp/modes/proposal/v1/proposal.proto | 37 --- proto/macp/modes/quorum/v1/quorum.proto | 28 -- proto/macp/modes/task/v1/task.proto | 49 --- proto/macp/v1/core.proto | 312 ------------------ proto/macp/v1/envelope.proto | 41 --- src/error.rs | 20 ++ src/lib.rs | 1 + src/main.rs | 12 +- src/mode/decision.rs | 71 +++- src/mode/handoff.rs | 87 ++++- src/mode/multi_round.rs | 1 + src/mode/passthrough.rs | 1 + src/mode/proposal.rs | 169 +++++++++- src/mode/quorum.rs | 113 ++++++- src/mode/task.rs | 144 +++++++- src/mode/util.rs | 52 +++ src/registry.rs | 5 + src/replay.rs | 39 ++- src/runtime.rs | 73 +++- src/server.rs | 190 ++++++++++- src/session.rs | 2 + src/storage/file.rs | 1 + src/storage/memory.rs | 1 + src/storage/migration.rs | 1 + src/storage/recovery.rs | 1 + src/storage/redis_backend.rs | 1 + src/storage/rocksdb.rs | 1 + tests/concurrent_messages.rs | 2 +- tests/conformance/decision_happy_path.json | 4 +- tests/conformance/decision_reject_paths.json | 4 +- tests/conformance/handoff_happy_path.json | 4 +- tests/conformance/handoff_reject_paths.json | 2 +- tests/conformance/multi_round_happy_path.json | 4 +- .../conformance/multi_round_reject_paths.json | 6 +- tests/conformance/proposal_happy_path.json | 4 +- tests/conformance/proposal_reject_paths.json | 4 +- tests/conformance/quorum_happy_path.json | 4 +- tests/conformance/quorum_reject_paths.json | 4 +- tests/conformance/task_happy_path.json | 4 +- tests/conformance/task_reject_paths.json | 2 +- tests/conformance_loader.rs | 2 +- tests/file_backend_integration.rs | 7 +- tests/integration_mode_lifecycle.rs | 4 +- tests/replay_round_trip.rs | 12 +- tests/stream_integration.rs | 2 +- 60 files changed, 1018 insertions(+), 746 deletions(-) delete mode 100644 proto/buf.lock delete mode 100644 proto/buf.yaml delete mode 100644 proto/macp/modes/decision/v1/decision.proto delete mode 100644 proto/macp/modes/handoff/v1/handoff.proto delete mode 100644 proto/macp/modes/proposal/v1/proposal.proto delete mode 100644 proto/macp/modes/quorum/v1/quorum.proto delete mode 100644 proto/macp/modes/task/v1/task.proto delete mode 100644 proto/macp/v1/core.proto delete mode 100644 proto/macp/v1/envelope.proto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61fc101..71e4340 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,69 +154,6 @@ jobs: - name: Build release run: cargo build --release - lint-protobuf: - name: Lint Protocol Buffers - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install buf - uses: bufbuild/buf-setup-action@v1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Lint protobuf with buf - run: buf lint proto - - - name: Check for breaking changes - if: github.event_name == 'pull_request' - run: | - git fetch origin main - # Skip if main branch doesn't have a buf module yet - if git show origin/main:proto/buf.yaml > /dev/null 2>&1; then - buf breaking proto --against '.git#branch=origin/main,subdir=proto' - else - echo "No buf module found on main branch, skipping breaking change check" - fi - - proto-sync: - name: Proto Sync Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: bufbuild/buf-setup-action@v1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Verify protos match pinned BSR version - run: | - TMPDIR=$(mktemp -d) - buf export buf.build/multiagentcoordinationprotocol/macp -o "$TMPDIR" - - DRIFT=0 - for proto in \ - macp/v1/envelope.proto \ - macp/v1/core.proto \ - macp/modes/decision/v1/decision.proto \ - macp/modes/proposal/v1/proposal.proto \ - macp/modes/task/v1/task.proto \ - macp/modes/handoff/v1/handoff.proto \ - macp/modes/quorum/v1/quorum.proto; do - if ! diff -q "$TMPDIR/$proto" "proto/$proto" > /dev/null 2>&1; then - echo "DRIFT: $proto" - diff -u "$TMPDIR/$proto" "proto/$proto" || true - DRIFT=1 - fi - done - rm -rf "$TMPDIR" - - if [ "$DRIFT" -ne 0 ]; then - echo "Proto files don't match BSR. Run 'make sync-protos'." - exit 1 - fi - echo "All proto files match BSR." - audit: name: Security Audit runs-on: ubuntu-latest @@ -275,7 +212,7 @@ jobs: ci-pass: name: All Checks Passed runs-on: ubuntu-latest - needs: [check, fmt, clippy, test, build, lint-protobuf, proto-sync, audit] + needs: [check, fmt, clippy, test, build, audit] steps: - name: Summary diff --git a/Cargo.lock b/Cargo.lock index e1086a1..3c34e3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,6 +1035,10 @@ dependencies = [ "libc", ] +[[package]] +name = "macp-proto" +version = "0.1.0" + [[package]] name = "macp-runtime" version = "0.4.0" @@ -1043,6 +1047,7 @@ dependencies = [ "async-trait", "chrono", "futures-core", + "macp-proto", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", diff --git a/Cargo.toml b/Cargo.toml index 322d808..d88d366 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,10 @@ opentelemetry-otlp = { version = "0.15", features = ["tonic"], optional = true } tracing-opentelemetry = { version = "0.23", optional = true } rocksdb = { version = "0.22", optional = true } redis = { version = "0.27", features = ["tokio-comp", "aio"], optional = true } +# Proto definitions from the spec repo (exposes DEP_MACP_PROTO_PROTO_DIR to build.rs via links metadata). +# For CI/production, switch to git dependency: +# macp-proto = { git = "https://github.com/multiagentcoordinationprotocol/multiagentcoordinationprotocol.git", tag = "proto-v0.1.0" } +macp-proto = { path = "../multiagentcoordinationprotocol/packages/proto-rust" } [dev-dependencies] tempfile = "3" diff --git a/Dockerfile b/Dockerfile index 4ca1857..8515ac4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ WORKDIR /app # Cache dependencies: copy manifests first, build a dummy, then copy real source COPY Cargo.toml Cargo.lock build.rs ./ -COPY proto/ proto/ RUN mkdir -p src && echo "fn main() {}" > src/main.rs && \ mkdir -p src/bin && \ cargo build --release 2>/dev/null || true && \ diff --git a/Makefile b/Makefile index faeb247..d36bf98 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,4 @@ -.PHONY: setup build test test-integration test-conformance test-all fmt clippy check audit coverage sync-protos sync-protos-local check-protos test-integration-grpc test-integration-agents test-integration-e2e test-integration-hosted - -SPEC_PROTO_DIR := ../multiagentcoordinationprotocol/schemas/proto -PROTO_FILES := macp/v1/envelope.proto macp/v1/core.proto macp/modes/decision/v1/decision.proto macp/modes/proposal/v1/proposal.proto macp/modes/task/v1/task.proto macp/modes/handoff/v1/handoff.proto macp/modes/quorum/v1/quorum.proto +.PHONY: setup build test test-integration test-conformance test-all fmt clippy check audit coverage test-integration-grpc test-integration-agents test-integration-e2e test-integration-hosted ## First-time setup: configure git hooks setup: @@ -36,25 +33,6 @@ audit: check: fmt clippy test -## Pull latest proto files from BSR -sync-protos: - buf export buf.build/multiagentcoordinationprotocol/macp -o proto - @echo "Done. Run 'git diff proto/' to review changes." - -## Sync from local sibling checkout (for development before BSR publish) -sync-protos-local: - @if [ ! -d "$(SPEC_PROTO_DIR)" ]; then \ - echo "Error: Spec repo not found at $(SPEC_PROTO_DIR)"; \ - echo "Use 'make sync-protos' to sync from BSR instead."; \ - exit 1; \ - fi - @for f in $(PROTO_FILES); do \ - mkdir -p proto/$$(dirname $$f); \ - cp "$(SPEC_PROTO_DIR)/$$f" "proto/$$f"; \ - echo " Copied $$f"; \ - done - @echo "Done. Run 'git diff proto/' to review changes." - ## Integration tests (gRPC, Rig agents) test-integration-grpc: cd integration_tests && cargo test --test tier1 -- --test-threads=1 @@ -67,22 +45,3 @@ test-integration-e2e: test-integration-hosted: cd integration_tests && cargo test -- --test-threads=1 - -## Check if local protos match BSR -check-protos: - @TMPDIR=$$(mktemp -d); \ - buf export buf.build/multiagentcoordinationprotocol/macp -o "$$TMPDIR"; \ - DRIFT=0; \ - for f in $(PROTO_FILES); do \ - if ! diff -q "$$TMPDIR/$$f" "proto/$$f" > /dev/null 2>&1; then \ - echo "DRIFT: $$f"; \ - DRIFT=1; \ - fi; \ - done; \ - rm -rf "$$TMPDIR"; \ - if [ "$$DRIFT" -eq 0 ]; then \ - echo "All proto files match BSR."; \ - else \ - echo "Run 'make sync-protos' to update."; \ - exit 1; \ - fi diff --git a/README.md b/README.md index 4e2de43..ea464a9 100644 --- a/README.md +++ b/README.md @@ -276,8 +276,8 @@ Check that the sender identity matches the session's participant list. For `Comm **`StorageFailed` error** The runtime requires write access to `MACP_DATA_DIR`. Check directory permissions. Log append failures are fatal — the runtime will not acknowledge a message without a durable record. -**Proto drift / `make check-protos` failure** -Run `make sync-protos` to update local proto files from BSR. +**Proto version mismatch** +Update the `macp-proto` dependency version in `Cargo.toml` and run `cargo build`. ## Testing diff --git a/build.rs b/build.rs index 52ecd29..798247a 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,6 @@ fn main() -> Result<(), Box> { + let proto_dir = + std::env::var("DEP_MACP_PROTO_PROTO_DIR").expect("macp-proto crate must set proto_dir"); tonic_prost_build::configure() .build_server(true) .compile_protos( @@ -11,7 +13,7 @@ fn main() -> Result<(), Box> { "macp/modes/handoff/v1/handoff.proto", "macp/modes/quorum/v1/quorum.proto", ], - &["proto"], + &[&proto_dir], )?; Ok(()) } diff --git a/docs/architecture.md b/docs/architecture.md index a13347c..2ad2dba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,6 +108,29 @@ Responsibilities: - enforce session-start policy - enforce per-sender rate limits +## 7. Policy layer (`src/policy/`) + +Responsibilities: + +- store and resolve governance policy definitions +- validate policy rules against mode-specific JSON schemas at registration +- evaluate governance constraints at commitment time +- provide default policy (`policy.default`) with no additional constraints + +Components: + +- `PolicyRegistry` — in-memory CRUD store with broadcast change notifications (mirrors `ModeRegistry` pattern) +- `PolicyDefinition` — canonical policy representation: id, mode target, rules (JSON), schema version +- Evaluators — per-mode commitment evaluation: decision (voting/quorum/veto threshold), proposal (counter-proposal round limits), task (output requirements), handoff (implicit accept timeout), quorum (abstention/threshold rules). Rule schemas aligned to RFC-MACP-0012 JSON schemas. +- Default policy — ships pre-registered, applies to all modes via wildcard `"*"`, imposes zero additional constraints + +Policy lifecycle: + +1. Registered via `RegisterPolicy` RPC or pre-loaded at startup +2. Resolved at `SessionStart` — bound to session as `policy_definition` +3. Evaluated at `Commitment` — mode-specific evaluator checks rules against session state +4. Persisted with session — replay uses stored definition, never re-resolves + ## Architecture diagram ``` @@ -117,6 +140,8 @@ Client Request | [Coordination Kernel] -- runtime.rs | + [Policy Layer] -- policy/ + | [Mode Registry] -- mode_registry.rs | \ [Mode Logic] [Discovery + Extension Lifecycle] diff --git a/docs/testing.md b/docs/testing.md index a06ab25..d9c2a45 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,3 +129,12 @@ Actions → "Integration Tests" → Run workflow → optionally check "Run Tier ``` Tier 3 E2E requires the `OPENAI_API_KEY` repository secret. + +## Policy tests + +Policy engine coverage spans multiple test layers: + +- **Unit tests** (`src/policy/`): ~80 tests covering evaluator algorithms (all 5 voting types, quorum, veto, evaluation requirements), registry CRUD, schema validation, default policy, and rule deserialization +- **Mode unit tests** (`src/mode/*.rs`): Policy denial paths in all 5 standard modes — verify that governance policies block commitment when rules aren't satisfied +- **Conformance tests**: JSON fixtures exercise mode lifecycles with policy_version binding +- **Integration tests** (`integration_tests/`): gRPC round-trip tests for RegisterPolicy, GetPolicy, ListPolicies, UnregisterPolicy, WatchPolicies RPCs, plus end-to-end policy enforcement (register policy → start session → verify commitment blocked) diff --git a/integration_tests/Cargo.lock b/integration_tests/Cargo.lock index 01b042d..6960e0d 100644 --- a/integration_tests/Cargo.lock +++ b/integration_tests/Cargo.lock @@ -1038,6 +1038,10 @@ dependencies = [ "uuid", ] +[[package]] +name = "macp-proto" +version = "0.1.0" + [[package]] name = "macp-runtime" version = "0.4.0" @@ -1046,6 +1050,7 @@ dependencies = [ "async-trait", "chrono", "futures-core", + "macp-proto", "prost", "prost-types", "serde", diff --git a/integration_tests/tests/tier1_protocol/mod.rs b/integration_tests/tests/tier1_protocol/mod.rs index 6b98b9d..fa3fe12 100644 --- a/integration_tests/tests/tier1_protocol/mod.rs +++ b/integration_tests/tests/tier1_protocol/mod.rs @@ -13,3 +13,4 @@ mod test_concurrent_sessions; mod test_session_lifecycle; mod test_reject_paths; mod test_rfc_cross_cutting; +mod test_policy_registry; diff --git a/proto/buf.lock b/proto/buf.lock deleted file mode 100644 index 4f98143..0000000 --- a/proto/buf.lock +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v2 diff --git a/proto/buf.yaml b/proto/buf.yaml deleted file mode 100644 index 0a714b4..0000000 --- a/proto/buf.yaml +++ /dev/null @@ -1,7 +0,0 @@ -version: v2 -lint: - use: - - STANDARD -breaking: - use: - - FILE diff --git a/proto/macp/modes/decision/v1/decision.proto b/proto/macp/modes/decision/v1/decision.proto deleted file mode 100644 index 82a28c6..0000000 --- a/proto/macp/modes/decision/v1/decision.proto +++ /dev/null @@ -1,31 +0,0 @@ -syntax = "proto3"; - -package macp.modes.decision.v1; - -message ProposalPayload { - string proposal_id = 1; - string option = 2; - string rationale = 3; - bytes supporting_data = 4; -} - -message EvaluationPayload { - string proposal_id = 1; - string recommendation = 2; // APPROVE | REVIEW | BLOCK | REJECT - double confidence = 3; - string reason = 4; -} - -message ObjectionPayload { - string proposal_id = 1; - string reason = 2; - string severity = 3; // low | medium | high | critical -} - -message VotePayload { - string proposal_id = 1; - string vote = 2; // approve | reject | abstain - string reason = 3; -} - -// Decision Mode typically reuses macp.v1.CommitmentPayload for terminal results. diff --git a/proto/macp/modes/handoff/v1/handoff.proto b/proto/macp/modes/handoff/v1/handoff.proto deleted file mode 100644 index ee0698d..0000000 --- a/proto/macp/modes/handoff/v1/handoff.proto +++ /dev/null @@ -1,30 +0,0 @@ -syntax = "proto3"; - -package macp.modes.handoff.v1; - -message HandoffOfferPayload { - string handoff_id = 1; - string target_participant = 2; - string scope = 3; - string reason = 4; -} - -message HandoffContextPayload { - string handoff_id = 1; - string content_type = 2; - bytes context = 3; -} - -message HandoffAcceptPayload { - string handoff_id = 1; - string accepted_by = 2; - string reason = 3; -} - -message HandoffDeclinePayload { - string handoff_id = 1; - string declined_by = 2; - string reason = 3; -} - -// Handoff Mode terminates with macp.v1.CommitmentPayload. diff --git a/proto/macp/modes/proposal/v1/proposal.proto b/proto/macp/modes/proposal/v1/proposal.proto deleted file mode 100644 index 96b5598..0000000 --- a/proto/macp/modes/proposal/v1/proposal.proto +++ /dev/null @@ -1,37 +0,0 @@ -syntax = "proto3"; - -package macp.modes.proposal.v1; - -message ProposalPayload { - string proposal_id = 1; - string title = 2; - string summary = 3; - bytes details = 4; - repeated string tags = 5; -} - -message CounterProposalPayload { - string proposal_id = 1; - string supersedes_proposal_id = 2; - string title = 3; - string summary = 4; - bytes details = 5; -} - -message AcceptPayload { - string proposal_id = 1; - string reason = 2; -} - -message RejectPayload { - string proposal_id = 1; - bool terminal = 2; - string reason = 3; -} - -message WithdrawPayload { - string proposal_id = 1; - string reason = 2; -} - -// Proposal Mode terminates with macp.v1.CommitmentPayload. diff --git a/proto/macp/modes/quorum/v1/quorum.proto b/proto/macp/modes/quorum/v1/quorum.proto deleted file mode 100644 index 52b98da..0000000 --- a/proto/macp/modes/quorum/v1/quorum.proto +++ /dev/null @@ -1,28 +0,0 @@ -syntax = "proto3"; - -package macp.modes.quorum.v1; - -message ApprovalRequestPayload { - string request_id = 1; - string action = 2; - string summary = 3; - bytes details = 4; - uint32 required_approvals = 5; -} - -message ApprovePayload { - string request_id = 1; - string reason = 2; -} - -message RejectPayload { - string request_id = 1; - string reason = 2; -} - -message AbstainPayload { - string request_id = 1; - string reason = 2; -} - -// Quorum Mode terminates with macp.v1.CommitmentPayload. diff --git a/proto/macp/modes/task/v1/task.proto b/proto/macp/modes/task/v1/task.proto deleted file mode 100644 index 6755f6c..0000000 --- a/proto/macp/modes/task/v1/task.proto +++ /dev/null @@ -1,49 +0,0 @@ -syntax = "proto3"; - -package macp.modes.task.v1; - -message TaskRequestPayload { - string task_id = 1; - string title = 2; - string instructions = 3; - string requested_assignee = 4; - bytes input = 5; - int64 deadline_unix_ms = 6; -} - -message TaskAcceptPayload { - string task_id = 1; - string assignee = 2; - string reason = 3; -} - -message TaskRejectPayload { - string task_id = 1; - string assignee = 2; - string reason = 3; -} - -message TaskUpdatePayload { - string task_id = 1; - string status = 2; - double progress = 3; - string message = 4; - bytes partial_output = 5; -} - -message TaskCompletePayload { - string task_id = 1; - string assignee = 2; - bytes output = 3; - string summary = 4; -} - -message TaskFailPayload { - string task_id = 1; - string assignee = 2; - string error_code = 3; - string reason = 4; - bool retryable = 5; -} - -// Task Mode terminates with macp.v1.CommitmentPayload. diff --git a/proto/macp/v1/core.proto b/proto/macp/v1/core.proto deleted file mode 100644 index 807e386..0000000 --- a/proto/macp/v1/core.proto +++ /dev/null @@ -1,312 +0,0 @@ -syntax = "proto3"; - -package macp.v1; - -import "macp/v1/envelope.proto"; - -message Root { - string uri = 1; - string name = 2; -} - -message ClientInfo { - string name = 1; - string title = 2; - string version = 3; - string description = 4; - string website_url = 5; -} - -message RuntimeInfo { - string name = 1; - string title = 2; - string version = 3; - string description = 4; - string website_url = 5; -} - -message SessionsCapability { - bool stream = 1; -} - -message CancellationCapability { - bool cancel_session = 1; -} - -message ProgressCapability { - bool progress = 1; -} - -message ManifestCapability { - bool get_manifest = 1; -} - -message ModeRegistryCapability { - bool list_modes = 1; - bool list_changed = 2; -} - -message RootsCapability { - bool list_roots = 1; - bool list_changed = 2; -} - -message ExperimentalCapabilities { - map features = 1; -} - -message Capabilities { - SessionsCapability sessions = 1; - CancellationCapability cancellation = 2; - ProgressCapability progress = 3; - ManifestCapability manifest = 4; - ModeRegistryCapability mode_registry = 5; - RootsCapability roots = 6; - ExperimentalCapabilities experimental = 100; -} - -message InitializeRequest { - repeated string supported_protocol_versions = 1; - ClientInfo client_info = 2; - Capabilities capabilities = 3; -} - -message InitializeResponse { - string selected_protocol_version = 1; - RuntimeInfo runtime_info = 2; - Capabilities capabilities = 3; - repeated string supported_modes = 4; - string instructions = 5; -} - -message SignalPayload { - string signal_type = 1; - bytes data = 2; - double confidence = 3; - string correlation_session_id = 4; -} - -message ProgressPayload { - string progress_token = 1; - double progress = 2; - double total = 3; - string message = 4; - string target_message_id = 5; -} - -message SessionStartPayload { - string intent = 1; - repeated string participants = 2; - string mode_version = 3; - string configuration_version = 4; - string policy_version = 5; - int64 ttl_ms = 6; - bytes context = 7; - repeated Root roots = 8; -} - -message SessionCancelPayload { - string reason = 1; - string cancelled_by = 2; -} - -message CommitmentPayload { - string commitment_id = 1; - string action = 2; - string authority_scope = 3; - string reason = 4; - string mode_version = 5; - string policy_version = 6; - string configuration_version = 7; -} - -message ParticipantActivity { - string participant_id = 1; - int64 last_message_at_unix_ms = 2; - uint32 message_count = 3; -} - -message SessionMetadata { - string session_id = 1; - string mode = 2; - SessionState state = 3; - int64 started_at_unix_ms = 4; - int64 expires_at_unix_ms = 5; - string mode_version = 6; - string configuration_version = 7; - string policy_version = 8; - repeated string participants = 9; - repeated ParticipantActivity participant_activity = 10; -} - -message GetSessionRequest { - string session_id = 1; -} - -message CancelSessionRequest { - string session_id = 1; - string reason = 2; -} - -// Empty agent_id requests the manifest of the serving runtime/agent. -// Non-empty agent_id requests a locally-known manifest for that identifier. -message GetManifestRequest { - string agent_id = 1; -} - -message TransportEndpoint { - string transport = 1; - string uri = 2; - repeated string content_types = 3; - map metadata = 4; -} - -message AgentManifest { - string agent_id = 1; - string title = 2; - string description = 3; - repeated string supported_modes = 4; - repeated string input_content_types = 5; - repeated string output_content_types = 6; - map metadata = 7; - repeated TransportEndpoint transport_endpoints = 8; -} - -message ModeDescriptor { - string mode = 1; - string mode_version = 2; - string title = 3; - string description = 4; - string determinism_class = 5; - string participant_model = 6; - repeated string message_types = 7; - repeated string terminal_message_types = 8; - map schema_uris = 9; -} - -message ListModesRequest {} - -message ListModesResponse { - repeated ModeDescriptor modes = 1; -} - -message ListRootsRequest {} - -message ListRootsResponse { - repeated Root roots = 1; -} - -message WatchModeRegistryRequest {} - -message WatchRootsRequest {} - -message RegistryChanged { - string registry = 1; - int64 observed_at_unix_ms = 2; -} - -message RootsChanged { - int64 observed_at_unix_ms = 1; -} - -message SendRequest { - Envelope envelope = 1; -} - -message SendResponse { - Ack ack = 1; -} - -// StreamSession carries canonical MACP envelopes only. -// Standard per-message negative acknowledgements remain the responsibility of Send. -message StreamSessionRequest { - Envelope envelope = 1; -} - -message StreamSessionResponse { - Envelope envelope = 1; -} - -message GetSessionResponse { - SessionMetadata metadata = 1; -} - -message CancelSessionResponse { - Ack ack = 1; -} - -message GetManifestResponse { - AgentManifest manifest = 1; -} - -message WatchModeRegistryResponse { - RegistryChanged change = 1; -} - -message WatchRootsResponse { - RootsChanged change = 1; -} - -// Extension mode lifecycle management -message ListExtModesRequest {} - -message ListExtModesResponse { - repeated ModeDescriptor modes = 1; -} - -message RegisterExtModeRequest { - ModeDescriptor descriptor = 1; -} - -message RegisterExtModeResponse { - bool ok = 1; - string error = 2; -} - -message UnregisterExtModeRequest { - string mode = 1; -} - -message UnregisterExtModeResponse { - bool ok = 1; - string error = 2; -} - -message PromoteModeRequest { - string mode = 1; - // Optional new identifier for the promoted mode (e.g., "macp.mode.foo.v1"). - // If empty, the existing identifier is kept. - string promoted_mode_name = 2; -} - -message PromoteModeResponse { - bool ok = 1; - string error = 2; - string mode = 3; -} - -message WatchSignalsRequest {} - -message WatchSignalsResponse { - Envelope envelope = 1; -} - -service MACPRuntimeService { - rpc Initialize(InitializeRequest) returns (InitializeResponse); - rpc Send(SendRequest) returns (SendResponse); - rpc StreamSession(stream StreamSessionRequest) returns (stream StreamSessionResponse); - rpc GetSession(GetSessionRequest) returns (GetSessionResponse); - rpc CancelSession(CancelSessionRequest) returns (CancelSessionResponse); - rpc GetManifest(GetManifestRequest) returns (GetManifestResponse); - rpc ListModes(ListModesRequest) returns (ListModesResponse); - rpc WatchModeRegistry(WatchModeRegistryRequest) returns (stream WatchModeRegistryResponse); - rpc ListRoots(ListRootsRequest) returns (ListRootsResponse); - rpc WatchRoots(WatchRootsRequest) returns (stream WatchRootsResponse); - // Extension mode lifecycle - rpc ListExtModes(ListExtModesRequest) returns (ListExtModesResponse); - rpc RegisterExtMode(RegisterExtModeRequest) returns (RegisterExtModeResponse); - rpc UnregisterExtMode(UnregisterExtModeRequest) returns (UnregisterExtModeResponse); - rpc PromoteMode(PromoteModeRequest) returns (PromoteModeResponse); - // Ambient signal observation - rpc WatchSignals(WatchSignalsRequest) returns (stream WatchSignalsResponse); -} diff --git a/proto/macp/v1/envelope.proto b/proto/macp/v1/envelope.proto deleted file mode 100644 index 9b66b58..0000000 --- a/proto/macp/v1/envelope.proto +++ /dev/null @@ -1,41 +0,0 @@ -syntax = "proto3"; - -package macp.v1; - -// Canonical MACP envelope and generic acknowledgement/error shapes. - -message Envelope { - string macp_version = 1; - string mode = 2; // empty for ambient Signals - string message_type = 3; - string message_id = 4; - string session_id = 5; // empty for ambient messages - string sender = 6; - int64 timestamp_unix_ms = 7; // informational only - bytes payload = 8; -} - -message MACPError { - string code = 1; - string message = 2; - string session_id = 3; - string message_id = 4; - bytes details = 5; -} - -enum SessionState { - SESSION_STATE_UNSPECIFIED = 0; - SESSION_STATE_OPEN = 1; - SESSION_STATE_RESOLVED = 2; - SESSION_STATE_EXPIRED = 3; -} - -message Ack { - bool ok = 1; - bool duplicate = 2; - string message_id = 3; - string session_id = 4; - int64 accepted_at_unix_ms = 5; - SessionState session_state = 6; - MACPError error = 7; -} diff --git a/src/error.rs b/src/error.rs index 5814f17..11e7aca 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,6 +39,12 @@ pub enum MacpError { StorageFailed, #[error("InvalidSessionId")] InvalidSessionId, + #[error("UnknownPolicyVersion")] + UnknownPolicyVersion, + #[error("PolicyDenied")] + PolicyDenied, + #[error("InvalidPolicyDefinition")] + InvalidPolicyDefinition, } impl MacpError { @@ -62,6 +68,9 @@ impl MacpError { MacpError::RateLimited => "RATE_LIMITED", MacpError::StorageFailed => "INTERNAL_ERROR", MacpError::InvalidSessionId => "INVALID_SESSION_ID", + MacpError::UnknownPolicyVersion => "UNKNOWN_POLICY_VERSION", + MacpError::PolicyDenied => "POLICY_DENIED", + MacpError::InvalidPolicyDefinition => "INVALID_POLICY_DEFINITION", } } } @@ -93,6 +102,12 @@ mod tests { (MacpError::RateLimited, "RATE_LIMITED"), (MacpError::StorageFailed, "INTERNAL_ERROR"), (MacpError::InvalidSessionId, "INVALID_SESSION_ID"), + (MacpError::UnknownPolicyVersion, "UNKNOWN_POLICY_VERSION"), + (MacpError::PolicyDenied, "POLICY_DENIED"), + ( + MacpError::InvalidPolicyDefinition, + "INVALID_POLICY_DEFINITION", + ), ]; for (error, expected_code) in cases { @@ -120,5 +135,10 @@ mod tests { assert_eq!(MacpError::RateLimited.to_string(), "RateLimited"); assert_eq!(MacpError::StorageFailed.to_string(), "StorageFailed"); assert_eq!(MacpError::InvalidSessionId.to_string(), "InvalidSessionId"); + assert_eq!( + MacpError::UnknownPolicyVersion.to_string(), + "UnknownPolicyVersion" + ); + assert_eq!(MacpError::PolicyDenied.to_string(), "PolicyDenied"); } } diff --git a/src/lib.rs b/src/lib.rs index 01a495e..6913792 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod log_store; pub mod metrics; pub mod mode; pub mod mode_registry; +pub mod policy; pub mod registry; pub mod replay; pub mod runtime; diff --git a/src/main.rs b/src/main.rs index 86bb47f..969b044 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod server; use macp_runtime::log_store::LogStore; use macp_runtime::mode_registry::ModeRegistry; use macp_runtime::pb; +use macp_runtime::policy::registry::PolicyRegistry; use macp_runtime::registry::SessionRegistry; use macp_runtime::replay::replay_session; use macp_runtime::runtime::Runtime; @@ -102,6 +103,7 @@ async fn main() -> Result<(), Box> { let registry = Arc::new(SessionRegistry::new()); let log_store = Arc::new(LogStore::new()); let mode_registry = Arc::new(ModeRegistry::build_default()); + let policy_registry = Arc::new(PolicyRegistry::new()); if !memory_only { // Replay sessions from logs @@ -130,7 +132,12 @@ async fn main() -> Result<(), Box> { continue; } - match replay_session(&session_id, &log_entries, &mode_registry) { + match replay_session( + &session_id, + &log_entries, + &mode_registry, + Some(&policy_registry), + ) { Ok(session) => { if let Err(e) = storage.save_session(&session).await { if strict_recovery { @@ -179,11 +186,12 @@ async fn main() -> Result<(), Box> { } } - let runtime = Arc::new(Runtime::with_mode_registry( + let runtime = Arc::new(Runtime::with_registries( Arc::clone(&storage), Arc::clone(®istry), Arc::clone(&log_store), mode_registry, + policy_registry, )); let security = SecurityLayer::from_env()?; let svc = MacpServer::new(Arc::clone(&runtime), security); diff --git a/src/mode/decision.rs b/src/mode/decision.rs index dc74715..d5db57c 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -1,6 +1,8 @@ use crate::decision_pb::{EvaluationPayload, ObjectionPayload, ProposalPayload, VotePayload}; use crate::error::MacpError; -use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; +use crate::mode::util::{ + check_commitment_authority, is_declared_participant, validate_commitment_payload_for_session, +}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::session::Session; @@ -137,13 +139,13 @@ impl Mode for DecisionMode { /// `Commitment`. fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { - "Proposal" | "Commitment" if env.sender == session.initiator_sender => Ok(()), + "Proposal" if env.sender == session.initiator_sender => Ok(()), + "Commitment" => check_commitment_authority(session, &env.sender), "Proposal" | "Evaluation" | "Objection" | "Vote" if is_declared_participant(&session.participants, &env.sender) => { Ok(()) } - "Commitment" => Err(MacpError::Forbidden), _ => Err(MacpError::Forbidden), } } @@ -250,6 +252,23 @@ impl Mode for DecisionMode { if !Self::commitment_ready(&state) { return Err(MacpError::InvalidPayload); } + // Evaluate governance policy if one is bound to the session. + if let Some(ref policy) = session.policy_definition { + let decision = crate::policy::evaluator::evaluate_decision_commitment( + policy, + &state, + &session.participants, + ); + if let crate::policy::PolicyDecision::Deny { reasons } = decision { + tracing::warn!( + session_id = %session.session_id, + policy_id = %policy.policy_id, + reasons = ?reasons, + "policy denied commitment" + ); + return Err(MacpError::PolicyDenied); + } + } state.phase = DecisionPhase::Committed; Ok(ModeResponse::PersistAndResolve { state: Self::encode_state(&state), @@ -289,6 +308,7 @@ mod tests { initiator_sender: "agent://orchestrator".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } @@ -811,4 +831,49 @@ mod tests { mode.on_message(&session, &env("agent://orchestrator", "Commitment", bad)) .unwrap(); } + + #[test] + fn policy_denies_commitment_when_vote_threshold_not_met() { + let mode = DecisionMode; + let mut session = test_session(); + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test-strict".into(), + mode: "macp.mode.decision.v1".into(), + description: "strict".into(), + rules: serde_json::json!({ + "voting": { "algorithm": "unanimous" } + }), + schema_version: 1, + }); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // Only one of the participants votes approve + let resp = mode + .on_message( + &session, + &env("agent://fraud", "Vote", vote("p1", "approve")), + ) + .unwrap(); + apply(&mut session, resp); + // Commitment should be denied by policy (unanimous requires all participants) + let err = mode + .on_message( + &session, + &env("agent://orchestrator", "Commitment", commitment(&session)), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "PolicyDenied"); + } } diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 2cf3ba4..131bde4 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -2,7 +2,9 @@ use crate::error::MacpError; use crate::handoff_pb::{ HandoffAcceptPayload, HandoffContextPayload, HandoffDeclinePayload, HandoffOfferPayload, }; -use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; +use crate::mode::util::{ + check_commitment_authority, is_declared_participant, validate_commitment_payload_for_session, +}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::session::Session; @@ -28,6 +30,8 @@ pub struct HandoffOfferRecord { pub accepted_by: Option, pub declined_by: Option, pub outcome_reason: Option, + #[serde(default)] + pub offered_at_ms: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,8 +69,7 @@ impl HandoffMode { impl Mode for HandoffMode { fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { - "Commitment" if env.sender == session.initiator_sender => Ok(()), - "Commitment" => Err(MacpError::Forbidden), + "Commitment" => check_commitment_authority(session, &env.sender), "HandoffOffer" | "HandoffContext" if env.sender == session.initiator_sender => Ok(()), _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), _ => Err(MacpError::Forbidden), @@ -128,6 +131,7 @@ impl Mode for HandoffMode { accepted_by: None, declined_by: None, outcome_reason: None, + offered_at_ms: 0, }, ); Ok(ModeResponse::PersistState(Self::encode_state(&state))) @@ -199,13 +203,42 @@ impl Mode for HandoffMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Commitment" => { - if env.sender != session.initiator_sender { - return Err(MacpError::Forbidden); - } validate_commitment_payload_for_session(session, &env.payload)?; + // RFC-MACP-0012: lazy implicit_accept_timeout_ms check + if let Some(ref policy) = session.policy_definition { + let rules: crate::policy::rules::HandoffPolicyRules = + serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + if rules.acceptance.implicit_accept_timeout_ms > 0 { + let now_ms = chrono::Utc::now().timestamp_millis(); + let timeout = rules.acceptance.implicit_accept_timeout_ms as i64; + for offer in state.offers.values_mut() { + if offer.disposition == HandoffDisposition::Offered + && offer.offered_at_ms > 0 + && (now_ms - offer.offered_at_ms) >= timeout + { + offer.disposition = HandoffDisposition::Accepted; + offer.accepted_by = Some(offer.target_participant.clone()); + offer.outcome_reason = Some("implicit accept (timeout)".into()); + } + } + } + } if !Self::commitment_ready(&state) { return Err(MacpError::InvalidPayload); } + // Evaluate governance policy if one is bound to the session. + if let Some(ref policy) = session.policy_definition { + let decision = crate::policy::evaluator::evaluate_handoff_commitment(policy); + if let crate::policy::PolicyDecision::Deny { reasons } = decision { + tracing::warn!( + session_id = %session.session_id, + policy_id = %policy.policy_id, + reasons = ?reasons, + "policy denied commitment" + ); + return Err(MacpError::PolicyDenied); + } + } Ok(ModeResponse::PersistAndResolve { state: Self::encode_state(&state), resolution: env.payload.clone(), @@ -244,6 +277,7 @@ mod tests { initiator_sender: "owner".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } @@ -920,4 +954,45 @@ mod tests { "InvalidPayload" ); } + + // --- Policy --- + + #[test] + fn handoff_policy_evaluator_always_allows() { + let mode = HandoffMode; + let mut session = base_session(); + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test-handoff".into(), + mode: "macp.mode.handoff.v1".into(), + description: "handoff policy".into(), + rules: serde_json::json!({ + "acceptance": { "implicit_accept_timeout_ms": 0 }, + "commitment": { "authority": "initiator_only" } + }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("target", "HandoffAccept", make_accept("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + // Handoff policy evaluator always allows — commitment should succeed + let result = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } } diff --git a/src/mode/multi_round.rs b/src/mode/multi_round.rs index f46e2aa..eeaca54 100644 --- a/src/mode/multi_round.rs +++ b/src/mode/multi_round.rs @@ -202,6 +202,7 @@ mod tests { initiator_sender: "coordinator".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/mode/passthrough.rs b/src/mode/passthrough.rs index 9943e18..746dad3 100644 --- a/src/mode/passthrough.rs +++ b/src/mode/passthrough.rs @@ -88,6 +88,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index 13976eb..287a88e 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -1,6 +1,7 @@ use crate::error::MacpError; use crate::mode::util::{ - is_declared_participant, participants_all_accept, validate_commitment_payload_for_session, + check_commitment_authority, is_declared_participant, participants_all_accept, + validate_commitment_payload_for_session, }; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; @@ -77,6 +78,18 @@ impl ProposalMode { } fn refresh_phase(session: &Session, state: &mut ProposalState) { + // RFC-MACP-0012: acceptance.criterion controls convergence check + let criterion = session + .policy_definition + .as_ref() + .map(|p| { + serde_json::from_value::(p.rules.clone()) + .unwrap_or_default() + .acceptance + .criterion + }) + .unwrap_or_else(|| "all_parties".to_string()); + state.phase = if !state.terminal_rejections.is_empty() { ProposalPhase::TerminalRejected } else if state @@ -84,10 +97,12 @@ impl ProposalMode { .values() .filter(|proposal| proposal.disposition == ProposalDisposition::Live) .any(|proposal| { - participants_all_accept( - &session.participants, - &state.accepts, + Self::check_acceptance_criterion( + &criterion, + session, + state, &proposal.proposal_id, + &proposal.proposer, ) }) { @@ -97,6 +112,37 @@ impl ProposalMode { }; } + fn check_acceptance_criterion( + criterion: &str, + session: &Session, + state: &ProposalState, + proposal_id: &str, + proposer: &str, + ) -> bool { + match criterion { + "counterparty" => { + // All participants except the proposer must accept + session + .participants + .iter() + .filter(|p| p.as_str() != proposer) + .all(|p| state.accepts.get(p).map(String::as_str) == Some(proposal_id)) + } + "initiator" => { + // Only the session initiator must accept + state + .accepts + .get(&session.initiator_sender) + .map(String::as_str) + == Some(proposal_id) + } + _ => { + // "all_parties" (default) + participants_all_accept(&session.participants, &state.accepts, proposal_id) + } + } + } + fn commitment_ready(state: &ProposalState) -> bool { matches!( state.phase, @@ -116,8 +162,7 @@ impl ProposalMode { impl Mode for ProposalMode { fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { - "Commitment" if env.sender == session.initiator_sender => Ok(()), - "Commitment" => Err(MacpError::Forbidden), + "Commitment" => check_commitment_authority(session, &env.sender), _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), _ => Err(MacpError::Forbidden), } @@ -215,7 +260,17 @@ impl Mode for ProposalMode { if !state.proposals.contains_key(&payload.proposal_id) { return Err(MacpError::InvalidPayload); } - if payload.terminal { + // RFC-MACP-0012: terminal_on_any_reject overrides per-message terminal flag + let is_terminal = payload.terminal + || session.policy_definition.as_ref().is_some_and(|p| { + serde_json::from_value::( + p.rules.clone(), + ) + .unwrap_or_default() + .rejection + .terminal_on_any_reject + }); + if is_terminal { state.terminal_rejections.push(TerminalRejectRecord { proposal_id: payload.proposal_id, sender: env.sender.clone(), @@ -247,14 +302,32 @@ impl Mode for ProposalMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Commitment" => { - if env.sender != session.initiator_sender { - return Err(MacpError::Forbidden); - } validate_commitment_payload_for_session(session, &env.payload)?; Self::refresh_phase(session, &mut state); if !Self::commitment_ready(&state) { return Err(MacpError::InvalidPayload); } + // Evaluate governance policy if one is bound to the session. + if let Some(ref policy) = session.policy_definition { + let counter_count = state + .proposals + .values() + .filter(|p| p.supersedes_proposal_id.is_some()) + .count(); + let decision = crate::policy::evaluator::evaluate_proposal_commitment( + policy, + counter_count, + ); + if let crate::policy::PolicyDecision::Deny { reasons } = decision { + tracing::warn!( + session_id = %session.session_id, + policy_id = %policy.policy_id, + reasons = ?reasons, + "policy denied commitment" + ); + return Err(MacpError::PolicyDenied); + } + } state.phase = ProposalPhase::Committed; Ok(ModeResponse::PersistAndResolve { state: Self::encode_state(&state), @@ -294,6 +367,7 @@ mod tests { initiator_sender: "agent://buyer".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } @@ -895,4 +969,79 @@ mod tests { "InvalidPayload" ); } + + #[test] + fn policy_denies_commitment_when_counter_proposal_limit_exceeded() { + let mode = ProposalMode; + let mut session = base_session(); + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test-limited".into(), + mode: "macp.mode.proposal.v1".into(), + description: "limited".into(), + rules: serde_json::json!({ + "counter_proposal": { "max_rounds": 1 } + }), + schema_version: 1, + }); + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, resp); + // Seller proposes p1 + let resp = mode + .on_message( + &session, + &env("agent://seller", "Proposal", make_proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // Buyer counter-proposes p2 (supersedes p1) -- 1st counter-proposal + let resp = mode + .on_message( + &session, + &env( + "agent://buyer", + "CounterProposal", + make_counter_proposal("p2", "p1"), + ), + ) + .unwrap(); + apply(&mut session, resp); + // Seller counter-proposes p3 (supersedes p2) -- 2nd counter-proposal + let resp = mode + .on_message( + &session, + &env( + "agent://seller", + "CounterProposal", + make_counter_proposal("p3", "p2"), + ), + ) + .unwrap(); + apply(&mut session, resp); + // Both accept p3 to reach convergence + let resp = mode + .on_message(&session, &env("agent://buyer", "Accept", make_accept("p3"))) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://seller", "Accept", make_accept("p3")), + ) + .unwrap(); + apply(&mut session, resp); + // Commitment should be denied (2 counter-proposals exceeds limit of 1) + let err = mode + .on_message( + &session, + &env( + "agent://buyer", + "Commitment", + commitment(&session, "proposal.accepted"), + ), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "PolicyDenied"); + } } diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs index 7b426f9..9199651 100644 --- a/src/mode/quorum.rs +++ b/src/mode/quorum.rs @@ -1,5 +1,7 @@ use crate::error::MacpError; -use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; +use crate::mode::util::{ + check_commitment_authority, is_declared_participant, validate_commitment_payload_for_session, +}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::quorum_pb::{AbstainPayload, ApprovalRequestPayload, ApprovePayload, RejectPayload}; @@ -72,8 +74,9 @@ impl QuorumMode { impl Mode for QuorumMode { fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { - "ApprovalRequest" | "Commitment" if env.sender == session.initiator_sender => Ok(()), - "ApprovalRequest" | "Commitment" => Err(MacpError::Forbidden), + "ApprovalRequest" if env.sender == session.initiator_sender => Ok(()), + "ApprovalRequest" => Err(MacpError::Forbidden), + "Commitment" => check_commitment_authority(session, &env.sender), _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), _ => Err(MacpError::Forbidden), } @@ -184,13 +187,44 @@ impl Mode for QuorumMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Commitment" => { - if env.sender != session.initiator_sender { - return Err(MacpError::Forbidden); - } validate_commitment_payload_for_session(session, &env.payload)?; if !Self::commitment_ready(session, &state) { return Err(MacpError::InvalidPayload); } + // Evaluate governance policy if one is bound to the session. + if let Some(ref policy) = session.policy_definition { + let approve_count = state + .ballots + .values() + .filter(|b| b.choice == BallotChoice::Approve) + .count(); + let reject_count = state + .ballots + .values() + .filter(|b| b.choice == BallotChoice::Reject) + .count(); + let abstain_count = state + .ballots + .values() + .filter(|b| b.choice == BallotChoice::Abstain) + .count(); + let decision = crate::policy::evaluator::evaluate_quorum_commitment( + policy, + approve_count, + reject_count, + abstain_count, + session.participants.len(), + ); + if let crate::policy::PolicyDecision::Deny { reasons } = decision { + tracing::warn!( + session_id = %session.session_id, + policy_id = %policy.policy_id, + reasons = ?reasons, + "policy denied commitment" + ); + return Err(MacpError::PolicyDenied); + } + } Ok(ModeResponse::PersistAndResolve { state: Self::encode_state(&state), resolution: env.payload.clone(), @@ -229,6 +263,7 @@ mod tests { initiator_sender: "coordinator".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } @@ -778,9 +813,8 @@ mod tests { .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes"))) .unwrap(); apply(&mut session, result); - let err = mode - .on_message(&session, &env("alice", "Commitment", commitment_payload())) - .unwrap_err(); + let commit_env = env("alice", "Commitment", commitment_payload()); + let err = mode.authorize_sender(&session, &commit_env).unwrap_err(); assert_eq!(err.to_string(), "Forbidden"); } @@ -927,4 +961,65 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "InvalidPayload"); } + + // --- Policy --- + + #[test] + fn policy_denies_commitment_when_quorum_not_met_due_to_abstentions() { + let mode = QuorumMode; + let mut session = base_session(); + // Require all 3 participants as voters, but abstentions don't count toward quorum + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test-strict-quorum".into(), + mode: "macp.mode.quorum.v1".into(), + description: "strict quorum".into(), + rules: serde_json::json!({ + "threshold": { "type": "n_of_m", "value": 3 }, + "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" } + }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 2), + ), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "yes")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message(&session, &env("bob", "Approve", make_approve("r1", "yes"))) + .unwrap(); + apply(&mut session, result); + // carol abstains — with counts_toward_quorum=false, effective voters = 2 < 3 + let result = mode + .on_message( + &session, + &env("carol", "Abstain", make_abstain("r1", "no opinion")), + ) + .unwrap(); + apply(&mut session, result); + // Commitment should be denied (quorum not met: 2 effective voters < 3 required) + let err = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "PolicyDenied"); + } } diff --git a/src/mode/task.rs b/src/mode/task.rs index 42c34e9..8a1feb7 100644 --- a/src/mode/task.rs +++ b/src/mode/task.rs @@ -1,5 +1,7 @@ use crate::error::MacpError; -use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; +use crate::mode::util::{ + check_commitment_authority, is_declared_participant, validate_commitment_payload_for_session, +}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::session::Session; @@ -101,8 +103,9 @@ impl TaskMode { impl Mode for TaskMode { fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { - "TaskRequest" | "Commitment" if env.sender == session.initiator_sender => Ok(()), - "TaskRequest" | "Commitment" => Err(MacpError::Forbidden), + "TaskRequest" if env.sender == session.initiator_sender => Ok(()), + "TaskRequest" => Err(MacpError::Forbidden), + "Commitment" => check_commitment_authority(session, &env.sender), _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), _ => Err(MacpError::Forbidden), } @@ -169,6 +172,20 @@ impl Mode for TaskMode { if state.active_assignee.is_some() { return Err(MacpError::InvalidPayload); } + // RFC-MACP-0012: check allow_reassignment_on_reject if prior rejections exist + if !state.rejections.is_empty() { + let allow = session.policy_definition.as_ref().is_some_and(|p| { + serde_json::from_value::( + p.rules.clone(), + ) + .unwrap_or_default() + .assignment + .allow_reassignment_on_reject + }); + if !allow { + return Err(MacpError::PolicyDenied); + } + } if !payload.assignee.is_empty() && payload.assignee != env.sender { return Err(MacpError::InvalidPayload); } @@ -266,13 +283,28 @@ impl Mode for TaskMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Commitment" => { - if env.sender != session.initiator_sender { - return Err(MacpError::Forbidden); - } validate_commitment_payload_for_session(session, &env.payload)?; if state.terminal_report.is_none() { return Err(MacpError::InvalidPayload); } + // Evaluate governance policy if one is bound to the session. + if let Some(ref policy) = session.policy_definition { + let has_output = matches!( + &state.terminal_report, + Some(TaskTerminalReport::Complete(record)) if !record.output.is_empty() + ); + let decision = + crate::policy::evaluator::evaluate_task_commitment(policy, has_output); + if let crate::policy::PolicyDecision::Deny { reasons } = decision { + tracing::warn!( + session_id = %session.session_id, + policy_id = %policy.policy_id, + reasons = ?reasons, + "policy denied commitment" + ); + return Err(MacpError::PolicyDenied); + } + } Ok(ModeResponse::PersistAndResolve { state: Self::encode_state(&state), resolution: env.payload.clone(), @@ -311,6 +343,7 @@ mod tests { initiator_sender: "planner".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } @@ -906,9 +939,8 @@ mod tests { ) .unwrap(); apply(&mut session, result); - let err = mode - .on_message(&session, &env("worker", "Commitment", commitment_payload())) - .unwrap_err(); + let commit_env = env("worker", "Commitment", commitment_payload()); + let err = mode.authorize_sender(&session, &commit_env).unwrap_err(); assert_eq!(err.to_string(), "Forbidden"); } @@ -1093,4 +1125,98 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "InvalidPayload"); } + + // --- Policy --- + + #[test] + fn policy_allows_commitment_when_output_present() { + let mode = TaskMode; + let mut session = base_session(); + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test-strict".into(), + mode: "macp.mode.task.v1".into(), + description: "strict".into(), + rules: serde_json::json!({ "completion": { "require_output": true } }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskAccept", make_task_accept("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskComplete", make_task_complete("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Commitment should succeed: output is required and task completion has output + let result = mode + .on_message( + &session, + &env("planner", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn policy_with_no_output_requirement_allows_commitment() { + let mode = TaskMode; + let mut session = base_session(); + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test-permissive".into(), + mode: "macp.mode.task.v1".into(), + description: "permissive".into(), + rules: serde_json::json!({ "completion": { "require_output": false } }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskAccept", make_task_accept("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskComplete", make_task_complete("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Commitment should succeed: require_output is false + let result = mode + .on_message( + &session, + &env("planner", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } } diff --git a/src/mode/util.rs b/src/mode/util.rs index ab97bf4..ced115c 100644 --- a/src/mode/util.rs +++ b/src/mode/util.rs @@ -38,6 +38,58 @@ pub fn is_declared_participant(participants: &[String], sender: &str) -> bool { participants.iter().any(|participant| participant == sender) } +/// Check whether the sender is authorized to commit per the policy's `commitment.authority` rule. +/// +/// If no policy is bound, defaults to initiator-only. Per RFC-MACP-0012 Section 4, +/// the `commitment` rule group controls who can emit a Commitment envelope. +pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> { + if let Some(ref policy) = session.policy_definition { + let rules: crate::policy::rules::CommitmentRules = extract_commitment_rules(&policy.rules); + match rules.authority.as_str() { + "any_participant" => { + if sender == session.initiator_sender + || is_declared_participant(&session.participants, sender) + { + Ok(()) + } else { + Err(MacpError::Forbidden) + } + } + "designated_role" => { + if rules.designated_roles.iter().any(|r| r == sender) { + Ok(()) + } else { + Err(MacpError::Forbidden) + } + } + _ => { + // "initiator_only" (default) + if sender == session.initiator_sender { + Ok(()) + } else { + Err(MacpError::Forbidden) + } + } + } + } else { + // No policy bound — default to initiator-only + if sender == session.initiator_sender { + Ok(()) + } else { + Err(MacpError::Forbidden) + } + } +} + +/// Extract the `commitment` section from any mode's policy rules JSON. +/// All RFC mode schemas include a `commitment` sub-object with `authority` and `designated_roles`. +fn extract_commitment_rules(rules: &serde_json::Value) -> crate::policy::rules::CommitmentRules { + rules + .get("commitment") + .and_then(|c| serde_json::from_value(c.clone()).ok()) + .unwrap_or_default() +} + pub fn participants_all_accept( participants: &[String], accepts: &std::collections::BTreeMap, diff --git a/src/registry.rs b/src/registry.rs index af34fea..93de849 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -32,6 +32,8 @@ pub(crate) struct PersistedSession { pub context: Vec, pub roots: Vec, pub initiator_sender: String, + #[serde(default)] + pub policy_definition: Option, } fn default_schema_version() -> u32 { @@ -66,6 +68,7 @@ impl From<&Session> for PersistedSession { }) .collect(), initiator_sender: session.initiator_sender.clone(), + policy_definition: session.policy_definition.clone(), } } } @@ -107,6 +110,7 @@ impl From for Session { initiator_sender: session.initiator_sender, participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: session.policy_definition, } } } @@ -246,6 +250,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/replay.rs b/src/replay.rs index db2ddbd..308d949 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -2,6 +2,7 @@ use crate::error::MacpError; use crate::log_store::{EntryKind, LogEntry}; use crate::mode_registry::ModeRegistry; use crate::pb::Envelope; +use crate::policy::registry::PolicyRegistry; use crate::registry::PersistedSession; use crate::session::{ extract_ttl_ms, parse_session_start_payload, validate_canonical_session_start_payload, Session, @@ -18,13 +19,16 @@ pub fn replay_session( session_id: &str, log_entries: &[LogEntry], registry: &ModeRegistry, + policy_registry: Option<&PolicyRegistry>, ) -> Result { // Try checkpoint-based fast path first - if let Some(session) = try_replay_from_checkpoint(session_id, log_entries, registry)? { + if let Some(session) = + try_replay_from_checkpoint(session_id, log_entries, registry, policy_registry)? + { return Ok(session); } - replay_from_start(session_id, log_entries, registry) + replay_from_start(session_id, log_entries, registry, policy_registry) } /// Attempt to restore from the last checkpoint entry and replay remaining entries. @@ -33,6 +37,7 @@ fn try_replay_from_checkpoint( session_id: &str, log_entries: &[LogEntry], registry: &ModeRegistry, + policy_registry: Option<&PolicyRegistry>, ) -> Result, MacpError> { let checkpoint_idx = log_entries .iter() @@ -49,6 +54,12 @@ fn try_replay_from_checkpoint( let mut session = Session::from(persisted); session.session_id = session_id.into(); + // Re-resolve policy definition if policy_version is bound (RFC-MACP-0012 Section 8) + if !session.policy_version.is_empty() && session.policy_definition.is_none() { + session.policy_definition = + policy_registry.and_then(|pr| pr.resolve(&session.policy_version).ok()); + } + let mode = registry .get_mode(&session.mode) .ok_or(MacpError::UnknownMode)?; @@ -121,6 +132,7 @@ fn replay_from_start( session_id: &str, log_entries: &[LogEntry], registry: &ModeRegistry, + policy_registry: Option<&PolicyRegistry>, ) -> Result { // 1. Find the SessionStart entry let start_entry = log_entries @@ -196,6 +208,11 @@ fn replay_from_start( initiator_sender: start_entry.sender.clone(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: if !start_payload.policy_version.is_empty() { + policy_registry.and_then(|pr| pr.resolve(&start_payload.policy_version).ok()) + } else { + None + }, }; // 4. Call mode.on_session_start(), apply response @@ -312,7 +329,7 @@ mod tests { incoming_entry("m4", "Commitment", "agent://orchestrator", commitment, 4000), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert_eq!(session.session_id, "s1"); assert!(session.seen_message_ids.contains("m1")); @@ -334,7 +351,7 @@ mod tests { original_time, )]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.started_at_unix_ms, original_time); assert_eq!(session.ttl_expiry, original_time + 60_000); assert_eq!(session.ttl_ms, 60_000); @@ -354,7 +371,7 @@ mod tests { internal_entry("TtlExpired", 61001), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Expired); } @@ -372,7 +389,7 @@ mod tests { internal_entry("SessionCancel", 5000), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Expired); } @@ -396,7 +413,7 @@ mod tests { incoming_entry("m2", "Vote", "agent://fraud", vote, 2000), ]; - let err = replay_session("s1", &entries, ®istry).unwrap_err(); + let err = replay_session("s1", &entries, ®istry, None).unwrap_err(); // The exact error variant depends on which check fails first (authorize_sender // or on_message); what matters is that replay does NOT silently succeed. let msg = err.to_string(); @@ -409,7 +426,7 @@ mod tests { #[test] fn replay_empty_log_returns_error() { let registry = make_registry(); - let result = replay_session("s1", &[], ®istry); + let result = replay_session("s1", &[], ®istry, None); assert!(result.is_err()); } @@ -454,7 +471,7 @@ mod tests { 2000, ), ]; - let full_session = replay_session("s1", &full_entries, ®istry).unwrap(); + let full_session = replay_session("s1", &full_entries, ®istry, None).unwrap(); // Create a checkpoint from the replayed session state let persisted = PersistedSession::from(&full_session); @@ -487,7 +504,7 @@ mod tests { incoming_entry("m3", "Vote", "agent://fraud", vote, 4000), ]; - let session = replay_session("s1", &entries_with_checkpoint, ®istry).unwrap(); + let session = replay_session("s1", &entries_with_checkpoint, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Open); // Should have dedup from checkpoint (m1, m2) plus newly replayed m3 assert!(session.seen_message_ids.contains("m1")); @@ -506,7 +523,7 @@ mod tests { start_payload_bytes(), 1000, )]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Open); assert!(session.seen_message_ids.contains("m1")); } diff --git a/src/runtime.rs b/src/runtime.rs index b1509ad..afceba1 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -6,6 +6,8 @@ use crate::log_store::{EntryKind, LogEntry, LogStore}; use crate::metrics::RuntimeMetrics; use crate::mode_registry::ModeRegistry; use crate::pb::{Envelope, ModeDescriptor}; +use crate::policy::registry::PolicyRegistry; +use crate::policy::PolicyDefinition; use crate::registry::SessionRegistry; use crate::session::{ extract_ttl_ms, parse_session_start_payload, validate_canonical_session_start_payload, @@ -27,6 +29,7 @@ pub struct Runtime { stream_bus: Arc, signal_bus: tokio::sync::broadcast::Sender, mode_registry: Arc, + policy_registry: Arc, metrics: Arc, checkpoint_interval: usize, } @@ -50,6 +53,22 @@ impl Runtime { registry: Arc, log_store: Arc, mode_registry: Arc, + ) -> Self { + Self::with_registries( + storage, + registry, + log_store, + mode_registry, + Arc::new(PolicyRegistry::new()), + ) + } + + pub fn with_registries( + storage: Arc, + registry: Arc, + log_store: Arc, + mode_registry: Arc, + policy_registry: Arc, ) -> Self { let checkpoint_interval = std::env::var("MACP_CHECKPOINT_INTERVAL") .ok() @@ -63,6 +82,7 @@ impl Runtime { stream_bus: Arc::new(SessionStreamBus::default()), signal_bus: signal_tx, mode_registry, + policy_registry, metrics: Arc::new(RuntimeMetrics::new()), checkpoint_interval, } @@ -104,6 +124,32 @@ impl Runtime { &self.mode_registry } + // ── Policy registry delegation ────────────────────────────────── + + pub fn register_policy(&self, definition: PolicyDefinition) -> Result<(), String> { + self.policy_registry.register(definition) + } + + pub fn unregister_policy(&self, policy_id: &str) -> Result<(), String> { + self.policy_registry.unregister(policy_id) + } + + pub fn get_policy(&self, policy_id: &str) -> Option { + self.policy_registry.get(policy_id) + } + + pub fn list_policies(&self, mode_filter: Option<&str>) -> Vec { + self.policy_registry.list(mode_filter) + } + + pub fn subscribe_policy_changes(&self) -> tokio::sync::broadcast::Receiver<()> { + self.policy_registry.subscribe_changes() + } + + pub fn policy_registry(&self) -> &Arc { + &self.policy_registry + } + pub fn metrics(&self) -> &Arc { &self.metrics } @@ -252,6 +298,24 @@ impl Runtime { } } + // Resolve the governance policy for this session (if policy_version is bound). + let policy_definition = if start_payload.policy_version.is_empty() { + None + } else { + match self.policy_registry.resolve(&start_payload.policy_version) { + Ok(policy) => { + // RFC 6.1: reject if policy mode doesn't match session mode + if policy.mode != "*" && policy.mode != mode_name { + return Err(MacpError::InvalidPolicyDefinition); + } + Some(policy) + } + Err(_) => { + return Err(MacpError::UnknownPolicyVersion); + } + } + }; + let accepted_at = Utc::now().timestamp_millis(); let ttl_expiry = accepted_at.saturating_add(ttl_ms); let session = Session { @@ -274,6 +338,7 @@ impl Runtime { initiator_sender: env.sender.clone(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition, }; let response = mode.on_session_start(&session, env)?; @@ -555,7 +620,7 @@ mod tests { participants, mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 1_000, context: vec![], roots: vec![], @@ -700,7 +765,7 @@ mod tests { participants: vec!["agent://fraud".into()], mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 1, context: vec![], roots: vec![], @@ -864,7 +929,7 @@ mod tests { participants: vec!["agent://fraud".into()], mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 1, context: vec![], roots: vec![], @@ -1004,7 +1069,7 @@ mod tests { authority_scope: "commercial".into(), reason: "bound".into(), mode_version: "1.0.0".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), configuration_version: "cfg-1".into(), } .encode_to_vec(); diff --git a/src/server.rs b/src/server.rs index ece2ff3..159c69e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2,16 +2,19 @@ use macp_runtime::error::MacpError; use macp_runtime::pb::macp_runtime_service_server::MacpRuntimeService; use macp_runtime::pb::{ Ack, CancelSessionRequest, CancelSessionResponse, CancellationCapability, Capabilities, - Envelope, GetManifestRequest, GetManifestResponse, GetSessionRequest, GetSessionResponse, - InitializeRequest, InitializeResponse, ListExtModesRequest, ListExtModesResponse, - ListModesRequest, ListModesResponse, ListRootsRequest, ListRootsResponse, + Envelope, GetManifestRequest, GetManifestResponse, GetPolicyRequest, GetPolicyResponse, + GetSessionRequest, GetSessionResponse, InitializeRequest, InitializeResponse, + ListExtModesRequest, ListExtModesResponse, ListModesRequest, ListModesResponse, + ListPoliciesRequest, ListPoliciesResponse, ListRootsRequest, ListRootsResponse, MacpError as PbMacpError, ManifestCapability, ModeRegistryCapability, ParticipantActivity, - ProgressCapability, PromoteModeRequest, PromoteModeResponse, RegisterExtModeRequest, - RegisterExtModeResponse, RootsCapability, RuntimeInfo, SendRequest, SendResponse, + PolicyDescriptor, PolicyRegistryCapability, ProgressCapability, PromoteModeRequest, + PromoteModeResponse, RegisterExtModeRequest, RegisterExtModeResponse, RegisterPolicyRequest, + RegisterPolicyResponse, RootsCapability, RuntimeInfo, SendRequest, SendResponse, SessionMetadata, SessionState as PbSessionState, SessionsCapability, StreamSessionRequest, StreamSessionResponse, UnregisterExtModeRequest, UnregisterExtModeResponse, - WatchModeRegistryRequest, WatchModeRegistryResponse, WatchRootsRequest, WatchRootsResponse, - WatchSignalsRequest, WatchSignalsResponse, + UnregisterPolicyRequest, UnregisterPolicyResponse, WatchModeRegistryRequest, + WatchModeRegistryResponse, WatchPoliciesRequest, WatchPoliciesResponse, WatchRootsRequest, + WatchRootsResponse, WatchSignalsRequest, WatchSignalsResponse, }; use macp_runtime::runtime::Runtime; use macp_runtime::security::{AuthIdentity, SecurityLayer}; @@ -371,6 +374,7 @@ impl MacpServer { MacpError::RateLimited => Status::resource_exhausted(err.to_string()), MacpError::StorageFailed => Status::internal(err.to_string()), MacpError::InvalidSessionId => Status::invalid_argument(err.to_string()), + MacpError::InvalidPolicyDefinition => Status::invalid_argument(err.to_string()), _ => Status::failed_precondition(err.to_string()), } } @@ -414,6 +418,11 @@ impl MacpRuntimeService for MacpServer { list_roots: true, list_changed: true, }), + policy_registry: Some(PolicyRegistryCapability { + register_policy: true, + list_policies: true, + list_changed: true, + }), experimental: Some(macp_runtime::pb::ExperimentalCapabilities { features: HashMap::from([ ("ext_mode_lifecycle".into(), "true".into()), @@ -782,6 +791,171 @@ impl MacpRuntimeService for MacpServer { })), } } + + // ── Governance policy lifecycle RPCs (RFC-MACP-0012) ──────────── + + async fn register_policy( + &self, + request: Request, + ) -> Result, Status> { + let identity = self + .security + .authenticate_metadata(request.metadata()) + .map_err(Self::status_from_error)?; + self.security + .authorize_mode_registry(&identity) + .map_err(Self::status_from_error)?; + let req = request.into_inner(); + let descriptor = req + .descriptor + .ok_or_else(|| Status::invalid_argument("descriptor required"))?; + let definition = Self::policy_descriptor_to_definition(&descriptor); + match self.runtime.register_policy(definition) { + Ok(()) => Ok(Response::new(RegisterPolicyResponse { + ok: true, + error: String::new(), + })), + Err(e) => Ok(Response::new(RegisterPolicyResponse { + ok: false, + error: e, + })), + } + } + + async fn unregister_policy( + &self, + request: Request, + ) -> Result, Status> { + let identity = self + .security + .authenticate_metadata(request.metadata()) + .map_err(Self::status_from_error)?; + self.security + .authorize_mode_registry(&identity) + .map_err(Self::status_from_error)?; + let req = request.into_inner(); + match self.runtime.unregister_policy(&req.policy_id) { + Ok(()) => Ok(Response::new(UnregisterPolicyResponse { + ok: true, + error: String::new(), + })), + Err(e) => Ok(Response::new(UnregisterPolicyResponse { + ok: false, + error: e, + })), + } + } + + async fn get_policy( + &self, + request: Request, + ) -> Result, Status> { + let _identity = self + .security + .authenticate_metadata(request.metadata()) + .map_err(Self::status_from_error)?; + let req = request.into_inner(); + let policy = self + .runtime + .get_policy(&req.policy_id) + .ok_or_else(|| Status::not_found(format!("Policy '{}' not found", req.policy_id)))?; + Ok(Response::new(GetPolicyResponse { + descriptor: Some(Self::policy_definition_to_descriptor(&policy)), + })) + } + + async fn list_policies( + &self, + request: Request, + ) -> Result, Status> { + let _identity = self + .security + .authenticate_metadata(request.metadata()) + .map_err(Self::status_from_error)?; + let req = request.into_inner(); + let mode_filter = if req.mode.is_empty() { + None + } else { + Some(req.mode.as_str()) + }; + let policies = self.runtime.list_policies(mode_filter); + let descriptors = policies + .iter() + .map(Self::policy_definition_to_descriptor) + .collect(); + Ok(Response::new(ListPoliciesResponse { descriptors })) + } + + type WatchPoliciesStream = std::pin::Pin< + Box> + Send>, + >; + + async fn watch_policies( + &self, + _request: Request, + ) -> Result, Status> { + let mut rx = self.runtime.subscribe_policy_changes(); + let runtime = Arc::clone(&self.runtime); + let stream = async_stream::try_stream! { + // Send initial state + let policies = runtime.list_policies(None); + let descriptors: Vec = policies + .iter() + .map(|p| MacpServer::policy_definition_to_descriptor(p)) + .collect(); + yield WatchPoliciesResponse { + descriptors, + observed_at_unix_ms: chrono::Utc::now().timestamp_millis() as u64, + }; + // Wait for changes + while rx.recv().await.is_ok() { + let policies = runtime.list_policies(None); + let descriptors: Vec = policies + .iter() + .map(|p| MacpServer::policy_definition_to_descriptor(p)) + .collect(); + yield WatchPoliciesResponse { + descriptors, + observed_at_unix_ms: chrono::Utc::now().timestamp_millis() as u64, + }; + } + }; + Ok(Response::new(Box::pin(stream))) + } +} + +// ── Policy type conversion helpers ────────────────────────────────── + +impl MacpServer { + fn policy_descriptor_to_definition( + descriptor: &PolicyDescriptor, + ) -> macp_runtime::policy::PolicyDefinition { + let rules: serde_json::Value = if descriptor.rules.is_empty() { + serde_json::json!({}) + } else { + serde_json::from_slice(&descriptor.rules).unwrap_or_else(|_| serde_json::json!({})) + }; + macp_runtime::policy::PolicyDefinition { + policy_id: descriptor.policy_id.clone(), + mode: descriptor.mode.clone(), + description: descriptor.description.clone(), + rules, + schema_version: descriptor.schema_version, + } + } + + fn policy_definition_to_descriptor( + definition: &macp_runtime::policy::PolicyDefinition, + ) -> PolicyDescriptor { + PolicyDescriptor { + policy_id: definition.policy_id.clone(), + mode: definition.mode.clone(), + description: definition.description.clone(), + rules: serde_json::to_vec(&definition.rules).unwrap_or_default(), + schema_version: definition.schema_version, + registered_at: String::new(), + } + } } #[cfg(test)] @@ -827,7 +1001,7 @@ mod tests { participants: vec!["agent://fraud".into()], mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 1000, context: vec![], roots: vec![], diff --git a/src/session.rs b/src/session.rs index 19788f6..5d8c3d3 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,6 +1,7 @@ use crate::error::MacpError; use crate::mode::ModeResponse; use crate::pb::SessionStartPayload; +use crate::policy::PolicyDefinition; use prost::Message; use std::collections::{HashMap, HashSet}; @@ -34,6 +35,7 @@ pub struct Session { pub initiator_sender: String, pub participant_message_counts: HashMap, pub participant_last_seen: HashMap, + pub policy_definition: Option, } impl Session { diff --git a/src/storage/file.rs b/src/storage/file.rs index bd4c89b..f933564 100644 --- a/src/storage/file.rs +++ b/src/storage/file.rs @@ -188,6 +188,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/storage/memory.rs b/src/storage/memory.rs index 1628bf8..939ea84 100644 --- a/src/storage/memory.rs +++ b/src/storage/memory.rs @@ -69,6 +69,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/storage/migration.rs b/src/storage/migration.rs index 0e2b2de..7a922bd 100644 --- a/src/storage/migration.rs +++ b/src/storage/migration.rs @@ -178,6 +178,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/storage/recovery.rs b/src/storage/recovery.rs index b11c95e..3a2066d 100644 --- a/src/storage/recovery.rs +++ b/src/storage/recovery.rs @@ -75,6 +75,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/storage/redis_backend.rs b/src/storage/redis_backend.rs index 9a38771..253a3b7 100644 --- a/src/storage/redis_backend.rs +++ b/src/storage/redis_backend.rs @@ -172,6 +172,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/src/storage/rocksdb.rs b/src/storage/rocksdb.rs index 450860d..02622fc 100644 --- a/src/storage/rocksdb.rs +++ b/src/storage/rocksdb.rs @@ -260,6 +260,7 @@ mod tests { initiator_sender: "alice".into(), participant_message_counts: std::collections::HashMap::new(), participant_last_seen: std::collections::HashMap::new(), + policy_definition: None, } } diff --git a/tests/concurrent_messages.rs b/tests/concurrent_messages.rs index a1f327b..782d43e 100644 --- a/tests/concurrent_messages.rs +++ b/tests/concurrent_messages.rs @@ -23,7 +23,7 @@ fn session_start(participants: Vec) -> Vec { participants, mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 60_000, context: vec![], roots: vec![], diff --git a/tests/conformance/decision_happy_path.json b/tests/conformance/decision_happy_path.json index 7d601cf..cf1ca6f 100644 --- a/tests/conformance/decision_happy_path.json +++ b/tests/conformance/decision_happy_path.json @@ -7,7 +7,7 @@ ], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -43,7 +43,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "accept" diff --git a/tests/conformance/decision_reject_paths.json b/tests/conformance/decision_reject_paths.json index 2268557..cfb83bd 100644 --- a/tests/conformance/decision_reject_paths.json +++ b/tests/conformance/decision_reject_paths.json @@ -7,7 +7,7 @@ ], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -45,7 +45,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "reject", diff --git a/tests/conformance/handoff_happy_path.json b/tests/conformance/handoff_happy_path.json index 6e341cb..5a2cb0f 100644 --- a/tests/conformance/handoff_happy_path.json +++ b/tests/conformance/handoff_happy_path.json @@ -4,7 +4,7 @@ "participants": ["agent://owner", "agent://target"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -31,7 +31,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "accept" diff --git a/tests/conformance/handoff_reject_paths.json b/tests/conformance/handoff_reject_paths.json index b40ba22..73246e6 100644 --- a/tests/conformance/handoff_reject_paths.json +++ b/tests/conformance/handoff_reject_paths.json @@ -7,7 +7,7 @@ ], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { diff --git a/tests/conformance/multi_round_happy_path.json b/tests/conformance/multi_round_happy_path.json index 4296f52..0946aec 100644 --- a/tests/conformance/multi_round_happy_path.json +++ b/tests/conformance/multi_round_happy_path.json @@ -4,7 +4,7 @@ "participants": ["agent://alice", "agent://bob"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -38,7 +38,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "accept" diff --git a/tests/conformance/multi_round_reject_paths.json b/tests/conformance/multi_round_reject_paths.json index e916c1e..b7c0d93 100644 --- a/tests/conformance/multi_round_reject_paths.json +++ b/tests/conformance/multi_round_reject_paths.json @@ -4,7 +4,7 @@ "participants": ["agent://alice", "agent://bob"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -17,7 +17,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "reject" @@ -46,7 +46,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "reject" diff --git a/tests/conformance/proposal_happy_path.json b/tests/conformance/proposal_happy_path.json index 824cf96..724a2f5 100644 --- a/tests/conformance/proposal_happy_path.json +++ b/tests/conformance/proposal_happy_path.json @@ -7,7 +7,7 @@ ], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -53,7 +53,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "accept" diff --git a/tests/conformance/proposal_reject_paths.json b/tests/conformance/proposal_reject_paths.json index 5b1af5a..72282e1 100644 --- a/tests/conformance/proposal_reject_paths.json +++ b/tests/conformance/proposal_reject_paths.json @@ -7,7 +7,7 @@ ], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -20,7 +20,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "reject", diff --git a/tests/conformance/quorum_happy_path.json b/tests/conformance/quorum_happy_path.json index acc38a3..a13a1e5 100644 --- a/tests/conformance/quorum_happy_path.json +++ b/tests/conformance/quorum_happy_path.json @@ -4,7 +4,7 @@ "participants": ["agent://alice", "agent://bob", "agent://carol"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -38,7 +38,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "accept" diff --git a/tests/conformance/quorum_reject_paths.json b/tests/conformance/quorum_reject_paths.json index 47e0a33..0156776 100644 --- a/tests/conformance/quorum_reject_paths.json +++ b/tests/conformance/quorum_reject_paths.json @@ -4,7 +4,7 @@ "participants": ["agent://alice", "agent://bob", "agent://carol"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -38,7 +38,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "reject" diff --git a/tests/conformance/task_happy_path.json b/tests/conformance/task_happy_path.json index a4ae447..c1cc844 100644 --- a/tests/conformance/task_happy_path.json +++ b/tests/conformance/task_happy_path.json @@ -4,7 +4,7 @@ "participants": ["agent://planner", "agent://worker"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { @@ -38,7 +38,7 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "policy-1", + "policy_version": "", "configuration_version": "cfg-1" }, "expect": "accept" diff --git a/tests/conformance/task_reject_paths.json b/tests/conformance/task_reject_paths.json index eb3c9db..fc6df98 100644 --- a/tests/conformance/task_reject_paths.json +++ b/tests/conformance/task_reject_paths.json @@ -4,7 +4,7 @@ "participants": ["agent://planner", "agent://worker"], "mode_version": "1.0.0", "configuration_version": "cfg-1", - "policy_version": "policy-1", + "policy_version": "", "ttl_ms": 60000, "messages": [ { diff --git a/tests/conformance_loader.rs b/tests/conformance_loader.rs index e967b78..870b5d7 100644 --- a/tests/conformance_loader.rs +++ b/tests/conformance_loader.rs @@ -320,7 +320,7 @@ async fn assert_replay_equivalence(rt: &Runtime, sid: &str, live_session: &Sessi .get_log(sid) .await .unwrap_or_else(|| panic!("missing log entries for {sid}")); - let replayed = replay_session(sid, &log, rt.mode_registry().as_ref()) + let replayed = replay_session(sid, &log, rt.mode_registry().as_ref(), None) .unwrap_or_else(|e| panic!("replay failed for {sid}: {e}")); assert_eq!(replayed.state, live_session.state, "replay state mismatch"); assert_eq!( diff --git a/tests/file_backend_integration.rs b/tests/file_backend_integration.rs index 64b2192..11d3a5d 100644 --- a/tests/file_backend_integration.rs +++ b/tests/file_backend_integration.rs @@ -19,7 +19,7 @@ fn session_start(participants: Vec) -> Vec { participants, mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 60_000, context: vec![], roots: vec![], @@ -54,7 +54,7 @@ fn commitment(action: &str) -> Vec { authority_scope: "test".into(), reason: "done".into(), mode_version: "1.0.0".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), configuration_version: "cfg-1".into(), } .encode_to_vec() @@ -144,6 +144,7 @@ async fn file_backend_full_lifecycle() { &sid, &log, &macp_runtime::mode_registry::ModeRegistry::build_default(), + None, ) .unwrap(); assert_eq!(replayed.state, SessionState::Resolved); @@ -203,7 +204,7 @@ async fn file_backend_crash_recovery_via_replay() { assert_eq!(log_entries.len(), 2); let mode_registry = ModeRegistry::build_default(); - let session = replay_session(&sid, &log_entries, &mode_registry).unwrap(); + let session = replay_session(&sid, &log_entries, &mode_registry, None).unwrap(); assert_eq!(session.state, SessionState::Open); assert_eq!(session.seen_message_ids.len(), 2); assert!(session.seen_message_ids.contains("m1")); diff --git a/tests/integration_mode_lifecycle.rs b/tests/integration_mode_lifecycle.rs index 08879b7..a63af23 100644 --- a/tests/integration_mode_lifecycle.rs +++ b/tests/integration_mode_lifecycle.rs @@ -25,7 +25,7 @@ fn session_start(participants: Vec) -> Vec { participants, mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 60_000, context: vec![], roots: vec![], @@ -60,7 +60,7 @@ fn commitment(action: &str) -> Vec { authority_scope: "test".into(), reason: "done".into(), mode_version: "1.0.0".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), configuration_version: "cfg-1".into(), } .encode_to_vec() diff --git a/tests/replay_round_trip.rs b/tests/replay_round_trip.rs index 47c8123..f36e5a5 100644 --- a/tests/replay_round_trip.rs +++ b/tests/replay_round_trip.rs @@ -110,7 +110,7 @@ fn replay_decision_session() { ), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert!(session.resolution.is_some()); assert_eq!(session.seen_message_ids.len(), 4); @@ -184,7 +184,7 @@ fn replay_proposal_session() { ), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert!(session.resolution.is_some()); assert_eq!(session.seen_message_ids.len(), 5); @@ -259,7 +259,7 @@ fn replay_task_session() { ), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert!(session.resolution.is_some()); } @@ -317,7 +317,7 @@ fn replay_handoff_session() { ), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert!(session.resolution.is_some()); } @@ -387,7 +387,7 @@ fn replay_quorum_session() { ), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert!(session.resolution.is_some()); assert_eq!(session.seen_message_ids.len(), 5); @@ -433,7 +433,7 @@ fn replay_multi_round_session() { ), ]; - let session = replay_session("s1", &entries, ®istry).unwrap(); + let session = replay_session("s1", &entries, ®istry, None).unwrap(); assert_eq!(session.state, SessionState::Resolved); assert!(session.resolution.is_some()); assert_eq!(session.seen_message_ids.len(), 4); diff --git a/tests/stream_integration.rs b/tests/stream_integration.rs index 401b120..04e0614 100644 --- a/tests/stream_integration.rs +++ b/tests/stream_integration.rs @@ -23,7 +23,7 @@ fn session_start(participants: Vec) -> Vec { participants, mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), - policy_version: "policy-1".into(), + policy_version: String::new(), ttl_ms: 60_000, context: vec![], roots: vec![], From 30c263469481fa0012e56e5c484ca69119d116b9 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Mon, 6 Apr 2026 16:47:00 -0700 Subject: [PATCH 02/10] Fix Policy Changes --- docs/architecture.md | 12 +- integration_tests/src/helpers.rs | 9 +- integration_tests/src/macp_tools/commit.rs | 24 +- integration_tests/src/macp_tools/decision.rs | 64 +- integration_tests/src/macp_tools/handoff.rs | 52 +- integration_tests/src/macp_tools/proposal.rs | 42 +- integration_tests/src/macp_tools/query.rs | 14 +- integration_tests/src/macp_tools/quorum.rs | 47 +- .../src/macp_tools/session_start.rs | 2 +- integration_tests/src/macp_tools/task.rs | 68 +- integration_tests/tests/common/mod.rs | 4 +- integration_tests/tests/tier1_protocol/mod.rs | 22 +- .../tier1_protocol/test_cancel_session.rs | 8 +- .../test_concurrent_sessions.rs | 2 +- .../tier1_protocol/test_decision_mode.rs | 2 +- .../tests/tier1_protocol/test_handoff_mode.rs | 2 +- .../tier1_protocol/test_mode_registry.rs | 2 +- .../tier1_protocol/test_multi_round_mode.rs | 2 +- .../tier1_protocol/test_policy_registry.rs | 512 +++++++ .../tier1_protocol/test_proposal_mode.rs | 2 +- .../tests/tier1_protocol/test_quorum_mode.rs | 2 +- .../tests/tier1_protocol/test_reject_paths.rs | 421 ++++-- .../tier1_protocol/test_rfc_cross_cutting.rs | 110 +- .../tests/tier1_protocol/test_task_mode.rs | 2 +- integration_tests/tests/tier2_agents/mod.rs | 4 +- .../tests/tier2_agents/test_decision_agent.rs | 121 +- .../tests/tier2_agents/test_handoff_agent.rs | 78 +- .../tests/tier2_agents/test_multi_agent.rs | 119 +- .../tier2_agents/test_proposal_negotiation.rs | 115 +- .../tier2_agents/test_task_delegation.rs | 115 +- .../tests/tier3_e2e/test_e2e_decision.rs | 28 +- .../test_e2e_decision_with_signals.rs | 195 ++- .../tests/tier3_e2e/test_e2e_task.rs | 3 +- src/bin/support/common.rs | 1 + src/error.rs | 8 +- src/mode/decision.rs | 135 +- src/mode/handoff.rs | 2 + src/mode/multi_round.rs | 1 + src/mode/passthrough.rs | 1 + src/mode/proposal.rs | 5 + src/mode/quorum.rs | 2 + src/mode/task.rs | 2 + src/mode/util.rs | 27 + src/policy/defaults.rs | 63 + src/policy/evaluator.rs | 1208 +++++++++++++++++ src/policy/mod.rs | 72 + src/policy/registry.rs | 537 ++++++++ src/policy/rules.rs | 428 ++++++ src/replay.rs | 3 +- src/runtime.rs | 41 +- src/server.rs | 47 +- tests/concurrent_messages.rs | 2 +- tests/conformance/decision_happy_path.json | 9 +- tests/conformance/decision_reject_paths.json | 6 +- tests/conformance/handoff_happy_path.json | 5 +- tests/conformance/multi_round_happy_path.json | 5 +- .../conformance/multi_round_reject_paths.json | 10 +- tests/conformance/proposal_happy_path.json | 8 +- tests/conformance/proposal_reject_paths.json | 5 +- tests/conformance/quorum_happy_path.json | 5 +- tests/conformance/quorum_reject_paths.json | 5 +- tests/conformance/task_happy_path.json | 5 +- tests/conformance_loader.rs | 2 + tests/file_backend_integration.rs | 7 +- tests/integration_mode_lifecycle.rs | 9 +- tests/replay_round_trip.rs | 3 +- tests/stream_integration.rs | 6 +- 67 files changed, 4369 insertions(+), 511 deletions(-) create mode 100644 integration_tests/tests/tier1_protocol/test_policy_registry.rs create mode 100644 src/policy/defaults.rs create mode 100644 src/policy/evaluator.rs create mode 100644 src/policy/mod.rs create mode 100644 src/policy/registry.rs create mode 100644 src/policy/rules.rs diff --git a/docs/architecture.md b/docs/architecture.md index 2ad2dba..f6c47cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,10 +127,18 @@ Components: Policy lifecycle: 1. Registered via `RegisterPolicy` RPC or pre-loaded at startup -2. Resolved at `SessionStart` — bound to session as `policy_definition` -3. Evaluated at `Commitment` — mode-specific evaluator checks rules against session state +2. Resolved at `SessionStart` — bound to session as `policy_definition`. Sessions always bind `policy_version` (defaults to `"policy.default"` when empty). +3. Evaluated at `Commitment` — mode-specific evaluator checks rules against session state. `CommitmentPayload.outcome_positive` must be consistent with the `action` field. 4. Persisted with session — replay uses stored definition, never re-resolves +Registration validates conditional constraints: `weighted` algorithm requires non-empty `weights`, `supermajority` requires `threshold > 0.5`, `designated_role` authority requires non-empty `designated_roles`. + +## Key protocol fields + +- `CommitmentPayload.outcome_positive` (bool) — indicates whether the commitment outcome is positive or negative. Must match the action suffix (e.g. `*.rejected` → `false`, `*.selected` → `true`). +- `SessionMetadata.initiator` (string) — the authenticated sender who created the session. Populated in `GetSession` responses. +- `SessionAlreadyExists` error — returned when a `SessionStart` is received for a `session_id` that already has an accepted `SessionStart` (maps to gRPC `ALREADY_EXISTS`). + ## Architecture diagram ``` diff --git a/integration_tests/src/helpers.rs b/integration_tests/src/helpers.rs index a066f77..260fd4e 100644 --- a/integration_tests/src/helpers.rs +++ b/integration_tests/src/helpers.rs @@ -75,6 +75,7 @@ pub fn commitment_payload( action: &str, authority_scope: &str, reason: &str, + outcome_positive: bool, ) -> Vec { CommitmentPayload { commitment_id: commitment_id.into(), @@ -84,6 +85,7 @@ pub fn commitment_payload( mode_version: MODE_VERSION.into(), policy_version: POLICY_VERSION.into(), configuration_version: CONFIG_VERSION.into(), + outcome_positive, } .encode_to_vec() } @@ -244,7 +246,12 @@ pub fn accept_proposal_payload(proposal_id: &str, reason: &str) -> Vec { // ── Task mode payload helpers ─────────────────────────────────────────── -pub fn task_request_payload(task_id: &str, title: &str, instructions: &str, assignee: &str) -> Vec { +pub fn task_request_payload( + task_id: &str, + title: &str, + instructions: &str, + assignee: &str, +) -> Vec { macp_runtime::task_pb::TaskRequestPayload { task_id: task_id.into(), title: title.into(), diff --git a/integration_tests/src/macp_tools/commit.rs b/integration_tests/src/macp_tools/commit.rs index 2ccfdc1..b2b39d1 100644 --- a/integration_tests/src/macp_tools/commit.rs +++ b/integration_tests/src/macp_tools/commit.rs @@ -2,9 +2,9 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; -use super::SharedClient; use super::decision::MacpToolError; +use super::SharedClient; +use crate::helpers; #[derive(Clone)] pub struct CommitTool { @@ -47,7 +47,8 @@ impl Tool for CommitTool { }, "required": ["session_id", "action", "authority_scope", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { @@ -56,14 +57,23 @@ impl Tool for CommitTool { &args.action, &args.authority_scope, &args.reason, + true, ); let env = helpers::envelope( - &self.mode, "Commitment", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + &self.mode, + "Commitment", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(CommitResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(CommitResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } diff --git a/integration_tests/src/macp_tools/decision.rs b/integration_tests/src/macp_tools/decision.rs index 16cf996..214671e 100644 --- a/integration_tests/src/macp_tools/decision.rs +++ b/integration_tests/src/macp_tools/decision.rs @@ -2,8 +2,8 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; use super::SharedClient; +use crate::helpers; // ── ProposeTool ───────────────────────────────────────────────────────── @@ -51,19 +51,28 @@ impl Tool for ProposeTool { }, "required": ["session_id", "proposal_id", "option", "rationale"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::proposal_payload(&args.proposal_id, &args.option, &args.rationale); let env = helpers::envelope( - helpers::MODE_DECISION, "Proposal", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_DECISION, + "Proposal", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -105,21 +114,33 @@ impl Tool for EvaluateTool { }, "required": ["session_id", "proposal_id", "recommendation", "confidence", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::evaluation_payload( - &args.proposal_id, &args.recommendation, args.confidence, &args.reason, + &args.proposal_id, + &args.recommendation, + args.confidence, + &args.reason, ); let env = helpers::envelope( - helpers::MODE_DECISION, "Evaluation", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_DECISION, + "Evaluation", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -159,18 +180,27 @@ impl Tool for VoteTool { }, "required": ["session_id", "proposal_id", "vote", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::vote_payload(&args.proposal_id, &args.vote, &args.reason); let env = helpers::envelope( - helpers::MODE_DECISION, "Vote", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_DECISION, + "Vote", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } diff --git a/integration_tests/src/macp_tools/handoff.rs b/integration_tests/src/macp_tools/handoff.rs index 77890e1..20ca275 100644 --- a/integration_tests/src/macp_tools/handoff.rs +++ b/integration_tests/src/macp_tools/handoff.rs @@ -2,9 +2,9 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; -use super::SharedClient; use super::decision::MacpToolError; +use super::SharedClient; +use crate::helpers; #[derive(Serialize)] pub struct ToolResult { @@ -48,19 +48,33 @@ impl Tool for HandoffOfferTool { }, "required": ["session_id", "handoff_id", "target", "scope", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { - let payload = helpers::handoff_offer_payload(&args.handoff_id, &args.target, &args.scope, &args.reason); + let payload = helpers::handoff_offer_payload( + &args.handoff_id, + &args.target, + &args.scope, + &args.reason, + ); let env = helpers::envelope( - helpers::MODE_HANDOFF, "HandoffOffer", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_HANDOFF, + "HandoffOffer", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -96,18 +110,28 @@ impl Tool for HandoffAcceptTool { }, "required": ["session_id", "handoff_id", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { - let payload = helpers::handoff_accept_payload(&args.handoff_id, &self.agent_id, &args.reason); + let payload = + helpers::handoff_accept_payload(&args.handoff_id, &self.agent_id, &args.reason); let env = helpers::envelope( - helpers::MODE_HANDOFF, "HandoffAccept", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_HANDOFF, + "HandoffAccept", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } diff --git a/integration_tests/src/macp_tools/proposal.rs b/integration_tests/src/macp_tools/proposal.rs index 6c4ae86..8163fc0 100644 --- a/integration_tests/src/macp_tools/proposal.rs +++ b/integration_tests/src/macp_tools/proposal.rs @@ -2,9 +2,9 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; -use super::SharedClient; use super::decision::MacpToolError; +use super::SharedClient; +use crate::helpers; #[derive(Serialize)] pub struct ToolResult { @@ -46,19 +46,28 @@ impl Tool for SubmitProposalTool { }, "required": ["session_id", "proposal_id", "title", "summary"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::proposal_mode_payload(&args.proposal_id, &args.title, &args.summary); let env = helpers::envelope( - helpers::MODE_PROPOSAL, "Proposal", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_PROPOSAL, + "Proposal", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -94,18 +103,27 @@ impl Tool for AcceptProposalTool { }, "required": ["session_id", "proposal_id", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::accept_proposal_payload(&args.proposal_id, &args.reason); let env = helpers::envelope( - helpers::MODE_PROPOSAL, "Accept", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_PROPOSAL, + "Accept", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } diff --git a/integration_tests/src/macp_tools/query.rs b/integration_tests/src/macp_tools/query.rs index 156f312..415cc1d 100644 --- a/integration_tests/src/macp_tools/query.rs +++ b/integration_tests/src/macp_tools/query.rs @@ -2,9 +2,9 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; -use super::SharedClient; use super::decision::MacpToolError; +use super::SharedClient; +use crate::helpers; #[derive(Clone)] pub struct GetSessionTool { @@ -41,14 +41,18 @@ impl Tool for GetSessionTool { }, "required": ["session_id"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let mut client = self.client.lock().await; let resp = helpers::get_session_as(&mut client, &self.agent_id, &args.session_id) - .await.map_err(|e| MacpToolError(e.to_string()))?; - let meta = resp.metadata.ok_or_else(|| MacpToolError("no metadata".into()))?; + .await + .map_err(|e| MacpToolError(e.to_string()))?; + let meta = resp + .metadata + .ok_or_else(|| MacpToolError("no metadata".into()))?; Ok(GetSessionResult { session_id: meta.session_id, mode: meta.mode, diff --git a/integration_tests/src/macp_tools/quorum.rs b/integration_tests/src/macp_tools/quorum.rs index f0d2d06..4051116 100644 --- a/integration_tests/src/macp_tools/quorum.rs +++ b/integration_tests/src/macp_tools/quorum.rs @@ -2,9 +2,9 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; -use super::SharedClient; use super::decision::MacpToolError; +use super::SharedClient; +use crate::helpers; #[derive(Serialize)] pub struct ToolResult { @@ -48,21 +48,33 @@ impl Tool for ApprovalRequestTool { }, "required": ["session_id", "request_id", "action", "summary", "required_approvals"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::approval_request_payload( - &args.request_id, &args.action, &args.summary, args.required_approvals, + &args.request_id, + &args.action, + &args.summary, + args.required_approvals, ); let env = helpers::envelope( - helpers::MODE_QUORUM, "ApprovalRequest", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_QUORUM, + "ApprovalRequest", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -98,18 +110,27 @@ impl Tool for ApproveTool { }, "required": ["session_id", "request_id", "reason"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::approve_payload(&args.request_id, &args.reason); let env = helpers::envelope( - helpers::MODE_QUORUM, "Approve", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_QUORUM, + "Approve", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } diff --git a/integration_tests/src/macp_tools/session_start.rs b/integration_tests/src/macp_tools/session_start.rs index b509300..0674abe 100644 --- a/integration_tests/src/macp_tools/session_start.rs +++ b/integration_tests/src/macp_tools/session_start.rs @@ -2,8 +2,8 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; use super::SharedClient; +use crate::helpers; #[derive(Clone)] pub struct StartSessionTool { diff --git a/integration_tests/src/macp_tools/task.rs b/integration_tests/src/macp_tools/task.rs index f4a8bad..436b73c 100644 --- a/integration_tests/src/macp_tools/task.rs +++ b/integration_tests/src/macp_tools/task.rs @@ -2,9 +2,9 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use serde::{Deserialize, Serialize}; -use crate::helpers; -use super::SharedClient; use super::decision::MacpToolError; +use super::SharedClient; +use crate::helpers; #[derive(Serialize)] pub struct ToolResult { @@ -50,19 +50,33 @@ impl Tool for TaskRequestTool { }, "required": ["session_id", "task_id", "title", "instructions", "assignee"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { - let payload = helpers::task_request_payload(&args.task_id, &args.title, &args.instructions, &args.assignee); + let payload = helpers::task_request_payload( + &args.task_id, + &args.title, + &args.instructions, + &args.assignee, + ); let env = helpers::envelope( - helpers::MODE_TASK, "TaskRequest", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_TASK, + "TaskRequest", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -98,19 +112,28 @@ impl Tool for TaskAcceptTool { }, "required": ["session_id", "task_id"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::task_accept_payload(&args.task_id, &self.agent_id); let env = helpers::envelope( - helpers::MODE_TASK, "TaskAccept", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_TASK, + "TaskAccept", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } @@ -148,18 +171,27 @@ impl Tool for TaskCompleteTool { }, "required": ["session_id", "task_id", "summary"] } - })).unwrap() + })) + .unwrap() } async fn call(&self, args: Self::Args) -> Result { let payload = helpers::task_complete_payload(&args.task_id, &self.agent_id, &args.summary); let env = helpers::envelope( - helpers::MODE_TASK, "TaskComplete", - &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + helpers::MODE_TASK, + "TaskComplete", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, ); let mut client = self.client.lock().await; let ack = helpers::send_as(&mut client, &self.agent_id, env) - .await.map_err(|e| MacpToolError(e.to_string()))?; - Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + .await + .map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { + ok: ack.ok, + session_state: ack.session_state, + }) } } diff --git a/integration_tests/tests/common/mod.rs b/integration_tests/tests/common/mod.rs index 13fc44d..e30abfb 100644 --- a/integration_tests/tests/common/mod.rs +++ b/integration_tests/tests/common/mod.rs @@ -1,10 +1,10 @@ use std::sync::OnceLock; -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; use macp_integration_tests::config::TestConfig; use macp_integration_tests::server_manager::ServerManager; -use tonic::transport::Channel; +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; use tokio::sync::Mutex; +use tonic::transport::Channel; /// Global server instance (started once, shared across all tests in a binary). static SERVER: OnceLock>> = OnceLock::new(); diff --git a/integration_tests/tests/tier1_protocol/mod.rs b/integration_tests/tests/tier1_protocol/mod.rs index fa3fe12..a1e2c56 100644 --- a/integration_tests/tests/tier1_protocol/mod.rs +++ b/integration_tests/tests/tier1_protocol/mod.rs @@ -1,16 +1,16 @@ -mod test_initialize; -mod test_decision_mode; -mod test_proposal_mode; -mod test_task_mode; -mod test_handoff_mode; -mod test_quorum_mode; -mod test_multi_round_mode; -mod test_stream_session; mod test_cancel_session; +mod test_concurrent_sessions; +mod test_decision_mode; mod test_error_paths; +mod test_handoff_mode; +mod test_initialize; mod test_mode_registry; -mod test_concurrent_sessions; -mod test_session_lifecycle; +mod test_multi_round_mode; +mod test_policy_registry; +mod test_proposal_mode; +mod test_quorum_mode; mod test_reject_paths; mod test_rfc_cross_cutting; -mod test_policy_registry; +mod test_session_lifecycle; +mod test_stream_session; +mod test_task_mode; diff --git a/integration_tests/tests/tier1_protocol/test_cancel_session.rs b/integration_tests/tests/tier1_protocol/test_cancel_session.rs index d98b60c..dfce5cb 100644 --- a/integration_tests/tests/tier1_protocol/test_cancel_session.rs +++ b/integration_tests/tests/tier1_protocol/test_cancel_session.rs @@ -26,7 +26,9 @@ async fn cancel_active_session() { assert!(ack.ok); // Cancel from initiator - let ack = cancel_session_as(&mut client, coord, &sid, "changed my mind").await.unwrap(); + let ack = cancel_session_as(&mut client, coord, &sid, "changed my mind") + .await + .unwrap(); assert!(ack.ok); // Verify session is no longer open @@ -58,7 +60,9 @@ async fn send_to_cancelled_session_fails() { .await .unwrap(); - cancel_session_as(&mut client, coord, &sid, "done").await.unwrap(); + cancel_session_as(&mut client, coord, &sid, "done") + .await + .unwrap(); // Try sending to cancelled session let ack = send_as( diff --git a/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs b/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs index 3128caa..ff9c19a 100644 --- a/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs +++ b/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs @@ -116,7 +116,7 @@ async fn parallel_decision_sessions_are_independent() { &new_message_id(), &sid1, coord, - commitment_payload("c1", "option-A", "team", "done"), + commitment_payload("c1", "option-A", "team", "done", true), ), ) .await diff --git a/integration_tests/tests/tier1_protocol/test_decision_mode.rs b/integration_tests/tests/tier1_protocol/test_decision_mode.rs index 8c0841f..bc43c42 100644 --- a/integration_tests/tests/tier1_protocol/test_decision_mode.rs +++ b/integration_tests/tests/tier1_protocol/test_decision_mode.rs @@ -86,7 +86,7 @@ async fn decision_happy_path() { &new_message_id(), &sid, coord, - commitment_payload("c1", "deploy-v2", "team", "consensus reached"), + commitment_payload("c1", "deploy-v2", "team", "consensus reached", true), ), ) .await diff --git a/integration_tests/tests/tier1_protocol/test_handoff_mode.rs b/integration_tests/tests/tier1_protocol/test_handoff_mode.rs index 0b3e852..8c0b353 100644 --- a/integration_tests/tests/tier1_protocol/test_handoff_mode.rs +++ b/integration_tests/tests/tier1_protocol/test_handoff_mode.rs @@ -86,7 +86,7 @@ async fn handoff_happy_path() { &new_message_id(), &sid, source, - commitment_payload("c1", "handoff-complete", "support", "transferred"), + commitment_payload("c1", "handoff-complete", "support", "transferred", true), ), ) .await diff --git a/integration_tests/tests/tier1_protocol/test_mode_registry.rs b/integration_tests/tests/tier1_protocol/test_mode_registry.rs index 9db6667..d800029 100644 --- a/integration_tests/tests/tier1_protocol/test_mode_registry.rs +++ b/integration_tests/tests/tier1_protocol/test_mode_registry.rs @@ -55,7 +55,7 @@ async fn register_and_unregister_ext_mode() { .register_ext_mode(with_sender( agent, RegisterExtModeRequest { - descriptor: Some(descriptor), + mode_descriptor: Some(descriptor), }, )) .await diff --git a/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs b/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs index 52bcd18..fbebeb6 100644 --- a/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs +++ b/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs @@ -113,7 +113,7 @@ async fn multi_round_happy_path() { &new_message_id(), &sid, agent_a, - commitment_payload("c1", "converged", "group", "all agreed"), + commitment_payload("c1", "converged", "group", "all agreed", true), ), ) .await diff --git a/integration_tests/tests/tier1_protocol/test_policy_registry.rs b/integration_tests/tests/tier1_protocol/test_policy_registry.rs new file mode 100644 index 0000000..d70ddf9 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_policy_registry.rs @@ -0,0 +1,512 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_runtime::pb::{ + GetPolicyRequest, ListPoliciesRequest, PolicyDescriptor, RegisterPolicyRequest, + SessionStartPayload, UnregisterPolicyRequest, +}; +use prost::Message; +use tonic::Request; + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +fn test_descriptor(policy_id: &str, mode: &str, rules_json: serde_json::Value) -> PolicyDescriptor { + PolicyDescriptor { + policy_id: policy_id.into(), + mode: mode.into(), + description: format!("test policy {}", policy_id), + rules: serde_json::to_vec(&rules_json).unwrap(), + schema_version: 1, + registered_at_unix_ms: 0, + } +} + +// ── RegisterPolicy / GetPolicy / ListPolicies / UnregisterPolicy ──── + +#[tokio::test] +async fn register_and_get_policy() { + let mut client = common::grpc_client().await; + let agent = "agent://policy-admin"; + let policy_id = format!("policy.test.{}", uuid::Uuid::new_v4().as_hyphenated()); + + let descriptor = test_descriptor( + &policy_id, + "macp.mode.decision.v1", + serde_json::json!({ "voting": { "algorithm": "majority", "threshold": 0.5 } }), + ); + + // Register + let resp = client + .register_policy(with_sender( + agent, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(resp.ok, "register failed: {}", resp.error); + + // Get + let resp = client + .get_policy(with_sender( + agent, + GetPolicyRequest { + policy_id: policy_id.clone(), + }, + )) + .await + .unwrap() + .into_inner(); + let fetched = resp.policy_descriptor.expect("descriptor present"); + assert_eq!(fetched.policy_id, policy_id); + assert_eq!(fetched.mode, "macp.mode.decision.v1"); + assert_eq!(fetched.schema_version, 1); +} + +#[tokio::test] +async fn list_policies_includes_default_and_registered() { + let mut client = common::grpc_client().await; + let agent = "agent://policy-lister"; + let policy_id = format!("policy.list-test.{}", uuid::Uuid::new_v4().as_hyphenated()); + + let descriptor = test_descriptor( + &policy_id, + "macp.mode.decision.v1", + serde_json::json!({ "voting": { "algorithm": "none" } }), + ); + + client + .register_policy(with_sender( + agent, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor), + }, + )) + .await + .unwrap(); + + let resp = client + .list_policies(with_sender( + agent, + ListPoliciesRequest { + mode: String::new(), + }, + )) + .await + .unwrap() + .into_inner(); + + let ids: Vec<&str> = resp + .descriptors + .iter() + .map(|d| d.policy_id.as_str()) + .collect(); + assert!(ids.contains(&"policy.default"), "default policy missing"); + assert!( + ids.contains(&policy_id.as_str()), + "registered policy missing" + ); +} + +#[tokio::test] +async fn list_policies_filters_by_mode() { + let mut client = common::grpc_client().await; + let agent = "agent://policy-filter"; + let policy_id = format!( + "policy.filter-test.{}", + uuid::Uuid::new_v4().as_hyphenated() + ); + + let descriptor = test_descriptor( + &policy_id, + "macp.mode.task.v1", + serde_json::json!({ "completion": { "require_output": true } }), + ); + + client + .register_policy(with_sender( + agent, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor), + }, + )) + .await + .unwrap(); + + // Filter by task mode + let resp = client + .list_policies(with_sender( + agent, + ListPoliciesRequest { + mode: "macp.mode.task.v1".into(), + }, + )) + .await + .unwrap() + .into_inner(); + + let ids: Vec<&str> = resp + .descriptors + .iter() + .map(|d| d.policy_id.as_str()) + .collect(); + assert!(ids.contains(&policy_id.as_str())); + // Default policy (mode="*") should also appear + assert!(ids.contains(&"policy.default")); +} + +#[tokio::test] +async fn unregister_policy_removes_it() { + let mut client = common::grpc_client().await; + let agent = "agent://policy-unregister"; + let policy_id = format!("policy.unreg-test.{}", uuid::Uuid::new_v4().as_hyphenated()); + + let descriptor = test_descriptor( + &policy_id, + "macp.mode.decision.v1", + serde_json::json!({ "voting": { "algorithm": "none" } }), + ); + + client + .register_policy(with_sender( + agent, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor), + }, + )) + .await + .unwrap(); + + // Unregister + let resp = client + .unregister_policy(with_sender( + agent, + UnregisterPolicyRequest { + policy_id: policy_id.clone(), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(resp.ok); + + // GetPolicy should now fail + let err = client + .get_policy(with_sender( + agent, + GetPolicyRequest { + policy_id: policy_id.clone(), + }, + )) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::NotFound); +} + +#[tokio::test] +async fn unregister_default_policy_fails() { + let mut client = common::grpc_client().await; + let agent = "agent://policy-unreg-default"; + + let resp = client + .unregister_policy(with_sender( + agent, + UnregisterPolicyRequest { + policy_id: "policy.default".into(), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(!resp.ok); +} + +#[tokio::test] +async fn register_duplicate_policy_fails() { + let mut client = common::grpc_client().await; + let agent = "agent://policy-dup"; + let policy_id = format!("policy.dup-test.{}", uuid::Uuid::new_v4().as_hyphenated()); + + let descriptor = test_descriptor( + &policy_id, + "macp.mode.decision.v1", + serde_json::json!({ "voting": { "algorithm": "none" } }), + ); + + let resp = client + .register_policy(with_sender( + agent, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor.clone()), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(resp.ok); + + let resp = client + .register_policy(with_sender( + agent, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(!resp.ok, "duplicate registration should fail"); + assert!(resp.error.contains("already registered")); +} + +// ── Unknown policy_version rejection at SessionStart ──────────────── + +#[tokio::test] +async fn unknown_policy_version_rejects_session_start() { + let mut client = common::grpc_client().await; + let session_id = new_session_id(); + let sender = "agent://policy-test-orchestrator"; + + let start_payload = SessionStartPayload { + intent: "test unknown policy".into(), + participants: vec!["agent://participant".into()], + mode_version: MODE_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + policy_version: "policy.nonexistent.v999".into(), + ttl_ms: 60_000, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + + let env = envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &session_id, + sender, + start_payload, + ); + + let ack = send_as(&mut client, sender, env).await.unwrap(); + assert!(!ack.ok, "should reject unknown policy_version"); + assert!( + ack.error.as_ref().map(|e| e.code.as_str()) == Some("UNKNOWN_POLICY_VERSION"), + "error code should be UNKNOWN_POLICY_VERSION, got: {:?}", + ack.error + ); +} + +// ── Policy enforcement: register policy → start session → verify ──── + +#[tokio::test] +async fn policy_enforcement_blocks_commitment_in_decision_mode() { + let mut client = common::grpc_client().await; + let admin = "agent://policy-enforce-admin"; + let orchestrator = "agent://policy-enforce-orchestrator"; + let participant = "agent://policy-enforce-participant"; + let session_id = new_session_id(); + let policy_id = format!("policy.enforce.{}", uuid::Uuid::new_v4().as_hyphenated()); + + // 1. Register a strict policy that requires unanimous voting + let descriptor = test_descriptor( + &policy_id, + "macp.mode.decision.v1", + serde_json::json!({ + "voting": { "algorithm": "unanimous" }, + "commitment": { "require_vote_quorum": false } + }), + ); + let resp = client + .register_policy(with_sender( + admin, + RegisterPolicyRequest { + policy_descriptor: Some(descriptor), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(resp.ok, "policy registration failed: {}", resp.error); + + // 2. Start a decision session bound to that policy + let start_payload = SessionStartPayload { + intent: "test enforcement".into(), + participants: vec![orchestrator.into(), participant.into()], + mode_version: MODE_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + policy_version: policy_id.clone(), + ttl_ms: 60_000, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + orchestrator, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &session_id, + orchestrator, + start_payload, + ), + ) + .await + .unwrap(); + assert!(ack.ok, "SessionStart should be accepted"); + + // 3. Orchestrator proposes + let ack = send_as( + &mut client, + orchestrator, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &session_id, + orchestrator, + proposal_payload("p1", "deploy", "ready to deploy"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // 4. Participant votes REJECT + let ack = send_as( + &mut client, + participant, + envelope( + MODE_DECISION, + "Vote", + &new_message_id(), + &session_id, + participant, + vote_payload("p1", "reject", "not ready"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // 5. Commitment should be DENIED by policy (unanimous requires all approve) + let commit_payload = macp_runtime::pb::CommitmentPayload { + commitment_id: "c1".into(), + action: "decision.selected".into(), + authority_scope: "test".into(), + reason: "bound".into(), + mode_version: MODE_VERSION.into(), + policy_version: policy_id, + configuration_version: CONFIG_VERSION.into(), + outcome_positive: true, + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + orchestrator, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &session_id, + orchestrator, + commit_payload, + ), + ) + .await + .unwrap(); + assert!(!ack.ok, "commitment should be denied by unanimous policy"); + assert!( + ack.error.as_ref().map(|e| e.code.as_str()) == Some("POLICY_DENIED"), + "error code should be POLICY_DENIED, got: {:?}", + ack.error + ); +} + +#[tokio::test] +async fn default_policy_allows_commitment() { + let mut client = common::grpc_client().await; + let orchestrator = "agent://default-pol-orch"; + let participant = "agent://default-pol-part"; + let session_id = new_session_id(); + + // Start session with default policy (policy.default) + let start_payload = + session_start_payload("test default policy", &[orchestrator, participant], 60_000); + let ack = send_as( + &mut client, + orchestrator, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &session_id, + orchestrator, + start_payload, + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Proposal + Vote + Commitment (standard happy path) + let ack = send_as( + &mut client, + orchestrator, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &session_id, + orchestrator, + proposal_payload("p1", "deploy", "go"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + let ack = send_as( + &mut client, + participant, + envelope( + MODE_DECISION, + "Vote", + &new_message_id(), + &session_id, + participant, + vote_payload("p1", "approve", "ok"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + let ack = send_as( + &mut client, + orchestrator, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &session_id, + orchestrator, + commitment_payload("c1", "decision.selected", "test", "bound", true), + ), + ) + .await + .unwrap(); + assert!(ack.ok, "default policy should allow commitment"); +} diff --git a/integration_tests/tests/tier1_protocol/test_proposal_mode.rs b/integration_tests/tests/tier1_protocol/test_proposal_mode.rs index f8bc699..c2de199 100644 --- a/integration_tests/tests/tier1_protocol/test_proposal_mode.rs +++ b/integration_tests/tests/tier1_protocol/test_proposal_mode.rs @@ -103,7 +103,7 @@ async fn proposal_happy_path() { &new_message_id(), &sid, buyer, - commitment_payload("c1", "accept-counter", "negotiation", "both accepted"), + commitment_payload("c1", "accept-counter", "negotiation", "both accepted", true), ), ) .await diff --git a/integration_tests/tests/tier1_protocol/test_quorum_mode.rs b/integration_tests/tests/tier1_protocol/test_quorum_mode.rs index d129b3e..01dc6b1 100644 --- a/integration_tests/tests/tier1_protocol/test_quorum_mode.rs +++ b/integration_tests/tests/tier1_protocol/test_quorum_mode.rs @@ -92,7 +92,7 @@ async fn quorum_happy_path() { &new_message_id(), &sid, requester, - commitment_payload("c1", "deploy-prod", "ops-team", "quorum reached"), + commitment_payload("c1", "deploy-prod", "ops-team", "quorum reached", true), ), ) .await diff --git a/integration_tests/tests/tier1_protocol/test_reject_paths.rs b/integration_tests/tests/tier1_protocol/test_reject_paths.rs index d57077d..c12d790 100644 --- a/integration_tests/tests/tier1_protocol/test_reject_paths.rs +++ b/integration_tests/tests/tier1_protocol/test_reject_paths.rs @@ -14,22 +14,52 @@ async fn decision_commitment_from_non_initiator_rejected() { let voter = "agent://voter"; // Start session (coord is initiator) - send_as(&mut client, coord, envelope( - MODE_DECISION, "SessionStart", &new_message_id(), &sid, coord, - session_start_payload("test", &[coord, voter], 30_000), - )).await.unwrap(); + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("test", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); // Proposal from coordinator - send_as(&mut client, coord, envelope( - MODE_DECISION, "Proposal", &new_message_id(), &sid, coord, - proposal_payload("p1", "option-A", "test"), - )).await.unwrap(); + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-A", "test"), + ), + ) + .await + .unwrap(); // Voter tries to Commit — only initiator can commit per RFC-MACP-0007 - let ack = send_as(&mut client, voter, envelope( - MODE_DECISION, "Commitment", &new_message_id(), &sid, voter, - commitment_payload("c1", "option-A", "team", "unauthorized"), - )).await.unwrap(); + let ack = send_as( + &mut client, + voter, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid, + voter, + commitment_payload("c1", "option-A", "team", "unauthorized", true), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "Non-initiator Commitment must be rejected"); } @@ -43,22 +73,52 @@ async fn proposal_commitment_without_accept_rejected() { let seller = "agent://seller"; // Start session - send_as(&mut client, buyer, envelope( - MODE_PROPOSAL, "SessionStart", &new_message_id(), &sid, buyer, - session_start_payload("negotiate", &[buyer, seller], 30_000), - )).await.unwrap(); + send_as( + &mut client, + buyer, + envelope( + MODE_PROPOSAL, + "SessionStart", + &new_message_id(), + &sid, + buyer, + session_start_payload("negotiate", &[buyer, seller], 30_000), + ), + ) + .await + .unwrap(); // Proposal from seller (no Accept yet) - send_as(&mut client, seller, envelope( - MODE_PROPOSAL, "Proposal", &new_message_id(), &sid, seller, - proposal_mode_payload("prop-1", "Offer", "$100"), - )).await.unwrap(); + send_as( + &mut client, + seller, + envelope( + MODE_PROPOSAL, + "Proposal", + &new_message_id(), + &sid, + seller, + proposal_mode_payload("prop-1", "Offer", "$100"), + ), + ) + .await + .unwrap(); // Buyer tries to Commit without any Accept — should be rejected - let ack = send_as(&mut client, buyer, envelope( - MODE_PROPOSAL, "Commitment", &new_message_id(), &sid, buyer, - commitment_payload("c1", "accept-offer", "negotiation", "premature"), - )).await.unwrap(); + let ack = send_as( + &mut client, + buyer, + envelope( + MODE_PROPOSAL, + "Commitment", + &new_message_id(), + &sid, + buyer, + commitment_payload("c1", "accept-offer", "negotiation", "premature", true), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "Commitment without Accept must be rejected"); } @@ -72,16 +132,36 @@ async fn task_request_from_non_initiator_rejected() { let worker = "agent://worker"; // Start session (planner is initiator) - send_as(&mut client, planner, envelope( - MODE_TASK, "SessionStart", &new_message_id(), &sid, planner, - session_start_payload("delegate", &[planner, worker], 30_000), - )).await.unwrap(); + send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "SessionStart", + &new_message_id(), + &sid, + planner, + session_start_payload("delegate", &[planner, worker], 30_000), + ), + ) + .await + .unwrap(); // Worker tries to send TaskRequest — only initiator can per RFC-MACP-0009 - let ack = send_as(&mut client, worker, envelope( - MODE_TASK, "TaskRequest", &new_message_id(), &sid, worker, - task_request_payload("t1", "Sneaky task", "unauthorized", planner), - )).await.unwrap(); + let ack = send_as( + &mut client, + worker, + envelope( + MODE_TASK, + "TaskRequest", + &new_message_id(), + &sid, + worker, + task_request_payload("t1", "Sneaky task", "unauthorized", planner), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "TaskRequest from non-initiator must be rejected"); } @@ -92,23 +172,53 @@ async fn task_duplicate_task_id_rejected() { let planner = "agent://planner"; let worker = "agent://worker"; - send_as(&mut client, planner, envelope( - MODE_TASK, "SessionStart", &new_message_id(), &sid, planner, - session_start_payload("delegate", &[planner, worker], 30_000), - )).await.unwrap(); + send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "SessionStart", + &new_message_id(), + &sid, + planner, + session_start_payload("delegate", &[planner, worker], 30_000), + ), + ) + .await + .unwrap(); // First TaskRequest succeeds - let ack = send_as(&mut client, planner, envelope( - MODE_TASK, "TaskRequest", &new_message_id(), &sid, planner, - task_request_payload("t1", "First task", "do something", worker), - )).await.unwrap(); + let ack = send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "TaskRequest", + &new_message_id(), + &sid, + planner, + task_request_payload("t1", "First task", "do something", worker), + ), + ) + .await + .unwrap(); assert!(ack.ok); // Second TaskRequest with same task_id — should be rejected (one request per session) - let ack = send_as(&mut client, planner, envelope( - MODE_TASK, "TaskRequest", &new_message_id(), &sid, planner, - task_request_payload("t1", "Duplicate task", "do again", worker), - )).await.unwrap(); + let ack = send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "TaskRequest", + &new_message_id(), + &sid, + planner, + task_request_payload("t1", "Duplicate task", "do again", worker), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "Duplicate TaskRequest must be rejected"); } @@ -121,17 +231,40 @@ async fn handoff_accept_without_offer_rejected() { let source = "agent://source"; let target = "agent://target"; - send_as(&mut client, source, envelope( - MODE_HANDOFF, "SessionStart", &new_message_id(), &sid, source, - session_start_payload("handoff", &[source, target], 30_000), - )).await.unwrap(); + send_as( + &mut client, + source, + envelope( + MODE_HANDOFF, + "SessionStart", + &new_message_id(), + &sid, + source, + session_start_payload("handoff", &[source, target], 30_000), + ), + ) + .await + .unwrap(); // Target tries to Accept without any HandoffOffer — should be rejected - let ack = send_as(&mut client, target, envelope( - MODE_HANDOFF, "HandoffAccept", &new_message_id(), &sid, target, - handoff_accept_payload("h1", target, "premature accept"), - )).await.unwrap(); - assert!(!ack.ok, "HandoffAccept without prior HandoffOffer must be rejected"); + let ack = send_as( + &mut client, + target, + envelope( + MODE_HANDOFF, + "HandoffAccept", + &new_message_id(), + &sid, + target, + handoff_accept_payload("h1", target, "premature accept"), + ), + ) + .await + .unwrap(); + assert!( + !ack.ok, + "HandoffAccept without prior HandoffOffer must be rejected" + ); } // ── Quorum Mode ───────────────────────────────────────────────────────── @@ -143,16 +276,36 @@ async fn quorum_approve_before_request_rejected() { let requester = "agent://requester"; let approver = "agent://approver"; - send_as(&mut client, requester, envelope( - MODE_QUORUM, "SessionStart", &new_message_id(), &sid, requester, - session_start_payload("approve", &[requester, approver], 30_000), - )).await.unwrap(); + send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "SessionStart", + &new_message_id(), + &sid, + requester, + session_start_payload("approve", &[requester, approver], 30_000), + ), + ) + .await + .unwrap(); // Approver tries to Approve without any ApprovalRequest — should be rejected - let ack = send_as(&mut client, approver, envelope( - MODE_QUORUM, "Approve", &new_message_id(), &sid, approver, - approve_payload("r1", "premature approve"), - )).await.unwrap(); + let ack = send_as( + &mut client, + approver, + envelope( + MODE_QUORUM, + "Approve", + &new_message_id(), + &sid, + approver, + approve_payload("r1", "premature approve"), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "Approve before ApprovalRequest must be rejected"); } @@ -164,28 +317,68 @@ async fn quorum_commitment_before_quorum_reached_rejected() { let approver1 = "agent://approver1"; let approver2 = "agent://approver2"; - send_as(&mut client, requester, envelope( - MODE_QUORUM, "SessionStart", &new_message_id(), &sid, requester, - session_start_payload("approve", &[requester, approver1, approver2], 30_000), - )).await.unwrap(); + send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "SessionStart", + &new_message_id(), + &sid, + requester, + session_start_payload("approve", &[requester, approver1, approver2], 30_000), + ), + ) + .await + .unwrap(); // ApprovalRequest needs 2 approvals - send_as(&mut client, requester, envelope( - MODE_QUORUM, "ApprovalRequest", &new_message_id(), &sid, requester, - approval_request_payload("r1", "deploy", "Deploy v2", 2), - )).await.unwrap(); + send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "ApprovalRequest", + &new_message_id(), + &sid, + requester, + approval_request_payload("r1", "deploy", "Deploy v2", 2), + ), + ) + .await + .unwrap(); // Only 1 approval (quorum not reached) - send_as(&mut client, approver1, envelope( - MODE_QUORUM, "Approve", &new_message_id(), &sid, approver1, - approve_payload("r1", "LGTM"), - )).await.unwrap(); + send_as( + &mut client, + approver1, + envelope( + MODE_QUORUM, + "Approve", + &new_message_id(), + &sid, + approver1, + approve_payload("r1", "LGTM"), + ), + ) + .await + .unwrap(); // Requester tries to Commit before quorum — should be rejected - let ack = send_as(&mut client, requester, envelope( - MODE_QUORUM, "Commitment", &new_message_id(), &sid, requester, - commitment_payload("c1", "deploy", "ops", "premature"), - )).await.unwrap(); + let ack = send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "Commitment", + &new_message_id(), + &sid, + requester, + commitment_payload("c1", "deploy", "ops", "premature", true), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "Commitment before quorum reached must be rejected"); } @@ -198,26 +391,66 @@ async fn multi_round_commitment_before_convergence_rejected() { let agent_a = "agent://agent-a"; let agent_b = "agent://agent-b"; - send_as(&mut client, agent_a, envelope( - MODE_MULTI_ROUND, "SessionStart", &new_message_id(), &sid, agent_a, - session_start_payload("converge", &[agent_a, agent_b], 30_000), - )).await.unwrap(); + send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "SessionStart", + &new_message_id(), + &sid, + agent_a, + session_start_payload("converge", &[agent_a, agent_b], 30_000), + ), + ) + .await + .unwrap(); // Divergent contributions (not converged) - send_as(&mut client, agent_a, envelope( - MODE_MULTI_ROUND, "Contribute", &new_message_id(), &sid, agent_a, - serde_json::to_vec(&serde_json::json!({"value": "alpha"})).unwrap(), - )).await.unwrap(); - - send_as(&mut client, agent_b, envelope( - MODE_MULTI_ROUND, "Contribute", &new_message_id(), &sid, agent_b, - serde_json::to_vec(&serde_json::json!({"value": "beta"})).unwrap(), - )).await.unwrap(); + send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "Contribute", + &new_message_id(), + &sid, + agent_a, + serde_json::to_vec(&serde_json::json!({"value": "alpha"})).unwrap(), + ), + ) + .await + .unwrap(); + + send_as( + &mut client, + agent_b, + envelope( + MODE_MULTI_ROUND, + "Contribute", + &new_message_id(), + &sid, + agent_b, + serde_json::to_vec(&serde_json::json!({"value": "beta"})).unwrap(), + ), + ) + .await + .unwrap(); // Try to Commit before convergence — should be rejected - let ack = send_as(&mut client, agent_a, envelope( - MODE_MULTI_ROUND, "Commitment", &new_message_id(), &sid, agent_a, - commitment_payload("c1", "premature", "group", "not converged"), - )).await.unwrap(); + let ack = send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "Commitment", + &new_message_id(), + &sid, + agent_a, + commitment_payload("c1", "premature", "group", "not converged", true), + ), + ) + .await + .unwrap(); assert!(!ack.ok, "Commitment before convergence must be rejected"); } diff --git a/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs b/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs index 12f936d..6a79cf7 100644 --- a/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs +++ b/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs @@ -5,8 +5,8 @@ use crate::common; use macp_integration_tests::helpers::*; use macp_runtime::pb::{ - CommitmentPayload, Envelope, GetManifestRequest, InitializeRequest, SendRequest, - SignalPayload, WatchSignalsRequest, + CommitmentPayload, Envelope, GetManifestRequest, InitializeRequest, SendRequest, SignalPayload, + WatchSignalsRequest, }; use prost::Message; use tonic::Request; @@ -62,10 +62,10 @@ async fn signal_with_empty_session_and_mode_accepted() { let mid = new_message_id(); let env = Envelope { macp_version: "1.0".into(), - mode: String::new(), // empty — required for Signals + mode: String::new(), // empty — required for Signals message_type: "Signal".into(), message_id: mid.clone(), - session_id: String::new(), // empty — required for Signals + session_id: String::new(), // empty — required for Signals sender: String::new(), timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), payload: signal_payload.encode_to_vec(), @@ -106,11 +106,18 @@ async fn signal_with_empty_session_and_mode_accepted() { .into_inner(); let ack = resp.ack.expect("ack present"); - let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or("(none)"); + let err_code = ack + .error + .as_ref() + .map(|e| e.code.as_str()) + .unwrap_or("(none)"); eprintln!(" Runtime Response:"); eprintln!(" ack.ok: {}", ack.ok); eprintln!(" ack.duplicate: {}", ack.duplicate); - eprintln!(" ack.session_state: {} (no session affected)", ack.session_state); + eprintln!( + " ack.session_state: {} (no session affected)", + ack.session_state + ); eprintln!(" ack.error: {err_code}"); eprintln!(); @@ -126,8 +133,14 @@ async fn signal_with_empty_session_and_mode_accepted() { eprintln!(" Session history was NOT modified. Session state unchanged."); eprintln!("─────────────────────────────────────────────────────────────"); - assert!(ack.ok, "Signal with empty session_id and mode should be accepted"); - assert_eq!(meta.state, 1, "Session should remain OPEN — Signal must not mutate state"); + assert!( + ack.ok, + "Signal with empty session_id and mode should be accepted" + ); + assert_eq!( + meta.state, 1, + "Session should remain OPEN — Signal must not mutate state" + ); } #[tokio::test] @@ -168,7 +181,11 @@ async fn signal_with_non_empty_session_rejected() { .into_inner(); let ack = resp.ack.expect("ack present"); - let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or("(none)"); + let err_code = ack + .error + .as_ref() + .map(|e| e.code.as_str()) + .unwrap_or("(none)"); eprintln!(" Response:"); eprintln!(" ack.ok: {}", ack.ok); eprintln!(" ack.error: {err_code}"); @@ -217,7 +234,11 @@ async fn signal_with_non_empty_mode_rejected() { .into_inner(); let ack = resp.ack.expect("ack present"); - let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or("(none)"); + let err_code = ack + .error + .as_ref() + .map(|e| e.code.as_str()) + .unwrap_or("(none)"); eprintln!(" Response:"); eprintln!(" ack.ok: {}", ack.ok); eprintln!(" ack.error: {err_code}"); @@ -249,7 +270,11 @@ async fn watch_signals_receives_broadcast_signal() { &new_message_id(), &sid, "agent://coordinator", - session_start_payload("signal demo", &["agent://coordinator", "agent://worker"], 30_000), + session_start_payload( + "signal demo", + &["agent://coordinator", "agent://worker"], + 30_000, + ), ), ) .await @@ -305,14 +330,11 @@ async fn watch_signals_receives_broadcast_signal() { eprintln!(" Send RPC ack: ok={}", ack.ok); // Agent A receives the Signal on the WatchSignals stream - let received = tokio::time::timeout( - std::time::Duration::from_secs(5), - signal_stream.message(), - ) - .await - .expect("should not timeout") - .expect("stream should not error") - .expect("should receive a message"); + let received = tokio::time::timeout(std::time::Duration::from_secs(5), signal_stream.message()) + .await + .expect("should not timeout") + .expect("stream should not error") + .expect("should receive a message"); let received_env = received.envelope.expect("envelope present"); let received_payload = @@ -321,15 +343,27 @@ async fn watch_signals_receives_broadcast_signal() { eprintln!(); eprintln!(" Agent A received Signal on WatchSignals stream:"); eprintln!(" sender: \"{}\"", received_env.sender); - eprintln!(" message_type: \"{}\"", received_env.message_type); - eprintln!(" message_id: \"{}\"", received_env.message_id); + eprintln!( + " message_type: \"{}\"", + received_env.message_type + ); + eprintln!( + " message_id: \"{}\"", + received_env.message_id + ); eprintln!(" SignalPayload:"); - eprintln!(" signal_type: \"{}\"", received_payload.signal_type); + eprintln!( + " signal_type: \"{}\"", + received_payload.signal_type + ); eprintln!( " data: \"{}\"", String::from_utf8_lossy(&received_payload.data) ); - eprintln!(" confidence: {}", received_payload.confidence); + eprintln!( + " confidence: {}", + received_payload.confidence + ); eprintln!( " correlation_session: \"{}\"", received_payload.correlation_session_id @@ -400,6 +434,7 @@ async fn commitment_with_wrong_mode_version_rejected() { mode_version: "2.0.0".into(), // WRONG — session bound "1.0.0" policy_version: POLICY_VERSION.into(), configuration_version: CONFIG_VERSION.into(), + outcome_positive: true, } .encode_to_vec(); @@ -469,6 +504,7 @@ async fn commitment_with_wrong_config_version_rejected() { mode_version: MODE_VERSION.into(), policy_version: POLICY_VERSION.into(), configuration_version: "wrong-config-version".into(), // WRONG + outcome_positive: true, } .encode_to_vec(); @@ -567,7 +603,10 @@ async fn rejected_message_does_not_consume_dedup_slot() { ) .await .unwrap(); - assert!(ack.ok, "Reused message_id from rejected message should be accepted"); + assert!( + ack.ok, + "Reused message_id from rejected message should be accepted" + ); assert!(!ack.duplicate, "Should NOT be flagged as duplicate"); } @@ -610,7 +649,10 @@ async fn duplicate_session_start_same_session_id_rejected() { ) .await .unwrap(); - assert!(!ack.ok, "Duplicate SessionStart for same session_id must be rejected"); + assert!( + !ack.ok, + "Duplicate SessionStart for same session_id must be rejected" + ); } // ── CancelSession Authorization (RFC-MACP-0001 §7.3) ─────────────────── @@ -669,7 +711,10 @@ async fn get_manifest_returns_all_modes_including_extensions() { let manifest = resp.manifest.expect("manifest present"); assert!(!manifest.agent_id.is_empty(), "agent_id should be set"); - assert!(!manifest.description.is_empty(), "description should be set"); + assert!( + !manifest.description.is_empty(), + "description should be set" + ); // Should include all 5 standard modes + extensions assert!( @@ -677,8 +722,12 @@ async fn get_manifest_returns_all_modes_including_extensions() { "GetManifest should include standard + extension modes, got {}", manifest.supported_modes.len() ); - assert!(manifest.supported_modes.contains(&"macp.mode.decision.v1".to_string())); - assert!(manifest.supported_modes.contains(&"ext.multi_round.v1".to_string())); + assert!(manifest + .supported_modes + .contains(&"macp.mode.decision.v1".to_string())); + assert!(manifest + .supported_modes + .contains(&"ext.multi_round.v1".to_string())); } #[tokio::test] @@ -693,7 +742,10 @@ async fn initialize_rejects_unsupported_protocol_version() { }) .await; - assert!(result.is_err(), "Initialize with unsupported version must fail"); + assert!( + result.is_err(), + "Initialize with unsupported version must fail" + ); let status = result.unwrap_err(); assert_eq!( status.code(), diff --git a/integration_tests/tests/tier1_protocol/test_task_mode.rs b/integration_tests/tests/tier1_protocol/test_task_mode.rs index 3df2672..50611cd 100644 --- a/integration_tests/tests/tier1_protocol/test_task_mode.rs +++ b/integration_tests/tests/tier1_protocol/test_task_mode.rs @@ -103,7 +103,7 @@ async fn task_happy_path() { &new_message_id(), &sid, planner, - commitment_payload("c1", "task-completed", "planner", "worker delivered"), + commitment_payload("c1", "task-completed", "planner", "worker delivered", true), ), ) .await diff --git a/integration_tests/tests/tier2_agents/mod.rs b/integration_tests/tests/tier2_agents/mod.rs index 49fe6c1..d1d4243 100644 --- a/integration_tests/tests/tier2_agents/mod.rs +++ b/integration_tests/tests/tier2_agents/mod.rs @@ -1,5 +1,5 @@ mod test_decision_agent; -mod test_task_delegation; -mod test_proposal_negotiation; mod test_handoff_agent; mod test_multi_agent; +mod test_proposal_negotiation; +mod test_task_delegation; diff --git a/integration_tests/tests/tier2_agents/test_decision_agent.rs b/integration_tests/tests/tier2_agents/test_decision_agent.rs index 3872dff..eba6456 100644 --- a/integration_tests/tests/tier2_agents/test_decision_agent.rs +++ b/integration_tests/tests/tier2_agents/test_decision_agent.rs @@ -1,6 +1,8 @@ use crate::common; use macp_integration_tests::helpers::*; -use macp_integration_tests::macp_tools::{self, decision::*, commit::*, query::*, session_start::*}; +use macp_integration_tests::macp_tools::{ + self, commit::*, decision::*, query::*, session_start::*, +}; use rig::tool::ToolSet; /// Two Rig tool-equipped agents drive a full decision lifecycle. @@ -14,14 +16,30 @@ async fn rig_tools_drive_decision_lifecycle() { // Build coordinator's toolset let mut coord_tools = ToolSet::default(); - coord_tools.add_tool(StartSessionTool { client: coord_client.clone(), agent_id: "agent://coord".into() }); - coord_tools.add_tool(ProposeTool { client: coord_client.clone(), agent_id: "agent://coord".into() }); - coord_tools.add_tool(CommitTool { client: coord_client.clone(), agent_id: "agent://coord".into(), mode: MODE_DECISION.into() }); - coord_tools.add_tool(GetSessionTool { client: coord_client.clone(), agent_id: "agent://coord".into() }); + coord_tools.add_tool(StartSessionTool { + client: coord_client.clone(), + agent_id: "agent://coord".into(), + }); + coord_tools.add_tool(ProposeTool { + client: coord_client.clone(), + agent_id: "agent://coord".into(), + }); + coord_tools.add_tool(CommitTool { + client: coord_client.clone(), + agent_id: "agent://coord".into(), + mode: MODE_DECISION.into(), + }); + coord_tools.add_tool(GetSessionTool { + client: coord_client.clone(), + agent_id: "agent://coord".into(), + }); // Build voter's toolset let mut voter_tools = ToolSet::default(); - voter_tools.add_tool(VoteTool { client: voter_client.clone(), agent_id: "agent://voter".into() }); + voter_tools.add_tool(VoteTool { + client: voter_client.clone(), + agent_id: "agent://voter".into(), + }); // Verify tool definitions are valid let defs = coord_tools.get_tool_definitions().await.unwrap(); @@ -32,51 +50,86 @@ async fn rig_tools_drive_decision_lifecycle() { assert!(defs.iter().any(|d| d.name == "macp_get_session")); // Step 1: Coordinator starts session (simulating LLM tool call) - let result = coord_tools.call("macp_start_session", serde_json::json!({ - "mode": MODE_DECISION, - "session_id": &sid, - "intent": "choose deployment strategy", - "participants": ["agent://coord", "agent://voter"], - "ttl_ms": 30000 - }).to_string()).await.unwrap(); + let result = coord_tools + .call( + "macp_start_session", + serde_json::json!({ + "mode": MODE_DECISION, + "session_id": &sid, + "intent": "choose deployment strategy", + "participants": ["agent://coord", "agent://voter"], + "ttl_ms": 30000 + }) + .to_string(), + ) + .await + .unwrap(); let result: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(result["ok"], true); // Step 2: Coordinator proposes - let result = coord_tools.call("macp_propose", serde_json::json!({ - "session_id": &sid, - "proposal_id": "p1", - "option": "deploy-v2", - "rationale": "better performance and reliability" - }).to_string()).await.unwrap(); + let result = coord_tools + .call( + "macp_propose", + serde_json::json!({ + "session_id": &sid, + "proposal_id": "p1", + "option": "deploy-v2", + "rationale": "better performance and reliability" + }) + .to_string(), + ) + .await + .unwrap(); let result: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(result["ok"], true); // Step 3: Voter votes - let result = voter_tools.call("macp_vote", serde_json::json!({ - "session_id": &sid, - "proposal_id": "p1", - "vote": "approve", - "reason": "looks good to me" - }).to_string()).await.unwrap(); + let result = voter_tools + .call( + "macp_vote", + serde_json::json!({ + "session_id": &sid, + "proposal_id": "p1", + "vote": "approve", + "reason": "looks good to me" + }) + .to_string(), + ) + .await + .unwrap(); let result: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(result["ok"], true); // Step 4: Coordinator commits - let result = coord_tools.call("macp_commit", serde_json::json!({ - "session_id": &sid, - "action": "deploy-v2", - "authority_scope": "team", - "reason": "voter approved" - }).to_string()).await.unwrap(); + let result = coord_tools + .call( + "macp_commit", + serde_json::json!({ + "session_id": &sid, + "action": "deploy-v2", + "authority_scope": "team", + "reason": "voter approved" + }) + .to_string(), + ) + .await + .unwrap(); let result: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(result["ok"], true); assert_eq!(result["session_state"], 2); // RESOLVED // Step 5: Coordinator checks session state - let result = coord_tools.call("macp_get_session", serde_json::json!({ - "session_id": &sid - }).to_string()).await.unwrap(); + let result = coord_tools + .call( + "macp_get_session", + serde_json::json!({ + "session_id": &sid + }) + .to_string(), + ) + .await + .unwrap(); let result: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(result["state"], 2); // RESOLVED } diff --git a/integration_tests/tests/tier2_agents/test_handoff_agent.rs b/integration_tests/tests/tier2_agents/test_handoff_agent.rs index ed44504..3859eab 100644 --- a/integration_tests/tests/tier2_agents/test_handoff_agent.rs +++ b/integration_tests/tests/tier2_agents/test_handoff_agent.rs @@ -12,12 +12,25 @@ async fn rig_tools_drive_handoff() { let sid = new_session_id(); let mut source_tools = ToolSet::default(); - source_tools.add_tool(StartSessionTool { client: source_client.clone(), agent_id: "agent://source".into() }); - source_tools.add_tool(HandoffOfferTool { client: source_client.clone(), agent_id: "agent://source".into() }); - source_tools.add_tool(CommitTool { client: source_client.clone(), agent_id: "agent://source".into(), mode: MODE_HANDOFF.into() }); + source_tools.add_tool(StartSessionTool { + client: source_client.clone(), + agent_id: "agent://source".into(), + }); + source_tools.add_tool(HandoffOfferTool { + client: source_client.clone(), + agent_id: "agent://source".into(), + }); + source_tools.add_tool(CommitTool { + client: source_client.clone(), + agent_id: "agent://source".into(), + mode: MODE_HANDOFF.into(), + }); let mut target_tools = ToolSet::default(); - target_tools.add_tool(HandoffAcceptTool { client: target_client.clone(), agent_id: "agent://target".into() }); + target_tools.add_tool(HandoffAcceptTool { + client: target_client.clone(), + agent_id: "agent://target".into(), + }); // Source starts session let r = source_tools.call("macp_start_session", serde_json::json!({ @@ -25,27 +38,54 @@ async fn rig_tools_drive_handoff() { "intent": "escalate customer issue", "participants": ["agent://source", "agent://target"], "ttl_ms": 30000 }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Source offers handoff - let r = source_tools.call("macp_handoff_offer", serde_json::json!({ - "session_id": &sid, "handoff_id": "h1", - "target": "agent://target", "scope": "customer-support", - "reason": "needs specialist" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = source_tools + .call( + "macp_handoff_offer", + serde_json::json!({ + "session_id": &sid, "handoff_id": "h1", + "target": "agent://target", "scope": "customer-support", + "reason": "needs specialist" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Target accepts - let r = target_tools.call("macp_handoff_accept", serde_json::json!({ - "session_id": &sid, "handoff_id": "h1", "reason": "ready to assist" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = target_tools + .call( + "macp_handoff_accept", + serde_json::json!({ + "session_id": &sid, "handoff_id": "h1", "reason": "ready to assist" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Source commits - let r = source_tools.call("macp_commit", serde_json::json!({ - "session_id": &sid, "action": "handoff-complete", - "authority_scope": "support", "reason": "transferred" - }).to_string()).await.unwrap(); + let r = source_tools + .call( + "macp_commit", + serde_json::json!({ + "session_id": &sid, "action": "handoff-complete", + "authority_scope": "support", "reason": "transferred" + }) + .to_string(), + ) + .await + .unwrap(); let v: serde_json::Value = serde_json::from_str(&r).unwrap(); assert!(v["ok"].as_bool().unwrap()); assert_eq!(v["session_state"], 2); diff --git a/integration_tests/tests/tier2_agents/test_multi_agent.rs b/integration_tests/tests/tier2_agents/test_multi_agent.rs index 02d616f..3728029 100644 --- a/integration_tests/tests/tier2_agents/test_multi_agent.rs +++ b/integration_tests/tests/tier2_agents/test_multi_agent.rs @@ -13,50 +13,109 @@ async fn rig_tools_drive_quorum_approval() { let sid = new_session_id(); let mut req_tools = ToolSet::default(); - req_tools.add_tool(StartSessionTool { client: req_client.clone(), agent_id: "agent://requester".into() }); - req_tools.add_tool(ApprovalRequestTool { client: req_client.clone(), agent_id: "agent://requester".into() }); - req_tools.add_tool(CommitTool { client: req_client.clone(), agent_id: "agent://requester".into(), mode: MODE_QUORUM.into() }); + req_tools.add_tool(StartSessionTool { + client: req_client.clone(), + agent_id: "agent://requester".into(), + }); + req_tools.add_tool(ApprovalRequestTool { + client: req_client.clone(), + agent_id: "agent://requester".into(), + }); + req_tools.add_tool(CommitTool { + client: req_client.clone(), + agent_id: "agent://requester".into(), + mode: MODE_QUORUM.into(), + }); let mut a1_tools = ToolSet::default(); - a1_tools.add_tool(ApproveTool { client: a1_client.clone(), agent_id: "agent://approver1".into() }); + a1_tools.add_tool(ApproveTool { + client: a1_client.clone(), + agent_id: "agent://approver1".into(), + }); let mut a2_tools = ToolSet::default(); - a2_tools.add_tool(ApproveTool { client: a2_client.clone(), agent_id: "agent://approver2".into() }); + a2_tools.add_tool(ApproveTool { + client: a2_client.clone(), + agent_id: "agent://approver2".into(), + }); // Requester starts session - let r = req_tools.call("macp_start_session", serde_json::json!({ - "mode": MODE_QUORUM, "session_id": &sid, - "intent": "approve production deploy", - "participants": ["agent://requester", "agent://approver1", "agent://approver2"], - "ttl_ms": 30000 - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = req_tools + .call( + "macp_start_session", + serde_json::json!({ + "mode": MODE_QUORUM, "session_id": &sid, + "intent": "approve production deploy", + "participants": ["agent://requester", "agent://approver1", "agent://approver2"], + "ttl_ms": 30000 + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Requester submits approval request (need 2 approvals) - let r = req_tools.call("macp_approval_request", serde_json::json!({ - "session_id": &sid, "request_id": "r1", - "action": "deploy-prod", "summary": "Deploy v2 to production", - "required_approvals": 2 - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = req_tools + .call( + "macp_approval_request", + serde_json::json!({ + "session_id": &sid, "request_id": "r1", + "action": "deploy-prod", "summary": "Deploy v2 to production", + "required_approvals": 2 + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Approver 1 approves - let r = a1_tools.call("macp_approve", serde_json::json!({ - "session_id": &sid, "request_id": "r1", "reason": "LGTM" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = a1_tools + .call( + "macp_approve", + serde_json::json!({ + "session_id": &sid, "request_id": "r1", "reason": "LGTM" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Approver 2 approves (quorum met) - let r = a2_tools.call("macp_approve", serde_json::json!({ - "session_id": &sid, "request_id": "r1", "reason": "Approved" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = a2_tools + .call( + "macp_approve", + serde_json::json!({ + "session_id": &sid, "request_id": "r1", "reason": "Approved" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Requester commits - let r = req_tools.call("macp_commit", serde_json::json!({ - "session_id": &sid, "action": "deploy-prod", - "authority_scope": "ops-team", "reason": "quorum reached" - }).to_string()).await.unwrap(); + let r = req_tools + .call( + "macp_commit", + serde_json::json!({ + "session_id": &sid, "action": "deploy-prod", + "authority_scope": "ops-team", "reason": "quorum reached" + }) + .to_string(), + ) + .await + .unwrap(); let v: serde_json::Value = serde_json::from_str(&r).unwrap(); assert!(v["ok"].as_bool().unwrap()); assert_eq!(v["session_state"], 2); diff --git a/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs b/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs index 14c4502..7156a20 100644 --- a/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs +++ b/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs @@ -12,45 +12,104 @@ async fn rig_tools_drive_proposal_negotiation() { let sid = new_session_id(); let mut buyer_tools = ToolSet::default(); - buyer_tools.add_tool(StartSessionTool { client: buyer_client.clone(), agent_id: "agent://buyer".into() }); - buyer_tools.add_tool(AcceptProposalTool { client: buyer_client.clone(), agent_id: "agent://buyer".into() }); - buyer_tools.add_tool(CommitTool { client: buyer_client.clone(), agent_id: "agent://buyer".into(), mode: MODE_PROPOSAL.into() }); + buyer_tools.add_tool(StartSessionTool { + client: buyer_client.clone(), + agent_id: "agent://buyer".into(), + }); + buyer_tools.add_tool(AcceptProposalTool { + client: buyer_client.clone(), + agent_id: "agent://buyer".into(), + }); + buyer_tools.add_tool(CommitTool { + client: buyer_client.clone(), + agent_id: "agent://buyer".into(), + mode: MODE_PROPOSAL.into(), + }); let mut seller_tools = ToolSet::default(); - seller_tools.add_tool(SubmitProposalTool { client: seller_client.clone(), agent_id: "agent://seller".into() }); - seller_tools.add_tool(AcceptProposalTool { client: seller_client.clone(), agent_id: "agent://seller".into() }); + seller_tools.add_tool(SubmitProposalTool { + client: seller_client.clone(), + agent_id: "agent://seller".into(), + }); + seller_tools.add_tool(AcceptProposalTool { + client: seller_client.clone(), + agent_id: "agent://seller".into(), + }); // Buyer starts session - let r = buyer_tools.call("macp_start_session", serde_json::json!({ - "mode": MODE_PROPOSAL, "session_id": &sid, - "intent": "negotiate price", "participants": ["agent://buyer", "agent://seller"], - "ttl_ms": 30000 - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = buyer_tools + .call( + "macp_start_session", + serde_json::json!({ + "mode": MODE_PROPOSAL, "session_id": &sid, + "intent": "negotiate price", "participants": ["agent://buyer", "agent://seller"], + "ttl_ms": 30000 + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Seller proposes - let r = seller_tools.call("macp_submit_proposal", serde_json::json!({ - "session_id": &sid, "proposal_id": "prop-1", - "title": "Initial offer", "summary": "$100 per unit" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = seller_tools + .call( + "macp_submit_proposal", + serde_json::json!({ + "session_id": &sid, "proposal_id": "prop-1", + "title": "Initial offer", "summary": "$100 per unit" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Both accept - let r = seller_tools.call("macp_accept_proposal", serde_json::json!({ - "session_id": &sid, "proposal_id": "prop-1", "reason": "fair price" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = seller_tools + .call( + "macp_accept_proposal", + serde_json::json!({ + "session_id": &sid, "proposal_id": "prop-1", "reason": "fair price" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); - let r = buyer_tools.call("macp_accept_proposal", serde_json::json!({ - "session_id": &sid, "proposal_id": "prop-1", "reason": "agreed" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = buyer_tools + .call( + "macp_accept_proposal", + serde_json::json!({ + "session_id": &sid, "proposal_id": "prop-1", "reason": "agreed" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Buyer commits - let r = buyer_tools.call("macp_commit", serde_json::json!({ - "session_id": &sid, "action": "accept-offer", - "authority_scope": "negotiation", "reason": "both accepted" - }).to_string()).await.unwrap(); + let r = buyer_tools + .call( + "macp_commit", + serde_json::json!({ + "session_id": &sid, "action": "accept-offer", + "authority_scope": "negotiation", "reason": "both accepted" + }) + .to_string(), + ) + .await + .unwrap(); let v: serde_json::Value = serde_json::from_str(&r).unwrap(); assert!(v["ok"].as_bool().unwrap()); assert_eq!(v["session_state"], 2); diff --git a/integration_tests/tests/tier2_agents/test_task_delegation.rs b/integration_tests/tests/tier2_agents/test_task_delegation.rs index 0de9f9c..e75d226 100644 --- a/integration_tests/tests/tier2_agents/test_task_delegation.rs +++ b/integration_tests/tests/tier2_agents/test_task_delegation.rs @@ -12,46 +12,105 @@ async fn rig_tools_drive_task_delegation() { let sid = new_session_id(); let mut planner_tools = ToolSet::default(); - planner_tools.add_tool(StartSessionTool { client: planner_client.clone(), agent_id: "agent://planner".into() }); - planner_tools.add_tool(TaskRequestTool { client: planner_client.clone(), agent_id: "agent://planner".into() }); - planner_tools.add_tool(CommitTool { client: planner_client.clone(), agent_id: "agent://planner".into(), mode: MODE_TASK.into() }); + planner_tools.add_tool(StartSessionTool { + client: planner_client.clone(), + agent_id: "agent://planner".into(), + }); + planner_tools.add_tool(TaskRequestTool { + client: planner_client.clone(), + agent_id: "agent://planner".into(), + }); + planner_tools.add_tool(CommitTool { + client: planner_client.clone(), + agent_id: "agent://planner".into(), + mode: MODE_TASK.into(), + }); let mut worker_tools = ToolSet::default(); - worker_tools.add_tool(TaskAcceptTool { client: worker_client.clone(), agent_id: "agent://worker".into() }); - worker_tools.add_tool(TaskCompleteTool { client: worker_client.clone(), agent_id: "agent://worker".into() }); + worker_tools.add_tool(TaskAcceptTool { + client: worker_client.clone(), + agent_id: "agent://worker".into(), + }); + worker_tools.add_tool(TaskCompleteTool { + client: worker_client.clone(), + agent_id: "agent://worker".into(), + }); // Planner starts session - let r = planner_tools.call("macp_start_session", serde_json::json!({ - "mode": MODE_TASK, "session_id": &sid, - "intent": "analyze data", "participants": ["agent://planner", "agent://worker"], - "ttl_ms": 30000 - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = planner_tools + .call( + "macp_start_session", + serde_json::json!({ + "mode": MODE_TASK, "session_id": &sid, + "intent": "analyze data", "participants": ["agent://planner", "agent://worker"], + "ttl_ms": 30000 + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Planner creates task - let r = planner_tools.call("macp_task_request", serde_json::json!({ - "session_id": &sid, "task_id": "t1", "title": "Data analysis", - "instructions": "Analyze Q4 metrics", "assignee": "agent://worker" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = planner_tools + .call( + "macp_task_request", + serde_json::json!({ + "session_id": &sid, "task_id": "t1", "title": "Data analysis", + "instructions": "Analyze Q4 metrics", "assignee": "agent://worker" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Worker accepts - let r = worker_tools.call("macp_task_accept", serde_json::json!({ - "session_id": &sid, "task_id": "t1" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = worker_tools + .call( + "macp_task_accept", + serde_json::json!({ + "session_id": &sid, "task_id": "t1" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Worker completes - let r = worker_tools.call("macp_task_complete", serde_json::json!({ - "session_id": &sid, "task_id": "t1", "summary": "Q4 metrics show 20% growth" - }).to_string()).await.unwrap(); - assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + let r = worker_tools + .call( + "macp_task_complete", + serde_json::json!({ + "session_id": &sid, "task_id": "t1", "summary": "Q4 metrics show 20% growth" + }) + .to_string(), + ) + .await + .unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"] + .as_bool() + .unwrap()); // Planner commits - let r = planner_tools.call("macp_commit", serde_json::json!({ - "session_id": &sid, "action": "task-completed", - "authority_scope": "planner", "reason": "worker delivered results" - }).to_string()).await.unwrap(); + let r = planner_tools + .call( + "macp_commit", + serde_json::json!({ + "session_id": &sid, "action": "task-completed", + "authority_scope": "planner", "reason": "worker delivered results" + }) + .to_string(), + ) + .await + .unwrap(); let v: serde_json::Value = serde_json::from_str(&r).unwrap(); assert!(v["ok"].as_bool().unwrap()); assert_eq!(v["session_state"], 2); diff --git a/integration_tests/tests/tier3_e2e/test_e2e_decision.rs b/integration_tests/tests/tier3_e2e/test_e2e_decision.rs index 4500726..9d7c299 100644 --- a/integration_tests/tests/tier3_e2e/test_e2e_decision.rs +++ b/integration_tests/tests/tier3_e2e/test_e2e_decision.rs @@ -2,8 +2,8 @@ use crate::common; use macp_integration_tests::helpers::*; use macp_integration_tests::macp_tools::{self, decision::*}; use rig::completion::Prompt; -use rig::providers::openai; use rig::prelude::*; +use rig::providers::openai; /// Realistic multi-agent decision coordination following the MACP spec. /// @@ -119,7 +119,10 @@ async fn real_llm_agents_coordinate_decision() { For recommendation use: APPROVE, REVIEW, BLOCK, or REJECT.\n\ For confidence use 0.0 to 1.0. Use EXACT session_id and proposal_id from prompt.", ) - .tool(EvaluateTool { client: fraud_client, agent_id: fraud_id.into() }) + .tool(EvaluateTool { + client: fraud_client, + agent_id: fraud_id.into(), + }) .build(); let growth_client = macp_tools::shared_client(ep).await; @@ -131,7 +134,10 @@ async fn real_llm_agents_coordinate_decision() { For recommendation use: APPROVE, REVIEW, BLOCK, or REJECT.\n\ For confidence use 0.0 to 1.0. Use EXACT session_id and proposal_id from prompt.", ) - .tool(EvaluateTool { client: growth_client, agent_id: growth_id.into() }) + .tool(EvaluateTool { + client: growth_client, + agent_id: growth_id.into(), + }) .build(); let compliance_client = macp_tools::shared_client(ep).await; @@ -143,7 +149,10 @@ async fn real_llm_agents_coordinate_decision() { For recommendation use: APPROVE, REVIEW, BLOCK, or REJECT.\n\ For confidence use 0.0 to 1.0. Use EXACT session_id and proposal_id from prompt.", ) - .tool(EvaluateTool { client: compliance_client, agent_id: compliance_id.into() }) + .tool(EvaluateTool { + client: compliance_client, + agent_id: compliance_id.into(), + }) .build(); let eval_prompt = |domain: &str| { @@ -175,7 +184,10 @@ async fn real_llm_agents_coordinate_decision() { ); let parallel_duration = start.elapsed(); - eprintln!(" All 3 agents completed in {:.1}s (parallel)\n", parallel_duration.as_secs_f64()); + eprintln!( + " All 3 agents completed in {:.1}s (parallel)\n", + parallel_duration.as_secs_f64() + ); // Log results match &fraud_result { @@ -214,6 +226,7 @@ async fn real_llm_agents_coordinate_decision() { "transfer.step-up-verification", "checkout-payments", "Specialist agents evaluated — proceeding with step-up verification", + true, ), ), ) @@ -241,7 +254,10 @@ async fn real_llm_agents_coordinate_decision() { eprintln!("\n═══════════════════════════════════════════════════════════════"); eprintln!(" PASSED"); eprintln!(" Orchestrator (code) proposed"); - eprintln!(" → 3 specialists (LLM) evaluated IN PARALLEL ({:.1}s)", parallel_duration.as_secs_f64()); + eprintln!( + " → 3 specialists (LLM) evaluated IN PARALLEL ({:.1}s)", + parallel_duration.as_secs_f64() + ); eprintln!(" → Orchestrator (code) committed"); eprintln!(" LLM reasoning happened OUTSIDE session (ambient plane)"); eprintln!(" Runtime serialized Evaluations by acceptance order"); diff --git a/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs b/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs index 90daa83..28fa4bc 100644 --- a/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs +++ b/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs @@ -1,13 +1,11 @@ use crate::common; use macp_integration_tests::helpers::*; use macp_integration_tests::macp_tools::{self, decision::*}; -use macp_runtime::pb::{ - Envelope, SendRequest, SignalPayload, WatchSignalsRequest, -}; +use macp_runtime::pb::{Envelope, SendRequest, SignalPayload, WatchSignalsRequest}; use prost::Message; use rig::completion::Prompt; -use rig::providers::openai; use rig::prelude::*; +use rig::providers::openai; use tonic::Request; fn with_sender(sender: &str, inner: T) -> Request { @@ -47,7 +45,12 @@ async fn send_signal( payload: payload.encode_to_vec(), }; let resp = client - .send(with_sender(sender_id, SendRequest { envelope: Some(env) })) + .send(with_sender( + sender_id, + SendRequest { + envelope: Some(env), + }, + )) .await .unwrap() .into_inner(); @@ -97,7 +100,10 @@ async fn decision_with_signals_full_flow() { // Orchestrator subscribes to WatchSignals BEFORE session starts // ═══════════════════════════════════════════════════════════════════ eprintln!("── Orchestrator subscribes to WatchSignals stream ───────────"); - let mut signal_watcher = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep.to_string()) + let mut signal_watcher = + macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect( + ep.to_string(), + ) .await .unwrap(); let mut signal_stream = signal_watcher @@ -114,25 +120,45 @@ async fn decision_with_signals_full_flow() { eprintln!(" (Coordination Plane — these enter session history)"); { let mut client = orch_client.lock().await; - let ack = send_as(&mut client, orch_id, envelope( - MODE_DECISION, "SessionStart", &new_message_id(), &sid, orch_id, - session_start_payload( - "Review suspicious $4,800 wire transfer", - &[fraud_id, growth_id, compliance_id], - 60_000, + let ack = send_as( + &mut client, + orch_id, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + orch_id, + session_start_payload( + "Review suspicious $4,800 wire transfer", + &[fraud_id, growth_id, compliance_id], + 60_000, + ), ), - )).await.unwrap(); + ) + .await + .unwrap(); assert!(ack.ok); eprintln!(" → [Session History #1] SessionStart from orchestrator"); - let ack = send_as(&mut client, orch_id, envelope( - MODE_DECISION, "Proposal", &new_message_id(), &sid, orch_id, - proposal_payload( - "transfer-review", - "Require step-up verification for $4,800 wire transfer", - "Fraud detection threshold triggered", + let ack = send_as( + &mut client, + orch_id, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + orch_id, + proposal_payload( + "transfer-review", + "Require step-up verification for $4,800 wire transfer", + "Fraud detection threshold triggered", + ), ), - )).await.unwrap(); + ) + .await + .unwrap(); assert!(ack.ok); eprintln!(" → [Session History #2] Proposal from orchestrator"); } @@ -145,7 +171,8 @@ async fn decision_with_signals_full_flow() { eprintln!(" Each agent: Signal(starting) → LLM reasons → Evaluation → Signal(done)\n"); let openai_client = openai::Client::from_env(); - let scenario = "A $4,800 wire transfer triggered fraud alerts. Proposal: require step-up verification."; + let scenario = + "A $4,800 wire transfer triggered fraud alerts. Proposal: require step-up verification."; // Build specialist agents let fraud_grpc = macp_tools::shared_client(ep).await; @@ -188,26 +215,93 @@ async fn decision_with_signals_full_flow() { let (fraud_res, growth_res, compliance_res) = tokio::join!( // Fraud agent flow async { - let mut sig_client = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep_str.clone()).await.unwrap(); - send_signal(&mut sig_client, fraud_id, "progress", "starting fraud risk analysis", 0.0, &sid_clone).await; + let mut sig_client = + macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect( + ep_str.clone(), + ) + .await + .unwrap(); + send_signal( + &mut sig_client, + fraud_id, + "progress", + "starting fraud risk analysis", + 0.0, + &sid_clone, + ) + .await; let result = fraud_agent.prompt(&eval_prompt("fraud-risk")).await; - send_signal(&mut sig_client, fraud_id, "completed", "fraud evaluation submitted", 1.0, &sid_clone).await; + send_signal( + &mut sig_client, + fraud_id, + "completed", + "fraud evaluation submitted", + 1.0, + &sid_clone, + ) + .await; result }, // Growth agent flow async { - let mut sig_client = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep_str.clone()).await.unwrap(); - send_signal(&mut sig_client, growth_id, "progress", "starting customer impact analysis", 0.0, &sid_clone).await; - let result = growth_agent.prompt(&eval_prompt("customer-experience")).await; - send_signal(&mut sig_client, growth_id, "completed", "growth evaluation submitted", 1.0, &sid_clone).await; + let mut sig_client = + macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect( + ep_str.clone(), + ) + .await + .unwrap(); + send_signal( + &mut sig_client, + growth_id, + "progress", + "starting customer impact analysis", + 0.0, + &sid_clone, + ) + .await; + let result = growth_agent + .prompt(&eval_prompt("customer-experience")) + .await; + send_signal( + &mut sig_client, + growth_id, + "completed", + "growth evaluation submitted", + 1.0, + &sid_clone, + ) + .await; result }, // Compliance agent flow async { - let mut sig_client = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep_str.clone()).await.unwrap(); - send_signal(&mut sig_client, compliance_id, "progress", "starting regulatory review", 0.0, &sid_clone).await; - let result = compliance_agent.prompt(&eval_prompt("regulatory-compliance")).await; - send_signal(&mut sig_client, compliance_id, "completed", "compliance evaluation submitted", 1.0, &sid_clone).await; + let mut sig_client = + macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect( + ep_str.clone(), + ) + .await + .unwrap(); + send_signal( + &mut sig_client, + compliance_id, + "progress", + "starting regulatory review", + 0.0, + &sid_clone, + ) + .await; + let result = compliance_agent + .prompt(&eval_prompt("regulatory-compliance")) + .await; + send_signal( + &mut sig_client, + compliance_id, + "completed", + "compliance evaluation submitted", + 1.0, + &sid_clone, + ) + .await; result }, ); @@ -228,7 +322,10 @@ async fn decision_with_signals_full_flow() { Err(e) => panic!("Compliance agent failed: {e}"), } eprintln!(); - eprintln!(" All 3 specialists completed in {:.1}s (parallel)\n", parallel_duration.as_secs_f64()); + eprintln!( + " All 3 specialists completed in {:.1}s (parallel)\n", + parallel_duration.as_secs_f64() + ); // ═══════════════════════════════════════════════════════════════════ // STEP 3: Drain the signal stream — show what orchestrator observed @@ -264,15 +361,26 @@ async fn decision_with_signals_full_flow() { eprintln!("── STEP 4: Orchestrator commits (Coordination Plane) ────────"); { let mut client = orch_client.lock().await; - let ack = send_as(&mut client, orch_id, envelope( - MODE_DECISION, "Commitment", &new_message_id(), &sid, orch_id, - commitment_payload( - "cmt-001", - "transfer.step-up-verification", - "checkout-payments", - "All specialists evaluated — proceeding with step-up verification", + let ack = send_as( + &mut client, + orch_id, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid, + orch_id, + commitment_payload( + "cmt-001", + "transfer.step-up-verification", + "checkout-payments", + "All specialists evaluated — proceeding with step-up verification", + true, + ), ), - )).await.unwrap(); + ) + .await + .unwrap(); assert!(ack.ok); assert_eq!(ack.session_state, 2); eprintln!(" → [Session History #6] Commitment from orchestrator"); @@ -308,6 +416,9 @@ async fn decision_with_signals_full_flow() { eprintln!("║ {signal_count} signals observed (progress + completed from each agent) ║"); eprintln!("║ Signals correlate with session but do NOT enter history ║"); eprintln!("║ ║"); - eprintln!("║ Agents: {:.1}s parallel | LLM used only for evaluations ║", parallel_duration.as_secs_f64()); + eprintln!( + "║ Agents: {:.1}s parallel | LLM used only for evaluations ║", + parallel_duration.as_secs_f64() + ); eprintln!("╚══════════════════════════════════════════════════════════════╝"); } diff --git a/integration_tests/tests/tier3_e2e/test_e2e_task.rs b/integration_tests/tests/tier3_e2e/test_e2e_task.rs index f665d26..848d567 100644 --- a/integration_tests/tests/tier3_e2e/test_e2e_task.rs +++ b/integration_tests/tests/tier3_e2e/test_e2e_task.rs @@ -2,8 +2,8 @@ use crate::common; use macp_integration_tests::helpers::*; use macp_integration_tests::macp_tools::{self, task::*}; use rig::completion::Prompt; -use rig::providers::openai; use rig::prelude::*; +use rig::providers::openai; /// Realistic task delegation: planner (plain code) delegates to worker (LLM). /// @@ -162,6 +162,7 @@ async fn real_llm_agents_delegate_task() { "task-completed", "planner", "Data analyst delivered Q4 revenue analysis with regional breakdown", + true, ), ), ) diff --git a/src/bin/support/common.rs b/src/bin/support/common.rs index 60cf33a..202658e 100644 --- a/src/bin/support/common.rs +++ b/src/bin/support/common.rs @@ -62,6 +62,7 @@ pub fn canonical_commitment_payload( mode_version: MODE_VERSION.into(), policy_version: POLICY_VERSION.into(), configuration_version: CONFIG_VERSION.into(), + outcome_positive: true, } .encode_to_vec() } diff --git a/src/error.rs b/src/error.rs index 11e7aca..491677b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,8 +6,8 @@ pub enum MacpError { InvalidMacpVersion, #[error("InvalidEnvelope")] InvalidEnvelope, - #[error("DuplicateSession")] - DuplicateSession, + #[error("SessionAlreadyExists")] + SessionAlreadyExists, #[error("UnknownSession")] UnknownSession, #[error("SessionNotOpen")] @@ -53,7 +53,7 @@ impl MacpError { match self { MacpError::InvalidMacpVersion => "UNSUPPORTED_PROTOCOL_VERSION", MacpError::InvalidEnvelope => "INVALID_ENVELOPE", - MacpError::DuplicateSession => "INVALID_ENVELOPE", + MacpError::SessionAlreadyExists => "SESSION_ALREADY_EXISTS", MacpError::UnknownSession => "SESSION_NOT_FOUND", MacpError::SessionNotOpen => "SESSION_NOT_OPEN", MacpError::TtlExpired => "SESSION_NOT_OPEN", @@ -87,7 +87,7 @@ mod tests { "UNSUPPORTED_PROTOCOL_VERSION", ), (MacpError::InvalidEnvelope, "INVALID_ENVELOPE"), - (MacpError::DuplicateSession, "INVALID_ENVELOPE"), + (MacpError::SessionAlreadyExists, "SESSION_ALREADY_EXISTS"), (MacpError::UnknownSession, "SESSION_NOT_FOUND"), (MacpError::SessionNotOpen, "SESSION_NOT_OPEN"), (MacpError::TtlExpired, "SESSION_NOT_OPEN"), diff --git a/src/mode/decision.rs b/src/mode/decision.rs index d5db57c..908421a 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -132,14 +132,11 @@ impl DecisionMode { impl Mode for DecisionMode { /// Authorize the sender for decision mode messages. /// - /// Implements the coordinator authority model reflected in the Decision RFC: - /// the session initiator may emit `Proposal` and `Commitment` regardless of - /// declared participants. Declared participants may emit `Proposal`, - /// `Evaluation`, `Objection`, and `Vote`. Only the initiator may emit - /// `Commitment`. + /// Authority matrix (RFC-MACP-0004): + /// - Proposal, Evaluation, Objection, Vote → declared participant only + /// - Commitment → initiator or policy-delegated role fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { - "Proposal" if env.sender == session.initiator_sender => Ok(()), "Commitment" => check_commitment_authority(session, &env.sender), "Proposal" | "Evaluation" | "Objection" | "Vote" if is_declared_participant(&session.participants, &env.sender) => @@ -198,6 +195,11 @@ impl Mode for DecisionMode { "Evaluation" => { let payload = EvaluationPayload::decode(&*env.payload) .map_err(|_| MacpError::InvalidPayload)?; + // RFC-MACP-0004: valid recommendation values + match payload.recommendation.to_uppercase().as_str() { + "APPROVE" | "REVIEW" | "BLOCK" | "REJECT" => {} + _ => return Err(MacpError::InvalidPayload), + } Self::ensure_can_deliberate(&state)?; Self::ensure_known_proposal(&state, &payload.proposal_id)?; state.evaluations.push(Evaluation { @@ -229,6 +231,11 @@ impl Mode for DecisionMode { "Vote" => { let payload = VotePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + // RFC-MACP-0004: valid vote values + match payload.vote.as_str() { + "approve" | "reject" | "abstain" => {} + _ => return Err(MacpError::InvalidPayload), + } Self::ensure_can_vote(&state)?; Self::ensure_known_proposal(&state, &payload.proposal_id)?; let proposal_votes = state.votes.entry(payload.proposal_id.clone()).or_default(); @@ -297,7 +304,11 @@ mod tests { resolution: None, mode: "macp.mode.decision.v1".into(), mode_state: vec![], - participants: vec!["agent://fraud".into(), "agent://growth".into()], + participants: vec![ + "agent://orchestrator".into(), + "agent://fraud".into(), + "agent://growth".into(), + ], seen_message_ids: HashSet::new(), intent: String::new(), mode_version: "1.0.0".into(), @@ -347,7 +358,7 @@ mod tests { fn evaluation(proposal_id: &str) -> Vec { EvaluationPayload { proposal_id: proposal_id.into(), - recommendation: "proceed".into(), + recommendation: "APPROVE".into(), confidence: 0.9, reason: "good".into(), } @@ -372,6 +383,7 @@ mod tests { mode_version: session.mode_version.clone(), policy_version: session.policy_version.clone(), configuration_version: session.configuration_version.clone(), + outcome_positive: true, } .encode_to_vec() } @@ -405,16 +417,114 @@ mod tests { } #[test] - fn initiator_can_propose_without_being_declared_participant() { + fn initiator_not_in_participants_cannot_propose() { let mode = DecisionMode; - let session = test_session(); - mode.authorize_sender( + let mut session = test_session(); + session.participants.retain(|p| p != "agent://orchestrator"); + let err = mode + .authorize_sender( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + #[test] + fn vote_with_invalid_value_rejected() { + let mode = DecisionMode; + let mut session = test_session(); + session + .participants + .push("agent://orchestrator".to_string()); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let bad_vote = VotePayload { + proposal_id: "p1".into(), + vote: "maybe".into(), + reason: String::new(), + } + .encode_to_vec(); + let err = mode + .on_message(&session, &env("agent://fraud", "Vote", bad_vote)) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn abstain_vote_accepted() { + let mode = DecisionMode; + let mut session = test_session(); + session + .participants + .push("agent://orchestrator".to_string()); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + mode.on_message( &session, - &env("agent://orchestrator", "Proposal", proposal("p1")), + &env("agent://fraud", "Vote", vote("p1", "abstain")), ) .unwrap(); } + #[test] + fn evaluation_with_invalid_recommendation_rejected() { + let mode = DecisionMode; + let mut session = test_session(); + session + .participants + .push("agent://orchestrator".to_string()); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let bad_eval = EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "meh".into(), + confidence: 0.5, + reason: "unclear".into(), + } + .encode_to_vec(); + let err = mode + .on_message(&session, &env("agent://fraud", "Evaluation", bad_eval)) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + #[test] fn duplicate_proposal_id_is_rejected() { let mode = DecisionMode; @@ -814,6 +924,7 @@ mod tests { mode_version: "wrong".into(), policy_version: session.policy_version.clone(), configuration_version: session.configuration_version.clone(), + outcome_positive: true, } .encode_to_vec(); diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 131bde4..477530c 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -303,6 +303,7 @@ mod tests { mode_version: "1.0.0".into(), policy_version: "policy".into(), configuration_version: "config".into(), + outcome_positive: true, } .encode_to_vec() } @@ -898,6 +899,7 @@ mod tests { mode_version: "wrong".into(), policy_version: "policy".into(), configuration_version: "config".into(), + outcome_positive: true, } .encode_to_vec(); let err = mode diff --git a/src/mode/multi_round.rs b/src/mode/multi_round.rs index eeaca54..9e61501 100644 --- a/src/mode/multi_round.rs +++ b/src/mode/multi_round.rs @@ -242,6 +242,7 @@ mod tests { mode_version: "1.0.0".into(), policy_version: String::new(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec(); Envelope { diff --git a/src/mode/passthrough.rs b/src/mode/passthrough.rs index 746dad3..c71225a 100644 --- a/src/mode/passthrough.rs +++ b/src/mode/passthrough.rs @@ -140,6 +140,7 @@ mod tests { mode_version: "1.0.0".into(), policy_version: String::new(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec(); let env = make_env("alice", "Commitment", payload); diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index 287a88e..da7cf50 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -389,6 +389,9 @@ mod tests { } fn commitment(session: &Session, action: &str) -> Vec { + let outcome_positive = !action.contains("rejected") + && !action.contains("failed") + && !action.contains("declined"); CommitmentPayload { commitment_id: "c1".into(), action: action.into(), @@ -397,6 +400,7 @@ mod tests { mode_version: session.mode_version.clone(), policy_version: session.policy_version.clone(), configuration_version: session.configuration_version.clone(), + outcome_positive, } .encode_to_vec() } @@ -783,6 +787,7 @@ mod tests { mode_version: "wrong".into(), policy_version: session.policy_version.clone(), configuration_version: session.configuration_version.clone(), + outcome_positive: true, } .encode_to_vec(); assert_eq!( diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs index 9199651..d4f8fb2 100644 --- a/src/mode/quorum.rs +++ b/src/mode/quorum.rs @@ -289,6 +289,7 @@ mod tests { mode_version: "1.0.0".into(), policy_version: "policy".into(), configuration_version: "config".into(), + outcome_positive: true, } .encode_to_vec() } @@ -938,6 +939,7 @@ mod tests { mode_version: "wrong".into(), policy_version: "policy".into(), configuration_version: "config".into(), + outcome_positive: true, } .encode_to_vec(); let err = mode diff --git a/src/mode/task.rs b/src/mode/task.rs index 8a1feb7..edc5241 100644 --- a/src/mode/task.rs +++ b/src/mode/task.rs @@ -369,6 +369,7 @@ mod tests { mode_version: "1.0.0".into(), policy_version: "policy".into(), configuration_version: "config".into(), + outcome_positive: true, } .encode_to_vec() } @@ -1102,6 +1103,7 @@ mod tests { mode_version: "wrong".into(), policy_version: "policy".into(), configuration_version: "config".into(), + outcome_positive: true, } .encode_to_vec(); let err = mode diff --git a/src/mode/util.rs b/src/mode/util.rs index ced115c..5ba8271 100644 --- a/src/mode/util.rs +++ b/src/mode/util.rs @@ -31,9 +31,36 @@ pub fn validate_commitment_payload_for_session( return Err(MacpError::InvalidPayload); } + // Validate outcome_positive consistency with action (RFC-0001 §7.3) + validate_outcome_positive(&commitment)?; + Ok(commitment) } +/// Validate that `outcome_positive` is consistent with the `action` field. +/// Actions ending in `rejected`, `failed`, or `declined` must have `outcome_positive = false`. +/// Actions ending in `selected`, `accepted`, `completed`, or `approved` must have `outcome_positive = true`. +fn validate_outcome_positive(commitment: &CommitmentPayload) -> Result<(), MacpError> { + let action = commitment.action.as_str(); + let negative_actions = ["rejected", "failed", "declined"]; + let positive_actions = ["selected", "accepted", "completed", "approved"]; + + let is_negative = negative_actions + .iter() + .any(|suffix| action.ends_with(suffix)); + let is_positive = positive_actions + .iter() + .any(|suffix| action.ends_with(suffix)); + + if is_negative && commitment.outcome_positive { + return Err(MacpError::InvalidPayload); + } + if is_positive && !commitment.outcome_positive { + return Err(MacpError::InvalidPayload); + } + Ok(()) +} + pub fn is_declared_participant(participants: &[String], sender: &str) -> bool { participants.iter().any(|participant| participant == sender) } diff --git a/src/policy/defaults.rs b/src/policy/defaults.rs new file mode 100644 index 0000000..0d4ee24 --- /dev/null +++ b/src/policy/defaults.rs @@ -0,0 +1,63 @@ +use super::PolicyDefinition; + +/// The default policy that ships with every runtime. +/// +/// Mode built-in rules apply with no additional governance constraints. +/// This policy uses `"*"` as the mode target, meaning it applies to all modes. +pub fn default_policy() -> PolicyDefinition { + PolicyDefinition { + policy_id: "policy.default".to_string(), + mode: "*".to_string(), + description: "Default policy \u{2014} mode built-in rules apply with no additional governance constraints".to_string(), + rules: serde_json::json!({ + "voting": { "algorithm": "none", "quorum": { "type": "count", "value": 0 } }, + "objection_handling": { "block_severity_vetoes": false, "veto_threshold": 1 }, + "evaluation": { "required_before_voting": false, "minimum_confidence": 0.0 }, + "commitment": { "authority": "initiator_only", "designated_roles": [], "require_vote_quorum": false } + }), + schema_version: 1, + } +} + +/// The policy ID reserved for the built-in default policy. +pub const DEFAULT_POLICY_ID: &str = "policy.default"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_policy_has_correct_id() { + let policy = default_policy(); + assert_eq!(policy.policy_id, DEFAULT_POLICY_ID); + } + + #[test] + fn default_policy_applies_to_all_modes() { + let policy = default_policy(); + assert_eq!(policy.mode, "*"); + } + + #[test] + fn default_policy_rules_are_valid_json() { + let policy = default_policy(); + assert!(policy.rules.is_object()); + assert!(policy.rules.get("voting").is_some()); + assert!(policy.rules.get("objection_handling").is_some()); + assert!(policy.rules.get("evaluation").is_some()); + assert!(policy.rules.get("commitment").is_some()); + } + + #[test] + fn default_policy_schema_version_is_one() { + let policy = default_policy(); + assert_eq!(policy.schema_version, 1); + } + + #[test] + fn default_policy_voting_algorithm_is_none() { + let policy = default_policy(); + let voting = policy.rules.get("voting").unwrap(); + assert_eq!(voting.get("algorithm").unwrap().as_str().unwrap(), "none"); + } +} diff --git a/src/policy/evaluator.rs b/src/policy/evaluator.rs new file mode 100644 index 0000000..35cbf7e --- /dev/null +++ b/src/policy/evaluator.rs @@ -0,0 +1,1208 @@ +use crate::mode::decision::{DecisionState, Vote}; +use crate::policy::rules::{ + DecisionPolicyRules, HandoffPolicyRules, ProposalPolicyRules, QuorumPolicyRules, + TaskPolicyRules, +}; +use crate::policy::{PolicyDecision, PolicyDefinition}; +use std::collections::BTreeMap; + +const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +fn check_schema_version(policy: &PolicyDefinition) -> Option { + if policy.schema_version != SUPPORTED_SCHEMA_VERSION { + Some(PolicyDecision::Deny { + reasons: vec![format!( + "unsupported policy schema version: {} (supported: {})", + policy.schema_version, SUPPORTED_SCHEMA_VERSION + )], + }) + } else { + None + } +} + +/// Evaluate whether the governance policy allows a commitment in Decision Mode. +/// +/// Checks: +/// 1. Evaluation confidence: do evaluations meet minimum_confidence? +/// 2. Objection veto: are there enough blocking objections to trigger veto? +/// 3. Quorum: have enough participants voted? +/// 4. Voting threshold: does the vote distribution satisfy the algorithm? +/// +/// Returns `PolicyDecision::Allow` or `PolicyDecision::Deny` with reasons. +pub fn evaluate_decision_commitment( + policy: &PolicyDefinition, + state: &DecisionState, + participants: &[String], +) -> PolicyDecision { + if let Some(deny) = check_schema_version(policy) { + return deny; + } + let rules: DecisionPolicyRules = + serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + + let mut deny_reasons: Vec = Vec::new(); + let mut allow_reasons: Vec = Vec::new(); + + // 1. Check evaluation requirements (minimum confidence threshold) + if rules.evaluation.required_before_voting && rules.evaluation.minimum_confidence > 0.0 { + let meets_confidence = state + .evaluations + .iter() + .any(|e| e.confidence >= rules.evaluation.minimum_confidence); + if state.evaluations.is_empty() || !meets_confidence { + deny_reasons.push(format!( + "no evaluation meets minimum confidence threshold: {:.2}", + rules.evaluation.minimum_confidence + )); + } + } else if rules.evaluation.required_before_voting && state.evaluations.is_empty() { + deny_reasons.push("evaluations required before voting but none provided".into()); + } + + // 2. Check blocking objections (veto by count of "block" severity objections) + if rules.objection_handling.block_severity_vetoes { + let blocking: Vec<&str> = state + .objections + .iter() + .filter(|o| o.severity == "block") + .map(|o| o.sender.as_str()) + .collect(); + if blocking.len() >= rules.objection_handling.veto_threshold as usize { + deny_reasons.push(format!( + "blocked by {} blocking objection(s) (veto threshold: {}), from: {}", + blocking.len(), + rules.objection_handling.veto_threshold, + blocking.join(", ") + )); + } + } + + // 3. Collect all votes across all proposals + let total_voters = count_unique_voters(&state.votes); + let participant_count = participants.len(); + + // 4. Check vote quorum + let quorum_met = check_quorum( + &rules.voting.quorum.quorum_type, + rules.voting.quorum.value, + total_voters, + participant_count, + ); + if rules.commitment.require_vote_quorum && !quorum_met { + deny_reasons.push(format!( + "vote quorum not met: {} voters of {} participants (quorum: {} {})", + total_voters, + participant_count, + rules.voting.quorum.value, + rules.voting.quorum.quorum_type + )); + } + + // 5. Check voting threshold per the algorithm + if rules.voting.algorithm != "none" { + match check_voting_algorithm( + &rules.voting.algorithm, + rules.voting.threshold, + &rules.voting.weights, + &state.votes, + participants, + ) { + VotingResult::Passed(reason) => allow_reasons.push(reason), + VotingResult::Failed(reason) => deny_reasons.push(reason), + VotingResult::NoVotes => { + if rules.commitment.require_vote_quorum { + deny_reasons.push("no votes cast".into()); + } + } + } + } else { + allow_reasons.push("voting algorithm is 'none'; no vote threshold required".into()); + } + + if deny_reasons.is_empty() { + if allow_reasons.is_empty() { + allow_reasons.push("policy constraints satisfied".into()); + } + PolicyDecision::Allow { + reasons: allow_reasons, + } + } else { + PolicyDecision::Deny { + reasons: deny_reasons, + } + } +} + +/// Count the number of unique voters across all proposals. +fn count_unique_voters(votes: &BTreeMap>) -> usize { + let mut voters = std::collections::HashSet::new(); + for proposal_votes in votes.values() { + for voter in proposal_votes.keys() { + voters.insert(voter.clone()); + } + } + voters.len() +} + +/// Check whether the quorum requirement is met. +/// Accepts "count", "n_of_m", and "percentage" as quorum types. +fn check_quorum( + quorum_type: &str, + value: f64, + actual_voters: usize, + total_participants: usize, +) -> bool { + match quorum_type { + "count" | "n_of_m" => actual_voters as f64 >= value, + "percentage" => { + if total_participants == 0 { + value <= 0.0 + } else { + let pct = (actual_voters as f64 / total_participants as f64) * 100.0; + pct >= value + } + } + _ => actual_voters as f64 >= value, + } +} + +enum VotingResult { + Passed(String), + Failed(String), + NoVotes, +} + +/// Check the voting algorithm against the collected votes. +/// +/// Supports: majority, supermajority, unanimous, weighted, plurality. +fn check_voting_algorithm( + algorithm: &str, + threshold: f64, + weights: &std::collections::HashMap, + votes: &BTreeMap>, + participants: &[String], +) -> VotingResult { + // Aggregate approve/reject counts across all proposals + let (approve_count, reject_count, total_votes) = aggregate_votes(votes); + + if total_votes == 0 { + return VotingResult::NoVotes; + } + + match algorithm { + "majority" => { + let ratio = approve_count as f64 / total_votes as f64; + if ratio >= threshold { + VotingResult::Passed(format!( + "majority vote passed: {:.1}% approve (threshold: {:.1}%)", + ratio * 100.0, + threshold * 100.0 + )) + } else { + VotingResult::Failed(format!( + "majority vote failed: {:.1}% approve, need >= {:.1}%", + ratio * 100.0, + threshold * 100.0 + )) + } + } + "supermajority" => { + let effective_threshold = if threshold > 0.5 { + threshold + } else { + 2.0 / 3.0 + }; + let ratio = approve_count as f64 / total_votes as f64; + if ratio >= effective_threshold { + VotingResult::Passed(format!( + "supermajority vote passed: {:.1}% approve (threshold: {:.1}%)", + ratio * 100.0, + effective_threshold * 100.0 + )) + } else { + VotingResult::Failed(format!( + "supermajority vote failed: {:.1}% approve, need >= {:.1}%", + ratio * 100.0, + effective_threshold * 100.0 + )) + } + } + "unanimous" => { + // All declared participants must have voted "approve" + let all_voted = participants.iter().all(|p| { + votes + .values() + .any(|pv| pv.get(p).map(|v| v.vote == "approve").unwrap_or(false)) + }); + if all_voted && reject_count == 0 { + VotingResult::Passed("unanimous vote passed: all participants approved".into()) + } else { + VotingResult::Failed(format!( + "unanimous vote failed: {} approve, {} reject out of {} participants", + approve_count, + reject_count, + participants.len() + )) + } + } + "weighted" => { + let (weighted_approve, weighted_total) = compute_weighted_votes(votes, weights); + if weighted_total == 0.0 { + return VotingResult::NoVotes; + } + let ratio = weighted_approve / weighted_total; + if ratio >= threshold { + VotingResult::Passed(format!( + "weighted vote passed: {:.1}% weighted approve (threshold: {:.1}%)", + ratio * 100.0, + threshold * 100.0 + )) + } else { + VotingResult::Failed(format!( + "weighted vote failed: {:.1}% weighted approve, need >= {:.1}%", + ratio * 100.0, + threshold * 100.0 + )) + } + } + "plurality" => { + if approve_count > reject_count { + VotingResult::Passed(format!( + "plurality vote passed: {} approve vs {} reject", + approve_count, reject_count + )) + } else if approve_count == reject_count { + VotingResult::Failed(format!( + "plurality vote tied: {} approve vs {} reject", + approve_count, reject_count + )) + } else { + VotingResult::Failed(format!( + "plurality vote failed: {} approve vs {} reject", + approve_count, reject_count + )) + } + } + _ => { + // Unknown algorithm: treat as pass-through + VotingResult::Passed(format!( + "unknown voting algorithm '{}'; allowing", + algorithm + )) + } + } +} + +/// Aggregate votes into approve/reject/total counts. +fn aggregate_votes(votes: &BTreeMap>) -> (usize, usize, usize) { + let mut approve = 0usize; + let mut reject = 0usize; + let mut total = 0usize; + + for proposal_votes in votes.values() { + for vote in proposal_votes.values() { + total += 1; + match vote.vote.as_str() { + "approve" => approve += 1, + "reject" => reject += 1, + _ => {} // abstain or other values don't count for/against + } + } + } + + (approve, reject, total) +} + +/// Compute weighted votes using the configured weight map. +fn compute_weighted_votes( + votes: &BTreeMap>, + weights: &std::collections::HashMap, +) -> (f64, f64) { + let mut weighted_approve = 0.0f64; + let mut weighted_total = 0.0f64; + + for proposal_votes in votes.values() { + for (voter, vote) in proposal_votes { + let weight = weights.get(voter).copied().unwrap_or(1.0); + weighted_total += weight; + if vote.vote == "approve" { + weighted_approve += weight; + } + } + } + + (weighted_approve, weighted_total) +} + +// ── Proposal Mode Evaluator ───────────────────────────────────────── + +/// Evaluate whether the governance policy allows a commitment in Proposal Mode. +/// +/// Checks: +/// - `counter_proposal.max_rounds`: if > 0, ensures counter-proposal count doesn't exceed limit +pub fn evaluate_proposal_commitment( + policy: &PolicyDefinition, + counter_proposal_count: usize, +) -> PolicyDecision { + if let Some(deny) = check_schema_version(policy) { + return deny; + } + let rules: ProposalPolicyRules = + serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + + let mut deny_reasons: Vec = Vec::new(); + let mut allow_reasons: Vec = Vec::new(); + + if rules.counter_proposal.max_rounds > 0 + && counter_proposal_count > rules.counter_proposal.max_rounds + { + deny_reasons.push(format!( + "counter-proposal limit exceeded: {} of {} allowed", + counter_proposal_count, rules.counter_proposal.max_rounds + )); + } + + if deny_reasons.is_empty() { + if allow_reasons.is_empty() { + allow_reasons.push("proposal policy constraints satisfied".into()); + } + PolicyDecision::Allow { + reasons: allow_reasons, + } + } else { + PolicyDecision::Deny { + reasons: deny_reasons, + } + } +} + +// ── Task Mode Evaluator ───────────────────────────────────────────── + +/// Evaluate whether the governance policy allows a commitment in Task Mode. +/// +/// Checks: +/// - `completion.require_output`: if true, task completion must include non-empty output +pub fn evaluate_task_commitment(policy: &PolicyDefinition, has_output: bool) -> PolicyDecision { + if let Some(deny) = check_schema_version(policy) { + return deny; + } + let rules: TaskPolicyRules = serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + + let mut deny_reasons: Vec = Vec::new(); + let mut allow_reasons: Vec = Vec::new(); + + if rules.completion.require_output && !has_output { + deny_reasons.push("policy requires task output before commitment".into()); + } + + if deny_reasons.is_empty() { + if allow_reasons.is_empty() { + allow_reasons.push("task policy constraints satisfied".into()); + } + PolicyDecision::Allow { + reasons: allow_reasons, + } + } else { + PolicyDecision::Deny { + reasons: deny_reasons, + } + } +} + +// ── Handoff Mode Evaluator ────────────────────────────────────────── + +/// Evaluate whether the governance policy allows a commitment in Handoff Mode. +/// +/// The RFC handoff rules (`acceptance.implicit_accept_timeout_ms`) are handled +/// at message-processing time via lazy evaluation, not at commitment evaluation. +/// This evaluator always allows. +pub fn evaluate_handoff_commitment(policy: &PolicyDefinition) -> PolicyDecision { + if let Some(deny) = check_schema_version(policy) { + return deny; + } + let _rules: HandoffPolicyRules = + serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + + PolicyDecision::Allow { + reasons: vec!["handoff policy constraints satisfied".into()], + } +} + +// ── Quorum Mode Evaluator ─────────────────────────────────────────── + +/// Evaluate whether the governance policy allows a commitment in Quorum Mode. +/// +/// Checks: +/// - `abstention`: if `counts_toward_quorum` is false and `interpretation` is not "ignored", +/// abstentions may affect quorum calculation +/// - `threshold`: if set, checks that voter participation meets the threshold requirement +pub fn evaluate_quorum_commitment( + policy: &PolicyDefinition, + approve_count: usize, + reject_count: usize, + abstain_count: usize, + total_participants: usize, +) -> PolicyDecision { + if let Some(deny) = check_schema_version(policy) { + return deny; + } + let rules: QuorumPolicyRules = serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + + let mut deny_reasons: Vec = Vec::new(); + let mut allow_reasons: Vec = Vec::new(); + + // Determine effective voter count based on abstention rules + let effective_voters = if rules.abstention.counts_toward_quorum { + approve_count + reject_count + abstain_count + } else { + approve_count + reject_count + }; + + // Handle abstention interpretation + let effective_reject_count = match rules.abstention.interpretation.as_str() { + "implicit_reject" => reject_count + abstain_count, + _ => reject_count, // "neutral" and "ignored" don't add to rejections + }; + + // If interpretation is "ignored", abstentions don't trigger any denial + // If interpretation is "neutral" or "implicit_reject" and abstentions exist + // but counts_toward_quorum is false, we just exclude them from voter count (already done above) + + // Check quorum threshold + let quorum_met = check_quorum( + &rules.threshold.threshold_type, + rules.threshold.value, + effective_voters, + total_participants, + ); + if rules.threshold.value > 0.0 && !quorum_met { + deny_reasons.push(format!( + "quorum not met: {} effective voters of {} participants (threshold: {} {})", + effective_voters, + total_participants, + rules.threshold.value, + rules.threshold.threshold_type + )); + } + + // Report effective rejection count for transparency + if effective_reject_count > reject_count { + allow_reasons.push(format!( + "abstentions interpreted as implicit rejections: {} effective rejections", + effective_reject_count + )); + } + + if deny_reasons.is_empty() { + if allow_reasons.is_empty() { + allow_reasons.push("quorum policy constraints satisfied".into()); + } + PolicyDecision::Allow { + reasons: allow_reasons, + } + } else { + PolicyDecision::Deny { + reasons: deny_reasons, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mode::decision::{DecisionPhase, Evaluation, Objection, Proposal}; + + fn make_policy(rules: serde_json::Value) -> PolicyDefinition { + PolicyDefinition { + policy_id: "test-policy".into(), + mode: "macp.mode.decision.v1".into(), + description: "test".into(), + rules, + schema_version: 1, + } + } + + fn make_state_with_votes(vote_entries: Vec<(&str, &str, &str)>) -> DecisionState { + let mut proposals = BTreeMap::new(); + let mut votes: BTreeMap> = BTreeMap::new(); + + for (proposal_id, voter, vote_value) in &vote_entries { + proposals + .entry(proposal_id.to_string()) + .or_insert_with(|| Proposal { + proposal_id: proposal_id.to_string(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }); + votes.entry(proposal_id.to_string()).or_default().insert( + voter.to_string(), + Vote { + proposal_id: proposal_id.to_string(), + vote: vote_value.to_string(), + reason: String::new(), + sender: voter.to_string(), + }, + ); + } + + DecisionState { + proposals, + evaluations: Vec::new(), + objections: Vec::new(), + votes, + phase: DecisionPhase::Voting, + } + } + + fn participants() -> Vec { + vec![ + "agent://fraud".into(), + "agent://growth".into(), + "agent://compliance".into(), + ] + } + + // ── Voting algorithm: none ────────────────────────────────────── + + #[test] + fn none_algorithm_always_allows() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" } + })); + let state = make_state_with_votes(vec![]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Voting algorithm: majority ────────────────────────────────── + + #[test] + fn majority_passes_with_sufficient_approvals() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "majority", "threshold": 0.5 } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn majority_fails_with_insufficient_approvals() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "majority", "threshold": 0.5 } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "reject"), + ("p1", "agent://growth", "reject"), + ("p1", "agent://compliance", "approve"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn majority_passes_on_exact_threshold() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "majority", "threshold": 0.5 } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // 50% >= 50% passes (threshold comparison uses >=) + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Voting algorithm: supermajority ───────────────────────────── + + #[test] + fn supermajority_passes_with_two_thirds() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "supermajority" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // 2/3 = 66.7% >= 66.7% + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn supermajority_fails_below_threshold() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "supermajority", "threshold": 0.75 } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // 2/3 = 66.7% < 75% + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Voting algorithm: unanimous ───────────────────────────────── + + #[test] + fn unanimous_passes_when_all_approve() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "unanimous" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "approve"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn unanimous_fails_with_any_reject() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "unanimous" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn unanimous_fails_when_participant_missing() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "unanimous" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + // compliance didn't vote + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Voting algorithm: weighted ────────────────────────────────── + + #[test] + fn weighted_passes_with_heavy_approve() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "weighted", + "threshold": 0.5, + "weights": { + "agent://fraud": 3.0, + "agent://growth": 1.0, + "agent://compliance": 1.0 + } + } + })); + // fraud (weight 3) approves, others reject => 3/5 = 60% > 50% + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "reject"), + ("p1", "agent://compliance", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn weighted_fails_with_heavy_reject() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "weighted", + "threshold": 0.5, + "weights": { + "agent://fraud": 3.0, + "agent://growth": 1.0, + "agent://compliance": 1.0 + } + } + })); + // fraud (weight 3) rejects, others approve => 2/5 = 40% < 50% + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "reject"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "approve"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Voting algorithm: plurality ───────────────────────────────── + + #[test] + fn plurality_passes_when_approves_exceed_rejects() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "plurality" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ("p1", "agent://compliance", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn plurality_fails_on_tie() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "plurality" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "reject"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Objection veto logic (count-based) ────────────────────────── + + #[test] + fn veto_blocks_commitment_when_blocking_objections_reach_threshold() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 1 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + state.objections.push(Objection { + proposal_id: "p1".into(), + reason: "too risky".into(), + severity: "block".into(), + sender: "agent://compliance".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn veto_allows_when_objections_below_threshold() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 3 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // Only 1 blocking objection, threshold is 3 + state.objections.push(Objection { + proposal_id: "p1".into(), + reason: "minor concern".into(), + severity: "block".into(), + sender: "agent://compliance".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn veto_ignores_non_blocking_severity_objections() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 1 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // "high" severity is NOT "block", so it should NOT trigger veto + state.objections.push(Objection { + proposal_id: "p1".into(), + reason: "serious concern".into(), + severity: "high".into(), + sender: "agent://compliance".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn veto_disabled_ignores_objections() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "objection_handling": { "block_severity_vetoes": false } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + state.objections.push(Objection { + proposal_id: "p1".into(), + reason: "critical issue".into(), + severity: "critical".into(), + sender: "agent://compliance".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Quorum checking ───────────────────────────────────────────── + + #[test] + fn quorum_count_requirement() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.5, + "quorum": { "type": "count", "value": 3 } + }, + "commitment": { "require_vote_quorum": true } + })); + // Only 2 voters, need 3 + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + if let PolicyDecision::Deny { reasons } = &result { + assert!(reasons.iter().any(|r| r.contains("quorum"))); + } + } + + #[test] + fn quorum_percentage_requirement() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.5, + "quorum": { "type": "percentage", "value": 100.0 } + }, + "commitment": { "require_vote_quorum": true } + })); + // Only 2 of 3 voted: 66.7% < 100% + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn quorum_not_required_by_default() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.5, + "quorum": { "type": "count", "value": 100 } + }, + "commitment": { "require_vote_quorum": false } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "approve"), + ("p1", "agent://growth", "approve"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // Quorum not met, but not required => allow (majority is met) + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Evaluation requirements (confidence-based) ─────────────────── + + #[test] + fn evaluation_denies_when_below_confidence() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "evaluation": { "required_before_voting": true, "minimum_confidence": 0.8 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // Evaluation with confidence below threshold + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "proceed".into(), + confidence: 0.5, + reason: "uncertain".into(), + sender: "agent://fraud".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn evaluation_allows_when_meets_confidence() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "evaluation": { "required_before_voting": true, "minimum_confidence": 0.8 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "proceed".into(), + confidence: 0.9, + reason: "good".into(), + sender: "agent://fraud".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn evaluation_denies_when_none_provided_but_required() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "evaluation": { "required_before_voting": true, "minimum_confidence": 0.0 } + })); + let state = make_state_with_votes(vec![]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Default policy always allows ──────────────────────────────── + + #[test] + fn default_policy_always_allows() { + let policy = crate::policy::defaults::default_policy(); + let state = make_state_with_votes(vec![]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── No votes with required quorum ─────────────────────────────── + + #[test] + fn no_votes_with_required_quorum_denies() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.5, + "quorum": { "type": "count", "value": 1 } + }, + "commitment": { "require_vote_quorum": true } + })); + let state = make_state_with_votes(vec![]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Multiple deny reasons ─────────────────────────────────────── + + #[test] + fn multiple_deny_reasons_accumulated() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.5, + "quorum": { "type": "count", "value": 10 } + }, + "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 1 }, + "commitment": { "require_vote_quorum": true } + })); + let mut state = make_state_with_votes(vec![("p1", "agent://fraud", "reject")]); + state.objections.push(Objection { + proposal_id: "p1".into(), + reason: "bad".into(), + severity: "block".into(), + sender: "agent://compliance".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + if let PolicyDecision::Deny { reasons } = result { + // Should have: veto, quorum, and voting threshold failures + assert!(reasons.len() >= 2); + } else { + panic!("expected Deny"); + } + } + + // ── Proposal evaluator ───────────────────────────────────────── + + #[test] + fn proposal_allows_within_counter_limit() { + let policy = make_policy(serde_json::json!({ + "counter_proposal": { "max_rounds": 5 } + })); + let result = super::evaluate_proposal_commitment(&policy, 3); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn proposal_denies_exceeding_counter_limit() { + let policy = make_policy(serde_json::json!({ + "counter_proposal": { "max_rounds": 2 } + })); + let result = super::evaluate_proposal_commitment(&policy, 5); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn proposal_zero_limit_allows_any() { + let policy = make_policy(serde_json::json!({ + "counter_proposal": { "max_rounds": 0 } + })); + let result = super::evaluate_proposal_commitment(&policy, 100); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Task evaluator ───────────────────────────────────────────── + + #[test] + fn task_allows_when_output_not_required() { + let policy = make_policy(serde_json::json!({ + "completion": { "require_output": false } + })); + let result = super::evaluate_task_commitment(&policy, false); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn task_allows_when_output_present_and_required() { + let policy = make_policy(serde_json::json!({ + "completion": { "require_output": true } + })); + let result = super::evaluate_task_commitment(&policy, true); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn task_denies_when_no_output_and_required() { + let policy = make_policy(serde_json::json!({ + "completion": { "require_output": true } + })); + let result = super::evaluate_task_commitment(&policy, false); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + // ── Handoff evaluator ────────────────────────────────────────── + + #[test] + fn handoff_always_allows() { + let policy = make_policy(serde_json::json!({ + "acceptance": { "implicit_accept_timeout_ms": 5000 } + })); + let result = super::evaluate_handoff_commitment(&policy); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn handoff_allows_with_default_rules() { + let policy = make_policy(serde_json::json!({})); + let result = super::evaluate_handoff_commitment(&policy); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Quorum evaluator ─────────────────────────────────────────── + + #[test] + fn quorum_allows_with_abstention_counting_toward_quorum() { + let policy = make_policy(serde_json::json!({ + "abstention": { "counts_toward_quorum": true, "interpretation": "neutral" } + })); + let result = super::evaluate_quorum_commitment(&policy, 2, 0, 1, 3); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn quorum_denies_when_threshold_not_met_excluding_abstentions() { + let policy = make_policy(serde_json::json!({ + "threshold": { "type": "n_of_m", "value": 3 }, + "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" } + })); + // 2 approve + 0 reject = 2 effective voters < 3 threshold + let result = super::evaluate_quorum_commitment(&policy, 2, 0, 1, 5); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn quorum_allows_when_threshold_met_including_abstentions() { + let policy = make_policy(serde_json::json!({ + "threshold": { "type": "n_of_m", "value": 3 }, + "abstention": { "counts_toward_quorum": true, "interpretation": "neutral" } + })); + // 2 approve + 0 reject + 1 abstain = 3 effective voters >= 3 threshold + let result = super::evaluate_quorum_commitment(&policy, 2, 0, 1, 5); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn quorum_implicit_reject_interpretation() { + let policy = make_policy(serde_json::json!({ + "abstention": { "counts_toward_quorum": true, "interpretation": "implicit_reject" } + })); + // Abstentions treated as rejections in the result + let result = super::evaluate_quorum_commitment(&policy, 2, 0, 1, 3); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn quorum_denies_when_quorum_not_met() { + let policy = make_policy(serde_json::json!({ + "threshold": { "type": "n_of_m", "value": 3 }, + "abstention": { "counts_toward_quorum": true } + })); + let result = super::evaluate_quorum_commitment(&policy, 1, 0, 0, 5); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn quorum_allows_when_quorum_met() { + let policy = make_policy(serde_json::json!({ + "threshold": { "type": "n_of_m", "value": 2 }, + "abstention": { "counts_toward_quorum": true } + })); + let result = super::evaluate_quorum_commitment(&policy, 2, 1, 0, 5); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } +} diff --git a/src/policy/mod.rs b/src/policy/mod.rs new file mode 100644 index 0000000..d1b5607 --- /dev/null +++ b/src/policy/mod.rs @@ -0,0 +1,72 @@ +pub mod defaults; +pub mod evaluator; +pub mod registry; +pub mod rules; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PolicyDefinition { + pub policy_id: String, + pub mode: String, + pub description: String, + pub rules: serde_json::Value, + pub schema_version: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PolicyDecision { + Allow { reasons: Vec }, + Deny { reasons: Vec }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PolicyError { + UnknownPolicy(String), + InvalidDefinition(String), + PolicyDenied(String), +} + +impl std::fmt::Display for PolicyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PolicyError::UnknownPolicy(id) => write!(f, "unknown policy: {}", id), + PolicyError::InvalidDefinition(msg) => write!(f, "invalid policy definition: {}", msg), + PolicyError::PolicyDenied(reason) => write!(f, "policy denied: {}", reason), + } + } +} + +impl std::error::Error for PolicyError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_error_display() { + let e = PolicyError::UnknownPolicy("p1".into()); + assert_eq!(e.to_string(), "unknown policy: p1"); + + let e = PolicyError::InvalidDefinition("bad".into()); + assert_eq!(e.to_string(), "invalid policy definition: bad"); + + let e = PolicyError::PolicyDenied("nope".into()); + assert_eq!(e.to_string(), "policy denied: nope"); + } + + #[test] + fn policy_definition_serialization_round_trip() { + let def = PolicyDefinition { + policy_id: "test".into(), + mode: "*".into(), + description: "test policy".into(), + rules: serde_json::json!({"voting": {"algorithm": "none"}}), + schema_version: 1, + }; + let json = serde_json::to_string(&def).unwrap(); + let parsed: PolicyDefinition = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.policy_id, "test"); + assert_eq!(parsed.schema_version, 1); + } +} diff --git a/src/policy/registry.rs b/src/policy/registry.rs new file mode 100644 index 0000000..b92930f --- /dev/null +++ b/src/policy/registry.rs @@ -0,0 +1,537 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use tokio::sync::broadcast; + +use super::defaults::{default_policy, DEFAULT_POLICY_ID}; +use super::rules::{ + CommitmentRules, DecisionPolicyRules, HandoffPolicyRules, ProposalPolicyRules, + QuorumPolicyRules, TaskPolicyRules, +}; +use super::{PolicyDefinition, PolicyError}; + +/// In-memory policy registry for governance policy definitions. +/// +/// Mirrors the `ModeRegistry` pattern: uses `RwLock` for entries and +/// `broadcast::Sender<()>` for change notifications. +pub struct PolicyRegistry { + entries: RwLock>, + change_tx: broadcast::Sender<()>, +} + +impl PolicyRegistry { + /// Create a new policy registry pre-loaded with the default policy. + pub fn new() -> Self { + let mut entries = HashMap::new(); + let default = default_policy(); + entries.insert(default.policy_id.clone(), default); + + let (change_tx, _) = broadcast::channel(16); + Self { + entries: RwLock::new(entries), + change_tx, + } + } + + /// Register a new policy definition. + /// + /// Returns an error if: + /// - The policy_id is empty + /// - The policy_id is the reserved default policy + /// - A policy with this id already exists + /// - The schema_version is 0 + pub fn register(&self, definition: PolicyDefinition) -> Result<(), String> { + Self::validate_definition(&definition)?; + + let mut guard = self.entries.write().expect("policy registry lock poisoned"); + if guard.contains_key(&definition.policy_id) { + return Err(format!( + "policy '{}' is already registered", + definition.policy_id + )); + } + guard.insert(definition.policy_id.clone(), definition); + drop(guard); + let _ = self.change_tx.send(()); + Ok(()) + } + + /// Unregister a policy by ID. + /// + /// Returns an error if: + /// - The policy is the reserved default policy + /// - The policy does not exist + pub fn unregister(&self, policy_id: &str) -> Result<(), String> { + if policy_id == DEFAULT_POLICY_ID { + return Err("cannot unregister the built-in default policy".into()); + } + + let mut guard = self.entries.write().expect("policy registry lock poisoned"); + if guard.remove(policy_id).is_none() { + return Err(format!("policy '{}' not found", policy_id)); + } + drop(guard); + let _ = self.change_tx.send(()); + Ok(()) + } + + /// Resolve a policy by version string. + /// + /// If the version string is empty, returns the default policy. + /// Otherwise, looks up the policy by ID. + pub fn resolve(&self, policy_version: &str) -> Result { + if policy_version.is_empty() { + return self + .get(DEFAULT_POLICY_ID) + .ok_or_else(|| PolicyError::UnknownPolicy(DEFAULT_POLICY_ID.into())); + } + self.get(policy_version) + .ok_or_else(|| PolicyError::UnknownPolicy(policy_version.into())) + } + + /// Direct lookup by policy ID. + pub fn get(&self, policy_id: &str) -> Option { + let guard = self.entries.read().expect("policy registry lock poisoned"); + guard.get(policy_id).cloned() + } + + /// List all policies, optionally filtered by target mode. + /// + /// If `mode_filter` is `Some(mode)`, returns only policies targeting that + /// specific mode or the wildcard `"*"`. If `None`, returns all policies. + pub fn list(&self, mode_filter: Option<&str>) -> Vec { + let guard = self.entries.read().expect("policy registry lock poisoned"); + let mut policies: Vec = guard + .values() + .filter(|p| match mode_filter { + Some(mode) => p.mode == mode || p.mode == "*", + None => true, + }) + .cloned() + .collect(); + policies.sort_by(|a, b| a.policy_id.cmp(&b.policy_id)); + policies + } + + /// Subscribe to policy registry change notifications. + pub fn subscribe_changes(&self) -> broadcast::Receiver<()> { + self.change_tx.subscribe() + } + + fn validate_definition(definition: &PolicyDefinition) -> Result<(), String> { + if definition.policy_id.trim().is_empty() { + return Err("policy_id must not be empty".into()); + } + if definition.policy_id == DEFAULT_POLICY_ID { + return Err(format!( + "cannot register with reserved policy_id '{}'", + DEFAULT_POLICY_ID + )); + } + if definition.schema_version == 0 { + return Err("schema_version must be > 0".into()); + } + if !definition.rules.is_object() { + return Err("rules must be a JSON object".into()); + } + // Validate that rules deserialize into the mode-specific schema. + Self::validate_rules_for_mode(&definition.mode, &definition.rules)?; + // Validate conditional constraints (RFC-MACP-0012). + Self::validate_conditional_constraints(&definition.mode, &definition.rules)?; + Ok(()) + } + + /// Validate that policy rules match the expected schema for the target mode. + /// + /// Wildcard (`"*"`) policies are validated against the Decision schema (superset). + /// Unknown modes are allowed (extension modes may have custom rules). + fn validate_rules_for_mode(mode: &str, rules: &serde_json::Value) -> Result<(), String> { + let result = match mode { + "macp.mode.decision.v1" | "*" => { + serde_json::from_value::(rules.clone()).map(|_| ()) + } + "macp.mode.proposal.v1" => { + serde_json::from_value::(rules.clone()).map(|_| ()) + } + "macp.mode.task.v1" => { + serde_json::from_value::(rules.clone()).map(|_| ()) + } + "macp.mode.handoff.v1" => { + serde_json::from_value::(rules.clone()).map(|_| ()) + } + "macp.mode.quorum.v1" => { + serde_json::from_value::(rules.clone()).map(|_| ()) + } + _ => return Ok(()), // Extension modes: accept any valid JSON object + }; + result.map_err(|e| format!("rules do not match schema for mode '{}': {}", mode, e)) + } + + /// Validate conditional constraints that depend on specific field values. + fn validate_conditional_constraints( + mode: &str, + rules: &serde_json::Value, + ) -> Result<(), String> { + // Decision mode (and wildcard) voting constraints + if matches!(mode, "macp.mode.decision.v1" | "*") { + if let Ok(decision) = serde_json::from_value::(rules.clone()) { + if decision.voting.algorithm == "weighted" && decision.voting.weights.is_empty() { + return Err( + "voting.algorithm 'weighted' requires non-empty voting.weights".into(), + ); + } + if decision.voting.algorithm == "supermajority" && decision.voting.threshold <= 0.5 + { + return Err( + "voting.algorithm 'supermajority' requires voting.threshold > 0.5".into(), + ); + } + } + } + + // All modes: commitment.designated_roles required when authority is designated_role + if let Some(commitment) = rules.get("commitment") { + if let Ok(cr) = serde_json::from_value::(commitment.clone()) { + if cr.authority == "designated_role" && cr.designated_roles.is_empty() { + return Err( + "commitment.authority 'designated_role' requires non-empty commitment.designated_roles".into(), + ); + } + } + } + + Ok(()) + } +} + +impl Default for PolicyRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_policy(id: &str) -> PolicyDefinition { + PolicyDefinition { + policy_id: id.into(), + mode: "macp.mode.decision.v1".into(), + description: "test policy".into(), + rules: serde_json::json!({ + "voting": { "algorithm": "majority", "threshold": 0.5 } + }), + schema_version: 1, + } + } + + #[test] + fn new_registry_contains_default_policy() { + let registry = PolicyRegistry::new(); + let default = registry.get(DEFAULT_POLICY_ID); + assert!(default.is_some()); + assert_eq!(default.unwrap().policy_id, DEFAULT_POLICY_ID); + } + + #[test] + fn register_new_policy() { + let registry = PolicyRegistry::new(); + registry + .register(test_policy("policy.fraud.strict")) + .unwrap(); + assert!(registry.get("policy.fraud.strict").is_some()); + } + + #[test] + fn register_duplicate_fails() { + let registry = PolicyRegistry::new(); + registry + .register(test_policy("policy.fraud.strict")) + .unwrap(); + let err = registry + .register(test_policy("policy.fraud.strict")) + .unwrap_err(); + assert!(err.contains("already registered")); + } + + #[test] + fn register_default_policy_id_fails() { + let registry = PolicyRegistry::new(); + let err = registry + .register(test_policy(DEFAULT_POLICY_ID)) + .unwrap_err(); + assert!(err.contains("reserved")); + } + + #[test] + fn register_empty_policy_id_fails() { + let registry = PolicyRegistry::new(); + let mut policy = test_policy("valid"); + policy.policy_id = "".into(); + let err = registry.register(policy).unwrap_err(); + assert!(err.contains("must not be empty")); + } + + #[test] + fn register_zero_schema_version_fails() { + let registry = PolicyRegistry::new(); + let mut policy = test_policy("policy.bad.schema"); + policy.schema_version = 0; + let err = registry.register(policy).unwrap_err(); + assert!(err.contains("schema_version")); + } + + #[test] + fn register_non_object_rules_fails() { + let registry = PolicyRegistry::new(); + let mut policy = test_policy("policy.bad.rules"); + policy.rules = serde_json::json!("not an object"); + let err = registry.register(policy).unwrap_err(); + assert!(err.contains("JSON object")); + } + + #[test] + fn unregister_policy() { + let registry = PolicyRegistry::new(); + registry.register(test_policy("policy.temp")).unwrap(); + assert!(registry.get("policy.temp").is_some()); + registry.unregister("policy.temp").unwrap(); + assert!(registry.get("policy.temp").is_none()); + } + + #[test] + fn unregister_default_fails() { + let registry = PolicyRegistry::new(); + let err = registry.unregister(DEFAULT_POLICY_ID).unwrap_err(); + assert!(err.contains("default policy")); + } + + #[test] + fn unregister_nonexistent_fails() { + let registry = PolicyRegistry::new(); + let err = registry.unregister("nonexistent").unwrap_err(); + assert!(err.contains("not found")); + } + + #[test] + fn resolve_empty_returns_default() { + let registry = PolicyRegistry::new(); + let policy = registry.resolve("").unwrap(); + assert_eq!(policy.policy_id, DEFAULT_POLICY_ID); + } + + #[test] + fn resolve_specific_policy() { + let registry = PolicyRegistry::new(); + registry + .register(test_policy("policy.fraud.strict")) + .unwrap(); + let policy = registry.resolve("policy.fraud.strict").unwrap(); + assert_eq!(policy.policy_id, "policy.fraud.strict"); + } + + #[test] + fn resolve_unknown_returns_error() { + let registry = PolicyRegistry::new(); + let err = registry.resolve("nonexistent").unwrap_err(); + assert!(matches!(err, PolicyError::UnknownPolicy(_))); + } + + #[test] + fn list_all_policies() { + let registry = PolicyRegistry::new(); + registry.register(test_policy("policy.a")).unwrap(); + registry.register(test_policy("policy.b")).unwrap(); + let all = registry.list(None); + assert_eq!(all.len(), 3); // default + a + b + } + + #[test] + fn list_filtered_by_mode() { + let registry = PolicyRegistry::new(); + registry.register(test_policy("policy.decision")).unwrap(); + let mut task_policy = test_policy("policy.task"); + task_policy.mode = "macp.mode.task.v1".into(); + registry.register(task_policy).unwrap(); + + let decision_policies = registry.list(Some("macp.mode.decision.v1")); + // Should include: default (mode="*") + policy.decision (mode matches) + assert_eq!(decision_policies.len(), 2); + + let task_policies = registry.list(Some("macp.mode.task.v1")); + // Should include: default (mode="*") + policy.task (mode matches) + assert_eq!(task_policies.len(), 2); + } + + #[test] + fn list_returns_sorted_by_id() { + let registry = PolicyRegistry::new(); + registry.register(test_policy("policy.z")).unwrap(); + registry.register(test_policy("policy.a")).unwrap(); + let all = registry.list(None); + let ids: Vec<&str> = all.iter().map(|p| p.policy_id.as_str()).collect(); + assert_eq!(ids, vec!["policy.a", "policy.default", "policy.z"]); + } + + #[test] + fn subscribe_notifies_on_register() { + let registry = PolicyRegistry::new(); + let mut rx = registry.subscribe_changes(); + registry.register(test_policy("policy.test")).unwrap(); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn subscribe_notifies_on_unregister() { + let registry = PolicyRegistry::new(); + registry.register(test_policy("policy.test")).unwrap(); + let mut rx = registry.subscribe_changes(); + registry.unregister("policy.test").unwrap(); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn register_valid_decision_rules_succeeds() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "policy.decision.strict".into(), + mode: "macp.mode.decision.v1".into(), + description: "strict decision".into(), + rules: serde_json::json!({ + "voting": { "algorithm": "unanimous" }, + "commitment": { "require_vote_quorum": true } + }), + schema_version: 1, + }; + registry.register(policy).unwrap(); + } + + #[test] + fn register_valid_proposal_rules_succeeds() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "policy.proposal.limited".into(), + mode: "macp.mode.proposal.v1".into(), + description: "limited proposals".into(), + rules: serde_json::json!({ + "acceptance": { "criterion": "all_parties" }, + "counter_proposal": { "max_rounds": 3 }, + "rejection": { "terminal_on_any_reject": false } + }), + schema_version: 1, + }; + registry.register(policy).unwrap(); + } + + #[test] + fn register_valid_task_rules_succeeds() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "policy.task.strict".into(), + mode: "macp.mode.task.v1".into(), + description: "strict task".into(), + rules: serde_json::json!({ + "assignment": { "allow_reassignment_on_reject": false }, + "completion": { "require_output": true } + }), + schema_version: 1, + }; + registry.register(policy).unwrap(); + } + + #[test] + fn register_valid_handoff_rules_succeeds() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "policy.handoff.strict".into(), + mode: "macp.mode.handoff.v1".into(), + description: "strict handoff".into(), + rules: serde_json::json!({ + "acceptance": { "implicit_accept_timeout_ms": 5000 }, + "commitment": { "authority": "initiator_only" } + }), + schema_version: 1, + }; + registry.register(policy).unwrap(); + } + + #[test] + fn register_valid_quorum_rules_succeeds() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "policy.quorum.strict".into(), + mode: "macp.mode.quorum.v1".into(), + description: "strict quorum".into(), + rules: serde_json::json!({ + "threshold": { "type": "percentage", "value": 75.0 }, + "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" } + }), + schema_version: 1, + }; + registry.register(policy).unwrap(); + } + + #[test] + fn register_extension_mode_accepts_any_rules() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "policy.custom.ext".into(), + mode: "ext.custom.v1".into(), + description: "custom extension".into(), + rules: serde_json::json!({ + "arbitrary_field": "any_value", + "nested": { "deep": true } + }), + schema_version: 1, + }; + registry.register(policy).unwrap(); + } + + #[test] + fn register_weighted_without_weights_fails() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "test-weighted".into(), + mode: "macp.mode.decision.v1".into(), + description: "weighted without weights".into(), + rules: serde_json::json!({ + "voting": { "algorithm": "weighted" } + }), + schema_version: 1, + }; + let err = registry.register(policy).unwrap_err(); + assert!(err.contains("weighted"), "error: {err}"); + } + + #[test] + fn register_supermajority_low_threshold_fails() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "test-super".into(), + mode: "macp.mode.decision.v1".into(), + description: "supermajority with low threshold".into(), + rules: serde_json::json!({ + "voting": { "algorithm": "supermajority", "threshold": 0.4 } + }), + schema_version: 1, + }; + let err = registry.register(policy).unwrap_err(); + assert!(err.contains("supermajority"), "error: {err}"); + } + + #[test] + fn register_designated_role_without_roles_fails() { + let registry = PolicyRegistry::new(); + let policy = PolicyDefinition { + policy_id: "test-designated".into(), + mode: "macp.mode.decision.v1".into(), + description: "designated_role without roles".into(), + rules: serde_json::json!({ + "commitment": { "authority": "designated_role" } + }), + schema_version: 1, + }; + let err = registry.register(policy).unwrap_err(); + assert!(err.contains("designated_role"), "error: {err}"); + } +} diff --git a/src/policy/rules.rs b/src/policy/rules.rs new file mode 100644 index 0000000..afed5b9 --- /dev/null +++ b/src/policy/rules.rs @@ -0,0 +1,428 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// ── Decision Policy Rules ─────────────────────────────────────────── + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct DecisionPolicyRules { + #[serde(default)] + pub voting: VotingRules, + #[serde(default)] + pub objection_handling: ObjectionHandlingRules, + #[serde(default)] + pub evaluation: EvaluationRules, + #[serde(default)] + pub commitment: CommitmentRules, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VotingRules { + #[serde(default = "default_algorithm")] + pub algorithm: String, + #[serde(default = "default_threshold")] + pub threshold: f64, + #[serde(default)] + pub quorum: QuorumRules, + #[serde(default)] + pub weights: HashMap, +} + +impl Default for VotingRules { + fn default() -> Self { + Self { + algorithm: default_algorithm(), + threshold: default_threshold(), + quorum: QuorumRules::default(), + weights: HashMap::new(), + } + } +} + +fn default_algorithm() -> String { + "none".into() +} + +fn default_threshold() -> f64 { + 0.5 +} + +/// Quorum rules used inside Decision mode's `voting.quorum`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct QuorumRules { + #[serde(default = "default_quorum_type", rename = "type")] + pub quorum_type: String, + #[serde(default)] + pub value: f64, +} + +impl Default for QuorumRules { + fn default() -> Self { + Self { + quorum_type: default_quorum_type(), + value: 0.0, + } + } +} + +fn default_quorum_type() -> String { + "count".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ObjectionHandlingRules { + #[serde(default)] + pub block_severity_vetoes: bool, + #[serde(default = "default_veto_threshold")] + pub veto_threshold: u32, +} + +impl Default for ObjectionHandlingRules { + fn default() -> Self { + Self { + block_severity_vetoes: false, + veto_threshold: default_veto_threshold(), + } + } +} + +fn default_veto_threshold() -> u32 { + 1 +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EvaluationRules { + #[serde(default)] + pub required_before_voting: bool, + #[serde(default)] + pub minimum_confidence: f64, +} + +impl Default for EvaluationRules { + fn default() -> Self { + Self { + required_before_voting: false, + minimum_confidence: 0.0, + } + } +} + +/// Commitment rules shared across all mode policy schemas (RFC-MACP-0012). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommitmentRules { + #[serde(default = "default_authority")] + pub authority: String, + #[serde(default)] + pub designated_roles: Vec, + #[serde(default)] + pub require_vote_quorum: bool, +} + +impl Default for CommitmentRules { + fn default() -> Self { + Self { + authority: default_authority(), + designated_roles: Vec::new(), + require_vote_quorum: false, + } + } +} + +fn default_authority() -> String { + "initiator_only".into() +} + +// ── Proposal Policy Rules (RFC-MACP-0012 Section 4.3) ────────────── + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct ProposalPolicyRules { + #[serde(default)] + pub acceptance: ProposalAcceptanceRules, + #[serde(default)] + pub counter_proposal: CounterProposalRules, + #[serde(default)] + pub rejection: RejectionRules, + #[serde(default)] + pub commitment: CommitmentRules, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ProposalAcceptanceRules { + #[serde(default = "default_acceptance_criterion")] + pub criterion: String, +} + +impl Default for ProposalAcceptanceRules { + fn default() -> Self { + Self { + criterion: default_acceptance_criterion(), + } + } +} + +fn default_acceptance_criterion() -> String { + "all_parties".into() +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct CounterProposalRules { + #[serde(default)] + pub max_rounds: usize, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct RejectionRules { + #[serde(default)] + pub terminal_on_any_reject: bool, +} + +// ── Task Policy Rules (RFC-MACP-0012 Section 4.4) ────────────────── + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct TaskPolicyRules { + #[serde(default)] + pub assignment: TaskAssignmentRules, + #[serde(default)] + pub completion: TaskCompletionRules, + #[serde(default)] + pub commitment: CommitmentRules, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TaskAssignmentRules { + #[serde(default)] + pub allow_reassignment_on_reject: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TaskCompletionRules { + #[serde(default)] + pub require_output: bool, +} + +// ── Handoff Policy Rules (RFC-MACP-0012 Section 4.5) ─────────────── + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct HandoffPolicyRules { + #[serde(default)] + pub acceptance: HandoffAcceptanceRules, + #[serde(default)] + pub commitment: CommitmentRules, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct HandoffAcceptanceRules { + #[serde(default)] + pub implicit_accept_timeout_ms: u64, +} + +// ── Quorum Policy Rules (RFC-MACP-0012 Section 4.2) ──────────────── + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct QuorumPolicyRules { + #[serde(default)] + pub threshold: QuorumThreshold, + #[serde(default)] + pub abstention: AbstentionRules, + #[serde(default)] + pub commitment: CommitmentRules, +} + +/// Threshold rules for Quorum mode (distinct from `QuorumRules` used in Decision mode's `voting.quorum`). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct QuorumThreshold { + #[serde(default = "default_threshold_type", rename = "type")] + pub threshold_type: String, + #[serde(default)] + pub value: f64, +} + +impl Default for QuorumThreshold { + fn default() -> Self { + Self { + threshold_type: default_threshold_type(), + value: 0.0, + } + } +} + +fn default_threshold_type() -> String { + "n_of_m".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AbstentionRules { + #[serde(default)] + pub counts_toward_quorum: bool, + #[serde(default = "default_interpretation")] + pub interpretation: String, +} + +impl Default for AbstentionRules { + fn default() -> Self { + Self { + counts_toward_quorum: false, + interpretation: default_interpretation(), + } + } +} + +fn default_interpretation() -> String { + "neutral".into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decision_policy_rules_defaults() { + let rules = DecisionPolicyRules::default(); + assert_eq!(rules.voting.algorithm, "none"); + assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON); + assert_eq!(rules.voting.quorum.quorum_type, "count"); + assert!(!rules.objection_handling.block_severity_vetoes); + assert_eq!(rules.objection_handling.veto_threshold, 1); + assert!(!rules.evaluation.required_before_voting); + assert!((rules.evaluation.minimum_confidence).abs() < f64::EPSILON); + assert_eq!(rules.commitment.authority, "initiator_only"); + assert!(rules.commitment.designated_roles.is_empty()); + assert!(!rules.commitment.require_vote_quorum); + } + + #[test] + fn decision_policy_rules_deserialization() { + let json = serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.6, + "quorum": { "type": "percentage", "value": 75.0 }, + "weights": { "agent://fraud": 2.0, "agent://growth": 1.0 } + }, + "objection_handling": { + "block_severity_vetoes": true, + "veto_threshold": 2 + }, + "evaluation": { + "required_before_voting": true, + "minimum_confidence": 0.8 + }, + "commitment": { + "authority": "designated_role", + "designated_roles": ["agent://lead"], + "require_vote_quorum": true + } + }); + + let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap(); + assert_eq!(rules.voting.algorithm, "majority"); + assert!((rules.voting.threshold - 0.6).abs() < f64::EPSILON); + assert_eq!(rules.voting.quorum.quorum_type, "percentage"); + assert!((rules.voting.quorum.value - 75.0).abs() < f64::EPSILON); + assert_eq!(*rules.voting.weights.get("agent://fraud").unwrap(), 2.0); + assert!(rules.objection_handling.block_severity_vetoes); + assert_eq!(rules.objection_handling.veto_threshold, 2); + assert!(rules.evaluation.required_before_voting); + assert!((rules.evaluation.minimum_confidence - 0.8).abs() < f64::EPSILON); + assert_eq!(rules.commitment.authority, "designated_role"); + assert_eq!(rules.commitment.designated_roles, vec!["agent://lead"]); + assert!(rules.commitment.require_vote_quorum); + } + + #[test] + fn partial_deserialization_fills_defaults() { + let json = serde_json::json!({ + "voting": { "algorithm": "unanimous" } + }); + let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap(); + assert_eq!(rules.voting.algorithm, "unanimous"); + assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON); + assert!(!rules.objection_handling.block_severity_vetoes); + assert_eq!(rules.objection_handling.veto_threshold, 1); + } + + #[test] + fn proposal_policy_rules_defaults() { + let rules = ProposalPolicyRules::default(); + assert_eq!(rules.acceptance.criterion, "all_parties"); + assert_eq!(rules.counter_proposal.max_rounds, 0); + assert!(!rules.rejection.terminal_on_any_reject); + assert_eq!(rules.commitment.authority, "initiator_only"); + } + + #[test] + fn proposal_policy_rules_deserialization() { + let json = serde_json::json!({ + "acceptance": { "criterion": "counterparty" }, + "counter_proposal": { "max_rounds": 3 }, + "rejection": { "terminal_on_any_reject": true }, + "commitment": { "authority": "any_participant" } + }); + let rules: ProposalPolicyRules = serde_json::from_value(json).unwrap(); + assert_eq!(rules.acceptance.criterion, "counterparty"); + assert_eq!(rules.counter_proposal.max_rounds, 3); + assert!(rules.rejection.terminal_on_any_reject); + assert_eq!(rules.commitment.authority, "any_participant"); + } + + #[test] + fn task_policy_rules_defaults() { + let rules = TaskPolicyRules::default(); + assert!(!rules.assignment.allow_reassignment_on_reject); + assert!(!rules.completion.require_output); + assert_eq!(rules.commitment.authority, "initiator_only"); + } + + #[test] + fn task_policy_rules_deserialization() { + let json = serde_json::json!({ + "assignment": { "allow_reassignment_on_reject": true }, + "completion": { "require_output": true }, + "commitment": { "authority": "initiator_only" } + }); + let rules: TaskPolicyRules = serde_json::from_value(json).unwrap(); + assert!(rules.assignment.allow_reassignment_on_reject); + assert!(rules.completion.require_output); + } + + #[test] + fn handoff_policy_rules_defaults() { + let rules = HandoffPolicyRules::default(); + assert_eq!(rules.acceptance.implicit_accept_timeout_ms, 0); + assert_eq!(rules.commitment.authority, "initiator_only"); + } + + #[test] + fn handoff_policy_rules_deserialization() { + let json = serde_json::json!({ + "acceptance": { "implicit_accept_timeout_ms": 5000 }, + "commitment": { "authority": "any_participant" } + }); + let rules: HandoffPolicyRules = serde_json::from_value(json).unwrap(); + assert_eq!(rules.acceptance.implicit_accept_timeout_ms, 5000); + assert_eq!(rules.commitment.authority, "any_participant"); + } + + #[test] + fn quorum_policy_rules_defaults() { + let rules = QuorumPolicyRules::default(); + assert_eq!(rules.threshold.threshold_type, "n_of_m"); + assert!((rules.threshold.value).abs() < f64::EPSILON); + assert!(!rules.abstention.counts_toward_quorum); + assert_eq!(rules.abstention.interpretation, "neutral"); + assert_eq!(rules.commitment.authority, "initiator_only"); + } + + #[test] + fn quorum_policy_rules_deserialization() { + let json = serde_json::json!({ + "threshold": { "type": "percentage", "value": 75.0 }, + "abstention": { "counts_toward_quorum": true, "interpretation": "implicit_reject" }, + "commitment": { "authority": "initiator_only" } + }); + let rules: QuorumPolicyRules = serde_json::from_value(json).unwrap(); + assert_eq!(rules.threshold.threshold_type, "percentage"); + assert!((rules.threshold.value - 75.0).abs() < f64::EPSILON); + assert!(rules.abstention.counts_toward_quorum); + assert_eq!(rules.abstention.interpretation, "implicit_reject"); + } +} diff --git a/src/replay.rs b/src/replay.rs index 308d949..5bdd0ae 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -244,7 +244,7 @@ mod tests { fn start_payload_bytes() -> Vec { SessionStartPayload { intent: "test".into(), - participants: vec!["agent://fraud".into()], + participants: vec!["agent://orchestrator".into(), "agent://fraud".into()], mode_version: "1.0.0".into(), configuration_version: "cfg-1".into(), policy_version: "policy-1".into(), @@ -313,6 +313,7 @@ mod tests { mode_version: "1.0.0".into(), policy_version: "policy-1".into(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec(); diff --git a/src/runtime.rs b/src/runtime.rs index afceba1..cd664b5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -277,7 +277,7 @@ impl Runtime { duplicate: true, }); } - return Err(MacpError::DuplicateSession); + return Err(MacpError::SessionAlreadyExists); } // Enforce max_open_sessions atomically under the write lock to @@ -298,21 +298,23 @@ impl Runtime { } } - // Resolve the governance policy for this session (if policy_version is bound). - let policy_definition = if start_payload.policy_version.is_empty() { - None + // Resolve the governance policy for this session. + // RFC-0003 §3: sessions MUST bind policy_version; default to "policy.default". + let effective_policy_version = if start_payload.policy_version.is_empty() { + crate::policy::defaults::DEFAULT_POLICY_ID.to_string() } else { - match self.policy_registry.resolve(&start_payload.policy_version) { - Ok(policy) => { - // RFC 6.1: reject if policy mode doesn't match session mode - if policy.mode != "*" && policy.mode != mode_name { - return Err(MacpError::InvalidPolicyDefinition); - } - Some(policy) - } - Err(_) => { - return Err(MacpError::UnknownPolicyVersion); + start_payload.policy_version.clone() + }; + let policy_definition = match self.policy_registry.resolve(&effective_policy_version) { + Ok(policy) => { + // RFC 6.1: reject if policy mode doesn't match session mode + if policy.mode != "*" && policy.mode != mode_name { + return Err(MacpError::InvalidPolicyDefinition); } + Some(policy) + } + Err(_) => { + return Err(MacpError::UnknownPolicyVersion); } }; @@ -332,7 +334,7 @@ impl Runtime { intent: start_payload.intent.clone(), mode_version: start_payload.mode_version.clone(), configuration_version: start_payload.configuration_version.clone(), - policy_version: start_payload.policy_version.clone(), + policy_version: effective_policy_version, context: start_payload.context.clone(), roots: start_payload.roots.clone(), initiator_sender: env.sender.clone(), @@ -709,7 +711,7 @@ mod tests { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://fraud".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]), ), None, ) @@ -965,7 +967,7 @@ mod tests { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://fraud".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]), ); rt.process(&start, None).await.unwrap(); let first = events.recv().await.unwrap(); @@ -1069,8 +1071,9 @@ mod tests { authority_scope: "commercial".into(), reason: "bound".into(), mode_version: "1.0.0".into(), - policy_version: String::new(), + policy_version: "policy.default".into(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec(); let result = rt @@ -1273,7 +1276,7 @@ mod tests { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://fraud".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]), ), None, ) diff --git a/src/server.rs b/src/server.rs index 159c69e..2bd4047 100644 --- a/src/server.rs +++ b/src/server.rs @@ -311,13 +311,17 @@ impl MacpServer { .await?; while let Some(envelope) = Self::try_next_stream_event(&mut session_events)? { yield StreamSessionResponse { - envelope: Some(envelope), + response: Some( + macp_runtime::pb::stream_session_response::Response::Envelope(envelope), + ), }; } } StreamAction::EmitEnvelope(envelope) => { yield StreamSessionResponse { - envelope: Some(envelope), + response: Some( + macp_runtime::pb::stream_session_response::Response::Envelope(envelope), + ), }; } StreamAction::ClientError(status) => { @@ -326,7 +330,9 @@ impl MacpServer { StreamAction::ClientDone => { while let Some(envelope) = Self::try_next_stream_event(&mut session_events)? { yield StreamSessionResponse { - envelope: Some(envelope), + response: Some( + macp_runtime::pb::stream_session_response::Response::Envelope(envelope), + ), }; } break; @@ -353,7 +359,9 @@ impl MacpServer { .await?; while let Some(envelope) = Self::try_next_stream_event(&mut session_events)? { yield StreamSessionResponse { - envelope: Some(envelope), + response: Some( + macp_runtime::pb::stream_session_response::Response::Envelope(envelope), + ), }; } } @@ -375,6 +383,7 @@ impl MacpServer { MacpError::StorageFailed => Status::internal(err.to_string()), MacpError::InvalidSessionId => Status::invalid_argument(err.to_string()), MacpError::InvalidPolicyDefinition => Status::invalid_argument(err.to_string()), + MacpError::SessionAlreadyExists => Status::already_exists(err.to_string()), _ => Status::failed_precondition(err.to_string()), } } @@ -510,6 +519,7 @@ impl MacpRuntimeService for MacpServer { policy_version: session.policy_version.clone(), participants: session.participants.clone(), participant_activity, + initiator: session.initiator_sender.clone(), }), })) } @@ -723,7 +733,7 @@ impl MacpRuntimeService for MacpServer { .map_err(Self::status_from_error)?; let req = request.into_inner(); let descriptor = req - .descriptor + .mode_descriptor .ok_or_else(|| Status::invalid_argument("descriptor required"))?; match self.runtime.register_extension(descriptor) { Ok(()) => Ok(Response::new(RegisterExtModeResponse { @@ -807,7 +817,7 @@ impl MacpRuntimeService for MacpServer { .map_err(Self::status_from_error)?; let req = request.into_inner(); let descriptor = req - .descriptor + .policy_descriptor .ok_or_else(|| Status::invalid_argument("descriptor required"))?; let definition = Self::policy_descriptor_to_definition(&descriptor); match self.runtime.register_policy(definition) { @@ -860,7 +870,7 @@ impl MacpRuntimeService for MacpServer { .get_policy(&req.policy_id) .ok_or_else(|| Status::not_found(format!("Policy '{}' not found", req.policy_id)))?; Ok(Response::new(GetPolicyResponse { - descriptor: Some(Self::policy_definition_to_descriptor(&policy)), + policy_descriptor: Some(Self::policy_definition_to_descriptor(&policy)), })) } @@ -901,22 +911,22 @@ impl MacpRuntimeService for MacpServer { let policies = runtime.list_policies(None); let descriptors: Vec = policies .iter() - .map(|p| MacpServer::policy_definition_to_descriptor(p)) + .map(MacpServer::policy_definition_to_descriptor) .collect(); yield WatchPoliciesResponse { descriptors, - observed_at_unix_ms: chrono::Utc::now().timestamp_millis() as u64, + observed_at_unix_ms: chrono::Utc::now().timestamp_millis(), }; // Wait for changes while rx.recv().await.is_ok() { let policies = runtime.list_policies(None); let descriptors: Vec = policies .iter() - .map(|p| MacpServer::policy_definition_to_descriptor(p)) + .map(MacpServer::policy_definition_to_descriptor) .collect(); yield WatchPoliciesResponse { descriptors, - observed_at_unix_ms: chrono::Utc::now().timestamp_millis() as u64, + observed_at_unix_ms: chrono::Utc::now().timestamp_millis(), }; } }; @@ -953,7 +963,7 @@ impl MacpServer { description: definition.description.clone(), rules: serde_json::to_vec(&definition.rules).unwrap_or_default(), schema_version: definition.schema_version, - registered_at: String::new(), + registered_at_unix_ms: 0, } } } @@ -1095,7 +1105,7 @@ mod tests { let server = MacpServer::new(runtime, security); let req = Request::new(RegisterExtModeRequest { - descriptor: Some(macp_runtime::pb::ModeDescriptor { + mode_descriptor: Some(macp_runtime::pb::ModeDescriptor { mode: "ext.custom.v1".into(), mode_version: "1.0.0".into(), message_types: vec!["SessionStart".into(), "Commitment".into()], @@ -1139,7 +1149,10 @@ mod tests { server.build_stream_session_stream(stream_identity("agent://orchestrator"), requests); let response = stream.next().await.unwrap().unwrap(); - let envelope = response.envelope.unwrap(); + let envelope = match response.response.unwrap() { + macp_runtime::pb::stream_session_response::Response::Envelope(e) => e, + _ => panic!("expected envelope"), + }; assert_eq!(envelope.message_type, "SessionStart"); assert_eq!(envelope.message_id, "m1"); assert!(stream.next().await.is_none()); @@ -1183,7 +1196,11 @@ mod tests { server.build_stream_session_stream(stream_identity("agent://orchestrator"), requests); let first = stream.next().await.unwrap().unwrap(); - assert_eq!(first.envelope.unwrap().session_id, sid1); + let first_env = match first.response.unwrap() { + macp_runtime::pb::stream_session_response::Response::Envelope(e) => e, + _ => panic!("expected envelope"), + }; + assert_eq!(first_env.session_id, sid1); let err = stream.next().await.unwrap().unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); } diff --git a/tests/concurrent_messages.rs b/tests/concurrent_messages.rs index 782d43e..01fbe8d 100644 --- a/tests/concurrent_messages.rs +++ b/tests/concurrent_messages.rs @@ -129,7 +129,7 @@ async fn concurrent_duplicate_messages_handled() { "m0", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://a".into()]), ), None, ) diff --git a/tests/conformance/decision_happy_path.json b/tests/conformance/decision_happy_path.json index cf1ca6f..e647670 100644 --- a/tests/conformance/decision_happy_path.json +++ b/tests/conformance/decision_happy_path.json @@ -2,6 +2,7 @@ "mode": "macp.mode.decision.v1", "initiator": "agent://orchestrator", "participants": [ + "agent://orchestrator", "agent://a", "agent://b" ], @@ -43,8 +44,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "accept" } @@ -54,7 +56,8 @@ "expected_resolution": { "action": "decision.selected", "mode_version": "1.0.0", - "configuration_version": "cfg-1" + "configuration_version": "cfg-1", + "outcome_positive": true }, "expected_mode_state": { "phase": "Committed", diff --git a/tests/conformance/decision_reject_paths.json b/tests/conformance/decision_reject_paths.json index cfb83bd..b5d6a4e 100644 --- a/tests/conformance/decision_reject_paths.json +++ b/tests/conformance/decision_reject_paths.json @@ -2,6 +2,7 @@ "mode": "macp.mode.decision.v1", "initiator": "agent://orchestrator", "participants": [ + "agent://orchestrator", "agent://a", "agent://b" ], @@ -45,8 +46,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "reject", "expected_error_code": "FORBIDDEN" diff --git a/tests/conformance/handoff_happy_path.json b/tests/conformance/handoff_happy_path.json index 5a2cb0f..8d6396c 100644 --- a/tests/conformance/handoff_happy_path.json +++ b/tests/conformance/handoff_happy_path.json @@ -31,8 +31,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "accept" } diff --git a/tests/conformance/multi_round_happy_path.json b/tests/conformance/multi_round_happy_path.json index 0946aec..4928855 100644 --- a/tests/conformance/multi_round_happy_path.json +++ b/tests/conformance/multi_round_happy_path.json @@ -38,8 +38,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "accept" } diff --git a/tests/conformance/multi_round_reject_paths.json b/tests/conformance/multi_round_reject_paths.json index b7c0d93..32d8d85 100644 --- a/tests/conformance/multi_round_reject_paths.json +++ b/tests/conformance/multi_round_reject_paths.json @@ -17,8 +17,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "reject" }, @@ -46,8 +47,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "reject" } diff --git a/tests/conformance/proposal_happy_path.json b/tests/conformance/proposal_happy_path.json index 724a2f5..0ec8f7e 100644 --- a/tests/conformance/proposal_happy_path.json +++ b/tests/conformance/proposal_happy_path.json @@ -53,8 +53,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "accept" } @@ -64,7 +65,8 @@ "expected_resolution": { "action": "proposal.accepted", "mode_version": "1.0.0", - "configuration_version": "cfg-1" + "configuration_version": "cfg-1", + "outcome_positive": true }, "expected_mode_state": { "phase": "Committed", diff --git a/tests/conformance/proposal_reject_paths.json b/tests/conformance/proposal_reject_paths.json index 72282e1..f64dbec 100644 --- a/tests/conformance/proposal_reject_paths.json +++ b/tests/conformance/proposal_reject_paths.json @@ -20,8 +20,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "reject", "expected_error_code": "INVALID_ENVELOPE" diff --git a/tests/conformance/quorum_happy_path.json b/tests/conformance/quorum_happy_path.json index a13a1e5..87a093c 100644 --- a/tests/conformance/quorum_happy_path.json +++ b/tests/conformance/quorum_happy_path.json @@ -38,8 +38,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "accept" } diff --git a/tests/conformance/quorum_reject_paths.json b/tests/conformance/quorum_reject_paths.json index 0156776..49efd7e 100644 --- a/tests/conformance/quorum_reject_paths.json +++ b/tests/conformance/quorum_reject_paths.json @@ -38,8 +38,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "reject" } diff --git a/tests/conformance/task_happy_path.json b/tests/conformance/task_happy_path.json index c1cc844..11953bd 100644 --- a/tests/conformance/task_happy_path.json +++ b/tests/conformance/task_happy_path.json @@ -38,8 +38,9 @@ "authority_scope": "test", "reason": "done", "mode_version": "1.0.0", - "policy_version": "", - "configuration_version": "cfg-1" + "policy_version": "policy.default", + "configuration_version": "cfg-1", + "outcome_positive": true }, "expect": "accept" } diff --git a/tests/conformance_loader.rs b/tests/conformance_loader.rs index 870b5d7..4d1bd6a 100644 --- a/tests/conformance_loader.rs +++ b/tests/conformance_loader.rs @@ -74,6 +74,7 @@ fn encode_payload(fixture: &ConformanceFixture, msg: &ConformanceMessage) -> Vec .as_str() .unwrap_or_default() .into(), + outcome_positive: p["outcome_positive"].as_bool().unwrap_or(true), } .encode_to_vec() } @@ -280,6 +281,7 @@ fn resolution_to_json(resolution: &[u8]) -> Option { "mode_version": commitment.mode_version, "policy_version": commitment.policy_version, "configuration_version": commitment.configuration_version, + "outcome_positive": commitment.outcome_positive, }) }) } diff --git a/tests/file_backend_integration.rs b/tests/file_backend_integration.rs index 11d3a5d..8560127 100644 --- a/tests/file_backend_integration.rs +++ b/tests/file_backend_integration.rs @@ -54,8 +54,9 @@ fn commitment(action: &str) -> Vec { authority_scope: "test".into(), reason: "done".into(), mode_version: "1.0.0".into(), - policy_version: String::new(), + policy_version: "policy.default".into(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec() } @@ -79,7 +80,7 @@ async fn file_backend_full_lifecycle() { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://a".into()]), ), None, ) @@ -169,7 +170,7 @@ async fn file_backend_crash_recovery_via_replay() { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://a".into()]), ), None, ) diff --git a/tests/integration_mode_lifecycle.rs b/tests/integration_mode_lifecycle.rs index a63af23..ac0f2bc 100644 --- a/tests/integration_mode_lifecycle.rs +++ b/tests/integration_mode_lifecycle.rs @@ -60,8 +60,9 @@ fn commitment(action: &str) -> Vec { authority_scope: "test".into(), reason: "done".into(), mode_version: "1.0.0".into(), - policy_version: String::new(), + policy_version: "policy.default".into(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec() } @@ -81,7 +82,11 @@ async fn decision_full_lifecycle_through_runtime() { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into(), "agent://b".into()]), + session_start(vec![ + "agent://orchestrator".into(), + "agent://a".into(), + "agent://b".into(), + ]), ), None, ) diff --git a/tests/replay_round_trip.rs b/tests/replay_round_trip.rs index f36e5a5..bf82bf9 100644 --- a/tests/replay_round_trip.rs +++ b/tests/replay_round_trip.rs @@ -53,6 +53,7 @@ fn commitment(action: &str) -> Vec { mode_version: "1.0.0".into(), policy_version: "policy-1".into(), configuration_version: "cfg-1".into(), + outcome_positive: true, } .encode_to_vec() } @@ -69,7 +70,7 @@ fn replay_decision_session() { "m1", "SessionStart", "agent://orchestrator", - start_payload(vec!["agent://a", "agent://b"]), + start_payload(vec!["agent://orchestrator", "agent://a", "agent://b"]), mode, 1000, ), diff --git a/tests/stream_integration.rs b/tests/stream_integration.rs index 04e0614..4fc58b9 100644 --- a/tests/stream_integration.rs +++ b/tests/stream_integration.rs @@ -66,7 +66,7 @@ async fn stream_receives_accepted_envelopes() { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://a".into()]), ), None, ) @@ -93,7 +93,7 @@ async fn stream_ordering_matches_processing_order() { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://a".into()]), ), None, ) @@ -143,7 +143,7 @@ async fn concurrent_subscribers_both_receive_events() { "m1", &sid, "agent://orchestrator", - session_start(vec!["agent://a".into()]), + session_start(vec!["agent://orchestrator".into(), "agent://a".into()]), ), None, ) From 1cde73e31d4e9f181e38fcb247b98c47da069568 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Mon, 6 Apr 2026 18:07:29 -0700 Subject: [PATCH 03/10] Fix the Policy and Modes --- .github/workflows/ci.yml | 10 + Makefile | 5 +- build.rs | 1 + docs/policy.md | 78 +++++ docs/protocol.md | 11 +- src/mode/decision.rs | 9 +- src/mode/handoff.rs | 70 +++++ src/mode/proposal.rs | 41 +++ src/mode/quorum.rs | 96 ++++++ src/mode/task.rs | 77 +++++ src/mode/util.rs | 4 +- src/policy/defaults.rs | 2 +- src/policy/evaluator.rs | 304 +++++++++++++++---- src/policy/rules.rs | 15 +- src/runtime.rs | 45 ++- src/server.rs | 78 ++++- tests/conformance/decision_happy_path.json | 2 +- tests/conformance/decision_reject_paths.json | 2 +- tests/replay_round_trip.rs | 2 +- 19 files changed, 753 insertions(+), 99 deletions(-) create mode 100644 docs/policy.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71e4340..3814800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,16 @@ jobs: env: MACP_MEMORY_ONLY: "1" + - name: Run conformance tests + run: cargo test conformance + env: + MACP_MEMORY_ONLY: "1" + + - name: Run policy tests + run: cargo test policy + env: + MACP_MEMORY_ONLY: "1" + build: name: Build runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index d36bf98..86c6ca7 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,10 @@ test-integration: test-conformance: cargo test conformance -test-all: fmt clippy test test-integration test-conformance +test-policy: + cargo test policy + +test-all: fmt clippy test test-integration test-conformance test-policy coverage: cargo tarpaulin --all-targets --out html diff --git a/build.rs b/build.rs index 798247a..8c74523 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,7 @@ fn main() -> Result<(), Box> { &[ "macp/v1/envelope.proto", "macp/v1/core.proto", + "macp/v1/policy.proto", "macp/modes/decision/v1/decision.proto", "macp/modes/proposal/v1/proposal.proto", "macp/modes/task/v1/task.proto", diff --git a/docs/policy.md b/docs/policy.md new file mode 100644 index 0000000..91a83d9 --- /dev/null +++ b/docs/policy.md @@ -0,0 +1,78 @@ +# Governance Policy Architecture + +This document describes the MACP runtime's governance policy framework, implementing RFC-MACP-0012. + +## Overview + +Governance policies provide declarative, deterministic rules that constrain coordination sessions beyond the built-in mode semantics. Policies are resolved at `SessionStart` and evaluated at `Commitment` time. + +## Policy Registry + +The policy registry (`src/policy/registry.rs`) is an in-memory store of `PolicyDefinition` objects. + +- **Default policy**: `policy.default` is always pre-loaded (mode `*`, empty rules, no constraints) +- **Registration**: `RegisterPolicy` gRPC RPC; validates rules against mode-specific JSON schema +- **Unregistration**: `UnregisterPolicy`; does not affect active sessions +- **Query**: `GetPolicy`, `ListPolicies` (with optional mode filter) +- **Watch**: `WatchPolicies` streams notifications on registry changes + +### Conditional Validation + +At registration time, the registry validates: +- `voting.algorithm == "weighted"` requires non-empty `voting.weights` +- `voting.algorithm == "supermajority"` requires `voting.threshold > 0.5` +- `commitment.authority == "designated_role"` requires non-empty `commitment.designated_roles` + +## Policy Resolution (SessionStart) + +When a `SessionStart` is processed (`src/runtime.rs`): + +1. Extract `policy_version` from `SessionStartPayload` +2. If empty, resolve to `"policy.default"` +3. Look up in policy registry; fail with `UNKNOWN_POLICY_VERSION` if not found +4. Verify mode match: policy `mode` must be `"*"` or match session mode +5. Store the resolved `PolicyDefinition` immutably on the `Session` struct + +## Policy Evaluation (Commitment) + +When a `Commitment` message is processed, each mode calls its evaluator (`src/policy/evaluator.rs`): + +| Mode | Evaluator | Checks | +|------|-----------|--------| +| Decision | `evaluate_decision_commitment()` | Evaluation confidence, objection veto, quorum, voting threshold | +| Proposal | `evaluate_proposal_commitment()` | Counter-proposal round limit | +| Task | `evaluate_task_commitment()` | Output requirement | +| Handoff | `evaluate_handoff_commitment()` | Always allows (implicit timeout handled by mode) | +| Quorum | `evaluate_quorum_commitment()` | Threshold, abstention interpretation | + +### Determinism + +Policy evaluation is a **pure function** of: resolved rules + accepted message history + declared participants. No wall-clock time, external calls, or randomness. + +## Per-Mode Rule Schemas + +Rule schemas are defined in `src/policy/rules.rs` as serde structs: + +- `DecisionPolicyRules`: voting, objection_handling, evaluation, commitment +- `QuorumPolicyRules`: threshold, abstention, commitment +- `ProposalPolicyRules`: acceptance, counter_proposal, rejection, commitment +- `TaskPolicyRules`: assignment, completion, commitment +- `HandoffPolicyRules`: acceptance, commitment + +## Replay Invariant + +The resolved `PolicyDefinition` is persisted in the session snapshot. During replay, the stored descriptor is used — never re-resolved from the registry. This ensures deterministic outcomes across time. + +## Error Codes + +| Code | HTTP | When | +|------|------|------| +| `UNKNOWN_POLICY_VERSION` | 404 | Policy not found at SessionStart | +| `POLICY_DENIED` | 403 | Commitment rejected by governance rules | +| `INVALID_POLICY_DEFINITION` | 400 | Policy fails validation at registration | + +## References + +- RFC-MACP-0012: Governance Policy Framework +- RFC-MACP-0001 Section 7.3: Session lifecycle +- RFC-MACP-0003: Determinism and replay integrity diff --git a/docs/protocol.md b/docs/protocol.md index f53b39f..ad72511 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -28,6 +28,14 @@ Clients should call `Initialize` before using the runtime. - `UnregisterExtMode` - `PromoteMode` +## Policy RPCs + +- `RegisterPolicy` — register a new governance policy descriptor +- `UnregisterPolicy` — remove a registered policy (does not affect active sessions) +- `GetPolicy` — retrieve a policy descriptor by ID +- `ListPolicies` — list registered policies, optionally filtered by mode +- `WatchPolicies` — stream notifications on policy registry changes + ## Streaming watch RPCs - `WatchModeRegistry` — sends the current registry state, then fires `RegistryChanged` on register/unregister/promote @@ -50,7 +58,8 @@ Clients should call `Initialize` before using the runtime. - stream attachment observes future accepted envelopes from the bind point; it does not backfill earlier history - accepted envelope order matches runtime admission order for that session - mixed-session streams are rejected with `FAILED_PRECONDITION` -- stream-level validation failures terminate the stream with a gRPC status; use `Send` if you need explicit per-message negative acknowledgements +- application-level validation errors (e.g. InvalidPayload, PolicyDenied) are sent as inline `MACPError` responses; the stream remains open (RFC-MACP-0001) +- transport-level failures (auth, rate limit, internal) terminate the stream with a gRPC status - to attach to an existing session without mutating it, send a session-scoped `Signal` envelope with the correct `session_id` and `mode` ## Strict session start rules diff --git a/src/mode/decision.rs b/src/mode/decision.rs index 908421a..c7b7a07 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -231,9 +231,10 @@ impl Mode for DecisionMode { "Vote" => { let payload = VotePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; - // RFC-MACP-0004: valid vote values - match payload.vote.as_str() { - "approve" | "reject" | "abstain" => {} + // RFC-MACP-0007: valid vote values (case-insensitive input, stored UPPERCASE) + let normalized_vote = payload.vote.to_uppercase(); + match normalized_vote.as_str() { + "APPROVE" | "REJECT" | "ABSTAIN" => {} _ => return Err(MacpError::InvalidPayload), } Self::ensure_can_vote(&state)?; @@ -246,7 +247,7 @@ impl Mode for DecisionMode { env.sender.clone(), Vote { proposal_id: payload.proposal_id, - vote: payload.vote, + vote: normalized_vote, reason: payload.reason, sender: env.sender.clone(), }, diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 477530c..668f200 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -997,4 +997,74 @@ mod tests { .unwrap(); assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); } + + // --- Second HandoffOffer while first pending --- + + #[test] + fn second_offer_to_different_target_while_first_pending_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + session.participants = vec!["owner".into(), "targetA".into(), "targetB".into()]; + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + // First offer to targetA — succeeds + let result = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "targetA")), + ) + .unwrap(); + apply(&mut session, result); + // Second offer to targetB while h1 is still pending — rejected + let err = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h2", "targetB")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- After HandoffAccept, further offers are allowed (prior resolved) --- + + #[test] + fn offer_after_accept_allowed_since_prior_resolved() { + let mode = HandoffMode; + let mut session = base_session(); + session.participants = vec!["owner".into(), "target".into(), "other".into()]; + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + // Send HandoffOffer to target + let result = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + // Target accepts — h1 disposition moves from Offered to Accepted + let result = mode + .on_message( + &session, + &env("target", "HandoffAccept", make_accept("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + // New HandoffOffer is allowed because no offer is in Offered disposition + let result = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h2", "other")), + ) + .unwrap(); + apply(&mut session, result); + let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap(); + assert_eq!(state.offers.len(), 2); + assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted); + assert_eq!(state.offers["h2"].disposition, HandoffDisposition::Offered); + } } diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index da7cf50..d56c163 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -1049,4 +1049,45 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "PolicyDenied"); } + + // --- CounterProposal does NOT retire original --- + + #[test] + fn counter_proposal_does_not_retire_original() { + let mode = ProposalMode; + let mut session = base_session(); + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, resp); + // Seller proposes p1 + let resp = mode + .on_message( + &session, + &env("agent://seller", "Proposal", make_proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // Buyer counter-proposes p2, superseding p1 + let resp = mode + .on_message( + &session, + &env( + "agent://buyer", + "CounterProposal", + make_counter_proposal("p2", "p1"), + ), + ) + .unwrap(); + apply(&mut session, resp); + // Verify both proposals are still live in the mode state + let state = decode(&session); + assert_eq!(state.proposals.len(), 2); + assert_eq!(state.proposals["p1"].disposition, ProposalDisposition::Live); + assert_eq!(state.proposals["p2"].disposition, ProposalDisposition::Live); + assert_eq!( + state.proposals["p2"].supersedes_proposal_id, + Some("p1".into()) + ); + } } diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs index d4f8fb2..efa8f46 100644 --- a/src/mode/quorum.rs +++ b/src/mode/quorum.rs @@ -1024,4 +1024,100 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "PolicyDenied"); } + + // --- All participants abstain — eligible for negative commitment --- + + #[test] + fn all_participants_abstain_allows_negative_commitment() { + let mode = QuorumMode; + let mut session = base_session(); + // 3 participants, required_approvals = 2 + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 2), + ), + ) + .unwrap(); + apply(&mut session, result); + // All 3 participants abstain + let result = mode + .on_message( + &session, + &env("alice", "Abstain", make_abstain("r1", "neutral")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("bob", "Abstain", make_abstain("r1", "neutral")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("carol", "Abstain", make_abstain("r1", "neutral")), + ) + .unwrap(); + apply(&mut session, result); + // Threshold is unreachable: 0 approvals + 0 remaining < 2 required + // commitment_ready() returns true, so a negative commitment should succeed + let negative_commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "quorum.rejected".into(), + authority_scope: "deploy".into(), + reason: "all abstained".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + outcome_positive: false, + } + .encode_to_vec(); + let result = mode + .on_message( + &session, + &env("coordinator", "Commitment", negative_commitment), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + // --- Initiator not in participants cannot cast ballot --- + + #[test] + fn initiator_not_in_participants_cannot_cast_ballot() { + let mode = QuorumMode; + let mut session = base_session(); + // coordinator is initiator but NOT in participants + // participants are alice, bob, carol (coordinator excluded) + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 2), + ), + ) + .unwrap(); + apply(&mut session, result); + // coordinator tries to Approve — should be Forbidden because coordinator + // is not a declared participant + let approve_env = env("coordinator", "Approve", make_approve("r1", "yes")); + let err = mode.authorize_sender(&session, &approve_env).unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } } diff --git a/src/mode/task.rs b/src/mode/task.rs index edc5241..0c640ad 100644 --- a/src/mode/task.rs +++ b/src/mode/task.rs @@ -1221,4 +1221,81 @@ mod tests { .unwrap(); assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); } + + // --- Competing TaskAccept --- + + #[test] + fn competing_task_accept_second_rejected() { + let mode = TaskMode; + let mut session = base_session(); + // Three participants: planner (initiator), w1, w2 + session.participants = vec!["planner".into(), "w1".into(), "w2".into()]; + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + // Open-assignee task (no specific requested_assignee) + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "")), + ) + .unwrap(); + apply(&mut session, result); + // First accept from w1 succeeds + let result = mode + .on_message( + &session, + &env("w1", "TaskAccept", make_task_accept("t1", "w1")), + ) + .unwrap(); + apply(&mut session, result); + let state: TaskState = serde_json::from_slice(&session.mode_state).unwrap(); + assert_eq!(state.active_assignee, Some("w1".into())); + // Second accept from w2 is rejected (active_assignee already set) + let err = mode + .on_message( + &session, + &env("w2", "TaskAccept", make_task_accept("t1", "w2")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Only active assignee can send TaskUpdate --- + + #[test] + fn only_active_assignee_can_send_task_update() { + let mode = TaskMode; + let mut session = base_session(); + session.participants = vec!["planner".into(), "agentA".into(), "agentB".into()]; + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + // Open-assignee task + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "")), + ) + .unwrap(); + apply(&mut session, result); + // agentA accepts the task + let result = mode + .on_message( + &session, + &env("agentA", "TaskAccept", make_task_accept("t1", "agentA")), + ) + .unwrap(); + apply(&mut session, result); + // agentB (non-assignee) attempts to send TaskUpdate — expect Forbidden + let err = mode + .on_message( + &session, + &env("agentB", "TaskUpdate", make_task_update("t1")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } } diff --git a/src/mode/util.rs b/src/mode/util.rs index 5ba8271..0f236f1 100644 --- a/src/mode/util.rs +++ b/src/mode/util.rs @@ -67,8 +67,8 @@ pub fn is_declared_participant(participants: &[String], sender: &str) -> bool { /// Check whether the sender is authorized to commit per the policy's `commitment.authority` rule. /// -/// If no policy is bound, defaults to initiator-only. Per RFC-MACP-0012 Section 4, -/// the `commitment` rule group controls who can emit a Commitment envelope. +/// RFC-MACP-0012 §4: the `commitment` rule group controls who can emit a Commitment +/// envelope. If no policy is bound, defaults to initiator-only (RFC-MACP-0001 §7.3). pub fn check_commitment_authority(session: &Session, sender: &str) -> Result<(), MacpError> { if let Some(ref policy) = session.policy_definition { let rules: crate::policy::rules::CommitmentRules = extract_commitment_rules(&policy.rules); diff --git a/src/policy/defaults.rs b/src/policy/defaults.rs index 0d4ee24..58c3f14 100644 --- a/src/policy/defaults.rs +++ b/src/policy/defaults.rs @@ -11,7 +11,7 @@ pub fn default_policy() -> PolicyDefinition { description: "Default policy \u{2014} mode built-in rules apply with no additional governance constraints".to_string(), rules: serde_json::json!({ "voting": { "algorithm": "none", "quorum": { "type": "count", "value": 0 } }, - "objection_handling": { "block_severity_vetoes": false, "veto_threshold": 1 }, + "objection_handling": { "critical_severity_vetoes": false, "veto_threshold": 1 }, "evaluation": { "required_before_voting": false, "minimum_confidence": 0.0 }, "commitment": { "authority": "initiator_only", "designated_roles": [], "require_vote_quorum": false } }), diff --git a/src/policy/evaluator.rs b/src/policy/evaluator.rs index 35cbf7e..8d469af 100644 --- a/src/policy/evaluator.rs +++ b/src/policy/evaluator.rs @@ -45,27 +45,38 @@ pub fn evaluate_decision_commitment( let mut allow_reasons: Vec = Vec::new(); // 1. Check evaluation requirements (minimum confidence threshold) + // RFC-MACP-0007: REVIEW evaluations are informational only and MUST NOT + // satisfy confidence thresholds or "required before voting" checks. + let qualifying_evaluations: Vec<_> = state + .evaluations + .iter() + .filter(|e| { + let rec = e.recommendation.to_uppercase(); + rec != "REVIEW" + }) + .collect(); + if rules.evaluation.required_before_voting && rules.evaluation.minimum_confidence > 0.0 { - let meets_confidence = state - .evaluations + let meets_confidence = qualifying_evaluations .iter() .any(|e| e.confidence >= rules.evaluation.minimum_confidence); - if state.evaluations.is_empty() || !meets_confidence { + if qualifying_evaluations.is_empty() || !meets_confidence { deny_reasons.push(format!( - "no evaluation meets minimum confidence threshold: {:.2}", + "no qualifying evaluation meets minimum confidence threshold: {:.2}", rules.evaluation.minimum_confidence )); } - } else if rules.evaluation.required_before_voting && state.evaluations.is_empty() { - deny_reasons.push("evaluations required before voting but none provided".into()); + } else if rules.evaluation.required_before_voting && qualifying_evaluations.is_empty() { + deny_reasons.push("evaluations required before voting but none provided (REVIEW evaluations are informational only)".into()); } - // 2. Check blocking objections (veto by count of "block" severity objections) - if rules.objection_handling.block_severity_vetoes { + // 2. Check critical objections (veto by count of "critical" severity objections) + // RFC-MACP-0007 §5: only Objections with severity "critical" trigger veto logic + if rules.objection_handling.critical_severity_vetoes { let blocking: Vec<&str> = state .objections .iter() - .filter(|o| o.severity == "block") + .filter(|o| o.severity == "critical") .map(|o| o.sender.as_str()) .collect(); if blocking.len() >= rules.objection_handling.veto_threshold as usize { @@ -229,11 +240,11 @@ fn check_voting_algorithm( } } "unanimous" => { - // All declared participants must have voted "approve" + // All declared participants must have voted "APPROVE" let all_voted = participants.iter().all(|p| { votes .values() - .any(|pv| pv.get(p).map(|v| v.vote == "approve").unwrap_or(false)) + .any(|pv| pv.get(p).map(|v| v.vote == "APPROVE").unwrap_or(false)) }); if all_voted && reject_count == 0 { VotingResult::Passed("unanimous vote passed: all participants approved".into()) @@ -304,9 +315,9 @@ fn aggregate_votes(votes: &BTreeMap>) -> (usize, for vote in proposal_votes.values() { total += 1; match vote.vote.as_str() { - "approve" => approve += 1, - "reject" => reject += 1, - _ => {} // abstain or other values don't count for/against + "APPROVE" => approve += 1, + "REJECT" => reject += 1, + _ => {} // ABSTAIN or other values don't count for/against } } } @@ -326,7 +337,7 @@ fn compute_weighted_votes( for (voter, vote) in proposal_votes { let weight = weights.get(voter).copied().unwrap_or(1.0); weighted_total += weight; - if vote.vote == "approve" { + if vote.vote == "APPROVE" { weighted_approve += weight; } } @@ -584,9 +595,9 @@ mod tests { "voting": { "algorithm": "majority", "threshold": 0.5 } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Allow { .. })); @@ -598,9 +609,9 @@ mod tests { "voting": { "algorithm": "majority", "threshold": 0.5 } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "reject"), - ("p1", "agent://growth", "reject"), - ("p1", "agent://compliance", "approve"), + ("p1", "agent://fraud", "REJECT"), + ("p1", "agent://growth", "REJECT"), + ("p1", "agent://compliance", "APPROVE"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Deny { .. })); @@ -612,8 +623,8 @@ mod tests { "voting": { "algorithm": "majority", "threshold": 0.5 } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); // 50% >= 50% passes (threshold comparison uses >=) @@ -628,9 +639,9 @@ mod tests { "voting": { "algorithm": "supermajority" } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); // 2/3 = 66.7% >= 66.7% @@ -643,9 +654,9 @@ mod tests { "voting": { "algorithm": "supermajority", "threshold": 0.75 } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); // 2/3 = 66.7% < 75% @@ -660,9 +671,9 @@ mod tests { "voting": { "algorithm": "unanimous" } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "approve"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "APPROVE"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Allow { .. })); @@ -674,9 +685,9 @@ mod tests { "voting": { "algorithm": "unanimous" } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Deny { .. })); @@ -688,8 +699,8 @@ mod tests { "voting": { "algorithm": "unanimous" } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), // compliance didn't vote ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); @@ -713,9 +724,9 @@ mod tests { })); // fraud (weight 3) approves, others reject => 3/5 = 60% > 50% let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "reject"), - ("p1", "agent://compliance", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "REJECT"), + ("p1", "agent://compliance", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Allow { .. })); @@ -736,9 +747,9 @@ mod tests { })); // fraud (weight 3) rejects, others approve => 2/5 = 40% < 50% let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "reject"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "approve"), + ("p1", "agent://fraud", "REJECT"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "APPROVE"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Deny { .. })); @@ -752,9 +763,9 @@ mod tests { "voting": { "algorithm": "plurality" } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), - ("p1", "agent://compliance", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Allow { .. })); @@ -766,8 +777,8 @@ mod tests { "voting": { "algorithm": "plurality" } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "reject"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "REJECT"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Deny { .. })); @@ -779,7 +790,7 @@ mod tests { fn veto_blocks_commitment_when_blocking_objections_reach_threshold() { let policy = make_policy(serde_json::json!({ "voting": { "algorithm": "none" }, - "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 1 } + "objection_handling": { "critical_severity_vetoes": true, "veto_threshold": 1 } })); let mut state = make_state_with_votes(vec![]); state.proposals.insert( @@ -794,7 +805,7 @@ mod tests { state.objections.push(Objection { proposal_id: "p1".into(), reason: "too risky".into(), - severity: "block".into(), + severity: "critical".into(), sender: "agent://compliance".into(), }); let result = evaluate_decision_commitment(&policy, &state, &participants()); @@ -805,7 +816,7 @@ mod tests { fn veto_allows_when_objections_below_threshold() { let policy = make_policy(serde_json::json!({ "voting": { "algorithm": "none" }, - "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 3 } + "objection_handling": { "critical_severity_vetoes": true, "veto_threshold": 3 } })); let mut state = make_state_with_votes(vec![]); state.proposals.insert( @@ -821,7 +832,7 @@ mod tests { state.objections.push(Objection { proposal_id: "p1".into(), reason: "minor concern".into(), - severity: "block".into(), + severity: "critical".into(), sender: "agent://compliance".into(), }); let result = evaluate_decision_commitment(&policy, &state, &participants()); @@ -832,7 +843,7 @@ mod tests { fn veto_ignores_non_blocking_severity_objections() { let policy = make_policy(serde_json::json!({ "voting": { "algorithm": "none" }, - "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 1 } + "objection_handling": { "critical_severity_vetoes": true, "veto_threshold": 1 } })); let mut state = make_state_with_votes(vec![]); state.proposals.insert( @@ -844,7 +855,7 @@ mod tests { sender: "initiator".into(), }, ); - // "high" severity is NOT "block", so it should NOT trigger veto + // "high" severity is NOT "critical", so it should NOT trigger veto state.objections.push(Objection { proposal_id: "p1".into(), reason: "serious concern".into(), @@ -859,7 +870,7 @@ mod tests { fn veto_disabled_ignores_objections() { let policy = make_policy(serde_json::json!({ "voting": { "algorithm": "none" }, - "objection_handling": { "block_severity_vetoes": false } + "objection_handling": { "critical_severity_vetoes": false } })); let mut state = make_state_with_votes(vec![]); state.proposals.insert( @@ -895,8 +906,8 @@ mod tests { })); // Only 2 voters, need 3 let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Deny { .. })); @@ -917,8 +928,8 @@ mod tests { })); // Only 2 of 3 voted: 66.7% < 100% let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); assert!(matches!(result, PolicyDecision::Deny { .. })); @@ -935,8 +946,8 @@ mod tests { "commitment": { "require_vote_quorum": false } })); let state = make_state_with_votes(vec![ - ("p1", "agent://fraud", "approve"), - ("p1", "agent://growth", "approve"), + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), ]); let result = evaluate_decision_commitment(&policy, &state, &participants()); // Quorum not met, but not required => allow (majority is met) @@ -1048,14 +1059,14 @@ mod tests { "threshold": 0.5, "quorum": { "type": "count", "value": 10 } }, - "objection_handling": { "block_severity_vetoes": true, "veto_threshold": 1 }, + "objection_handling": { "critical_severity_vetoes": true, "veto_threshold": 1 }, "commitment": { "require_vote_quorum": true } })); - let mut state = make_state_with_votes(vec![("p1", "agent://fraud", "reject")]); + let mut state = make_state_with_votes(vec![("p1", "agent://fraud", "REJECT")]); state.objections.push(Objection { proposal_id: "p1".into(), reason: "bad".into(), - severity: "block".into(), + severity: "critical".into(), sender: "agent://compliance".into(), }); let result = evaluate_decision_commitment(&policy, &state, &participants()); @@ -1205,4 +1216,169 @@ mod tests { let result = super::evaluate_quorum_commitment(&policy, 2, 1, 0, 5); assert!(matches!(result, PolicyDecision::Allow { .. })); } + + // ── REVIEW evaluation filtering ──────────────────────────────── + + #[test] + fn review_evaluation_does_not_satisfy_required_before_voting() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "evaluation": { "required_before_voting": true, "minimum_confidence": 0.5 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // Only REVIEW evaluations — these are informational and MUST NOT + // satisfy the required_before_voting check. + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "REVIEW".into(), + confidence: 0.9, + reason: "needs more analysis".into(), + sender: "agent://fraud".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn review_evaluation_does_not_satisfy_minimum_confidence() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "evaluation": { "required_before_voting": true, "minimum_confidence": 0.5 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // REVIEW evaluation with high confidence (filtered out) + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "REVIEW".into(), + confidence: 0.9, + reason: "informational only".into(), + sender: "agent://fraud".into(), + }); + // APPROVE evaluation with confidence below threshold + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: 0.3, + reason: "low confidence approval".into(), + sender: "agent://growth".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // REVIEW is filtered out; APPROVE at 0.3 < 0.5 threshold => deny + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn approve_evaluation_alongside_review_allows() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "evaluation": { "required_before_voting": true, "minimum_confidence": 0.5 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // REVIEW evaluation (filtered out from qualifying evaluations) + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "REVIEW".into(), + confidence: 0.9, + reason: "informational only".into(), + sender: "agent://fraud".into(), + }); + // APPROVE evaluation that meets the confidence threshold + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: 0.8, + reason: "high confidence approval".into(), + sender: "agent://growth".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // APPROVE at 0.8 >= 0.5 threshold => allow + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + // ── Critical objection veto vs BLOCK evaluation ──────────────── + + #[test] + fn critical_severity_objection_triggers_veto() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "objection_handling": { "critical_severity_vetoes": true, "veto_threshold": 1 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // A single critical-severity objection should trigger veto + state.objections.push(Objection { + proposal_id: "p1".into(), + reason: "unacceptable risk".into(), + severity: "critical".into(), + sender: "agent://compliance".into(), + }); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn block_evaluation_does_not_trigger_objection_veto_logic() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "none" }, + "objection_handling": { "critical_severity_vetoes": true, "veto_threshold": 1 } + })); + let mut state = make_state_with_votes(vec![]); + state.proposals.insert( + "p1".into(), + Proposal { + proposal_id: "p1".into(), + option: "option-1".into(), + rationale: "reason".into(), + sender: "initiator".into(), + }, + ); + // A BLOCK evaluation is NOT an objection — veto logic only looks + // at the objections list, not evaluations. + state.evaluations.push(Evaluation { + proposal_id: "p1".into(), + recommendation: "BLOCK".into(), + confidence: 0.95, + reason: "strongly disagree".into(), + sender: "agent://compliance".into(), + }); + // No objections in the objections list + let result = evaluate_decision_commitment(&policy, &state, &participants()); + // BLOCK evaluation != critical objection, so veto logic is not triggered + assert!(matches!(result, PolicyDecision::Allow { .. })); + } } diff --git a/src/policy/rules.rs b/src/policy/rules.rs index afed5b9..cf795a7 100644 --- a/src/policy/rules.rs +++ b/src/policy/rules.rs @@ -70,8 +70,9 @@ fn default_quorum_type() -> String { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ObjectionHandlingRules { - #[serde(default)] - pub block_severity_vetoes: bool, + /// RFC-MACP-0012: objections with severity "critical" trigger veto logic. + #[serde(default, alias = "critical_severity_vetoes")] + pub critical_severity_vetoes: bool, #[serde(default = "default_veto_threshold")] pub veto_threshold: u32, } @@ -79,7 +80,7 @@ pub struct ObjectionHandlingRules { impl Default for ObjectionHandlingRules { fn default() -> Self { Self { - block_severity_vetoes: false, + critical_severity_vetoes: false, veto_threshold: default_veto_threshold(), } } @@ -280,7 +281,7 @@ mod tests { assert_eq!(rules.voting.algorithm, "none"); assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON); assert_eq!(rules.voting.quorum.quorum_type, "count"); - assert!(!rules.objection_handling.block_severity_vetoes); + assert!(!rules.objection_handling.critical_severity_vetoes); assert_eq!(rules.objection_handling.veto_threshold, 1); assert!(!rules.evaluation.required_before_voting); assert!((rules.evaluation.minimum_confidence).abs() < f64::EPSILON); @@ -299,7 +300,7 @@ mod tests { "weights": { "agent://fraud": 2.0, "agent://growth": 1.0 } }, "objection_handling": { - "block_severity_vetoes": true, + "critical_severity_vetoes": true, "veto_threshold": 2 }, "evaluation": { @@ -319,7 +320,7 @@ mod tests { assert_eq!(rules.voting.quorum.quorum_type, "percentage"); assert!((rules.voting.quorum.value - 75.0).abs() < f64::EPSILON); assert_eq!(*rules.voting.weights.get("agent://fraud").unwrap(), 2.0); - assert!(rules.objection_handling.block_severity_vetoes); + assert!(rules.objection_handling.critical_severity_vetoes); assert_eq!(rules.objection_handling.veto_threshold, 2); assert!(rules.evaluation.required_before_voting); assert!((rules.evaluation.minimum_confidence - 0.8).abs() < f64::EPSILON); @@ -336,7 +337,7 @@ mod tests { let rules: DecisionPolicyRules = serde_json::from_value(json).unwrap(); assert_eq!(rules.voting.algorithm, "unanimous"); assert!((rules.voting.threshold - 0.5).abs() < f64::EPSILON); - assert!(!rules.objection_handling.block_severity_vetoes); + assert!(!rules.objection_handling.critical_severity_vetoes); assert_eq!(rules.objection_handling.veto_threshold, 1); } diff --git a/src/runtime.rs b/src/runtime.rs index cd664b5..46d2d0e 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -299,7 +299,9 @@ impl Runtime { } // Resolve the governance policy for this session. - // RFC-0003 §3: sessions MUST bind policy_version; default to "policy.default". + // RFC-MACP-0012 §6.1: policy_version is resolved at SessionStart; empty + // resolves to "policy.default". The resolved PolicyDescriptor is stored + // immutably on the session for deterministic replay (RFC-MACP-0003 §3). let effective_policy_version = if start_payload.policy_version.is_empty() { crate::policy::defaults::DEFAULT_POLICY_ID.to_string() } else { @@ -383,6 +385,13 @@ impl Runtime { }) } + /// Process a session-scoped message following the RFC-MACP-0001 Section 7.3 + /// terminal-state transition order: + /// 1. Check session OPEN + /// 2. Validate message (mode.authorize_sender + mode.on_message) + /// 3. Accept into history (log_store.append) + /// 4. Transition to RESOLVED (session.apply_mode_response) + /// 5. Reject subsequent messages (enforced by step 1 on next call) async fn process_message(&self, env: &Envelope) -> Result { let mut guard = self.registry.sessions.write().await; let session = guard @@ -466,13 +475,19 @@ impl Runtime { }) } - /// Process a Signal envelope. Signals are informational out-of-band - /// notifications (progress, heartbeat, etc.). Broadcast to signal - /// subscribers but no session state mutation. + /// Process a Signal or Progress envelope. Signals are informational out-of-band + /// notifications. Progress messages carry structured ProgressPayload. + /// Neither mutates session state — both are broadcast to subscribers. async fn process_signal(&self, env: &Envelope) -> Result { + // RFC-MACP-0001: validate ProgressPayload structure for Progress messages. + if env.message_type == "Progress" && !env.payload.is_empty() { + let _: crate::pb::ProgressPayload = + prost::Message::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + } tracing::debug!( sender = %env.sender, message_id = %env.message_id, + message_type = %env.message_type, "signal received" ); let _ = self.signal_bus.send(env.clone()); @@ -499,10 +514,14 @@ impl Runtime { guard.get(session_id).cloned() } + /// Cancel a session. The `cancelled_by` parameter MUST be the authenticated + /// sender of the CancelSession RPC (RFC-MACP-0001 Section 7.3: CancelSession + /// is a Core control-plane message; mode authorization does not apply). pub async fn cancel_session( &self, session_id: &str, reason: &str, + cancelled_by: &str, ) -> Result { let mut guard = self.registry.sessions.write().await; let session = guard.get_mut(session_id).ok_or(MacpError::UnknownSession)?; @@ -518,9 +537,15 @@ impl Runtime { }); } + // RFC-MACP-0001: runtime encodes a proper SessionCancelPayload with + // `cancelled_by` set to the authenticated sender identity. + let cancel_payload = crate::pb::SessionCancelPayload { + reason: reason.to_string(), + cancelled_by: cancelled_by.to_string(), + }; let cancel_entry = Self::make_internal_entry( "SessionCancel", - reason.as_bytes(), + &prost::Message::encode_to_vec(&cancel_payload), session_id, &session.mode, ); @@ -951,7 +976,10 @@ mod tests { .await .unwrap(); tokio::time::sleep(std::time::Duration::from_millis(5)).await; - let result = rt.cancel_session(&sid, "cleanup").await.unwrap(); + let result = rt + .cancel_session(&sid, "cleanup", "agent://orchestrator") + .await + .unwrap(); assert_eq!(result.session_state, SessionState::Expired); } @@ -1375,7 +1403,10 @@ mod tests { .await .unwrap(); - let err = rt.cancel_session(&sid, "test cancel").await.unwrap_err(); + let err = rt + .cancel_session(&sid, "test cancel", "agent://orchestrator") + .await + .unwrap_err(); assert_eq!(err.to_string(), "StorageFailed"); } } diff --git a/src/server.rs b/src/server.rs index 2bd4047..9153a64 100644 --- a/src/server.rs +++ b/src/server.rs @@ -195,7 +195,7 @@ impl MacpServer { } if let Some(bound) = bound_session_id.as_ref() { if bound != &envelope.session_id { - return Err(Status::failed_precondition( + return Err(Status::invalid_argument( "StreamSession may only carry envelopes for one session_id", )); } @@ -208,12 +208,12 @@ impl MacpServer { if !is_session_start { if let Some(session) = self.runtime.get_session_checked(&envelope.session_id).await { if envelope.mode != session.mode { - return Err(Status::failed_precondition( + return Err(Status::invalid_argument( "INVALID_ENVELOPE: envelope mode does not match the bound session mode", )); } if session.state != SessionState::Open { - return Err(Status::failed_precondition("SESSION_NOT_OPEN")); + return Err(Status::invalid_argument("SESSION_NOT_OPEN")); } } else if envelope.message_type == "Signal" { return Err(Status::not_found(format!( @@ -301,14 +301,37 @@ impl MacpServer { match action { StreamAction::ProcessRequest(req) => { - server + match server .process_stream_request( &identity, req, &mut bound_session_id, &mut session_events, ) - .await?; + .await + { + Ok(()) => {} + Err(status) if Self::is_stream_terminal_error(&status) => { + Err(status)?; + } + Err(status) => { + // RFC-MACP-0001: application-level validation errors + // are sent as inline MACPError; stream remains open. + yield StreamSessionResponse { + response: Some( + macp_runtime::pb::stream_session_response::Response::Error( + PbMacpError { + code: status.message().to_string(), + message: status.message().to_string(), + session_id: bound_session_id.clone().unwrap_or_default(), + message_id: String::new(), + details: vec![], + }, + ), + ), + }; + } + } while let Some(envelope) = Self::try_next_stream_event(&mut session_events)? { yield StreamSessionResponse { response: Some( @@ -349,14 +372,35 @@ impl MacpServer { } else { match inbound.next().await { Some(Ok(req)) => { - server + match server .process_stream_request( &identity, req, &mut bound_session_id, &mut session_events, ) - .await?; + .await + { + Ok(()) => {} + Err(status) if Self::is_stream_terminal_error(&status) => { + Err(status)?; + } + Err(status) => { + yield StreamSessionResponse { + response: Some( + macp_runtime::pb::stream_session_response::Response::Error( + PbMacpError { + code: status.message().to_string(), + message: status.message().to_string(), + session_id: bound_session_id.clone().unwrap_or_default(), + message_id: String::new(), + details: vec![], + }, + ), + ), + }; + } + } while let Some(envelope) = Self::try_next_stream_event(&mut session_events)? { yield StreamSessionResponse { response: Some( @@ -374,6 +418,21 @@ impl MacpServer { Box::pin(output) } + /// Returns true if the error should terminate a StreamSession stream. + /// Transport and binding errors terminate. Application-level validation + /// errors (from `runtime.process()`) are sent as inline MACPError per RFC-0001. + fn is_stream_terminal_error(status: &Status) -> bool { + matches!( + status.code(), + tonic::Code::Unauthenticated + | tonic::Code::Internal + | tonic::Code::ResourceExhausted + | tonic::Code::InvalidArgument + | tonic::Code::NotFound + | tonic::Code::AlreadyExists + ) + } + fn status_from_error(err: MacpError) -> Status { match err { MacpError::Unauthenticated => Status::unauthenticated(err.to_string()), @@ -543,10 +602,11 @@ impl MacpRuntimeService for MacpServer { "FORBIDDEN: only the session initiator can cancel", )); } + let sender = identity.sender.clone(); let req = request.into_inner(); match self .runtime - .cancel_session(&req.session_id, &req.reason) + .cancel_session(&req.session_id, &req.reason, &sender) .await { Ok(result) => Ok(Response::new(CancelSessionResponse { @@ -1202,7 +1262,7 @@ mod tests { }; assert_eq!(first_env.session_id, sid1); let err = stream.next().await.unwrap().unwrap_err(); - assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert_eq!(err.code(), tonic::Code::InvalidArgument); } #[tokio::test] diff --git a/tests/conformance/decision_happy_path.json b/tests/conformance/decision_happy_path.json index e647670..e6a8e4c 100644 --- a/tests/conformance/decision_happy_path.json +++ b/tests/conformance/decision_happy_path.json @@ -70,7 +70,7 @@ "votes": { "p1": { "agent://a": { - "vote": "approve" + "vote": "APPROVE" } } } diff --git a/tests/conformance/decision_reject_paths.json b/tests/conformance/decision_reject_paths.json index b5d6a4e..d312716 100644 --- a/tests/conformance/decision_reject_paths.json +++ b/tests/conformance/decision_reject_paths.json @@ -84,7 +84,7 @@ "votes": { "p1": { "agent://a": { - "vote": "approve" + "vote": "APPROVE" } } } diff --git a/tests/replay_round_trip.rs b/tests/replay_round_trip.rs index bf82bf9..ebe282e 100644 --- a/tests/replay_round_trip.rs +++ b/tests/replay_round_trip.rs @@ -117,7 +117,7 @@ fn replay_decision_session() { assert_eq!(session.seen_message_ids.len(), 4); let mode_state: serde_json::Value = serde_json::from_slice(&session.mode_state).unwrap(); assert_eq!(mode_state["phase"], "Committed"); - assert_eq!(mode_state["votes"]["p1"]["agent://a"]["vote"], "approve"); + assert_eq!(mode_state["votes"]["p1"]["agent://a"]["vote"], "APPROVE"); } #[test] From e5ac71dcdd6fdefc197fdd7de8798778347b4791 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Mon, 6 Apr 2026 20:18:40 -0700 Subject: [PATCH 04/10] Update the Runtime based on the RFC changes --- src/mode/handoff.rs | 98 +++++++++++++++++---- src/mode/proposal.rs | 55 ++++++------ src/mode/quorum.rs | 189 +++++++++++++++++++++++++++++++++++++++- src/policy/evaluator.rs | 147 ++++++++++++++++++++++++++++--- src/runtime.rs | 13 ++- src/server.rs | 21 +++-- 6 files changed, 457 insertions(+), 66 deletions(-) diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 668f200..7185bf7 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -107,6 +107,8 @@ impl Mode for HandoffMode { "HandoffOffer" => { let payload = HandoffOfferPayload::decode(&*env.payload) .map_err(|_| MacpError::InvalidPayload)?; + // RFC-MACP-0010: At most one offer may be outstanding at any time. + // Once an offer is accepted, no further offers may be issued. if payload.handoff_id.is_empty() || payload.target_participant.is_empty() || state.offers.contains_key(&payload.handoff_id) @@ -116,6 +118,10 @@ impl Mode for HandoffMode { .offers .values() .any(|o| o.disposition == HandoffDisposition::Offered) + || state + .offers + .values() + .any(|o| o.disposition == HandoffDisposition::Accepted) { return Err(MacpError::InvalidPayload); } @@ -131,7 +137,7 @@ impl Mode for HandoffMode { accepted_by: None, declined_by: None, outcome_reason: None, - offered_at_ms: 0, + offered_at_ms: chrono::Utc::now().timestamp_millis(), }, ); Ok(ModeResponse::PersistState(Self::encode_state(&state))) @@ -808,7 +814,8 @@ mod tests { } #[test] - fn second_offer_after_first_accepted_succeeds() { + fn second_offer_after_first_accepted_is_rejected() { + // RFC-MACP-0010: "Once an offer is accepted, no further offers may be issued." let mode = HandoffMode; let mut session = base_session(); session.participants = vec!["owner".into(), "target".into(), "other".into()]; @@ -830,11 +837,13 @@ mod tests { ) .unwrap(); apply(&mut session, result); - mode.on_message( - &session, - &env("owner", "HandoffOffer", make_offer("h2", "other")), - ) - .unwrap(); + let err = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h2", "other")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); } #[test] @@ -1030,7 +1039,9 @@ mod tests { // --- After HandoffAccept, further offers are allowed (prior resolved) --- #[test] - fn offer_after_accept_allowed_since_prior_resolved() { + fn offer_after_accept_blocked_per_rfc() { + // RFC-MACP-0010: "Once an offer is accepted, no further offers may be issued + // for the Session. Only one final Commitment may resolve the Session." let mode = HandoffMode; let mut session = base_session(); session.participants = vec!["owner".into(), "target".into(), "other".into()]; @@ -1038,7 +1049,6 @@ mod tests { .on_session_start(&session, &env("owner", "SessionStart", vec![])) .unwrap(); apply(&mut session, result); - // Send HandoffOffer to target let result = mode .on_message( &session, @@ -1046,7 +1056,6 @@ mod tests { ) .unwrap(); apply(&mut session, result); - // Target accepts — h1 disposition moves from Offered to Accepted let result = mode .on_message( &session, @@ -1054,17 +1063,76 @@ mod tests { ) .unwrap(); apply(&mut session, result); - // New HandoffOffer is allowed because no offer is in Offered disposition - let result = mode + // New HandoffOffer MUST be rejected after an offer has been accepted + let err = mode .on_message( &session, &env("owner", "HandoffOffer", make_offer("h2", "other")), ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap(); + assert_eq!(state.offers.len(), 1); + assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted); + } + + #[test] + fn offered_at_ms_is_populated() { + let mode = HandoffMode; + let mut session = base_session(); + session.participants = vec!["owner".into(), "target".into()]; + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "target")), + ) .unwrap(); apply(&mut session, result); let state: HandoffState = serde_json::from_slice(&session.mode_state).unwrap(); - assert_eq!(state.offers.len(), 2); - assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted); - assert_eq!(state.offers["h2"].disposition, HandoffDisposition::Offered); + assert!( + state.offers["h1"].offered_at_ms > 0, + "offered_at_ms should be set" + ); + } + + #[test] + fn implicit_accept_timeout_fires() { + // RFC-MACP-0010: when implicit_accept_timeout_ms policy is set and + // sufficient time has elapsed, the offer is auto-accepted at commitment. + let mode = HandoffMode; + let mut session = base_session(); + session.participants = vec!["owner".into(), "target".into()]; + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "auto-accept".into(), + mode: "macp.mode.handoff.v1".into(), + description: "short timeout".into(), + rules: serde_json::json!({ + "acceptance": { "implicit_accept_timeout_ms": 1 }, + "commitment": { "authority": "initiator_only" } + }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + // Wait a tiny bit so the timeout elapses (offered_at_ms is now > 0) + std::thread::sleep(std::time::Duration::from_millis(5)); + // Commitment should succeed — implicit accept triggers + let commit = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(commit, ModeResponse::PersistAndResolve { .. })); } } diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index d56c163..6aa93a2 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -226,6 +226,25 @@ impl Mode for ProposalMode { { return Err(MacpError::InvalidPayload); } + // RFC-MACP-0012: enforce max_rounds at submission time to prevent + // unbounded state growth. The evaluator also checks at commitment. + if let Some(ref policy) = session.policy_definition { + let rules = + serde_json::from_value::( + policy.rules.clone(), + ) + .unwrap_or_default(); + if rules.counter_proposal.max_rounds > 0 { + let counter_count = state + .proposals + .values() + .filter(|p| p.supersedes_proposal_id.is_some()) + .count(); + if counter_count >= rules.counter_proposal.max_rounds { + return Err(MacpError::InvalidPayload); + } + } + } state.proposals.insert( payload.proposal_id.clone(), ProposalRecord { @@ -976,7 +995,8 @@ mod tests { } #[test] - fn policy_denies_commitment_when_counter_proposal_limit_exceeded() { + fn policy_blocks_counter_proposal_at_submission_when_limit_exceeded() { + // RFC-MACP-0012: max_rounds is enforced both at submission and commitment time. let mode = ProposalMode; let mut session = base_session(); session.policy_definition = Some(crate::policy::PolicyDefinition { @@ -1000,7 +1020,7 @@ mod tests { ) .unwrap(); apply(&mut session, resp); - // Buyer counter-proposes p2 (supersedes p1) -- 1st counter-proposal + // Buyer counter-proposes p2 (supersedes p1) -- 1st counter-proposal: allowed let resp = mode .on_message( &session, @@ -1012,8 +1032,9 @@ mod tests { ) .unwrap(); apply(&mut session, resp); - // Seller counter-proposes p3 (supersedes p2) -- 2nd counter-proposal - let resp = mode + // Seller counter-proposes p3 (supersedes p2) -- 2nd counter-proposal: + // REJECTED at submission time (max_rounds=1, already 1 counter-proposal) + let err = mode .on_message( &session, &env( @@ -1022,32 +1043,8 @@ mod tests { make_counter_proposal("p3", "p2"), ), ) - .unwrap(); - apply(&mut session, resp); - // Both accept p3 to reach convergence - let resp = mode - .on_message(&session, &env("agent://buyer", "Accept", make_accept("p3"))) - .unwrap(); - apply(&mut session, resp); - let resp = mode - .on_message( - &session, - &env("agent://seller", "Accept", make_accept("p3")), - ) - .unwrap(); - apply(&mut session, resp); - // Commitment should be denied (2 counter-proposals exceeds limit of 1) - let err = mode - .on_message( - &session, - &env( - "agent://buyer", - "Commitment", - commitment(&session, "proposal.accepted"), - ), - ) .unwrap_err(); - assert_eq!(err.to_string(), "PolicyDenied"); + assert_eq!(err.to_string(), "InvalidPayload"); } // --- CounterProposal does NOT retire original --- diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs index efa8f46..19b6273 100644 --- a/src/mode/quorum.rs +++ b/src/mode/quorum.rs @@ -52,11 +52,34 @@ impl QuorumMode { serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState) } + /// Resolve the effective approval threshold, considering policy overrides. + /// + /// RFC-MACP-0011: "When policy specifies a threshold override, it replaces + /// (not supplements) the required_approvals value from ApprovalRequest." + fn effective_threshold(session: &Session, request: &ApprovalRequestRecord) -> u32 { + if let Some(ref policy) = session.policy_definition { + let rules: crate::policy::rules::QuorumPolicyRules = + serde_json::from_value(policy.rules.clone()).unwrap_or_default(); + if rules.threshold.value > 0.0 { + return match rules.threshold.threshold_type.as_str() { + "percentage" => { + let n = session.participants.len() as f64; + (rules.threshold.value / 100.0 * n).ceil() as u32 + } + // "n_of_m" or "count" — use value directly + _ => rules.threshold.value as u32, + }; + } + } + request.required_approvals + } + fn commitment_ready(session: &Session, state: &QuorumState) -> bool { let request = match &state.request { Some(request) => request, None => return false, }; + let required = Self::effective_threshold(session, request); let approvals = state .ballots .values() @@ -66,8 +89,7 @@ impl QuorumMode { let counted = state.ballots.len() as u32; let remaining = total_eligible.saturating_sub(counted); // Commitment is ready if threshold reached OR threshold is mathematically unreachable - approvals >= request.required_approvals - || approvals + remaining < request.required_approvals + approvals >= required || approvals + remaining < required } } @@ -281,15 +303,19 @@ mod tests { } fn commitment_payload() -> Vec { + commitment("quorum.approved", true) + } + + fn commitment(action: &str, outcome_positive: bool) -> Vec { CommitmentPayload { commitment_id: "c1".into(), - action: "quorum.approved".into(), + action: action.into(), authority_scope: "deploy".into(), reason: "threshold met".into(), mode_version: "1.0.0".into(), policy_version: "policy".into(), configuration_version: "config".into(), - outcome_positive: true, + outcome_positive, } .encode_to_vec() } @@ -1120,4 +1146,159 @@ mod tests { let err = mode.authorize_sender(&session, &approve_env).unwrap_err(); assert_eq!(err.to_string(), "Forbidden"); } + + // ── Quorum policy threshold override (RFC-MACP-0011) ─────────── + + #[test] + fn policy_threshold_overrides_required_approvals() { + let mode = QuorumMode; + let mut session = base_session(); + // Policy sets n_of_m threshold to 1, while ApprovalRequest requires 3 + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "threshold-override".into(), + mode: "macp.mode.quorum.v1".into(), + description: "low threshold".into(), + rules: serde_json::json!({ + "threshold": { "type": "n_of_m", "value": 1.0 } + }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 3), // requires 3, but policy overrides to 1 + ), + ) + .unwrap(); + apply(&mut session, result); + // Just 1 approval should make commitment ready (policy overrides to 1) + let result = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "yes")), + ) + .unwrap(); + apply(&mut session, result); + // Commitment should succeed + let commit = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(commit, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn policy_percentage_threshold() { + let mode = QuorumMode; + let mut session = base_session(); + // 3 participants, 50% → ceil(1.5) = 2 required + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "pct-override".into(), + mode: "macp.mode.quorum.v1".into(), + description: "percentage threshold".into(), + rules: serde_json::json!({ + "threshold": { "type": "percentage", "value": 50.0 } + }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 3), + ), + ) + .unwrap(); + apply(&mut session, result); + // 1 approval is not enough (need 2 for 50% of 3) + let result = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "yes")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + // 2nd approval makes it ready + let result = mode + .on_message( + &session, + &env("bob", "Approve", make_approve("r1", "agreed")), + ) + .unwrap(); + apply(&mut session, result); + let commit = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(commit, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn all_abstain_eligible_for_negative_commitment() { + // RFC-MACP-0011: "When all eligible participants have abstained, the Session + // becomes eligible for Commitment with a negative outcome." + let mode = QuorumMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 2), + ), + ) + .unwrap(); + apply(&mut session, result); + // All 3 participants abstain + for sender in &["alice", "bob", "carol"] { + let result = mode + .on_message( + &session, + &env(sender, "Abstain", make_abstain("r1", "neutral")), + ) + .unwrap(); + apply(&mut session, result); + } + // Commitment should be ready (0 approvals + 0 remaining < 2 required) + let commit = mode + .on_message( + &session, + &env( + "coordinator", + "Commitment", + commitment("quorum.rejected", false), + ), + ) + .unwrap(); + assert!(matches!(commit, ModeResponse::PersistAndResolve { .. })); + } } diff --git a/src/policy/evaluator.rs b/src/policy/evaluator.rs index 8d469af..1a2f8fd 100644 --- a/src/policy/evaluator.rs +++ b/src/policy/evaluator.rs @@ -194,16 +194,17 @@ fn check_voting_algorithm( votes: &BTreeMap>, participants: &[String], ) -> VotingResult { - // Aggregate approve/reject counts across all proposals - let (approve_count, reject_count, total_votes) = aggregate_votes(votes); + // Aggregate approve/reject counts across all proposals. + // RFC-MACP-0004: abstain votes are excluded from ratio denominators. + let (approve_count, reject_count, _abstain_count, non_abstain_total) = aggregate_votes(votes); - if total_votes == 0 { + if non_abstain_total == 0 { return VotingResult::NoVotes; } match algorithm { "majority" => { - let ratio = approve_count as f64 / total_votes as f64; + let ratio = approve_count as f64 / non_abstain_total as f64; if ratio >= threshold { VotingResult::Passed(format!( "majority vote passed: {:.1}% approve (threshold: {:.1}%)", @@ -224,7 +225,7 @@ fn check_voting_algorithm( } else { 2.0 / 3.0 }; - let ratio = approve_count as f64 / total_votes as f64; + let ratio = approve_count as f64 / non_abstain_total as f64; if ratio >= effective_threshold { VotingResult::Passed(format!( "supermajority vote passed: {:.1}% approve (threshold: {:.1}%)", @@ -305,27 +306,36 @@ fn check_voting_algorithm( } } -/// Aggregate votes into approve/reject/total counts. -fn aggregate_votes(votes: &BTreeMap>) -> (usize, usize, usize) { +/// Aggregate votes into approve/reject/abstain counts. +/// +/// RFC-MACP-0004: Abstain votes do NOT count toward approval or rejection +/// thresholds by default. The fourth element (`non_abstain_total`) is the +/// denominator for ratio-based algorithms (majority, supermajority, etc.). +fn aggregate_votes( + votes: &BTreeMap>, +) -> (usize, usize, usize, usize) { let mut approve = 0usize; let mut reject = 0usize; - let mut total = 0usize; + let mut abstain = 0usize; for proposal_votes in votes.values() { for vote in proposal_votes.values() { - total += 1; match vote.vote.as_str() { "APPROVE" => approve += 1, "REJECT" => reject += 1, - _ => {} // ABSTAIN or other values don't count for/against + _ => abstain += 1, // ABSTAIN or other values don't count for/against } } } - (approve, reject, total) + let non_abstain_total = approve + reject; + (approve, reject, abstain, non_abstain_total) } /// Compute weighted votes using the configured weight map. +/// +/// RFC-MACP-0004: Abstain votes are excluded from the weighted total +/// so they do not dilute the approval ratio. fn compute_weighted_votes( votes: &BTreeMap>, weights: &std::collections::HashMap, @@ -335,6 +345,10 @@ fn compute_weighted_votes( for proposal_votes in votes.values() { for (voter, vote) in proposal_votes { + // Skip abstain votes — they don't count toward the threshold + if vote.vote != "APPROVE" && vote.vote != "REJECT" { + continue; + } let weight = weights.get(voter).copied().unwrap_or(1.0); weighted_total += weight; if vote.vote == "APPROVE" { @@ -1381,4 +1395,115 @@ mod tests { // BLOCK evaluation != critical objection, so veto logic is not triggered assert!(matches!(result, PolicyDecision::Allow { .. })); } + + // ── Abstention excluded from voting ratio (RFC-MACP-0004) ────── + + #[test] + fn abstain_excluded_from_majority_ratio() { + // 3 approve, 0 reject, 2 abstain → ratio = 3/3 = 100%, not 3/5 = 60% + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.9 + } + })); + let mut state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://compliance", "APPROVE"), + ("p1", "agent://ops", "APPROVE"), + ("p1", "agent://abstainer1", "ABSTAIN"), + ("p1", "agent://abstainer2", "ABSTAIN"), + ]); + let participants = vec![ + "agent://fraud".into(), + "agent://compliance".into(), + "agent://ops".into(), + "agent://abstainer1".into(), + "agent://abstainer2".into(), + ]; + let result = evaluate_decision_commitment(&policy, &state, &participants); + // 3/3 = 100% >= 90% threshold → pass (abstentions excluded from denominator) + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn abstain_excluded_from_supermajority_ratio() { + // 1 approve, 1 reject, 3 abstain → ratio = 1/2 = 50%, which fails 2/3 supermajority + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "supermajority", + "threshold": 0.67 + } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://compliance", "REJECT"), + ("p1", "agent://abstainer0", "ABSTAIN"), + ("p1", "agent://abstainer1", "ABSTAIN"), + ("p1", "agent://abstainer2", "ABSTAIN"), + ]); + let participants = vec![ + "agent://fraud".into(), + "agent://compliance".into(), + "agent://abstainer0".into(), + "agent://abstainer1".into(), + "agent://abstainer2".into(), + ]; + let result = evaluate_decision_commitment(&policy, &state, &participants); + assert!(matches!(result, PolicyDecision::Deny { .. })); + } + + #[test] + fn all_abstain_returns_no_votes() { + // All abstain → non_abstain_total = 0 → NoVotes → algorithm skipped + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "majority", + "threshold": 0.5 + } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://abstainer0", "ABSTAIN"), + ("p1", "agent://abstainer1", "ABSTAIN"), + ("p1", "agent://abstainer2", "ABSTAIN"), + ]); + let participants = vec![ + "agent://abstainer0".into(), + "agent://abstainer1".into(), + "agent://abstainer2".into(), + ]; + // No non-abstain votes → algorithm returns NoVotes → no deny + let result = evaluate_decision_commitment(&policy, &state, &participants); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } + + #[test] + fn weighted_votes_exclude_abstain() { + let policy = make_policy(serde_json::json!({ + "voting": { + "algorithm": "weighted", + "threshold": 0.6, + "weights": { + "agent://heavy": 10.0, + "agent://light": 1.0, + "agent://abstainer": 100.0 + } + } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://heavy", "APPROVE"), + ("p1", "agent://light", "REJECT"), + ("p1", "agent://abstainer", "ABSTAIN"), + ]); + let participants = vec![ + "agent://heavy".into(), + "agent://light".into(), + "agent://abstainer".into(), + ]; + // weighted_approve = 10.0, weighted_total = 10.0 + 1.0 = 11.0 + // ratio = 10/11 ≈ 0.909 >= 0.6 → pass + // Without fix, would be 10/(10+1+100) = 0.09 → fail + let result = evaluate_decision_commitment(&policy, &state, &participants); + assert!(matches!(result, PolicyDecision::Allow { .. })); + } } diff --git a/src/runtime.rs b/src/runtime.rs index 46d2d0e..88e2a57 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -367,8 +367,17 @@ impl Runtime { session.apply_mode_response(response); let result_state = session.state.clone(); - // 3. Best-effort session save - self.save_session_to_storage(&session).await; + // 3. Session save — fatal on SessionStart to ensure snapshot durability. + // For subsequent messages, the log entry (COMMIT POINT) is already persisted + // so snapshot failure is recoverable via replay. + if let Err(err) = self.storage.save_session(&session).await { + tracing::error!( + session_id = %session.session_id, + error = %err, + "failed to persist session snapshot at SessionStart" + ); + return Err(MacpError::StorageFailed); + } self.metrics.record_session_start(mode_name); tracing::info!( session_id = %env.session_id, diff --git a/src/server.rs b/src/server.rs index 9153a64..bfdffbc 100644 --- a/src/server.rs +++ b/src/server.rs @@ -164,9 +164,15 @@ impl MacpServer { *receiver = None; Ok(None) } - Err(TryRecvError::Lagged(skipped)) => Err(Status::resource_exhausted(format!( - "StreamSession receiver fell behind by {skipped} envelopes" - ))), + Err(TryRecvError::Lagged(skipped)) => { + // Instead of terminating the stream, log the lag and continue. + // The subscriber misses N messages but stays connected. + tracing::warn!( + skipped, + "StreamSession receiver fell behind; skipping envelopes" + ); + Ok(None) + } } } @@ -597,9 +603,14 @@ impl MacpRuntimeService for MacpServer { .get_session_checked(&session_id) .await .ok_or_else(|| Status::not_found(format!("Session '{}' not found", session_id)))?; - if identity.sender != session.initiator_sender { + // RFC-MACP-0001: "Only the initiator and policy-delegated roles may cancel." + // CancelSession is a Core control-plane message — mode authorization does not apply. + if identity.sender != session.initiator_sender + && macp_runtime::mode::util::check_commitment_authority(&session, &identity.sender) + .is_err() + { return Err(Status::permission_denied( - "FORBIDDEN: only the session initiator can cancel", + "FORBIDDEN: only the session initiator or policy-delegated roles can cancel", )); } let sender = identity.sender.clone(); From 6cf7bc89e79e20b5b666154134b48fcb2129d4e0 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Tue, 7 Apr 2026 09:42:07 -0700 Subject: [PATCH 05/10] Improve the runtime to align with RFC --- Cargo.lock | 15 ++ Cargo.toml | 1 + src/error.rs | 19 ++- src/log_store.rs | 4 + src/main.rs | 106 +++++++++++++ src/metrics.rs | 19 ++- src/mode/decision.rs | 52 ++++++- src/mode/handoff.rs | 40 ++--- src/mode/multi_round.rs | 33 +++- src/mode/proposal.rs | 27 +++- src/mode/quorum.rs | 51 ++++++- src/mode/task.rs | 60 +++++++- src/mode/util.rs | 118 ++++++++++++++ src/mode_registry.rs | 38 +++-- src/policy/registry.rs | 8 +- src/replay.rs | 26 +++- src/runtime.rs | 288 ++++++++++++++++++++++++++++++++++- src/security.rs | 18 +++ src/server.rs | 89 ++++++++++- src/storage/compaction.rs | 4 +- src/storage/file.rs | 1 + src/storage/migration.rs | 1 + src/storage/recovery.rs | 1 + src/storage/redis_backend.rs | 1 + src/storage/rocksdb.rs | 1 + src/stream_bus.rs | 10 +- tests/replay_round_trip.rs | 1 + 27 files changed, 963 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c34e3a..4d56efc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,6 +1062,7 @@ dependencies = [ "tokio", "tokio-stream", "tonic 0.14.5", + "tonic-health", "tonic-prost", "tonic-prost-build", "tracing", @@ -1949,6 +1950,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -2033,6 +2035,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tonic-health" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ff0636fef47afb3ec02818f5bceb4377b8abb9d6a386aeade18bd6212f8eb7" +dependencies = [ + "prost 0.14.3", + "tokio", + "tokio-stream", + "tonic 0.14.5", + "tonic-prost", +] + [[package]] name = "tonic-prost" version = "0.14.5" diff --git a/Cargo.toml b/Cargo.toml index d88d366..d02915a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", [dependencies] tokio = { version = "1", features = ["full"] } tonic = { version = "0.14", features = ["transport", "tls-ring"] } +tonic-health = "0.14" prost = "0.14" prost-types = "0.14" tonic-prost = "0.14" diff --git a/src/error.rs b/src/error.rs index 491677b..5b68b15 100644 --- a/src/error.rs +++ b/src/error.rs @@ -42,7 +42,7 @@ pub enum MacpError { #[error("UnknownPolicyVersion")] UnknownPolicyVersion, #[error("PolicyDenied")] - PolicyDenied, + PolicyDenied { reasons: Vec }, #[error("InvalidPolicyDefinition")] InvalidPolicyDefinition, } @@ -69,7 +69,7 @@ impl MacpError { MacpError::StorageFailed => "INTERNAL_ERROR", MacpError::InvalidSessionId => "INVALID_SESSION_ID", MacpError::UnknownPolicyVersion => "UNKNOWN_POLICY_VERSION", - MacpError::PolicyDenied => "POLICY_DENIED", + MacpError::PolicyDenied { .. } => "POLICY_DENIED", MacpError::InvalidPolicyDefinition => "INVALID_POLICY_DEFINITION", } } @@ -103,7 +103,12 @@ mod tests { (MacpError::StorageFailed, "INTERNAL_ERROR"), (MacpError::InvalidSessionId, "INVALID_SESSION_ID"), (MacpError::UnknownPolicyVersion, "UNKNOWN_POLICY_VERSION"), - (MacpError::PolicyDenied, "POLICY_DENIED"), + ( + MacpError::PolicyDenied { + reasons: vec!["test".into()], + }, + "POLICY_DENIED", + ), ( MacpError::InvalidPolicyDefinition, "INVALID_POLICY_DEFINITION", @@ -139,6 +144,12 @@ mod tests { MacpError::UnknownPolicyVersion.to_string(), "UnknownPolicyVersion" ); - assert_eq!(MacpError::PolicyDenied.to_string(), "PolicyDenied"); + assert_eq!( + MacpError::PolicyDenied { + reasons: vec!["test".into()] + } + .to_string(), + "PolicyDenied" + ); } } diff --git a/src/log_store.rs b/src/log_store.rs index b1cba1c..0f3a03f 100644 --- a/src/log_store.rs +++ b/src/log_store.rs @@ -22,6 +22,9 @@ pub struct LogEntry { pub mode: String, #[serde(default)] pub macp_version: String, + /// Original envelope timestamp for replay determinism. + #[serde(default)] + pub timestamp_unix_ms: i64, } pub struct LogStore { @@ -72,6 +75,7 @@ mod tests { session_id: String::new(), mode: String::new(), macp_version: String::new(), + timestamp_unix_ms: 1_700_000_000_000, } } diff --git a/src/main.rs b/src/main.rs index 969b044..5081535 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,10 +13,73 @@ use macp_runtime::storage::{ }; use server::MacpServer; use std::io; +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use tonic::transport::{Identity, Server, ServerTlsConfig}; +/// Validate environment variables at startup. Returns a list of errors, if any. +fn validate_env_config() -> Vec { + let mut errors = Vec::new(); + + // Validate MACP_BIND_ADDR is a valid socket address + if let Ok(val) = std::env::var("MACP_BIND_ADDR") { + if val.parse::().is_err() { + errors.push(format!( + "MACP_BIND_ADDR: '{val}' is not a valid socket address (expected host:port, e.g. 127.0.0.1:50051)" + )); + } + } + + // Validate TLS cert/key paths exist if specified + if let Ok(cert_path) = std::env::var("MACP_TLS_CERT_PATH") { + if !std::path::Path::new(&cert_path).exists() { + errors.push(format!( + "MACP_TLS_CERT_PATH: file does not exist: {cert_path}" + )); + } + } + if let Ok(key_path) = std::env::var("MACP_TLS_KEY_PATH") { + if !std::path::Path::new(&key_path).exists() { + errors.push(format!( + "MACP_TLS_KEY_PATH: file does not exist: {key_path}" + )); + } + } + + // Validate positive integer environment variables + for var_name in [ + "MACP_MAX_PAYLOAD_BYTES", + "MACP_SESSION_START_LIMIT_PER_MINUTE", + "MACP_MESSAGE_LIMIT_PER_MINUTE", + ] { + if let Ok(val) = std::env::var(var_name) { + match val.parse::() { + Ok(0) => { + errors.push(format!("{var_name}: must be a positive integer, got '0'")); + } + Ok(_) => {} + Err(_) => { + errors.push(format!( + "{var_name}: '{val}' is not a valid positive integer" + )); + } + } + } + } + + // Validate non-negative integer environment variable + if let Ok(val) = std::env::var("MACP_CHECKPOINT_INTERVAL") { + if val.parse::().is_err() { + errors.push(format!( + "MACP_CHECKPOINT_INTERVAL: '{val}' is not a valid non-negative integer" + )); + } + } + + errors +} + #[tokio::main] async fn main() -> Result<(), Box> { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() @@ -58,6 +121,19 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt().with_env_filter(env_filter).init(); } + // Validate environment configuration early + let config_errors = validate_env_config(); + if !config_errors.is_empty() { + for err in &config_errors { + tracing::error!("Configuration error: {err}"); + } + return Err(format!( + "startup aborted: {} configuration error(s) detected", + config_errors.len() + ) + .into()); + } + let addr = std::env::var("MACP_BIND_ADDR") .unwrap_or_else(|_| "127.0.0.1:50051".into()) .parse()?; @@ -223,10 +299,39 @@ async fn main() -> Result<(), Box> { } }; + // Set up gRPC health check service + let (health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + let server_future = builder + .add_service(health_service) .add_service(pb::macp_runtime_service_server::MacpRuntimeServiceServer::new(svc)) .serve(addr); + // Background cleanup task: expire TTL-exceeded sessions and evict stale ones. + let cleanup_interval_secs: u64 = std::env::var("MACP_CLEANUP_INTERVAL_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); + let session_retention_secs: u64 = std::env::var("MACP_SESSION_RETENTION_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3600); + let cleanup_runtime = runtime.clone(); + let cleanup_handle = tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(cleanup_interval_secs)); + loop { + interval.tick().await; + cleanup_runtime.cleanup_expired_sessions().await; + cleanup_runtime + .evict_stale_sessions(session_retention_secs) + .await; + } + }); + tokio::select! { result = server_future => { result?; @@ -235,6 +340,7 @@ async fn main() -> Result<(), Box> { tracing::info!("shutting down gracefully..."); } } + cleanup_handle.abort(); // Final snapshot: persist all sessions to storage if !memory_only { diff --git a/src/metrics.rs b/src/metrics.rs index 6a2ea4f..0b75890 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -34,6 +34,11 @@ impl Default for ModeMetrics { } } +/// Maximum number of distinct mode names tracked in metrics. +/// Beyond this limit, metrics are aggregated into an "_overflow" bucket. +const MAX_MODE_CARDINALITY: usize = 1000; +const OVERFLOW_MODE: &str = "_overflow"; + pub struct RuntimeMetrics { per_mode: RwLock>>, } @@ -95,13 +100,21 @@ impl RuntimeMetrics { fn get_or_create(&self, mode: &str) -> Arc { { - let guard = self.per_mode.read().unwrap(); + let guard = self.per_mode.read().unwrap_or_else(|e| e.into_inner()); if let Some(metrics) = guard.get(mode) { return Arc::clone(metrics); } } - let mut guard = self.per_mode.write().unwrap(); + let mut guard = self.per_mode.write().unwrap_or_else(|e| e.into_inner()); + // If at cardinality limit, aggregate into overflow bucket + if guard.len() >= MAX_MODE_CARDINALITY && !guard.contains_key(mode) { + return Arc::clone( + guard + .entry(OVERFLOW_MODE.to_string()) + .or_insert_with(|| Arc::new(ModeMetrics::default())), + ); + } Arc::clone( guard .entry(mode.to_string()) @@ -110,7 +123,7 @@ impl RuntimeMetrics { } pub fn snapshot(&self) -> Vec<(String, MetricsSnapshot)> { - let guard = self.per_mode.read().unwrap(); + let guard = self.per_mode.read().unwrap_or_else(|e| e.into_inner()); guard .iter() .map(|(mode, m)| { diff --git a/src/mode/decision.rs b/src/mode/decision.rs index c7b7a07..a224595 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -274,7 +274,7 @@ impl Mode for DecisionMode { reasons = ?reasons, "policy denied commitment" ); - return Err(MacpError::PolicyDenied); + return Err(MacpError::PolicyDenied { reasons }); } } state.phase = DecisionPhase::Committed; @@ -944,6 +944,56 @@ mod tests { .unwrap(); } + #[test] + fn negative_outcome_commitment_succeeds() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + // Add a proposal + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // Add a vote (reject) + let resp = mode + .on_message( + &session, + &env("agent://fraud", "Vote", vote("p1", "reject")), + ) + .unwrap(); + apply(&mut session, resp); + // Commit with negative outcome + let negative_commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "decision.rejected".into(), + authority_scope: "payments".into(), + reason: "proposal rejected by voters".into(), + mode_version: session.mode_version.clone(), + policy_version: session.policy_version.clone(), + configuration_version: session.configuration_version.clone(), + outcome_positive: false, + } + .encode_to_vec(); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Commitment", negative_commitment), + ) + .unwrap(); + assert!(matches!(resp, ModeResponse::PersistAndResolve { .. })); + apply(&mut session, resp); + assert_eq!(decode(&session).phase, DecisionPhase::Committed); + } + #[test] fn policy_denies_commitment_when_vote_threshold_not_met() { let mode = DecisionMode; diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 7185bf7..1232e45 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -70,7 +70,13 @@ impl Mode for HandoffMode { fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { match env.message_type.as_str() { "Commitment" => check_commitment_authority(session, &env.sender), - "HandoffOffer" | "HandoffContext" if env.sender == session.initiator_sender => Ok(()), + // HandoffOffer: only initiator can offer + "HandoffOffer" if env.sender == session.initiator_sender => Ok(()), + "HandoffOffer" => Err(MacpError::Forbidden), + // HandoffContext: any declared participant (on_message enforces offerer match) + "HandoffContext" if is_declared_participant(&session.participants, &env.sender) => { + Ok(()) + } _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), _ => Err(MacpError::Forbidden), } @@ -137,7 +143,7 @@ impl Mode for HandoffMode { accepted_by: None, declined_by: None, outcome_reason: None, - offered_at_ms: chrono::Utc::now().timestamp_millis(), + offered_at_ms: env.timestamp_unix_ms, }, ); Ok(ModeResponse::PersistState(Self::encode_state(&state))) @@ -215,7 +221,8 @@ impl Mode for HandoffMode { let rules: crate::policy::rules::HandoffPolicyRules = serde_json::from_value(policy.rules.clone()).unwrap_or_default(); if rules.acceptance.implicit_accept_timeout_ms > 0 { - let now_ms = chrono::Utc::now().timestamp_millis(); + // Use envelope timestamp for replay determinism + let now_ms = env.timestamp_unix_ms; let timeout = rules.acceptance.implicit_accept_timeout_ms as i64; for offer in state.offers.values_mut() { if offer.disposition == HandoffDisposition::Offered @@ -242,7 +249,7 @@ impl Mode for HandoffMode { reasons = ?reasons, "policy denied commitment" ); - return Err(MacpError::PolicyDenied); + return Err(MacpError::PolicyDenied { reasons }); } } Ok(ModeResponse::PersistAndResolve { @@ -295,7 +302,7 @@ mod tests { message_id: format!("{}-{}", sender, message_type), session_id: "s1".into(), sender: sender.into(), - timestamp_unix_ms: 0, + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), payload, } } @@ -1111,7 +1118,7 @@ mod tests { mode: "macp.mode.handoff.v1".into(), description: "short timeout".into(), rules: serde_json::json!({ - "acceptance": { "implicit_accept_timeout_ms": 1 }, + "acceptance": { "implicit_accept_timeout_ms": 100 }, "commitment": { "authority": "initiator_only" } }), schema_version: 1, @@ -1120,19 +1127,16 @@ mod tests { .on_session_start(&session, &env("owner", "SessionStart", vec![])) .unwrap(); apply(&mut session, result); - let result = mode - .on_message( - &session, - &env("owner", "HandoffOffer", make_offer("h1", "target")), - ) - .unwrap(); + // Offer with a specific timestamp + let offer_time = 1000i64; + let mut offer_env = env("owner", "HandoffOffer", make_offer("h1", "target")); + offer_env.timestamp_unix_ms = offer_time; + let result = mode.on_message(&session, &offer_env).unwrap(); apply(&mut session, result); - // Wait a tiny bit so the timeout elapses (offered_at_ms is now > 0) - std::thread::sleep(std::time::Duration::from_millis(5)); - // Commitment should succeed — implicit accept triggers - let commit = mode - .on_message(&session, &env("owner", "Commitment", commitment_payload())) - .unwrap(); + // Commitment with timestamp past the timeout (offer_time + 100ms = 1100) + let mut commit_env = env("owner", "Commitment", commitment_payload()); + commit_env.timestamp_unix_ms = offer_time + 200; // well past 100ms timeout + let commit = mode.on_message(&session, &commit_env).unwrap(); assert!(matches!(commit, ModeResponse::PersistAndResolve { .. })); } } diff --git a/src/mode/multi_round.rs b/src/mode/multi_round.rs index 9e61501..049aa91 100644 --- a/src/mode/multi_round.rs +++ b/src/mode/multi_round.rs @@ -86,7 +86,7 @@ impl Mode for MultiRoundMode { match env.message_type.as_str() { "Contribute" => self.handle_contribute(session, env), "Commitment" => self.handle_commitment(session, env), - _ => Ok(ModeResponse::NoOp), + _ => Err(MacpError::InvalidPayload), } } @@ -523,7 +523,7 @@ mod tests { } #[test] - fn non_contribute_message_returns_noop() { + fn non_contribute_message_rejected() { let mode = MultiRoundMode; let state = MultiRoundState { round: 0, @@ -544,8 +544,8 @@ mod tests { payload: b"hello".to_vec(), }; - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::NoOp)); + let err = mode.on_message(&session, &env).unwrap_err(); + assert_eq!(err.error_code(), "INVALID_ENVELOPE"); } #[test] @@ -627,4 +627,29 @@ mod tests { _ => panic!("Expected PersistState with converged=true"), } } + + #[test] + fn unknown_message_type_rejected() { + let mode = MultiRoundMode; + let state = MultiRoundState { + round: 0, + participants: vec!["alice".into(), "bob".into()], + contributions: BTreeMap::new(), + convergence_type: "all_equal".into(), + converged: false, + }; + let session = session_with_state(&state); + let env = Envelope { + macp_version: "1.0".into(), + mode: "ext.multi_round.v1".into(), + message_type: "UnknownType".into(), + message_id: "msg-unknown".into(), + session_id: "s1".into(), + sender: "alice".into(), + timestamp_unix_ms: 0, + payload: vec![], + }; + let err = mode.on_message(&session, &env).unwrap_err(); + assert_eq!(err.error_code(), "INVALID_ENVELOPE"); + } } diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index 6aa93a2..1efde1a 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -47,11 +47,22 @@ pub struct TerminalRejectRecord { pub reason: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RejectRecord { + pub proposal_id: String, + pub sender: String, + pub reason: String, + pub terminal: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProposalState { pub proposals: BTreeMap, pub accepts: BTreeMap, pub terminal_rejections: Vec, + /// All rejections (terminal and non-terminal) for audit trail. + #[serde(default)] + pub rejections: Vec, #[serde(default)] pub phase: ProposalPhase, } @@ -289,6 +300,13 @@ impl Mode for ProposalMode { .rejection .terminal_on_any_reject }); + // Always record the rejection for audit trail + state.rejections.push(RejectRecord { + proposal_id: payload.proposal_id.clone(), + sender: env.sender.clone(), + reason: payload.reason.clone(), + terminal: is_terminal, + }); if is_terminal { state.terminal_rejections.push(TerminalRejectRecord { proposal_id: payload.proposal_id, @@ -344,7 +362,7 @@ impl Mode for ProposalMode { reasons = ?reasons, "policy denied commitment" ); - return Err(MacpError::PolicyDenied); + return Err(MacpError::PolicyDenied { reasons }); } } state.phase = ProposalPhase::Committed; @@ -744,6 +762,13 @@ mod tests { ) .unwrap(); apply(&mut session, resp); + // Verify the rejection is still recorded in state for audit + let state = decode(&session); + assert_eq!(state.rejections.len(), 1); + assert_eq!(state.rejections[0].proposal_id, "p1"); + assert!(!state.rejections[0].terminal); + assert!(state.terminal_rejections.is_empty()); + // But commitment should still be blocked assert_eq!( mode.on_message( &session, diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs index 19b6273..e3f539a 100644 --- a/src/mode/quorum.rs +++ b/src/mode/quorum.rs @@ -244,7 +244,7 @@ impl Mode for QuorumMode { reasons = ?reasons, "policy denied commitment" ); - return Err(MacpError::PolicyDenied); + return Err(MacpError::PolicyDenied { reasons }); } } Ok(ModeResponse::PersistAndResolve { @@ -1051,6 +1051,55 @@ mod tests { assert_eq!(err.to_string(), "PolicyDenied"); } + // --- Negative outcome commitment --- + + #[test] + fn negative_outcome_quorum_rejected() { + let mode = QuorumMode; + let mut session = base_session(); + // 3 participants, required_approvals = 2 + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 2), + ), + ) + .unwrap(); + apply(&mut session, result); + // Two participants reject, making the threshold (2 approvals) unreachable + let result = mode + .on_message( + &session, + &env("alice", "Reject", make_reject("r1", "not ready")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("bob", "Reject", make_reject("r1", "disagree")), + ) + .unwrap(); + apply(&mut session, result); + // Threshold is unreachable: 0 approvals + 1 remaining < 2 required + // Commit with negative outcome + let negative_commitment = commitment("quorum.rejected", false); + let result = mode + .on_message( + &session, + &env("coordinator", "Commitment", negative_commitment), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + // --- All participants abstain — eligible for negative commitment --- #[test] diff --git a/src/mode/task.rs b/src/mode/task.rs index 0c640ad..12ba1d0 100644 --- a/src/mode/task.rs +++ b/src/mode/task.rs @@ -183,7 +183,11 @@ impl Mode for TaskMode { .allow_reassignment_on_reject }); if !allow { - return Err(MacpError::PolicyDenied); + return Err(MacpError::PolicyDenied { + reasons: vec![ + "reassignment after rejection not allowed by policy".into() + ], + }); } } if !payload.assignee.is_empty() && payload.assignee != env.sender { @@ -302,7 +306,7 @@ impl Mode for TaskMode { reasons = ?reasons, "policy denied commitment" ); - return Err(MacpError::PolicyDenied); + return Err(MacpError::PolicyDenied { reasons }); } } Ok(ModeResponse::PersistAndResolve { @@ -992,6 +996,58 @@ mod tests { assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); } + // --- Negative outcome commitment --- + + #[test] + fn negative_outcome_task_failed() { + let mode = TaskMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + // Send TaskRequest + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Worker accepts + let result = mode + .on_message( + &session, + &env("worker", "TaskAccept", make_task_accept("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Worker reports failure + let result = mode + .on_message( + &session, + &env("worker", "TaskFail", make_task_fail("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Commit with negative outcome + let negative_commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "task.failed".into(), + authority_scope: "ops".into(), + reason: "task failed".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + outcome_positive: false, + } + .encode_to_vec(); + let result = mode + .on_message(&session, &env("planner", "Commitment", negative_commitment)) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + // --- Duplicate rejection --- #[test] diff --git a/src/mode/util.rs b/src/mode/util.rs index 0f236f1..f047fa2 100644 --- a/src/mode/util.rs +++ b/src/mode/util.rs @@ -127,3 +127,121 @@ pub fn participants_all_accept( .iter() .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::pb::CommitmentPayload; + + fn make_commitment(action: &str, outcome_positive: bool) -> CommitmentPayload { + CommitmentPayload { + commitment_id: "c1".into(), + action: action.into(), + authority_scope: "scope".into(), + reason: "reason".into(), + mode_version: "1.0.0".into(), + policy_version: String::new(), + configuration_version: "cfg-1".into(), + outcome_positive, + } + } + + // --- outcome_positive validation: RFC-defined positive actions --- + + #[test] + fn decision_selected_positive_ok() { + assert!(validate_outcome_positive(&make_commitment("decision.selected", true)).is_ok()); + } + + #[test] + fn decision_selected_negative_rejected() { + assert!(validate_outcome_positive(&make_commitment("decision.selected", false)).is_err()); + } + + #[test] + fn decision_rejected_negative_ok() { + assert!(validate_outcome_positive(&make_commitment("decision.rejected", false)).is_ok()); + } + + #[test] + fn decision_rejected_positive_rejected() { + assert!(validate_outcome_positive(&make_commitment("decision.rejected", true)).is_err()); + } + + #[test] + fn proposal_accepted_positive_ok() { + assert!(validate_outcome_positive(&make_commitment("proposal.accepted", true)).is_ok()); + } + + #[test] + fn proposal_accepted_negative_rejected() { + assert!(validate_outcome_positive(&make_commitment("proposal.accepted", false)).is_err()); + } + + #[test] + fn proposal_rejected_negative_ok() { + assert!(validate_outcome_positive(&make_commitment("proposal.rejected", false)).is_ok()); + } + + #[test] + fn proposal_rejected_positive_rejected() { + assert!(validate_outcome_positive(&make_commitment("proposal.rejected", true)).is_err()); + } + + #[test] + fn task_completed_positive_ok() { + assert!(validate_outcome_positive(&make_commitment("task.completed", true)).is_ok()); + } + + #[test] + fn task_completed_negative_rejected() { + assert!(validate_outcome_positive(&make_commitment("task.completed", false)).is_err()); + } + + #[test] + fn task_failed_negative_ok() { + assert!(validate_outcome_positive(&make_commitment("task.failed", false)).is_ok()); + } + + #[test] + fn task_failed_positive_rejected() { + assert!(validate_outcome_positive(&make_commitment("task.failed", true)).is_err()); + } + + #[test] + fn handoff_accepted_positive_ok() { + assert!(validate_outcome_positive(&make_commitment("handoff.accepted", true)).is_ok()); + } + + #[test] + fn handoff_declined_negative_ok() { + assert!(validate_outcome_positive(&make_commitment("handoff.declined", false)).is_ok()); + } + + #[test] + fn handoff_declined_positive_rejected() { + assert!(validate_outcome_positive(&make_commitment("handoff.declined", true)).is_err()); + } + + #[test] + fn quorum_approved_positive_ok() { + assert!(validate_outcome_positive(&make_commitment("quorum.approved", true)).is_ok()); + } + + #[test] + fn quorum_rejected_negative_ok() { + assert!(validate_outcome_positive(&make_commitment("quorum.rejected", false)).is_ok()); + } + + #[test] + fn quorum_rejected_positive_rejected() { + assert!(validate_outcome_positive(&make_commitment("quorum.rejected", true)).is_err()); + } + + #[test] + fn custom_action_no_known_suffix_any_outcome_ok() { + // Actions without recognized suffixes pass validation regardless of outcome_positive + assert!(validate_outcome_positive(&make_commitment("custom.action", true)).is_ok()); + assert!(validate_outcome_positive(&make_commitment("custom.action", false)).is_ok()); + } +} diff --git a/src/mode_registry.rs b/src/mode_registry.rs index 47f05cc..2fffa34 100644 --- a/src/mode_registry.rs +++ b/src/mode_registry.rs @@ -364,7 +364,7 @@ impl ModeRegistry { } pub fn get_mode(&self, name: &str) -> Option> { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); if guard.contains_key(name) { Some(ModeRef { registry: self, @@ -376,12 +376,12 @@ impl ModeRegistry { } pub fn standard_mode_names(&self) -> Vec { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); Self::ordered_standard_names(&guard) } pub fn standard_mode_descriptors(&self) -> Vec { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); Self::ordered_standard_names(&guard) .into_iter() .filter_map(|name| guard.get(&name).and_then(ModeRegistration::descriptor)) @@ -389,7 +389,7 @@ impl ModeRegistry { } pub fn extension_mode_names(&self) -> Vec { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); let mut names: Vec = guard .iter() .filter(|(_, e)| !e.standards_track) @@ -400,7 +400,7 @@ impl ModeRegistry { } pub fn extension_mode_descriptors(&self) -> Vec { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); let mut descriptors: Vec = guard .iter() .filter(|(_, e)| !e.standards_track) @@ -411,14 +411,14 @@ impl ModeRegistry { } pub fn all_mode_names(&self) -> Vec { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); let mut names: Vec = guard.keys().cloned().collect(); names.sort(); names } pub fn all_mode_descriptors(&self) -> Vec { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); let mut descriptors: Vec = guard .values() .filter_map(ModeRegistration::descriptor) @@ -428,7 +428,7 @@ impl ModeRegistry { } pub fn all_mode_conformance(&self) -> Vec<(String, ModeConformanceCatalog)> { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); let mut conformance: Vec<(String, ModeConformanceCatalog)> = guard .iter() .map(|(name, entry)| (name.clone(), entry.conformance_catalog())) @@ -438,18 +438,28 @@ impl ModeRegistry { } pub fn is_standard_mode(&self, name: &str) -> bool { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); guard.get(name).map(|e| e.standards_track).unwrap_or(false) } pub fn requires_strict_session_start(&self, name: &str) -> bool { - let guard = self.entries.read().expect("mode registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); guard .get(name) .map(|entry| entry.strict_session_start) .unwrap_or(false) } + /// Returns the mode_version from the mode's descriptor, if available. + pub fn get_mode_version(&self, name: &str) -> Option { + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); + guard + .get(name) + .and_then(|entry| entry.descriptor()) + .map(|d| d.mode_version) + .filter(|v| !v.is_empty()) + } + fn validate_extension_descriptor(descriptor: &ModeDescriptor) -> Result<(), String> { if descriptor.mode.trim().is_empty() { return Err("mode name must not be empty".into()); @@ -507,7 +517,7 @@ impl ModeRegistry { let schema_provider = Arc::new(StaticModeSchemaProvider::new(schema_uris)); let conformance_provider = Arc::new(StaticModeConformanceProvider::default()); - let mut guard = self.entries.write().expect("mode registry lock poisoned"); + let mut guard = self.entries.write().unwrap_or_else(|e| e.into_inner()); if guard.contains_key(&name) { return Err(format!("mode '{}' is already registered", name)); } @@ -531,7 +541,7 @@ impl ModeRegistry { /// Unregister a dynamically registered extension mode. pub fn unregister_extension(&self, mode: &str) -> Result<(), String> { - let mut guard = self.entries.write().expect("mode registry lock poisoned"); + let mut guard = self.entries.write().unwrap_or_else(|e| e.into_inner()); match guard.get(mode) { None => return Err(format!("mode '{}' not found", mode)), Some(entry) if entry.builtin => { @@ -551,7 +561,7 @@ impl ModeRegistry { /// Promote an extension mode to standards-track. /// Optionally re-keys the entry with a new identifier. pub fn promote_mode(&self, mode: &str, new_name: Option<&str>) -> Result { - let mut guard = self.entries.write().expect("mode registry lock poisoned"); + let mut guard = self.entries.write().unwrap_or_else(|e| e.into_inner()); let entry = guard .get(mode) .ok_or_else(|| format!("mode '{}' not found", mode))?; @@ -598,7 +608,7 @@ impl<'a> ModeRef<'a> { .registry .entries .read() - .expect("mode registry lock poisoned"); + .unwrap_or_else(|e| e.into_inner()); guard .get(&self.name) .map(|entry| Arc::clone(&entry.factory)) diff --git a/src/policy/registry.rs b/src/policy/registry.rs index b92930f..d0b62d9 100644 --- a/src/policy/registry.rs +++ b/src/policy/registry.rs @@ -42,7 +42,7 @@ impl PolicyRegistry { pub fn register(&self, definition: PolicyDefinition) -> Result<(), String> { Self::validate_definition(&definition)?; - let mut guard = self.entries.write().expect("policy registry lock poisoned"); + let mut guard = self.entries.write().unwrap_or_else(|e| e.into_inner()); if guard.contains_key(&definition.policy_id) { return Err(format!( "policy '{}' is already registered", @@ -65,7 +65,7 @@ impl PolicyRegistry { return Err("cannot unregister the built-in default policy".into()); } - let mut guard = self.entries.write().expect("policy registry lock poisoned"); + let mut guard = self.entries.write().unwrap_or_else(|e| e.into_inner()); if guard.remove(policy_id).is_none() { return Err(format!("policy '{}' not found", policy_id)); } @@ -90,7 +90,7 @@ impl PolicyRegistry { /// Direct lookup by policy ID. pub fn get(&self, policy_id: &str) -> Option { - let guard = self.entries.read().expect("policy registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); guard.get(policy_id).cloned() } @@ -99,7 +99,7 @@ impl PolicyRegistry { /// If `mode_filter` is `Some(mode)`, returns only policies targeting that /// specific mode or the wildcard `"*"`. If `None`, returns all policies. pub fn list(&self, mode_filter: Option<&str>) -> Vec { - let guard = self.entries.read().expect("policy registry lock poisoned"); + let guard = self.entries.read().unwrap_or_else(|e| e.into_inner()); let mut policies: Vec = guard .values() .filter(|p| match mode_filter { diff --git a/src/replay.rs b/src/replay.rs index 5bdd0ae..1fbf00d 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -54,8 +54,15 @@ fn try_replay_from_checkpoint( let mut session = Session::from(persisted); session.session_id = session_id.into(); - // Re-resolve policy definition if policy_version is bound (RFC-MACP-0012 Section 8) + // Re-resolve policy definition if policy_version is bound but missing from checkpoint. + // This can happen with legacy checkpoints. The resolved definition may differ from the + // original if the policy was modified since the session started (RFC-MACP-0012 Section 8). if !session.policy_version.is_empty() && session.policy_definition.is_none() { + tracing::warn!( + session_id, + policy_version = %session.policy_version, + "checkpoint missing policy_definition; re-resolving from registry (may differ from original)" + ); session.policy_definition = policy_registry.and_then(|pr| pr.resolve(&session.policy_version).ok()); } @@ -96,7 +103,13 @@ fn replay_entry( message_id: entry.message_id.clone(), session_id: session_id.into(), sender: entry.sender.clone(), - timestamp_unix_ms: entry.received_at_ms, + // Use original envelope timestamp for replay determinism; + // fall back to received_at_ms for legacy log entries. + timestamp_unix_ms: if entry.timestamp_unix_ms != 0 { + entry.timestamp_unix_ms + } else { + entry.received_at_ms + }, payload: entry.raw_payload.clone(), }; @@ -184,7 +197,11 @@ fn replay_from_start( message_id: start_entry.message_id.clone(), session_id: session_id.into(), sender: start_entry.sender.clone(), - timestamp_unix_ms: start_entry.received_at_ms, + timestamp_unix_ms: if start_entry.timestamp_unix_ms != 0 { + start_entry.timestamp_unix_ms + } else { + start_entry.received_at_ms + }, payload: start_entry.raw_payload.clone(), }; @@ -272,6 +289,7 @@ mod tests { session_id: "s1".into(), mode: "macp.mode.decision.v1".into(), macp_version: "1.0".into(), + timestamp_unix_ms: received_at_ms, } } @@ -286,6 +304,7 @@ mod tests { session_id: "s1".into(), mode: "macp.mode.decision.v1".into(), macp_version: "1.0".into(), + timestamp_unix_ms: received_at_ms, } } @@ -487,6 +506,7 @@ mod tests { session_id: "s1".into(), mode: "macp.mode.decision.v1".into(), macp_version: "1.0".into(), + timestamp_unix_ms: 3000, }; // A vote after the checkpoint diff --git a/src/runtime.rs b/src/runtime.rs index 88e2a57..bc5b8dc 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -182,6 +182,7 @@ impl Runtime { session_id: env.session_id.clone(), mode: env.mode.clone(), macp_version: env.macp_version.clone(), + timestamp_unix_ms: env.timestamp_unix_ms, } } @@ -191,9 +192,10 @@ impl Runtime { session_id: &str, mode: &str, ) -> LogEntry { + let now = Utc::now().timestamp_millis(); LogEntry { message_id: String::new(), - received_at_ms: Utc::now().timestamp_millis(), + received_at_ms: now, sender: "_runtime".into(), message_type: message_type.into(), raw_payload: payload.to_vec(), @@ -201,6 +203,7 @@ impl Runtime { session_id: session_id.into(), mode: mode.into(), macp_version: "1.0".into(), + timestamp_unix_ms: now, } } @@ -267,6 +270,22 @@ impl Runtime { if require_complete_start { validate_canonical_session_start_payload(&start_payload)?; } + + // Validate mode_version matches the registered descriptor's version + if let Some(descriptor_version) = self.mode_registry.get_mode_version(mode_name) { + if !start_payload.mode_version.is_empty() + && start_payload.mode_version != descriptor_version + { + tracing::warn!( + mode = mode_name, + payload_version = %start_payload.mode_version, + descriptor_version = %descriptor_version, + "mode_version mismatch" + ); + return Err(MacpError::InvalidEnvelope); + } + } + let ttl_ms = extract_ttl_ms(&start_payload)?; let mut guard = self.registry.sessions.write().await; @@ -612,9 +631,10 @@ impl Runtime { return; } }; + let now = Utc::now().timestamp_millis(); let checkpoint = LogEntry { message_id: String::new(), - received_at_ms: Utc::now().timestamp_millis(), + received_at_ms: now, sender: "_runtime".into(), message_type: "Checkpoint".into(), raw_payload, @@ -622,6 +642,7 @@ impl Runtime { session_id: session_id.into(), mode: session.mode.clone(), macp_version: String::new(), + timestamp_unix_ms: now, }; if let Err(e) = self.storage.append_log_entry(session_id, &checkpoint).await { tracing::warn!(session_id, error = %e, "failed to write checkpoint"); @@ -630,6 +651,75 @@ impl Runtime { self.log_store.append(session_id, checkpoint).await; tracing::debug!(session_id, log_len, "checkpoint inserted"); } + + /// Expire all sessions that have exceeded their TTL. + /// Called by the background cleanup task to proactively transition + /// stale sessions without waiting for the next incoming message. + pub async fn cleanup_expired_sessions(&self) { + let now = Utc::now().timestamp_millis(); + let mut guard = self.registry.sessions.write().await; + let expired_ids: Vec = guard + .iter() + .filter(|(_, s)| s.state == SessionState::Open && now > s.ttl_expiry) + .map(|(id, _)| id.clone()) + .collect(); + + for session_id in &expired_ids { + if let Some(session) = guard.get_mut(session_id) { + if session.state != SessionState::Open || now <= session.ttl_expiry { + continue; + } + let entry = Self::make_internal_entry("TtlExpired", b"", session_id, &session.mode); + if let Err(e) = self.storage.append_log_entry(session_id, &entry).await { + tracing::warn!( + session_id, + error = %e, + "failed to write TTL expiry during cleanup" + ); + continue; + } + self.log_store.append(session_id, entry).await; + session.state = SessionState::Expired; + self.metrics.record_session_expired(&session.mode); + self.save_session_to_storage(session).await; + tracing::info!(session_id, "session expired via background cleanup"); + } + } + + if !expired_ids.is_empty() { + tracing::info!( + count = expired_ids.len(), + "background cleanup expired sessions" + ); + } + } + + /// Evict resolved/expired sessions older than `retention_secs` from memory. + /// Sessions remain queryable from durable storage even after eviction. + pub async fn evict_stale_sessions(&self, retention_secs: u64) { + let now = Utc::now().timestamp_millis(); + let cutoff = now - (retention_secs as i64 * 1000); + let mut guard = self.registry.sessions.write().await; + let evict_ids: Vec = guard + .iter() + .filter(|(_, s)| { + matches!(s.state, SessionState::Resolved | SessionState::Expired) + && s.started_at_unix_ms < cutoff + }) + .map(|(id, _)| id.clone()) + .collect(); + + for id in &evict_ids { + guard.remove(id); + } + + if !evict_ids.is_empty() { + tracing::info!( + count = evict_ids.len(), + "evicted stale sessions from memory" + ); + } + } } #[cfg(test)] @@ -1418,4 +1508,198 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "StorageFailed"); } + + #[tokio::test] + async fn ttl_expiration_rejects_message() { + let rt = make_runtime(); + let sid = new_sid(); + let payload = SessionStartPayload { + intent: "intent".into(), + participants: vec!["agent://orchestrator".into(), "agent://fraud".into()], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: String::new(), + ttl_ms: 1, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + rt.process( + &env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + &sid, + "agent://orchestrator", + payload, + ), + None, + ) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let proposal = ProposalPayload { + proposal_id: "p1".into(), + option: "step-up".into(), + rationale: "risk".into(), + supporting_data: vec![], + } + .encode_to_vec(); + let err = rt + .process( + &env( + "macp.mode.decision.v1", + "Proposal", + "m2", + &sid, + "agent://orchestrator", + proposal, + ), + None, + ) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "TtlExpired"); + } + + #[tokio::test] + async fn cleanup_expired_sessions_marks_expired() { + let rt = make_runtime(); + let sid = new_sid(); + let payload = SessionStartPayload { + intent: "intent".into(), + participants: vec!["agent://fraud".into()], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: String::new(), + ttl_ms: 1, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + rt.process( + &env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + &sid, + "agent://orchestrator", + payload, + ), + None, + ) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + rt.cleanup_expired_sessions().await; + let session = rt.get_session_checked(&sid).await.unwrap(); + assert_eq!(session.state, SessionState::Expired); + } + + #[tokio::test] + async fn evict_stale_sessions_removes_resolved() { + let rt = make_runtime(); + let sid = new_sid(); + // Start a decision session + rt.process( + &env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + &sid, + "agent://orchestrator", + session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]), + ), + None, + ) + .await + .unwrap(); + // Send a Proposal + let proposal = ProposalPayload { + proposal_id: "p1".into(), + option: "step-up".into(), + rationale: "risk".into(), + supporting_data: vec![], + } + .encode_to_vec(); + rt.process( + &env( + "macp.mode.decision.v1", + "Proposal", + "m2", + &sid, + "agent://orchestrator", + proposal, + ), + None, + ) + .await + .unwrap(); + // Commit to resolve the session + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "decision.selected".into(), + authority_scope: "payments".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy.default".into(), + configuration_version: "cfg-1".into(), + outcome_positive: true, + } + .encode_to_vec(); + let result = rt + .process( + &env( + "macp.mode.decision.v1", + "Commitment", + "m3", + &sid, + "agent://orchestrator", + commitment, + ), + None, + ) + .await + .unwrap(); + assert_eq!(result.session_state, SessionState::Resolved); + // Wait a moment so the session's started_at_unix_ms is strictly in the past + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + // Evict with retention = 0 (evict immediately) + rt.evict_stale_sessions(0).await; + // Session should no longer be in the in-memory registry + assert!(rt.registry.get_session(&sid).await.is_none()); + } + + #[tokio::test] + async fn session_start_with_wrong_mode_version_rejected() { + let rt = make_runtime(); + let sid = new_sid(); + let payload = SessionStartPayload { + intent: "test".into(), + participants: vec!["agent://orchestrator".into(), "agent://worker".into()], + mode_version: "99.0.0".into(), // wrong version + configuration_version: "cfg-1".into(), + policy_version: String::new(), + ttl_ms: 60_000, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + + let err = rt + .process( + &env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + &sid, + "agent://orchestrator", + payload, + ), + None, + ) + .await + .unwrap_err(); + assert_eq!(err.error_code(), "INVALID_ENVELOPE"); + } } diff --git a/src/security.rs b/src/security.rs index 6b2c3a0..6d23aed 100644 --- a/src/security.rs +++ b/src/security.rs @@ -230,6 +230,24 @@ impl SecurityLayer { ) -> Result<(), MacpError> { let now = Instant::now(); let mut guard = bucket.lock().await; + + // Prune stale senders whose events are all outside the window. + // Limit pruning to at most 100 entries per call to bound latency. + let stale_keys: Vec = guard + .iter() + .filter(|(_, deque)| { + deque + .back() + .map(|last| now.duration_since(*last) > config.window) + .unwrap_or(true) + }) + .map(|(k, _)| k.clone()) + .take(100) + .collect(); + for key in stale_keys { + guard.remove(&key); + } + let deque = guard.entry(sender.to_string()).or_default(); while deque .front() diff --git a/src/server.rs b/src/server.rs index bfdffbc..91ca392 100644 --- a/src/server.rs +++ b/src/server.rs @@ -45,6 +45,9 @@ impl MacpServer { if env.message_type.is_empty() || env.message_id.is_empty() { return Err(MacpError::InvalidEnvelope); } + // RFC-MACP-0001: Signals MUST have empty session_id and empty mode. + // Progress messages MAY be ambient (empty session_id/mode) or session-scoped. + let is_ambient_type = env.message_type == "Signal" || env.message_type == "Progress"; if env.message_type == "Signal" { if !env.session_id.is_empty() { return Err(MacpError::InvalidEnvelope); @@ -53,12 +56,20 @@ impl MacpServer { return Err(MacpError::InvalidEnvelope); } } - if env.message_type != "Signal" && env.session_id.is_empty() { + if env.message_type == "Progress" && env.session_id.is_empty() { + // Ambient Progress: mode must also be empty + if !env.mode.trim().is_empty() { + return Err(MacpError::InvalidEnvelope); + } + } + if !is_ambient_type && env.session_id.is_empty() { return Err(MacpError::InvalidEnvelope); } - if env.message_type != "Signal" && env.mode.trim().is_empty() { + if !is_ambient_type && env.mode.trim().is_empty() { return Err(MacpError::InvalidEnvelope); } + // Session-scoped Progress must have non-empty mode (enforced above for non-ambient types, + // and ambient Progress with non-empty session_id falls through to here naturally) if env.payload.len() > self.security.max_payload_bytes { return Err(MacpError::PayloadTooLarge); } @@ -74,6 +85,7 @@ impl MacpServer { } fn make_error_ack(e: &MacpError, env: &Envelope) -> Ack { + let details = Self::error_details_bytes(e); Ack { ok: false, duplicate: false, @@ -86,11 +98,22 @@ impl MacpServer { message: e.to_string(), session_id: env.session_id.clone(), message_id: env.message_id.clone(), - details: vec![], + details, }), } } + /// Serialize structured error details as JSON bytes for the `details` field. + /// Currently only `PolicyDenied` carries additional detail (its reasons list). + fn error_details_bytes(e: &MacpError) -> Vec { + match e { + MacpError::PolicyDenied { reasons } => { + serde_json::to_vec(&serde_json::json!({ "reasons": reasons })).unwrap_or_default() + } + _ => vec![], + } + } + fn apply_authenticated_sender( identity: &AuthIdentity, mut env: Envelope, @@ -449,6 +472,23 @@ impl MacpServer { MacpError::InvalidSessionId => Status::invalid_argument(err.to_string()), MacpError::InvalidPolicyDefinition => Status::invalid_argument(err.to_string()), MacpError::SessionAlreadyExists => Status::already_exists(err.to_string()), + MacpError::PolicyDenied { ref reasons } => { + let details = Self::error_details_bytes(&err); + let msg = if reasons.is_empty() { + "PolicyDenied".to_string() + } else { + format!("PolicyDenied: {}", reasons.join("; ")) + }; + let mut status = Status::failed_precondition(msg); + if !details.is_empty() { + // Attach JSON details as binary metadata so clients can parse structured reasons. + let val = tonic::metadata::MetadataValue::from_bytes(&details); + status + .metadata_mut() + .insert_bin("macp-error-details-bin", val); + } + status + } _ => Status::failed_precondition(err.to_string()), } } @@ -1508,6 +1548,49 @@ mod tests { assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); } + #[tokio::test] + async fn ambient_progress_accepted() { + let (server, _) = make_server(); + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Progress".into(), + message_id: "prog-1".into(), + session_id: String::new(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: vec![], + }, + ) + .await; + assert!(ack.ok); + } + + #[tokio::test] + async fn ambient_progress_with_mode_rejected() { + let (server, _) = make_server(); + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "Progress".into(), + message_id: "prog-2".into(), + session_id: String::new(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: vec![], + }, + ) + .await; + assert!(!ack.ok); + assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); + } + #[tokio::test] async fn manifest_advertises_stream_enabled() { let (server, _) = make_server(); diff --git a/src/storage/compaction.rs b/src/storage/compaction.rs index 0baab70..cf356a2 100644 --- a/src/storage/compaction.rs +++ b/src/storage/compaction.rs @@ -19,9 +19,10 @@ pub async fn compact_session_log( let raw_payload = serde_json::to_vec(&persisted) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let now = chrono::Utc::now().timestamp_millis(); let checkpoint = LogEntry { message_id: String::new(), - received_at_ms: chrono::Utc::now().timestamp_millis(), + received_at_ms: now, sender: "_runtime".into(), message_type: "Checkpoint".into(), raw_payload, @@ -29,6 +30,7 @@ pub async fn compact_session_log( session_id: session_id.into(), mode: session.mode.clone(), macp_version: String::new(), + timestamp_unix_ms: now, }; storage.replace_log(session_id, &[checkpoint]).await diff --git a/src/storage/file.rs b/src/storage/file.rs index f933564..65394f5 100644 --- a/src/storage/file.rs +++ b/src/storage/file.rs @@ -203,6 +203,7 @@ mod tests { session_id: String::new(), mode: String::new(), macp_version: String::new(), + timestamp_unix_ms: 1_700_000_000_000, } } diff --git a/src/storage/migration.rs b/src/storage/migration.rs index 7a922bd..1af895b 100644 --- a/src/storage/migration.rs +++ b/src/storage/migration.rs @@ -193,6 +193,7 @@ mod tests { session_id: String::new(), mode: String::new(), macp_version: String::new(), + timestamp_unix_ms: 1_700_000_000_000, } } diff --git a/src/storage/recovery.rs b/src/storage/recovery.rs index 3a2066d..1946e5e 100644 --- a/src/storage/recovery.rs +++ b/src/storage/recovery.rs @@ -90,6 +90,7 @@ mod tests { session_id: String::new(), mode: String::new(), macp_version: String::new(), + timestamp_unix_ms: 1_700_000_000_000, } } diff --git a/src/storage/redis_backend.rs b/src/storage/redis_backend.rs index 253a3b7..e63cefc 100644 --- a/src/storage/redis_backend.rs +++ b/src/storage/redis_backend.rs @@ -187,6 +187,7 @@ mod tests { session_id: String::new(), mode: String::new(), macp_version: String::new(), + timestamp_unix_ms: 1_700_000_000_000, } } diff --git a/src/storage/rocksdb.rs b/src/storage/rocksdb.rs index 02622fc..65096b1 100644 --- a/src/storage/rocksdb.rs +++ b/src/storage/rocksdb.rs @@ -275,6 +275,7 @@ mod tests { session_id: String::new(), mode: String::new(), macp_version: String::new(), + timestamp_unix_ms: 1_700_000_000_000, } } diff --git a/src/stream_bus.rs b/src/stream_bus.rs index 25ad852..fcb8bd4 100644 --- a/src/stream_bus.rs +++ b/src/stream_bus.rs @@ -25,10 +25,7 @@ impl SessionStreamBus { } pub fn subscribe(&self, session_id: &str) -> broadcast::Receiver { - let mut guard = self - .channels - .lock() - .expect("session stream bus lock poisoned"); + let mut guard = self.channels.lock().unwrap_or_else(|e| e.into_inner()); guard .entry(session_id.to_string()) .or_insert_with(|| { @@ -40,10 +37,7 @@ impl SessionStreamBus { pub fn publish(&self, session_id: &str, envelope: Envelope) { let sender = { - let guard = self - .channels - .lock() - .expect("session stream bus lock poisoned"); + let guard = self.channels.lock().unwrap_or_else(|e| e.into_inner()); guard.get(session_id).cloned() }; if let Some(sender) = sender { diff --git a/tests/replay_round_trip.rs b/tests/replay_round_trip.rs index ebe282e..4ef2cfa 100644 --- a/tests/replay_round_trip.rs +++ b/tests/replay_round_trip.rs @@ -41,6 +41,7 @@ fn incoming( session_id: "s1".into(), mode: mode.into(), macp_version: "1.0".into(), + timestamp_unix_ms: ts, } } From 9aee17cc96ba5cfeb874d757620a9487688018c2 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Tue, 7 Apr 2026 12:36:53 -0700 Subject: [PATCH 06/10] Improve the runtime to align with RFC --- integration_tests/Cargo.lock | 15 + integration_tests/tests/tier1_protocol/mod.rs | 1 + .../tier1_protocol/test_validation_gaps.rs | 261 ++++++++++++++++++ src/mode/decision.rs | 223 ++++++++++++++- src/mode/proposal.rs | 62 ++++- src/runtime.rs | 73 +++++ src/server.rs | 33 +++ src/session.rs | 27 ++ 8 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 integration_tests/tests/tier1_protocol/test_validation_gaps.rs diff --git a/integration_tests/Cargo.lock b/integration_tests/Cargo.lock index 6960e0d..fffd5ac 100644 --- a/integration_tests/Cargo.lock +++ b/integration_tests/Cargo.lock @@ -1059,6 +1059,7 @@ dependencies = [ "tokio", "tokio-stream", "tonic", + "tonic-health", "tonic-prost", "tonic-prost-build", "tracing", @@ -2135,6 +2136,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -2208,6 +2210,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tonic-health" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ff0636fef47afb3ec02818f5bceb4377b8abb9d6a386aeade18bd6212f8eb7" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tonic-prost" version = "0.14.5" diff --git a/integration_tests/tests/tier1_protocol/mod.rs b/integration_tests/tests/tier1_protocol/mod.rs index a1e2c56..27ba4a7 100644 --- a/integration_tests/tests/tier1_protocol/mod.rs +++ b/integration_tests/tests/tier1_protocol/mod.rs @@ -14,3 +14,4 @@ mod test_rfc_cross_cutting; mod test_session_lifecycle; mod test_stream_session; mod test_task_mode; +mod test_validation_gaps; diff --git a/integration_tests/tests/tier1_protocol/test_validation_gaps.rs b/integration_tests/tests/tier1_protocol/test_validation_gaps.rs new file mode 100644 index 0000000..6817215 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_validation_gaps.rs @@ -0,0 +1,261 @@ +//! Integration tests for RFC compliance validation gap fixes. +//! These tests validate input validation added during the production hardening audit. + +use crate::common; +use macp_integration_tests::helpers::*; +use macp_runtime::decision_pb::EvaluationPayload; +use macp_runtime::pb::{Envelope, InitializeRequest, SessionStartPayload, SignalPayload}; +use prost::Message; +use tonic::Request; + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +// ── Decision Mode: Confidence Bounds (RFC-MACP-0004 §2.2) ───────────── + +#[tokio::test] +async fn evaluation_confidence_out_of_bounds_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let evaluator = "agent://evaluator"; + + // Start a decision session + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("confidence test", &[coord, evaluator], 30_000), + ), + ) + .await + .unwrap(); + + // Submit a proposal first + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-1", "testing"), + ), + ) + .await + .unwrap(); + + // Evaluation with confidence > 1.0 should be rejected + let bad_eval = EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: 2.0, + reason: "too confident".into(), + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + evaluator, + envelope( + MODE_DECISION, + "Evaluation", + &new_message_id(), + &sid, + evaluator, + bad_eval, + ), + ) + .await + .unwrap(); + assert!(!ack.ok, "Evaluation with confidence > 1.0 must be rejected"); +} + +// ── Decision Mode: Objection Severity Enum (RFC-MACP-0004 §2.3) ─────── + +#[tokio::test] +async fn objection_invalid_severity_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let objector = "agent://objector"; + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("severity test", &[coord, objector], 30_000), + ), + ) + .await + .unwrap(); + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-1", "testing"), + ), + ) + .await + .unwrap(); + + // Objection with invalid severity should be rejected + let bad_obj = macp_runtime::decision_pb::ObjectionPayload { + proposal_id: "p1".into(), + reason: "bad".into(), + severity: "urgent".into(), // not a valid enum value + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + objector, + envelope( + MODE_DECISION, + "Objection", + &new_message_id(), + &sid, + objector, + bad_obj, + ), + ) + .await + .unwrap(); + assert!( + !ack.ok, + "Objection with invalid severity must be rejected" + ); +} + +// ── Signal Payload Validation (RFC-MACP-0001 §4) ────────────────────── + +#[tokio::test] +async fn signal_empty_signal_type_rejected() { + let mut client = common::grpc_client().await; + + // Signal with non-empty payload but empty signal_type should be rejected + let signal_payload = SignalPayload { + signal_type: String::new(), + data: b"some data".to_vec(), + confidence: 0.0, + correlation_session_id: String::new(), + } + .encode_to_vec(); + + let env = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: new_message_id(), + session_id: String::new(), + sender: String::new(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: signal_payload, + }; + + let ack = client + .send(with_sender( + "agent://signaler", + macp_runtime::pb::SendRequest { + envelope: Some(env), + }, + )) + .await + .unwrap() + .into_inner() + .ack + .unwrap(); + assert!( + !ack.ok, + "Signal with empty signal_type must be rejected" + ); +} + +// ── Max Participants (Safety Limit) ─────────────────────────────────── + +#[tokio::test] +async fn session_start_too_many_participants_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + + // Create a participant list with 1001 entries + let mut participants: Vec = (0..1001).map(|i| format!("agent://p{i}")).collect(); + participants.insert(0, coord.to_string()); + + let payload = SessionStartPayload { + intent: "overflow test".into(), + participants, + mode_version: MODE_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + policy_version: POLICY_VERSION.into(), + ttl_ms: 30_000, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + payload, + ), + ) + .await + .unwrap(); + assert!( + !ack.ok, + "SessionStart with >1000 participants must be rejected" + ); +} + +// ── Initialize RPC: Empty Versions (RFC-MACP-0001 §4.1) ────────────── + +#[tokio::test] +async fn initialize_empty_versions_rejected() { + let mut client = common::grpc_client().await; + + let err = client + .initialize(Request::new(InitializeRequest { + supported_protocol_versions: vec![], + client_info: None, + capabilities: None, + })) + .await + .unwrap_err(); + assert_eq!( + err.code(), + tonic::Code::InvalidArgument, + "Empty supported_protocol_versions must return InvalidArgument" + ); +} diff --git a/src/mode/decision.rs b/src/mode/decision.rs index a224595..ed01860 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -200,6 +200,10 @@ impl Mode for DecisionMode { "APPROVE" | "REVIEW" | "BLOCK" | "REJECT" => {} _ => return Err(MacpError::InvalidPayload), } + // RFC-MACP-0004 §2.2: confidence must be a normalized value in [0.0, 1.0] + if payload.confidence < 0.0 || payload.confidence > 1.0 { + return Err(MacpError::InvalidPayload); + } Self::ensure_can_deliberate(&state)?; Self::ensure_known_proposal(&state, &payload.proposal_id)?; state.evaluations.push(Evaluation { @@ -214,16 +218,21 @@ impl Mode for DecisionMode { "Objection" => { let payload = ObjectionPayload::decode(&*env.payload) .map_err(|_| MacpError::InvalidPayload)?; + // RFC-MACP-0004 §2.3: severity must be one of {critical, high, medium, low} + let severity = if payload.severity.is_empty() { + "medium".into() + } else { + match payload.severity.to_lowercase().as_str() { + "critical" | "high" | "medium" | "low" => payload.severity.to_lowercase(), + _ => return Err(MacpError::InvalidPayload), + } + }; Self::ensure_can_deliberate(&state)?; Self::ensure_known_proposal(&state, &payload.proposal_id)?; state.objections.push(Objection { proposal_id: payload.proposal_id, reason: payload.reason, - severity: if payload.severity.is_empty() { - "medium".into() - } else { - payload.severity - }, + severity, sender: env.sender.clone(), }); Ok(ModeResponse::PersistState(Self::encode_state(&state))) @@ -1038,4 +1047,208 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "PolicyDenied"); } + + #[test] + fn evaluation_confidence_out_of_bounds_rejected() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let bad_eval = EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: 1.5, + reason: "too confident".into(), + } + .encode_to_vec(); + assert_eq!( + mode.on_message(&session, &env("agent://fraud", "Evaluation", bad_eval)) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); + } + + #[test] + fn evaluation_confidence_negative_rejected() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let bad_eval = EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: -0.1, + reason: "negative".into(), + } + .encode_to_vec(); + assert_eq!( + mode.on_message(&session, &env("agent://fraud", "Evaluation", bad_eval)) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); + } + + #[test] + fn evaluation_confidence_boundary_accepted() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // confidence=0.0 should be accepted + let eval_zero = EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: 0.0, + reason: "zero".into(), + } + .encode_to_vec(); + let resp = mode + .on_message(&session, &env("agent://fraud", "Evaluation", eval_zero)) + .unwrap(); + apply(&mut session, resp); + // confidence=1.0 should be accepted + let eval_one = EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "REVIEW".into(), + confidence: 1.0, + reason: "one".into(), + } + .encode_to_vec(); + mode.on_message(&session, &env("agent://growth", "Evaluation", eval_one)) + .unwrap(); + } + + #[test] + fn objection_invalid_severity_rejected() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let bad_objection = ObjectionPayload { + proposal_id: "p1".into(), + reason: "bad".into(), + severity: "urgent".into(), + } + .encode_to_vec(); + assert_eq!( + mode.on_message(&session, &env("agent://fraud", "Objection", bad_objection)) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); + } + + #[test] + fn objection_valid_severities_accepted() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + for severity in &["critical", "high", "medium", "low"] { + let obj = ObjectionPayload { + proposal_id: "p1".into(), + reason: "reason".into(), + severity: severity.to_string(), + } + .encode_to_vec(); + let resp = mode + .on_message(&session, &env("agent://fraud", "Objection", obj)) + .unwrap(); + apply(&mut session, resp); + } + } + + #[test] + fn objection_severity_case_normalized() { + let mode = DecisionMode; + let mut session = test_session(); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let obj = ObjectionPayload { + proposal_id: "p1".into(), + reason: "reason".into(), + severity: "CRITICAL".into(), + } + .encode_to_vec(); + let resp = mode + .on_message(&session, &env("agent://fraud", "Objection", obj)) + .unwrap(); + apply(&mut session, resp); + let state = decode(&session); + assert_eq!(state.objections[0].severity, "critical"); + } } diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index 1efde1a..75af97c 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -287,7 +287,8 @@ impl Mode for ProposalMode { "Reject" => { let payload = RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; - if !state.proposals.contains_key(&payload.proposal_id) { + // Reject only applies to live proposals (consistent with Accept validation) + if Self::live_proposal(&state, &payload.proposal_id).is_none() { return Err(MacpError::InvalidPayload); } // RFC-MACP-0012: terminal_on_any_reject overrides per-message terminal flag @@ -320,6 +321,9 @@ impl Mode for ProposalMode { "Withdraw" => { let payload = WithdrawPayload::decode(&*env.payload) .map_err(|_| MacpError::InvalidPayload)?; + if payload.proposal_id.trim().is_empty() { + return Err(MacpError::InvalidPayload); + } let record = state .proposals .get_mut(&payload.proposal_id) @@ -1112,4 +1116,60 @@ mod tests { Some("p1".into()) ); } + + #[test] + fn reject_withdrawn_proposal_fails() { + let mode = ProposalMode; + let mut session = base_session(); + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://buyer", "Proposal", make_proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // Withdraw p1 + let resp = mode + .on_message( + &session, + &env("agent://buyer", "Withdraw", make_withdraw("p1")), + ) + .unwrap(); + apply(&mut session, resp); + // Reject on withdrawn proposal should fail + assert_eq!( + mode.on_message( + &session, + &env("agent://seller", "Reject", make_reject("p1", false)) + ) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); + } + + #[test] + fn withdraw_empty_proposal_id_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, resp); + let empty_withdraw = WithdrawPayload { + proposal_id: String::new(), + reason: "empty".into(), + } + .encode_to_vec(); + assert_eq!( + mode.on_message(&session, &env("agent://buyer", "Withdraw", empty_withdraw)) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); + } } diff --git a/src/runtime.rs b/src/runtime.rs index bc5b8dc..dae488b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -507,6 +507,15 @@ impl Runtime { /// notifications. Progress messages carry structured ProgressPayload. /// Neither mutates session state — both are broadcast to subscribers. async fn process_signal(&self, env: &Envelope) -> Result { + // RFC-MACP-0001 §4 / RFC-MACP-0010: validate SignalPayload structure. + // signal_type must be non-empty when a payload is present. + if env.message_type == "Signal" && !env.payload.is_empty() { + let signal: crate::pb::SignalPayload = + prost::Message::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + if signal.signal_type.trim().is_empty() { + return Err(MacpError::InvalidPayload); + } + } // RFC-MACP-0001: validate ProgressPayload structure for Progress messages. if env.message_type == "Progress" && !env.payload.is_empty() { let _: crate::pb::ProgressPayload = @@ -1702,4 +1711,68 @@ mod tests { .unwrap_err(); assert_eq!(err.error_code(), "INVALID_ENVELOPE"); } + + #[tokio::test] + async fn signal_empty_signal_type_rejected() { + let rt = make_runtime(); + // Use non-default data so proto3 serializes a non-empty payload + let signal_payload = crate::pb::SignalPayload { + signal_type: String::new(), + data: b"some data".to_vec(), + confidence: 0.0, + correlation_session_id: String::new(), + } + .encode_to_vec(); + let signal = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: "sig-1".into(), + session_id: String::new(), + sender: "agent://a".into(), + timestamp_unix_ms: 0, + payload: signal_payload, + }; + let err = rt.process_signal(&signal).await.unwrap_err(); + assert_eq!(err.error_code(), "INVALID_ENVELOPE"); + } + + #[tokio::test] + async fn signal_valid_payload_accepted() { + let rt = make_runtime(); + let signal_payload = crate::pb::SignalPayload { + signal_type: "heartbeat".into(), + data: vec![], + confidence: 0.8, + correlation_session_id: String::new(), + } + .encode_to_vec(); + let signal = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: "sig-2".into(), + session_id: String::new(), + sender: "agent://a".into(), + timestamp_unix_ms: 0, + payload: signal_payload, + }; + rt.process_signal(&signal).await.unwrap(); + } + + #[tokio::test] + async fn signal_empty_payload_accepted() { + let rt = make_runtime(); + let signal = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: "sig-3".into(), + session_id: String::new(), + sender: "agent://a".into(), + timestamp_unix_ms: 0, + payload: vec![], + }; + rt.process_signal(&signal).await.unwrap(); + } } diff --git a/src/server.rs b/src/server.rs index 91ca392..7fa633a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -501,6 +501,11 @@ impl MacpRuntimeService for MacpServer { request: Request, ) -> Result, Status> { let req = request.into_inner(); + if req.supported_protocol_versions.is_empty() { + return Err(Status::invalid_argument( + "INVALID_REQUEST: supported_protocol_versions must not be empty", + )); + } if !req.supported_protocol_versions.iter().any(|v| v == "1.0") { return Err(Status::failed_precondition( "UNSUPPORTED_PROTOCOL_VERSION: no mutually supported protocol version", @@ -1605,4 +1610,32 @@ mod tests { let caps = resp.into_inner().capabilities.unwrap(); assert!(caps.sessions.unwrap().stream); } + + #[tokio::test] + async fn initialize_empty_versions_rejected() { + let (server, _) = make_server(); + let err = server + .initialize(Request::new(InitializeRequest { + supported_protocol_versions: vec![], + client_info: None, + capabilities: None, + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + } + + #[tokio::test] + async fn initialize_unsupported_version_rejected() { + let (server, _) = make_server(); + let err = server + .initialize(Request::new(InitializeRequest { + supported_protocol_versions: vec!["2.0".into()], + client_info: None, + capabilities: None, + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + } } diff --git a/src/session.rs b/src/session.rs index 5d8c3d3..a2d8858 100644 --- a/src/session.rs +++ b/src/session.rs @@ -107,6 +107,12 @@ pub fn validate_canonical_session_start_payload( return Err(MacpError::InvalidPayload); } + // Safety limit: prevent resource exhaustion from excessively large participant lists. + const MAX_PARTICIPANTS: usize = 1000; + if payload.participants.len() > MAX_PARTICIPANTS { + return Err(MacpError::InvalidPayload); + } + let mut seen = HashSet::new(); for participant in &payload.participants { let participant = participant.trim(); @@ -366,4 +372,25 @@ mod tests { "InvalidSessionId" ); } + + #[test] + fn too_many_participants_rejected() { + let participants: Vec = (0..1001).map(|i| format!("agent://p{i}")).collect(); + let bytes = encode_payload(5000, participants); + let payload = parse_session_start_payload(&bytes).unwrap(); + assert_eq!( + validate_canonical_session_start_payload(&payload) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); + } + + #[test] + fn max_participants_accepted() { + let participants: Vec = (0..1000).map(|i| format!("agent://p{i}")).collect(); + let bytes = encode_payload(5000, participants); + let payload = parse_session_start_payload(&bytes).unwrap(); + validate_canonical_session_start_payload(&payload).unwrap(); + } } From 3bcfa97dc4e27151f7a7263d1a960def1aa0926e Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Tue, 7 Apr 2026 12:55:14 -0700 Subject: [PATCH 07/10] Push the CI Changes --- Cargo.lock | 1 + Cargo.toml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d56efc..c6da4ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1038,6 +1038,7 @@ dependencies = [ [[package]] name = "macp-proto" version = "0.1.0" +source = "git+https://github.com/multiagentcoordinationprotocol/multiagentcoordinationprotocol.git#07d80fc1052cc1c96eaf069a799889c1d204dc95" [[package]] name = "macp-runtime" diff --git a/Cargo.toml b/Cargo.toml index d02915a..60f4c48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,9 +35,9 @@ tracing-opentelemetry = { version = "0.23", optional = true } rocksdb = { version = "0.22", optional = true } redis = { version = "0.27", features = ["tokio-comp", "aio"], optional = true } # Proto definitions from the spec repo (exposes DEP_MACP_PROTO_PROTO_DIR to build.rs via links metadata). -# For CI/production, switch to git dependency: -# macp-proto = { git = "https://github.com/multiagentcoordinationprotocol/multiagentcoordinationprotocol.git", tag = "proto-v0.1.0" } -macp-proto = { path = "../multiagentcoordinationprotocol/packages/proto-rust" } +# For local proto development, temporarily switch to a path dependency: +# macp-proto = { path = "../multiagentcoordinationprotocol/packages/proto-rust" } +macp-proto = { git = "https://github.com/multiagentcoordinationprotocol/multiagentcoordinationprotocol.git" } [dev-dependencies] tempfile = "3" From 999c6904b1554921eb52aa2895af50b73422b557 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Tue, 7 Apr 2026 13:03:12 -0700 Subject: [PATCH 08/10] Fix the cliipy --- src/policy/evaluator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/policy/evaluator.rs b/src/policy/evaluator.rs index 1a2f8fd..8ba313b 100644 --- a/src/policy/evaluator.rs +++ b/src/policy/evaluator.rs @@ -1407,7 +1407,7 @@ mod tests { "threshold": 0.9 } })); - let mut state = make_state_with_votes(vec![ + let state = make_state_with_votes(vec![ ("p1", "agent://fraud", "APPROVE"), ("p1", "agent://compliance", "APPROVE"), ("p1", "agent://ops", "APPROVE"), From 91f3b95ce71880c59cb071acb872ca566c03fea9 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Tue, 7 Apr 2026 13:48:08 -0700 Subject: [PATCH 09/10] Improve the runtime to align with RFC --- src/policy/evaluator.rs | 26 ++++++++++++-- src/replay.rs | 10 +++--- src/runtime.rs | 79 ++++++++++++++++++++++++++--------------- src/security.rs | 1 - src/server.rs | 10 +++--- 5 files changed, 85 insertions(+), 41 deletions(-) diff --git a/src/policy/evaluator.rs b/src/policy/evaluator.rs index 8ba313b..7fc8f99 100644 --- a/src/policy/evaluator.rs +++ b/src/policy/evaluator.rs @@ -297,9 +297,9 @@ fn check_voting_algorithm( } } _ => { - // Unknown algorithm: treat as pass-through - VotingResult::Passed(format!( - "unknown voting algorithm '{}'; allowing", + // Unknown or misspelled algorithm must not silently pass. + VotingResult::Failed(format!( + "unknown voting algorithm '{}'; supported: majority, supermajority, unanimous, weighted, plurality, none", algorithm )) } @@ -1506,4 +1506,24 @@ mod tests { let result = evaluate_decision_commitment(&policy, &state, &participants); assert!(matches!(result, PolicyDecision::Allow { .. })); } + + // ── Unknown voting algorithm ─────────────────────────────────── + + #[test] + fn unknown_voting_algorithm_denies_commitment() { + let policy = make_policy(serde_json::json!({ + "voting": { "algorithm": "majrity" } + })); + let state = make_state_with_votes(vec![ + ("p1", "agent://fraud", "APPROVE"), + ("p1", "agent://growth", "APPROVE"), + ("p1", "agent://compliance", "APPROVE"), + ]); + let result = evaluate_decision_commitment(&policy, &state, &participants()); + assert!( + matches!(result, PolicyDecision::Deny { .. }), + "unknown voting algorithm must deny, got: {:?}", + result + ); + } } diff --git a/src/replay.rs b/src/replay.rs index 1fbf00d..3821dd1 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -37,7 +37,7 @@ fn try_replay_from_checkpoint( session_id: &str, log_entries: &[LogEntry], registry: &ModeRegistry, - policy_registry: Option<&PolicyRegistry>, + _policy_registry: Option<&PolicyRegistry>, ) -> Result, MacpError> { let checkpoint_idx = log_entries .iter() @@ -57,14 +57,16 @@ fn try_replay_from_checkpoint( // Re-resolve policy definition if policy_version is bound but missing from checkpoint. // This can happen with legacy checkpoints. The resolved definition may differ from the // original if the policy was modified since the session started (RFC-MACP-0012 Section 8). + // Policy definitions MUST be serialized in checkpoint entries. Any checkpoint + // missing a policy definition was created by a legacy version and cannot be + // trusted for deterministic replay — fall back to full replay from SessionStart. if !session.policy_version.is_empty() && session.policy_definition.is_none() { tracing::warn!( session_id, policy_version = %session.policy_version, - "checkpoint missing policy_definition; re-resolving from registry (may differ from original)" + "checkpoint missing policy_definition; falling back to full replay for deterministic policy resolution" ); - session.policy_definition = - policy_registry.and_then(|pr| pr.resolve(&session.policy_version).ok()); + return Ok(None); } let mode = registry diff --git a/src/runtime.rs b/src/runtime.rs index dae488b..5fdeb0f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -491,7 +491,9 @@ impl Runtime { // 3. Best-effort session save + checkpoint self.save_session_to_storage(session).await; if result_state == SessionState::Resolved { - self.maybe_compact_log(&env.session_id, session).await; + if !self.maybe_compact_log(&env.session_id, session).await { + self.force_insert_checkpoint(&env.session_id, session).await; + } } else { self.maybe_insert_checkpoint(&env.session_id, session).await; } @@ -593,7 +595,9 @@ impl Runtime { self.log_store.append(session_id, cancel_entry).await; session.state = SessionState::Expired; self.save_session_to_storage(session).await; - self.maybe_compact_log(session_id, session).await; + if !self.maybe_compact_log(session_id, session).await { + self.force_insert_checkpoint(session_id, session).await; + } self.metrics.record_session_cancelled(&session.mode); tracing::info!(session_id, reason, "session cancelled"); @@ -604,39 +608,31 @@ impl Runtime { } /// Best-effort log compaction for terminal sessions. - async fn maybe_compact_log(&self, session_id: &str, session: &Session) { - if let Err(e) = - crate::storage::compaction::compact_session_log(&*self.storage, session_id, session) - .await + /// Returns `true` if compaction succeeded, `false` if skipped or failed. + async fn maybe_compact_log(&self, session_id: &str, session: &Session) -> bool { + match crate::storage::compaction::compact_session_log(&*self.storage, session_id, session) + .await { - tracing::debug!( - session_id, - error = %e, - "log compaction skipped (backend may not support it)" - ); + Ok(()) => true, + Err(e) => { + tracing::debug!( + session_id, + error = %e, + "log compaction skipped (backend may not support it)" + ); + false + } } } - /// Insert a checkpoint entry if the log has reached the configured interval. - async fn maybe_insert_checkpoint(&self, session_id: &str, session: &Session) { - if self.checkpoint_interval == 0 { - return; - } - let log_len = self - .log_store - .get_log(session_id) - .await - .map(|l| l.len()) - .unwrap_or(0); - // Only checkpoint at interval boundaries, and not on the first entry - if log_len < self.checkpoint_interval || log_len % self.checkpoint_interval != 0 { - return; - } + /// Force a checkpoint entry regardless of interval settings. + /// Used as a fallback when compaction fails on terminal sessions. + async fn force_insert_checkpoint(&self, session_id: &str, session: &Session) { let persisted = crate::registry::PersistedSession::from(session); let raw_payload = match serde_json::to_vec(&persisted) { Ok(bytes) => bytes, Err(e) => { - tracing::warn!(session_id, error = %e, "failed to serialize checkpoint"); + tracing::warn!(session_id, error = %e, "failed to serialize forced checkpoint"); return; } }; @@ -654,11 +650,33 @@ impl Runtime { timestamp_unix_ms: now, }; if let Err(e) = self.storage.append_log_entry(session_id, &checkpoint).await { - tracing::warn!(session_id, error = %e, "failed to write checkpoint"); + tracing::warn!(session_id, error = %e, "failed to write forced checkpoint"); return; } self.log_store.append(session_id, checkpoint).await; - tracing::debug!(session_id, log_len, "checkpoint inserted"); + tracing::debug!( + session_id, + "forced checkpoint inserted for terminal session" + ); + } + + /// Insert a checkpoint entry if the log has reached the configured interval. + async fn maybe_insert_checkpoint(&self, session_id: &str, session: &Session) { + if self.checkpoint_interval == 0 { + return; + } + let log_len = self + .log_store + .get_log(session_id) + .await + .map(|l| l.len()) + .unwrap_or(0); + // Only checkpoint at interval boundaries, and not on the first entry + if log_len < self.checkpoint_interval || log_len % self.checkpoint_interval != 0 { + return; + } + self.force_insert_checkpoint(session_id, session).await; + tracing::debug!(session_id, log_len, "checkpoint inserted at interval"); } /// Expire all sessions that have exceeded their TTL. @@ -691,6 +709,9 @@ impl Runtime { session.state = SessionState::Expired; self.metrics.record_session_expired(&session.mode); self.save_session_to_storage(session).await; + if !self.maybe_compact_log(session_id, session).await { + self.force_insert_checkpoint(session_id, session).await; + } tracing::info!(session_id, "session expired via background cleanup"); } } diff --git a/src/security.rs b/src/security.rs index 6d23aed..a427927 100644 --- a/src/security.rs +++ b/src/security.rs @@ -242,7 +242,6 @@ impl SecurityLayer { .unwrap_or(true) }) .map(|(k, _)| k.clone()) - .take(100) .collect(); for key in stale_keys { guard.remove(&key); diff --git a/src/server.rs b/src/server.rs index 7fa633a..72b1424 100644 --- a/src/server.rs +++ b/src/server.rs @@ -188,13 +188,15 @@ impl MacpServer { Ok(None) } Err(TryRecvError::Lagged(skipped)) => { - // Instead of terminating the stream, log the lag and continue. - // The subscriber misses N messages but stays connected. + // Terminate the stream so the client knows it missed messages. + // Consistent with the async recv() path which also returns ResourceExhausted. tracing::warn!( skipped, - "StreamSession receiver fell behind; skipping envelopes" + "StreamSession receiver fell behind; terminating stream" ); - Ok(None) + Err(Status::resource_exhausted(format!( + "StreamSession receiver fell behind by {skipped} envelopes" + ))) } } } From dee35132190139e2c3e1342f9c986a378d40ef27 Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Tue, 7 Apr 2026 15:21:46 -0700 Subject: [PATCH 10/10] Improve the runtime docs --- docs/API.md | 247 ++++++++++++++ docs/README.md | 118 ++----- docs/architecture.md | 323 +++++++++++------- docs/deployment.md | 116 ++++--- docs/examples.md | 217 +++--------- docs/getting-started.md | 228 +++++++++++++ docs/modes.md | 82 +++++ docs/policy.md | 256 +++++++++----- docs/protocol.md | 121 ------- docs/sdk-guide.md | 170 +++++++++ docs/testing.md | 119 +++---- integration_tests/Cargo.lock | 1 + .../tests/tier3_e2e/test_e2e_decision.rs | 2 +- .../test_e2e_decision_with_signals.rs | 2 +- src/mode/decision.rs | 4 +- src/mode/handoff.rs | 27 +- src/mode/task.rs | 126 ++++++- src/runtime.rs | 10 +- tests/conformance/handoff_reject_paths.json | 4 +- 19 files changed, 1448 insertions(+), 725 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/getting-started.md create mode 100644 docs/modes.md delete mode 100644 docs/protocol.md create mode 100644 docs/sdk-guide.md diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b856336 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,247 @@ +# API Reference + +This is the reference for all 18 gRPC RPCs exposed by the MACP Runtime on `macp.v1.MACPRuntimeService`. The default endpoint is `127.0.0.1:50051`, configurable via `MACP_BIND_ADDR`. + +For protocol-level transport semantics, see the [protocol transports documentation](https://www.multiagentcoordinationprotocol.io/docs/transports). + +## Protocol Handshake + +### Initialize + +Every client session should begin with an `Initialize` call to negotiate the protocol version and discover runtime capabilities. + +```protobuf +rpc Initialize(InitializeRequest) returns (InitializeResponse) +``` + +The client sends its supported protocol versions in descending preference order. The runtime selects the highest mutually supported version and returns it along with its identity, capabilities, and supported modes. + +**Request fields**: +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `supported_protocol_versions` | repeated string | Yes | Versions in descending preference | +| `client_info` | ClientInfo | No | Client name, version, description | +| `capabilities` | Capabilities | No | Client capabilities | + +**Response fields**: +| Field | Type | Description | +|-------|------|-------------| +| `selected_protocol_version` | string | Selected mutual version | +| `runtime_info` | RuntimeInfo | `name: "macp-runtime"`, `version: "0.4.0"` | +| `capabilities` | Capabilities | Runtime capabilities (streaming, cancellation, policy, etc.) | +| `supported_modes` | repeated string | All supported mode identifiers | +| `instructions` | string | Optional human-readable guidance | + +Returns `UNSUPPORTED_PROTOCOL_VERSION` if no mutual version exists. + +**Capabilities advertised**: `sessions.stream`, `cancellation.cancel_session`, `progress.progress`, `manifest.get_manifest`, `mode_registry.list_modes`, `mode_registry.list_changed`, `roots.list_roots`, `roots.list_changed`, `policy_registry.register_policy`, `policy_registry.list_policies`, `policy_registry.list_changed`. + +## Message Transport + +### Send + +The primary RPC for submitting messages. Accepts a single envelope and returns an acknowledgement indicating whether the message was accepted. + +```protobuf +rpc Send(SendRequest) returns (SendResponse) +``` + +**Envelope fields**: +| Field | Type | Description | +|-------|------|-------------| +| `macp_version` | string | Must be `"1.0"` | +| `mode` | string | Mode identifier (empty for signals) | +| `message_type` | string | `"SessionStart"`, `"Proposal"`, `"Commitment"`, `"Signal"`, etc. | +| `message_id` | string | Unique ID for deduplication | +| `session_id` | string | Target session (empty for signals) | +| `sender` | string | Overridden by runtime with authenticated identity | +| `timestamp_unix_ms` | int64 | Client timestamp (informational) | +| `payload` | bytes | Protobuf-encoded mode-specific payload | + +**Ack fields**: +| Field | Type | Description | +|-------|------|-------------| +| `ok` | bool | Whether the message was accepted | +| `duplicate` | bool | True if `message_id` was already processed | +| `message_id` | string | Echo of the submitted ID | +| `session_id` | string | Session the message was applied to | +| `accepted_at_unix_ms` | int64 | Server acceptance timestamp | +| `session_state` | SessionState | Session state after processing | +| `error` | MACPError | Present when `ok` is false | + +The runtime overrides `envelope.sender` with the authenticated identity. If the envelope contains a non-empty `sender` that does not match the authenticated identity, the request is rejected with `UNAUTHENTICATED`. + +### StreamSession + +Provides bidirectional streaming scoped to a single session. Clients send envelopes and receive all accepted envelopes for that session in real time. + +```protobuf +rpc StreamSession(stream StreamSessionRequest) returns (stream StreamSessionResponse) +``` + +The first envelope on the stream binds it to a `session_id`. All subsequent envelopes must target the same session. Responses contain either an accepted `envelope` or an application-level `error` (the stream stays open for application errors). If the client falls behind the broadcast buffer, the stream terminates with `ResourceExhausted`. + +## Session Lifecycle + +### GetSession + +Retrieves metadata and current state for a session. + +```protobuf +rpc GetSession(GetSessionRequest) returns (GetSessionResponse) +``` + +Returns `SessionMetadata` with the session's mode, state, TTL deadline, bound versions, participants, per-participant activity summaries, and initiator identity. Only the session initiator and declared participants can query a session. + +### CancelSession + +Allows the session initiator to terminate a session. This is a core control-plane operation -- mode authorization does not apply. + +```protobuf +rpc CancelSession(CancelSessionRequest) returns (CancelSessionResponse) +``` + +**Request fields**: `session_id` (string), `reason` (string, optional). + +Only the session initiator can cancel. The runtime writes a `SessionCancelPayload` to the log with `cancelled_by` set to the authenticated sender. If the session is already terminal, the current state is returned without error. + +## Discovery + +### GetManifest + +Returns the runtime's full capability manifest, including all supported modes (standards-track and extensions), content types, and identity information. + +```protobuf +rpc GetManifest(GetManifestRequest) returns (GetManifestResponse) +``` + +### ListModes + +Returns descriptors for standards-track modes only. Extension modes are excluded. + +```protobuf +rpc ListModes(ListModesRequest) returns (ListModesResponse) +``` + +Each `ModeDescriptor` includes the mode identifier, version, title, description, determinism class, participant model, accepted message types, terminal message types, and schema URIs. + +### ListRoots + +Discovers available resource roots. + +```protobuf +rpc ListRoots(ListRootsRequest) returns (ListRootsResponse) +``` + +Returns a list of `Root` entries, each with a `uri` and `name`. + +## Extension Mode Lifecycle + +### ListExtModes + +Returns descriptors for extension modes, including both built-in extensions (like `ext.multi_round.v1`) and dynamically registered ones. + +```protobuf +rpc ListExtModes(ListExtModesRequest) returns (ListExtModesResponse) +``` + +### RegisterExtMode + +Dynamically registers a new extension mode. The mode identifier must not be empty, must not already exist, and must not use the reserved `macp.mode.*` namespace. Requires `can_manage_mode_registry` on the auth identity. + +```protobuf +rpc RegisterExtMode(RegisterExtModeRequest) returns (RegisterExtModeResponse) +``` + +### UnregisterExtMode + +Removes a dynamically registered extension mode. Built-in modes cannot be unregistered. + +```protobuf +rpc UnregisterExtMode(UnregisterExtModeRequest) returns (UnregisterExtModeResponse) +``` + +### PromoteMode + +Promotes an extension mode to standards-track status, optionally assigning a new identifier. + +```protobuf +rpc PromoteMode(PromoteModeRequest) returns (PromoteModeResponse) +``` + +## Governance Policy + +### RegisterPolicy + +Registers a governance policy definition. The runtime validates the rules against the target mode's schema and enforces conditional constraints (for example, `weighted` algorithm requires a non-empty `weights` map). The built-in `policy.default` cannot be overwritten. + +```protobuf +rpc RegisterPolicy(RegisterPolicyRequest) returns (RegisterPolicyResponse) +``` + +See the [Policy page](policy.md) for JSON rule examples and validation details. + +### UnregisterPolicy, GetPolicy, ListPolicies + +Standard CRUD operations for the policy registry. `UnregisterPolicy` cannot remove `policy.default`. `ListPolicies` accepts an optional mode filter. + +```protobuf +rpc UnregisterPolicy(UnregisterPolicyRequest) returns (UnregisterPolicyResponse) +rpc GetPolicy(GetPolicyRequest) returns (GetPolicyResponse) +rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse) +``` + +## Streaming Watches + +### WatchModeRegistry + +Server-streaming RPC that sends a notification on connection and then fires whenever the mode registry changes (register, unregister, or promote). + +```protobuf +rpc WatchModeRegistry(WatchModeRegistryRequest) returns (stream WatchModeRegistryResponse) +``` + +### WatchRoots + +Server-streaming RPC for root change notifications. + +```protobuf +rpc WatchRoots(WatchRootsRequest) returns (stream WatchRootsResponse) +``` + +### WatchSignals + +Server-streaming RPC that delivers ambient signal broadcasts. Signals have empty `session_id` and empty `mode`, carry a `SignalPayload` with `signal_type`, `data`, optional `confidence`, and optional `correlation_session_id`. Signals never enter session history. + +```protobuf +rpc WatchSignals(WatchSignalsRequest) returns (stream WatchSignalsResponse) +``` + +### WatchPolicies + +Server-streaming RPC that fires when policies are registered or unregistered. + +```protobuf +rpc WatchPolicies(WatchPoliciesRequest) returns (stream WatchPoliciesResponse) +``` + +## Authentication + +The runtime checks credentials in this order: + +1. **Bearer token**: `Authorization: Bearer ` or `x-macp-token: ` header. The token is mapped to an `AuthIdentity` via the configured token file. +2. **Dev mode**: `x-macp-agent-id: ` header, only when `MACP_ALLOW_DEV_SENDER_HEADER=1`. Grants all capabilities. +3. **Reject**: Returns `UNAUTHENTICATED`. + +See the [Getting Started guide](getting-started.md) for token configuration examples. + +## Rate Limiting + +The runtime enforces per-sender sliding-window rate limits: + +| Limit | Default | Environment variable | +|-------|---------|---------------------| +| Session starts per minute | 60 | `MACP_SESSION_START_LIMIT_PER_MINUTE` | +| Messages per minute | 600 | `MACP_MESSAGE_LIMIT_PER_MINUTE` | + +When a limit is exceeded, the runtime returns `RATE_LIMITED`. diff --git a/docs/README.md b/docs/README.md index a0f96fe..f8d76a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,99 +1,47 @@ -# MACP Runtime documentation +# MACP Runtime Documentation -This directory documents the runtime implementation profile for `macp-runtime v0.4.0`. +**Version**: v0.4.0 | **Protocol**: MACP 1.0 | **Language**: Rust -The RFC/spec repository is still the normative source for MACP semantics. These runtime docs focus on how this implementation behaves today: startup configuration, security model, persistence profile, mode surface, and local-development examples. +The MACP Runtime is the reference implementation of the [Multi-Agent Coordination Protocol](https://www.multiagentcoordinationprotocol.io). It is a coordination kernel written in Rust that enforces session boundaries, validates messages, manages append-only history, and serializes concurrent agent interactions over gRPC. -## What is in this runtime profile +This documentation covers the **runtime implementation** -- how to build, configure, deploy, and integrate with it. For protocol-level concepts like sessions, modes, signals, determinism, and the two-plane architecture, see the [protocol documentation](https://www.multiagentcoordinationprotocol.io/docs). -- MACP server over gRPC with unary RPCs and per-session bidirectional streaming -- five standards-track modes from the main RFC repository and one built-in extension -- strict canonical `SessionStart` for standards-track modes and qualifying extensions -- authenticated sender derivation -- payload limits and rate limiting -- optional file-backed persistence for sessions and accepted-history logs -- extension mode lifecycle management (register, unregister, promote) +## What the runtime provides -## Standards-track modes +The runtime ships as a single binary that exposes 18 gRPC RPCs over TLS. It supports the five standards-track coordination modes (Decision, Proposal, Task, Handoff, Quorum) and one built-in extension mode for iterative convergence. A governance policy framework evaluates rules at commitment time, and pluggable storage backends (file, RocksDB, Redis, or in-memory) handle persistence with append-only logs and checkpoint-based replay. -- `macp.mode.decision.v1` -- `macp.mode.proposal.v1` -- `macp.mode.task.v1` -- `macp.mode.handoff.v1` -- `macp.mode.quorum.v1` +Authentication is handled through bearer tokens mapped to agent identities, with per-sender rate limiting for both session creation and message throughput. In development, a header-based identity shortcut lets you get started without configuring tokens. -## Built-in extension modes +## Documentation -- `ext.multi_round.v1` +### Getting started +- [**Getting Started**](getting-started.md) -- Build the runtime, start a server, and run your first coordination session +- [**Examples**](examples.md) -- Runnable example clients for every mode, with troubleshooting tips -## Freeze profile +### Implementation reference +- [**Architecture**](architecture.md) -- Rust layer design, request processing flows, concurrency model, and source layout +- [**API Reference**](API.md) -- All 18 gRPC RPCs with request/response fields, authentication, and rate limiting +- [**Modes**](modes.md) -- Runtime implementation details for each mode's state machine +- [**Policy**](policy.md) -- Policy registration, JSON rule examples, evaluation internals, and error handling -The current runtime is intended to be the freeze candidate for unary and streaming SDKs and reference examples. +### Operations +- [**Deployment**](deployment.md) -- Production configuration, storage backends, crash recovery, containers, and monitoring +- [**Testing**](testing.md) -- Three test tiers, conformance fixtures, and CI/CD integration -Implemented and supported: +### For SDK authors +- [**SDK Developer Guide**](sdk-guide.md) -- Patterns for building client libraries: envelope construction, error handling, streaming, and retries -- `Initialize` -- `Send` -- `StreamSession` -- `GetSession` -- `CancelSession` -- `GetManifest` -- `ListModes` -- `ListRoots` +## Protocol documentation -Extension mode lifecycle: +The runtime implements the protocol as specified in the RFCs. For protocol-level topics, refer to the specification documentation: -- `ListExtModes` -- `RegisterExtMode` -- `UnregisterExtMode` -- `PromoteMode` - -Streaming watch RPCs: - -- `WatchModeRegistry` — sends initial state, then fires on register/unregister/promote changes -- `WatchRoots` — sends initial state, holds stream open - -## Security model - -Production expectations: - -- TLS transport -- bearer-token authentication -- runtime-derived `Envelope.sender` -- per-request authorization -- payload size limits -- rate limiting - -Local development shortcut: - -```bash -export MACP_ALLOW_INSECURE=1 -export MACP_ALLOW_DEV_SENDER_HEADER=1 -cargo run -``` - -In dev mode, example clients attach `x-macp-agent-id` metadata and may use plaintext transport. - -## Persistence model - -By default the runtime persists state under `.macp-data/` via `FileBackend`: - -- per-session directories containing `session.json` and append-only `log.jsonl` -- crash recovery reconciles dedup state from the log on startup -- atomic writes (tmp file + rename) prevent partial-write corruption - -If a snapshot file contains corrupt or incompatible JSON, the runtime logs a warning to stderr and starts with empty state. - -Disable persistence with: - -```bash -export MACP_MEMORY_ONLY=1 -``` - -## Document map - -- `../README.md` — root-level quick start and configuration reference -- `examples.md` — updated local-development examples and canonical message patterns -- `protocol.md` — implementation notes and protocol surface summary -- `architecture.md` — runtime component layout and mode registry design -- `deployment.md` — production deployment guide, container notes, and environment reference +| Topic | Link | +|-------|------| +| Architecture and two-plane model | [Protocol Architecture](https://www.multiagentcoordinationprotocol.io/docs/architecture) | +| Session lifecycle | [Protocol Lifecycle](https://www.multiagentcoordinationprotocol.io/docs/lifecycle) | +| Coordination modes | [Protocol Modes](https://www.multiagentcoordinationprotocol.io/docs/modes) | +| Governance policies | [Protocol Policy](https://www.multiagentcoordinationprotocol.io/docs/policy) | +| Determinism and replay | [Protocol Determinism](https://www.multiagentcoordinationprotocol.io/docs/determinism) | +| Security model | [Protocol Security](https://www.multiagentcoordinationprotocol.io/docs/security) | +| Transport bindings | [Protocol Transports](https://www.multiagentcoordinationprotocol.io/docs/transports) | +| Agent discovery | [Protocol Discovery](https://www.multiagentcoordinationprotocol.io/docs/discovery) | diff --git a/docs/architecture.md b/docs/architecture.md index f6c47cd..a884856 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,176 +1,243 @@ -# Runtime architecture +# Runtime Architecture -`macp-runtime v0.4.0` is organized as a small set of explicit layers. +The MACP Runtime is a coordination kernel written in Rust. It receives agent messages over gRPC, validates and orders them, dispatches to mode-specific logic, and persists an append-only history that can be replayed to reconstruct any session. -## 1. Transport adapter (`src/server.rs`) +This page describes the runtime's internal design: its layer structure, how requests flow through the system, how concurrency is managed, and how persistence works. For the protocol-level architecture -- the two-plane model, session lifecycle, and determinism guarantees -- see the [protocol architecture documentation](https://www.multiagentcoordinationprotocol.io/docs/architecture). -Responsibilities: +## Layers -- receive gRPC requests -- authenticate request metadata -- derive the runtime sender identity -- enforce payload limits and rate limits -- translate runtime errors into gRPC responses and MACP `Ack` values +The runtime is organized into six layers, each with a clear responsibility boundary: -## 2. Coordination kernel (`src/runtime.rs`) +``` +Agents (external) + | + v gRPC (tonic) ++-----------------------------------------------------------+ +| Transport Layer (src/server.rs) | +| 18 RPC handlers, authentication, envelope validation, | +| sender derivation, rate limiting | ++-----------------------------------------------------------+ + | + v ++-----------------------------------------------------------+ +| Coordination Kernel (src/runtime.rs) | +| Session lifecycle, deduplication, TTL enforcement, | +| mode dispatch, signal broadcast | ++-----------------------------------------------------------+ + | + v ++-----------------------------------------------------------+ +| Mode Layer (src/mode/*.rs) | +| Decision, Proposal, Task, Handoff, Quorum, | +| Multi-Round, Passthrough | ++-----------------------------------------------------------+ + | + v ++-----------------------------------------------------------+ +| Policy Layer (src/policy/*.rs) | +| Policy registry, per-mode commitment evaluators, | +| rule validation | ++-----------------------------------------------------------+ + | + v ++-----------------------------------------------------------+ +| Storage Layer (src/storage/*.rs) | +| File, RocksDB, Redis, and in-memory backends, | +| append-only logs, checkpointing, compaction | ++-----------------------------------------------------------+ + | + v ++-----------------------------------------------------------+ +| Replay (src/replay.rs) | +| Session rebuild from log entries, checkpoint fast path | ++-----------------------------------------------------------+ +``` + +The **transport layer** terminates gRPC connections, authenticates requests using bearer tokens or development headers, validates envelope structure, overrides the sender field with the authenticated identity, and enforces per-sender rate limits. It never touches session state directly. -Responsibilities: +The **coordination kernel** is the heart of the runtime. It manages session creation, deduplication, lazy TTL expiration, and dispatches messages to the appropriate mode handler. It also broadcasts signals on the ambient plane and publishes accepted envelopes to streaming subscribers. -- route envelopes by message type -- validate and create sessions -- apply mode authorization and mode transitions -- enforce accepted-history ordering -- enforce lazy TTL expiry on reads and writes -- persist updated session snapshots +The **mode layer** implements each coordination mode as a pure function over session state. Mode handlers receive an immutable session reference and an envelope, and return a declarative response telling the kernel what to do -- persist state, resolve the session, or both. Modes never mutate state directly. -## 3. Mode Registry (`src/mode_registry.rs`) +The **policy layer** evaluates governance rules at commitment time. Each mode has a dedicated evaluator that checks whether accumulated session history satisfies the policy's constraints. -The `ModeRegistry` is the single source of truth for mode dispatch, replay, and discovery. It eliminates the previous pattern of hardcoded mode maps in `runtime.rs`, `main.rs`, and `replay.rs`. +The **storage layer** provides a pluggable persistence backend behind a common trait. All backends support the same operations: creating session storage, appending log entries, loading logs for replay, and managing checkpoints. -Responsibilities: +The **replay engine** reconstructs sessions from their append-only logs on startup. It can optionally use checkpoints as a fast path, only replaying entries written after the last checkpoint. -- register all mode implementations -- provide mode lookup for dispatch and replay -- provide standards-track mode names for `ListModes` -- provide mode descriptors for `ListModes` and `GetManifest` +## Request flow: Send RPC -The registry uses `RwLock` for thread-safe dynamic mode registration. +When a client calls `Send` with an envelope, the request passes through the full pipeline: -Key methods: +``` +Client --Send(Envelope)--> server.rs::send() + | + +-- authenticate_metadata() -> AuthIdentity + +-- validate_envelope_shape() -> check version, non-empty fields, size + +-- apply_authenticated_sender() -> override envelope.sender + +-- authorize_mode() -> check allowed_modes whitelist + +-- enforce_rate_limit() -> sliding window check + | + +-- runtime.process(envelope) + | | + | +-- [SessionStart] + | | validate session ID format + | | look up mode in registry + | | parse and validate payload (participants, versions, TTL) + | | resolve policy version + | | call mode.on_session_start() + | | persist log entry <-- COMMIT POINT + | | insert session into registry + | | publish to stream subscribers + | | + | +-- [Signal] + | | validate SignalPayload + | | broadcast on signal bus (no state mutation) + | | + | +-- [Session message] + | dedup check (seen_message_ids) + | verify mode matches session + | lazy TTL expiration check + | verify session is OPEN + | call mode.authorize_sender() + | call mode.on_message() + | persist log entry <-- COMMIT POINT + | apply mode response to session + | if resolved: compact or checkpoint + | publish to stream subscribers + | + +-- build Ack and return SendResponse +``` -- `build_default()` — constructs the canonical mode set (5 standards-track + 1 built-in extension) -- `get_mode(name)` — mode lookup for dispatch -- `standard_mode_names()` — drives `ListModes` -- `standard_mode_descriptors()` — drives `ListModes` response -- `all_mode_names()` — drives `GetManifest` and `Initialize` (all modes) -- `extension_mode_descriptors()` — drives `ListExtModes` -- `register_extension(descriptor)` — dynamic extension registration -- `unregister_extension(mode)` — dynamic extension removal (built-in modes cannot be removed) -- `promote_mode(mode, new_name)` — promote extension to standards-track -- `subscribe_changes()` — broadcast channel for `WatchModeRegistry` +The critical property is the **commit point**: the log entry is persisted to durable storage before any in-memory state is updated. If the server crashes after the commit point, replay will reconstruct the session correctly on restart. If it crashes before, the message was never acknowledged and the client can safely retry. -## 4. Mode layer (`src/mode/*`) +## Request flow: StreamSession -Responsibilities: +Bidirectional streaming works similarly but binds the stream to a single session: -- encode coordination semantics per mode -- validate mode-specific payloads -- authorize mode-specific message types -- return declarative `ModeResponse` values for the kernel to apply +The first envelope on the stream establishes the session binding. All subsequent envelopes must target the same session. The client receives all accepted envelopes for that session (from any participant), delivered through a per-session broadcast channel. -Implemented modes: +Application-level errors like validation failures or authorization denials are sent as inline error messages on the stream without closing it. Transport-level errors like authentication failure terminate the stream. If the client falls behind the broadcast buffer (capacity: 256 envelopes), the stream is terminated with `ResourceExhausted` and the client must reconnect. -- Decision — enforced phase transitions (Proposal -> Evaluation -> Voting -> Committed) -- Proposal — negotiation with counterproposals, acceptance convergence, terminal rejections -- Task — delegated task with serial assignment, progress tracking, terminal reports -- Handoff — serial handoff offers with accept/decline disposition -- Quorum — threshold approval with ballots -- MultiRound (`ext.multi_round.v1`) — built-in extension: iterative value convergence with explicit Commitment -- PassthroughMode — generic handler for dynamically registered extension modes +## Key types -## 5. Storage layer +The runtime's core abstractions are expressed as a small set of Rust types: -### Storage backend (`src/storage.rs`) +```rust +// Session state machine -- monotonic transitions only +enum SessionState { Open, Resolved, Expired } -Provides the `StorageBackend` trait with two implementations: +// Mode contract -- modes are pure functions over session state +trait Mode: Send + Sync { + fn on_session_start(&self, session: &Session, env: &Envelope) -> Result; + fn on_message(&self, session: &Session, env: &Envelope) -> Result; + fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError>; +} -- `FileBackend` — per-session directories containing `session.json` and append-only `log.jsonl`, with crash recovery and atomic writes -- `MemoryBackend` — no-op backend for `MACP_MEMORY_ONLY=1` +// Declarative responses -- modes never mutate state directly +enum ModeResponse { + NoOp, // no state change + PersistState(Vec), // update mode state + Resolve(Vec), // terminate session + PersistAndResolve { state: Vec, resolution: Vec }, +} -### Session registry (`src/registry.rs`) +// Storage contract -- all backends implement this trait +trait StorageBackend: Send + Sync { + async fn append_log_entry(&self, session_id: &str, entry: &LogEntry) -> io::Result<()>; + async fn load_log(&self, session_id: &str) -> io::Result>; + async fn save_session(&self, session: &Session) -> io::Result<()>; + async fn load_session(&self, session_id: &str) -> io::Result>; + // ... additional methods for lifecycle management +} -In-memory cache of all sessions, loaded from `FileBackend` on startup. Stores: +// Security identity -- derived from auth tokens, never from envelope +struct AuthIdentity { + sender: String, + allowed_modes: Option>, + can_start_sessions: bool, + max_open_sessions: Option, + can_manage_mode_registry: bool, +} +``` -- session metadata -- bound versions -- participants -- dedup state -- current session state +The `Mode` trait is the central abstraction for extensibility. Each mode receives an immutable reference to the session and returns a `ModeResponse` that the kernel applies. This design ensures modes cannot introduce inconsistencies by directly mutating shared state. -Supports optional file-backed snapshot persistence for backward compatibility. +## Durability model -### Log store (`src/log_store.rs`) +The runtime uses a two-phase write strategy: -In-memory cache of accepted-history logs. Stores: +1. **Log entry persisted first** -- This is the commit point. The append-only log survives server crashes, and the replay engine can reconstruct any session from it. -- accepted incoming envelopes -- runtime-generated internal events such as TTL expiry and session cancellation +2. **In-memory state updated second** -- Session snapshots are a cache optimization. If a snapshot is missing or stale, replay rebuilds the correct state from the log. -On-disk persistence is handled by `FileBackend`, not by LogStore. +Session snapshots are written on each state change as a best-effort optimization. For `SessionStart`, snapshot failure is treated as fatal (the initial snapshot must be durable). For subsequent messages, the log entry is the authoritative record and snapshot failure is non-fatal. -## 6. Security layer (`src/security.rs`) +### Checkpointing -Responsibilities: +The runtime supports two forms of log compaction: -- load token-to-identity mappings -- derive sender identities from metadata -- enforce allowed-mode policy -- enforce session-start policy -- enforce per-sender rate limits +- **Interval-based checkpoints**: After a configurable number of log entries (`MACP_CHECKPOINT_INTERVAL`), the runtime writes a checkpoint that captures the full session state. Replay can start from the checkpoint instead of replaying the entire log. -## 7. Policy layer (`src/policy/`) +- **Terminal compaction**: When a session reaches a terminal state (resolved, cancelled, or expired), the runtime compacts its log into a single checkpoint entry. If compaction fails, a forced checkpoint is written as a fallback. -Responsibilities: +## Concurrency model -- store and resolve governance policy definitions -- validate policy rules against mode-specific JSON schemas at registration -- evaluate governance constraints at commitment time -- provide default policy (`policy.default`) with no additional constraints +The runtime processes different sessions in parallel but serializes access within each session: -Components: +- **Per-session serialization**: The session registry uses `RwLock`. Mutations to a session acquire a write lock, ensuring only one message is processed at a time for that session. -- `PolicyRegistry` — in-memory CRUD store with broadcast change notifications (mirrors `ModeRegistry` pattern) -- `PolicyDefinition` — canonical policy representation: id, mode target, rules (JSON), schema version -- Evaluators — per-mode commitment evaluation: decision (voting/quorum/veto threshold), proposal (counter-proposal round limits), task (output requirements), handoff (implicit accept timeout), quorum (abstention/threshold rules). Rule schemas aligned to RFC-MACP-0012 JSON schemas. -- Default policy — ships pre-registered, applies to all modes via wildcard `"*"`, imposes zero additional constraints +- **Cross-session parallelism**: Messages targeting different sessions are processed concurrently with no coordination between them. -Policy lifecycle: +- **Stream bus**: Each session has a `tokio::sync::broadcast` channel (capacity 256) for delivering accepted envelopes to `StreamSession` subscribers. A separate global broadcast channel handles ambient signals via `WatchSignals`. -1. Registered via `RegisterPolicy` RPC or pre-loaded at startup -2. Resolved at `SessionStart` — bound to session as `policy_definition`. Sessions always bind `policy_version` (defaults to `"policy.default"` when empty). -3. Evaluated at `Commitment` — mode-specific evaluator checks rules against session state. `CommitmentPayload.outcome_positive` must be consistent with the `action` field. -4. Persisted with session — replay uses stored definition, never re-resolves +- **Background tasks**: A periodic cleanup task runs every 60 seconds (configurable via `MACP_CLEANUP_INTERVAL_SECS`) to expire sessions that have exceeded their TTL and evict terminal sessions from memory after a retention period. -Registration validates conditional constraints: `weighted` algorithm requires non-empty `weights`, `supermajority` requires `threshold > 0.5`, `designated_role` authority requires non-empty `designated_roles`. +## Mode registry -## Key protocol fields +The `ModeRegistry` is the single source of truth for which modes the runtime supports. It is built at startup with the five standards-track modes and the built-in `ext.multi_round.v1` extension. -- `CommitmentPayload.outcome_positive` (bool) — indicates whether the commitment outcome is positive or negative. Must match the action suffix (e.g. `*.rejected` → `false`, `*.selected` → `true`). -- `SessionMetadata.initiator` (string) — the authenticated sender who created the session. Populated in `GetSession` responses. -- `SessionAlreadyExists` error — returned when a `SessionStart` is received for a `session_id` that already has an accepted `SessionStart` (maps to gRPC `ALREADY_EXISTS`). +At runtime, the registry supports dynamic extension management: `RegisterExtMode` adds new extensions backed by a generic passthrough handler, `UnregisterExtMode` removes them (built-in modes are protected), and `PromoteMode` elevates an extension to standards-track status. All changes are broadcast to `WatchModeRegistry` subscribers. -## Architecture diagram +## Source layout ``` -Client Request - | - [Transport/gRPC] -- server.rs, security.rs - | - [Coordination Kernel] -- runtime.rs - | - [Policy Layer] -- policy/ - | - [Mode Registry] -- mode_registry.rs - | \ - [Mode Logic] [Discovery + Extension Lifecycle] - mode/*.rs ListModes, ListExtModes, GetManifest, - RegisterExtMode, UnregisterExtMode, PromoteMode - | - [Storage Layer] -- storage.rs, log_store.rs - | - [Replay] -- replay.rs +src/ + main.rs -- Startup, TLS config, persistence wiring, background tasks + server.rs -- gRPC adapter (18 RPCs), auth, validation, streaming + runtime.rs -- Coordination kernel, session lifecycle, mode dispatch + session.rs -- Session model, SessionStart validation, ID rules + security.rs -- Auth config, sender derivation, rate limiting + error.rs -- Error types and RFC error code mapping + registry.rs -- In-memory session registry + log_store.rs -- In-memory log cache + stream_bus.rs -- Per-session broadcast channels for StreamSession + metrics.rs -- Atomic per-mode counters + mode_registry.rs -- Mode lookup, extension lifecycle + replay.rs -- Session rebuild from logs, checkpoint fast path + mode/ + mod.rs -- Mode trait and standard descriptors + decision.rs -- Decision mode state machine + proposal.rs -- Proposal mode (negotiation, convergence) + task.rs -- Task mode (assignment, progress, completion) + handoff.rs -- Handoff mode (serial offers, context, acceptance) + quorum.rs -- Quorum mode (threshold approval, ballots) + multi_round.rs -- Built-in extension: iterative convergence + passthrough.rs -- Generic handler for dynamic extensions + util.rs -- Shared commitment validation and authority checks + policy/ + mod.rs -- Policy types and decision structures + registry.rs -- Policy CRUD with broadcast notifications + evaluator.rs -- Per-mode commitment evaluation + rules.rs -- Serde structs for mode-specific rule schemas + defaults.rs -- Default policy (no constraints) + storage/ + mod.rs -- StorageBackend trait and backend selection + file.rs -- File backend: per-session dirs, atomic writes + memory.rs -- In-memory backend for testing + rocksdb.rs -- RocksDB backend (feature-gated) + redis_backend.rs -- Redis backend (feature-gated) + compaction.rs -- Log compaction for terminal sessions + recovery.rs -- Crash recovery (.tmp file cleanup) + migration.rs -- Storage format migration ``` - -## Request path summary - -1. gRPC request arrives in `MacpServer` -2. request metadata is authenticated -3. sender identity is derived and envelope spoofing is rejected -4. runtime processes the envelope -5. accepted messages mutate state and log history -6. updated session snapshots are persisted -7. an `Ack` is returned to the caller - -## Freeze-profile design choice - -The runtime now exposes `StreamSession` as a per-session accepted-envelope stream. Each gRPC stream binds to one session and receives canonical MACP envelopes in runtime acceptance order. Unary `Send` remains the path for explicit per-message acknowledgement semantics. diff --git a/docs/deployment.md b/docs/deployment.md index 9b6b675..4c0adc3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,49 +1,85 @@ # Deployment Guide +This guide covers everything you need to run the MACP Runtime in production: configuration, storage backends, crash recovery, monitoring, and container deployment. For protocol-level deployment topologies and security requirements, see the [protocol deployment](https://www.multiagentcoordinationprotocol.io/docs/deployment) and [protocol security](https://www.multiagentcoordinationprotocol.io/docs/security) documentation. + ## Production checklist -1. **TLS certificates** — Set `MACP_TLS_CERT_PATH` and `MACP_TLS_KEY_PATH` to valid PEM files -2. **Auth tokens** — Create a `tokens.json` file mapping bearer tokens to agent identities and set `MACP_AUTH_TOKENS_FILE` -3. **Data directory** — Ensure `MACP_DATA_DIR` points to a directory with write permissions -4. **Bind address** — Set `MACP_BIND_ADDR` to the desired listen address (default: `127.0.0.1:50051`) +Before exposing the runtime to production traffic, ensure these four items are configured: + +1. **TLS certificates** -- Set `MACP_TLS_CERT_PATH` and `MACP_TLS_KEY_PATH` to valid PEM files. The runtime refuses to start without TLS unless `MACP_ALLOW_INSECURE=1` is set. + +2. **Authentication tokens** -- Create a `tokens.json` mapping bearer tokens to agent identities and set `MACP_AUTH_TOKENS_FILE`. See the [Getting Started guide](getting-started.md) for the token format. + +3. **Data directory** -- Ensure `MACP_DATA_DIR` points to a directory with write permissions. This is where session logs and snapshots are stored. + +4. **Bind address** -- Set `MACP_BIND_ADDR` to the desired listen address. The default `127.0.0.1:50051` only accepts local connections. + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MACP_BIND_ADDR` | `127.0.0.1:50051` | gRPC listen address | +| `MACP_TLS_CERT_PATH` | -- | TLS certificate PEM (required unless insecure) | +| `MACP_TLS_KEY_PATH` | -- | TLS private key PEM (required unless insecure) | +| `MACP_AUTH_TOKENS_FILE` | -- | Path to bearer token configuration file | +| `MACP_AUTH_TOKENS_JSON` | -- | Inline bearer token config as JSON string | +| `MACP_DATA_DIR` | `.macp-data` | Directory for session persistence | +| `MACP_STORAGE_BACKEND` | `file` | Backend: `file`, `rocksdb`, `redis` | +| `MACP_ROCKSDB_PATH` | `.macp-data/rocksdb` | RocksDB database path | +| `MACP_REDIS_URL` | `redis://127.0.0.1:6379` | Redis connection URL | +| `MACP_MEMORY_ONLY` | off | Set to `1` to disable persistence entirely | +| `MACP_ALLOW_INSECURE` | off | Allow plaintext connections (development only) | +| `MACP_ALLOW_DEV_SENDER_HEADER` | off | Trust `x-macp-agent-id` header (development only) | +| `MACP_MAX_PAYLOAD_BYTES` | `1048576` | Maximum envelope payload size in bytes | +| `MACP_SESSION_START_LIMIT_PER_MINUTE` | `60` | Per-sender session creation rate limit | +| `MACP_MESSAGE_LIMIT_PER_MINUTE` | `600` | Per-sender message rate limit | +| `MACP_CHECKPOINT_INTERVAL` | `0` (disabled) | Log entries between checkpoints | +| `MACP_CLEANUP_INTERVAL_SECS` | `60` | Background TTL cleanup interval in seconds | +| `MACP_SESSION_RETENTION_SECS` | `3600` | How long terminal sessions stay in memory | +| `MACP_STRICT_RECOVERY` | off | Set to `1` to fail on any recovery error | +| `RUST_LOG` | `info` | Log level filter | + +## Storage backends + +The runtime supports four storage configurations, selected via `MACP_STORAGE_BACKEND`: -## Environment variable reference +**File backend** (default) stores each session in its own directory under `MACP_DATA_DIR/sessions//`. An append-only `log.jsonl` records every accepted message, and a `session.json` snapshot is written on each state change. Writes use an atomic tmp-file-then-rename pattern to prevent partial-write corruption. -| Variable | Required | Default | Description | -|---|---|---|---| -| `MACP_BIND_ADDR` | No | `127.0.0.1:50051` | gRPC listen address | -| `MACP_TLS_CERT_PATH` | Yes* | — | Path to TLS certificate PEM | -| `MACP_TLS_KEY_PATH` | Yes* | — | Path to TLS private key PEM | -| `MACP_AUTH_TOKENS_FILE` | No | — | Path to `tokens.json` for bearer token auth | -| `MACP_DATA_DIR` | No | `.macp-data` | Directory for session persistence | -| `MACP_MEMORY_ONLY` | No | — | Set to `1` to disable persistence | -| `MACP_ALLOW_INSECURE` | No | — | Set to `1` to allow plaintext (dev only) | -| `MACP_ALLOW_DEV_SENDER_HEADER` | No | — | Set to `1` to trust `x-macp-sender` header (dev only) | -| `MACP_MAX_OPEN_SESSIONS` | No | — | Per-initiator open session limit | -| `MACP_MAX_PAYLOAD_BYTES` | No | `1048576` | Maximum envelope payload size | +**RocksDB backend** uses an embedded key-value store for higher throughput. Enable it by building with the `rocksdb-backend` Cargo feature and setting `MACP_STORAGE_BACKEND=rocksdb`. The database path defaults to `MACP_ROCKSDB_PATH`. -*TLS is required unless `MACP_ALLOW_INSECURE=1`. +**Redis backend** stores session data in a remote Redis instance, useful for shared-nothing deployments. Enable it with the `redis-backend` feature and set `MACP_STORAGE_BACKEND=redis` with `MACP_REDIS_URL` pointing to your Redis instance. -## Persistence and crash recovery +**Memory-only mode** disables persistence entirely. Set `MACP_MEMORY_ONLY=1` for testing or ephemeral workloads. All session data is lost when the process exits. -When `MACP_MEMORY_ONLY` is not set: +## Crash recovery -- Each session gets a directory under `MACP_DATA_DIR/sessions//` -- An append-only `log.jsonl` records every accepted message and internal event -- A `session.json` snapshot is written on each state change (best-effort) -- On startup, all sessions are rebuilt from their `log.jsonl` files via `replay_session()` -- Log append failures are **fatal** — the runtime rejects the message rather than acknowledging without a durable record -- Atomic writes (tmp file + rename) prevent partial-write corruption +When persistence is enabled, the runtime rebuilds all sessions from their append-only logs on startup. This process is fully automatic: + +- Each session's `log.jsonl` is replayed through the mode engine to reconstruct the session state. +- If a checkpoint exists, replay starts from the checkpoint and only processes subsequent entries. +- Temporary files (`.tmp` suffixes) left by interrupted atomic writes are cleaned up. +- The number of recovered sessions is logged at startup. + +Log append failures are treated as fatal: the runtime rejects the message rather than acknowledging it without a durable record. This ensures the log is always the authoritative source of truth. + +If `MACP_STRICT_RECOVERY=1` is set, the runtime exits on any recovery error. Without it, individual session recovery failures are logged as warnings and the remaining sessions are loaded normally. ## Monitoring -- **stderr warnings** — Failed persistence operations, replay errors, and recovered session counts are logged to stderr -- **Session counts** — On startup, the runtime prints the number of replayed sessions -- **TTL expiry** — Sessions are lazily expired on next read or write; no background reaper -- **Rate limiting** — Per-sender rate limits for `SessionStart` and in-session messages +The runtime provides operational visibility through several mechanisms: + +**Logging** -- All significant events are logged to stderr: session creation, resolution, expiration, recovery results, persistence failures, and rate limit hits. Set `RUST_LOG` to `debug` for detailed request-level logging. + +**TTL enforcement** -- Sessions are expired both lazily (on next access) and proactively by a background task running every `MACP_CLEANUP_INTERVAL_SECS`. This ensures expired sessions are cleaned up even if no new messages arrive. + +**Session eviction** -- Terminal sessions (resolved or expired) are evicted from memory after `MACP_SESSION_RETENTION_SECS` to bound memory usage. Their data remains on disk and can be replayed if needed. + +**Log compaction** -- When a session reaches a terminal state, the runtime automatically compacts its log into a single checkpoint entry. This reduces storage footprint for completed sessions. ## Container deployment +Here is a minimal multi-stage Dockerfile for building and running the runtime: + ```dockerfile FROM rust:1.85 AS builder WORKDIR /app @@ -60,17 +96,17 @@ ENV MACP_DATA_DIR=/data CMD ["macp-runtime"] ``` -Key considerations: +When deploying in containers: -- Mount a persistent volume at `MACP_DATA_DIR` for session durability -- Expose port 50051 (or the configured `MACP_BIND_ADDR` port) -- Provide TLS certificates via mounted secrets -- Set `MACP_AUTH_TOKENS_FILE` to a mounted secrets file for production auth +- Mount a persistent volume at `MACP_DATA_DIR` so session logs survive container restarts. +- Expose port 50051 (or the port configured via `MACP_BIND_ADDR`). +- Provide TLS certificates and auth tokens via mounted secrets. +- Set `MACP_BIND_ADDR=0.0.0.0:50051` to accept connections from outside the container. -## Dev tool prerequisites +## Development tools -For development, install these additional tools: +For development and CI, these additional tools are useful: -- `cargo-tarpaulin` — Coverage reporting (`cargo install cargo-tarpaulin`) -- `cargo-audit` — Dependency security auditing (`cargo install cargo-audit`) -- `buf` — Protocol buffer tooling (for proto sync and lint) +- `cargo-tarpaulin` for coverage reporting +- `cargo-audit` for dependency security auditing +- `buf` for protocol buffer linting and management diff --git a/docs/examples.md b/docs/examples.md index 2e6c706..545ebc1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,8 +1,10 @@ -# Examples and local development guide +# Examples -These examples target `macp-runtime v0.4.0` and the stream-capable freeze profile. +The runtime ships with example clients in `src/bin` that demonstrate each coordination mode. This page walks through what each example does and highlights the runtime behavior you should pay attention to. -They intentionally use the local-development security shortcut: +For protocol-level example transcripts, see the [protocol examples documentation](https://www.multiagentcoordinationprotocol.io/docs/examples). + +All examples target `macp-runtime v0.4.0` and use the development security shortcut: ```bash export MACP_ALLOW_INSECURE=1 @@ -10,236 +12,97 @@ export MACP_ALLOW_DEV_SENDER_HEADER=1 cargo run ``` -The example binaries in `src/bin` attach `x-macp-agent-id` metadata so the runtime can derive the authenticated sender. - -## Ground rules for sessions with strict validation - -For these standards-track modes and built-in extensions: - -- `macp.mode.decision.v1` -- `macp.mode.proposal.v1` -- `macp.mode.task.v1` -- `macp.mode.handoff.v1` -- `macp.mode.quorum.v1` -- `ext.multi_round.v1` (built-in extension) - -`SessionStartPayload` must include all of the following: - -- `participants` -- `mode_version` -- `configuration_version` -- positive `ttl_ms` +The example binaries attach `x-macp-agent-id` metadata so the runtime can derive the authenticated sender. Every example creates a session with the required fields: `participants`, `mode_version` (`"1.0.0"`), `configuration_version` (`"config.default"`), and a positive `ttl_ms`. -The example clients use: - -- `mode_version = "1.0.0"` -- `configuration_version = "config.default"` -- `policy_version = "policy.default"` - -## Example 1: Decision Mode - -Run: +## Decision Mode ```bash cargo run --bin client ``` -Flow: - -1. `Initialize` -2. `ListModes` -3. `SessionStart` by `coordinator` -4. `Proposal` by `coordinator` -5. `Evaluation` by a participant -6. `Vote` by a participant -7. `Commitment` by `coordinator` -8. `GetSession` - -Important runtime behavior: +The coordinator initializes the connection, lists available modes, starts a Decision session, submits a proposal, receives an evaluation and a vote from participants, then commits the outcome. After commitment, it queries the session with `GetSession` to confirm the terminal state. -- initiator/coordinator may emit `Proposal` and `Commitment` -- declared participants may also emit `Proposal`, `Evaluation`, `Objection`, and `Vote` -- duplicate proposal IDs are rejected -- votes are tracked per proposal, per sender -- `CommitmentPayload` version fields must match the bound session versions +The runtime enforces automatic phase progression: the first evaluation moves the session from the Proposal phase to Evaluation, and the first vote moves it to Voting. Once in the Voting phase, new proposals are rejected. Commitment version fields must match the bound session versions. -## Example 2: Proposal Mode - -Run: +## Proposal Mode ```bash cargo run --bin proposal_client ``` -Flow: - -1. buyer starts the session -2. seller creates a proposal -3. buyer counters -4. both required participants accept the same live proposal -5. buyer emits `Commitment` - -Important runtime behavior: +A buyer starts a session, the seller creates a proposal, the buyer counters with a different offer, both participants accept the same live proposal, and the buyer commits. The session does not resolve merely because a proposal exists -- convergence (all required parties accepting the same proposal) must be reached before a commitment is accepted. -- a proposal session does **not** resolve merely because a proposal exists -- `Commitment` is accepted only after acceptance convergence or a terminal rejection - -## Example 3: Task Mode - -Run: +## Task Mode ```bash cargo run --bin task_client ``` -Flow: - -1. planner starts the session -2. planner sends `TaskRequest` -3. worker sends `TaskAccept` -4. worker sends `TaskUpdate` -5. worker sends `TaskComplete` -6. planner emits `Commitment` +A planner starts a session, sends a task request, a worker accepts the task, sends a progress update, and reports completion. The planner then commits the result. Only the active assignee can send task updates -- this is enforced against the authenticated sender identity. -## Example 4: Handoff Mode - -Run: +## Handoff Mode ```bash cargo run --bin handoff_client ``` -Flow: - -1. owner starts the session -2. owner sends `HandoffOffer` -3. owner sends `HandoffContext` -4. target sends `HandoffAccept` -5. owner emits `Commitment` - -## Example 5: Quorum Mode +An owner starts a session, sends a handoff offer with context, and the target agent accepts the offer. The owner commits to finalize the responsibility transfer. Only one outstanding offer is allowed at a time, and once an offer is accepted, no further offers can be issued. -Run: +## Quorum Mode ```bash cargo run --bin quorum_client ``` -Flow: - -1. coordinator starts the session -2. coordinator sends `ApprovalRequest` -3. participants send ballots -4. coordinator emits `Commitment` after threshold is satisfied - -## Example 6: Multi-round mode (built-in extension) +A coordinator starts a session, sends an approval request, participants submit their ballots, and the coordinator commits once the approval threshold is met. The runtime accepts a commitment either when enough approvals exist or when the threshold becomes mathematically unreachable. -Run: +## Multi-Round Mode ```bash cargo run --bin multi_round_client ``` -Flow: +A coordinator starts a session using `ext.multi_round.v1`, participants exchange contributions across multiple rounds, and the runtime tracks convergence (all participants contributing the same value). Convergence does not auto-resolve the session -- an explicit commitment is still required. This mode is discoverable via `ListExtModes`, not `ListModes`. -1. coordinator starts the session with mode `ext.multi_round.v1` and strict `SessionStart` (participants, mode_version, configuration_version, ttl_ms) -2. participants exchange contributions across multiple rounds -3. convergence is tracked by the runtime -4. coordinator emits `Commitment` after convergence +## StreamSession -Important runtime behavior: +`StreamSession` provides per-session bidirectional streaming. A stream is bound to a session by sending the first session-scoped envelope. From that point, the client receives all accepted envelopes for that session in real time. -- `ext.multi_round.v1` is a built-in extension mode, discoverable via `ListExtModes` -- convergence is tracked but does not auto-resolve the session — an explicit `Commitment` is required -- uses the same strict `SessionStart` contract as standards-track modes +Key behaviors to note: -## Example 7: StreamSession +- The stream starts observing from the bind point -- there is no replay of earlier history. +- Use `SessionStart` to create a new session over the stream, or send a session-scoped message to attach to an existing one. +- Mixed-session streams (envelopes targeting different sessions) are rejected. +- Application errors are delivered inline without closing the stream. -`StreamSession` provides per-session bidirectional streaming. The `Initialize` response advertises `stream: true`. +## Extension Mode Lifecycle -`StreamSession` emits only accepted canonical MACP envelopes. A single gRPC stream binds to one session. If a client needs negative per-message acknowledgements, it should continue to use `Send`. +The runtime supports dynamic extension management through four RPCs: -Practical notes: +1. **`ListExtModes`** discovers available extensions (including `ext.multi_round.v1`). +2. **`RegisterExtMode`** registers a new extension with a mode descriptor. The runtime creates a passthrough handler for it. +3. **`UnregisterExtMode`** removes a dynamic extension (built-in modes are protected). +4. **`PromoteMode`** promotes an extension to standards-track, optionally renaming it. -- bind a stream by sending a session-scoped envelope for the target session -- use `SessionStart` to create a new session over the stream -- stream attachment starts observing future accepted envelopes from the bind point; it does not replay earlier history -- use a session-scoped `Signal` envelope with the correct `session_id` and `mode` to attach to an existing session without mutating it -- mixed-session streams are rejected with `FAILED_PRECONDITION` +Extension mode names must not use the reserved `macp.mode.*` namespace. All registry changes are broadcast to `WatchModeRegistry` subscribers, and both `GetManifest` and `Initialize` include all modes. -## Example 8: Extension mode lifecycle - -The runtime supports dynamic extension mode management via gRPC: - -1. **`ListExtModes`** — discover available extension modes (e.g. `ext.multi_round.v1`) -2. **`RegisterExtMode`** — register a new extension mode by providing a `ModeDescriptor` with the mode name, message types, and terminal message types; the runtime creates a passthrough handler -3. **`UnregisterExtMode`** — remove a dynamically registered extension (built-in modes like `ext.multi_round.v1` cannot be removed) -4. **`PromoteMode`** — promote an extension to standards-track, optionally renaming the identifier (e.g. `ext.foo.v1` to `macp.mode.foo.v1`) - -Important runtime behavior: - -- extension mode names must not use the reserved `macp.mode.*` namespace -- dynamically registered modes use a passthrough handler that accepts any listed message type and requires explicit `Commitment` from the initiator to resolve -- `WatchModeRegistry` subscribers receive `RegistryChanged` events on every register, unregister, or promote operation -- `GetManifest` and `Initialize` always include all modes (standards-track + extensions); `ListModes` only returns standards-track - -## Example 9: Freeze-check / error-path client - -Run: +## Error Path Testing ```bash cargo run --bin fuzz_client ``` -This client exercises common failure paths for the freeze profile, including: - -- invalid protocol version -- empty mode -- invalid payloads -- duplicate messages -- unauthorized sender spoofing -- payload too large -- session access without membership - -## Session ID policy - -Session IDs must be either: - -- **UUID v4/v7** in hyphenated lowercase canonical form (36 characters, e.g. `550e8400-e29b-41d4-a716-446655440000`) -- **Base64url token** of at least 22 characters using only `[A-Za-z0-9_-]` - -Human-readable or short IDs (e.g. `"my-session"`, `"s1"`) are rejected with `INVALID_SESSION_ID`. The example clients generate UUID v4 session IDs automatically. - -## Common troubleshooting - -### `UNAUTHENTICATED` - -Either send a bearer token that exists in the configured auth map, or start the runtime with: - -```bash -export MACP_ALLOW_DEV_SENDER_HEADER=1 -``` - -and ensure the client sets `x-macp-agent-id`. - -### `INVALID_ENVELOPE` on `SessionStart` - -For a standards-track mode or built-in extension, check that: +This client deliberately exercises failure paths: invalid protocol versions, empty modes, malformed payloads, duplicate messages, unauthorized sender spoofing, oversized payloads, and session access without membership. Use it to verify that the runtime rejects invalid requests correctly. -- the mode name is canonical -- the payload is not empty -- `mode_version` is present -- `configuration_version` is present -- `ttl_ms > 0` -- participants are present and unique +## Troubleshooting -### `SESSION_NOT_OPEN` +**`UNAUTHENTICATED`**: Start the runtime with `MACP_ALLOW_DEV_SENDER_HEADER=1` and ensure the client sets the `x-macp-agent-id` header. -The session is already resolved or expired. Use `GetSession` to confirm the terminal state. +**`INVALID_ENVELOPE` on SessionStart**: Verify that the mode name is canonical, the payload is not empty, and all four required fields (`mode_version`, `configuration_version`, `ttl_ms > 0`, `participants`) are present. -### `RATE_LIMITED` +**`SESSION_NOT_OPEN`**: The session has already been resolved or expired. Use `GetSession` to confirm the terminal state. -Increase the limits only if you understand the operational impact: +**`RATE_LIMITED`**: Increase the limits if appropriate: ```bash export MACP_SESSION_START_LIMIT_PER_MINUTE=120 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..a669fe1 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,228 @@ +# Getting Started + +This guide walks you from a fresh checkout to your first coordination session. By the end, you will have the runtime running locally and will have completed a full Decision Mode session through the gRPC API. + +For protocol concepts like sessions, modes, and the two-plane model, see the [protocol documentation](https://www.multiagentcoordinationprotocol.io/docs). + +## Prerequisites + +You need a Rust stable toolchain (1.75 or later) and the Protocol Buffers compiler. + +```bash +# macOS +brew install protobuf + +# Ubuntu / Debian +sudo apt-get install -y protobuf-compiler + +# Verify +protoc --version +rustc --version +``` + +## Build and run + +Clone the repository and build: + +```bash +git clone https://github.com/multiagentcoordinationprotocol/runtime.git +cd runtime +cargo build +``` + +### Starting a development server + +For local development, two environment variables disable TLS and enable a header-based identity shortcut. This lets you send requests without configuring tokens or certificates: + +```bash +export MACP_ALLOW_INSECURE=1 +export MACP_ALLOW_DEV_SENDER_HEADER=1 +cargo run +``` + +The server listens on `127.0.0.1:50051` and trusts the `x-macp-agent-id` gRPC metadata header as the authenticated sender identity. + +### Starting a production server + +In production, the runtime requires TLS and token-based authentication: + +```bash +export MACP_TLS_CERT_PATH=/path/to/server.crt +export MACP_TLS_KEY_PATH=/path/to/server.key +export MACP_AUTH_TOKENS_FILE=/path/to/tokens.json +cargo run +``` + +See the [Deployment Guide](deployment.md) for the full environment variable reference. + +## Your first session + +A coordination session has four steps: negotiate the protocol version, create a session, exchange mode-specific messages, and bind the terminal outcome with a commitment. + +### Step 1: Initialize + +The client sends its supported protocol versions and the runtime selects one. This also exchanges capability information so the client knows which features are available. + +``` +-> InitializeRequest { + supported_protocol_versions: ["1.0"], + client_info: { name: "my-agent", version: "0.1.0" } + } + +<- InitializeResponse { + selected_protocol_version: "1.0", + runtime_info: { name: "macp-runtime", version: "0.4.0" }, + supported_modes: [ + "macp.mode.decision.v1", + "macp.mode.proposal.v1", + "macp.mode.task.v1", + "macp.mode.handoff.v1", + "macp.mode.quorum.v1", + "ext.multi_round.v1" + ] + } +``` + +### Step 2: Create a session + +Send a `SessionStart` envelope to create a Decision Mode session with two participants. Every standards-track session requires four fields in the payload: `participants`, `mode_version`, `configuration_version`, and a positive `ttl_ms`. + +``` +-> Send(Envelope { + macp_version: "1.0", + mode: "macp.mode.decision.v1", + message_type: "SessionStart", + message_id: "msg-001", + session_id: "550e8400-e29b-41d4-a716-446655440000", + sender: "", + timestamp_unix_ms: 1712500000000, + payload: SessionStartPayload { + intent: "Decide whether to deploy v2.0", + participants: ["agent://analyst", "agent://reviewer"], + mode_version: "1.0.0", + configuration_version: "config.default", + policy_version: "", + ttl_ms: 60000 + } + }) + +<- Ack { ok: true, session_state: OPEN } +``` + +The `sender` field is left empty because the runtime overrides it with the authenticated identity. The empty `policy_version` resolves to the built-in `policy.default`, which imposes no governance constraints beyond the mode's own rules. + +Session IDs must be either UUID v4/v7 in hyphenated lowercase form or base64url tokens of at least 22 characters. Short or human-readable IDs like `"my-session"` are rejected. + +### Step 3: Exchange messages + +In Decision Mode, participants propose options, evaluate them, and vote. The session initiator and declared participants can all send proposals. Evaluations and votes reference proposals by ID. + +``` +-> Send(Envelope { message_type: "Proposal", payload: ProposalPayload { + proposal_id: "p1", + option: "deploy-v2", + rationale: "All tests passing, metrics stable" + }}) +<- Ack { ok: true } + +-> Send(Envelope { sender: "agent://analyst", message_type: "Vote", payload: VotePayload { + proposal_id: "p1", + vote: "APPROVE", + reason: "Risk assessment passed" + }}) +<- Ack { ok: true } +``` + +### Step 4: Commit + +The session initiator binds the terminal outcome. The commitment payload must echo the session's bound `mode_version` and `configuration_version` -- the runtime rejects mismatches. + +``` +-> Send(Envelope { message_type: "Commitment", payload: CommitmentPayload { + commitment_id: "c1", + action: "decision.selected", + authority_scope: "deployment", + reason: "Unanimous approval for deploy-v2", + mode_version: "1.0.0", + configuration_version: "config.default", + policy_version: "policy.default", + outcome_positive: true + }}) +<- Ack { ok: true, session_state: RESOLVED } +``` + +The session is now terminal. Any subsequent messages targeting it are rejected with `SESSION_NOT_OPEN`. + +## Authentication + +### Development mode + +In development mode, clients set the `x-macp-agent-id` gRPC metadata header to declare their identity: + +``` +metadata: { "x-macp-agent-id": "agent://my-agent" } +``` + +The runtime trusts this header directly. This is only available when `MACP_ALLOW_DEV_SENDER_HEADER=1` is set. + +### Production mode + +Create a `tokens.json` file that maps bearer tokens to agent identities and capabilities: + +```json +[ + { + "token": "secret-token-for-analyst", + "sender": "agent://analyst", + "allowed_modes": ["macp.mode.decision.v1", "macp.mode.task.v1"], + "can_start_sessions": true, + "max_open_sessions": 10, + "can_manage_mode_registry": false + }, + { + "token": "secret-token-for-reviewer", + "sender": "agent://reviewer", + "allowed_modes": [], + "can_start_sessions": true, + "can_manage_mode_registry": false + } +] +``` + +Setting `allowed_modes` to an empty array grants access to all modes. The runtime derives the sender identity from the token, so agents cannot spoof their identity. Clients authenticate by sending `Authorization: Bearer ` in the gRPC metadata. + +## Running the example clients + +The repository includes example clients in `src/bin` that demonstrate each mode. Start the development server in one terminal, then run any example in another: + +```bash +# Terminal 1: start the server +export MACP_ALLOW_INSECURE=1 && export MACP_ALLOW_DEV_SENDER_HEADER=1 && cargo run + +# Terminal 2: run examples +cargo run --bin client # Decision mode +cargo run --bin proposal_client # Proposal mode +cargo run --bin task_client # Task mode +cargo run --bin handoff_client # Handoff mode +cargo run --bin quorum_client # Quorum mode +cargo run --bin multi_round_client # Multi-round extension +cargo run --bin fuzz_client # Error path testing +``` + +## Common errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `UNAUTHENTICATED` | No valid credential provided | Set `MACP_ALLOW_DEV_SENDER_HEADER=1` and send the `x-macp-agent-id` header | +| `INVALID_ENVELOPE` | Missing required SessionStart fields | Ensure `participants`, `mode_version`, `configuration_version`, and `ttl_ms > 0` are all present | +| `SESSION_NOT_OPEN` | Session already resolved or expired | Use `GetSession` to check state; start a new session | +| `INVALID_SESSION_ID` | Session ID format not accepted | Use UUID v4/v7 or base64url (22+ characters) | +| `FORBIDDEN` | Sender not authorized for this message | Check the mode's authority rules; ensure the sender is in the participants list | +| `RATE_LIMITED` | Too many requests per minute | Wait for the rate window to expire, or increase the limit via environment variables | + +## Next steps + +- [**Examples**](examples.md) for worked examples of every mode +- [**API Reference**](API.md) for the full gRPC surface +- [**Modes**](modes.md) for runtime implementation details +- [**Deployment Guide**](deployment.md) for production setup diff --git a/docs/modes.md b/docs/modes.md new file mode 100644 index 0000000..db9fb4e --- /dev/null +++ b/docs/modes.md @@ -0,0 +1,82 @@ +# Coordination Modes + +This page documents the runtime's implementation of each coordination mode -- the internal state machines, phase progression rules, and implementation-specific behavior. For mode specifications, message types, authority matrices, and protocol-level semantics, see the [protocol modes documentation](https://www.multiagentcoordinationprotocol.io/docs/modes) and the individual mode RFCs. + +## Decision Mode + +**Source**: `src/mode/decision.rs` | **Identifier**: `macp.mode.decision.v1` + +The decision mode tracks proposals, evaluations, objections, and votes through an automatic phase progression. Its internal state consists of: + +- A map of proposals keyed by `proposal_id` +- Lists of evaluations and objections +- A nested map of votes keyed by `proposal_id` then by sender +- A phase indicator that advances automatically as the session progresses + +**Phase progression** is automatic: the first `Evaluation` message moves the phase from Proposal to Evaluation, and the first `Vote` moves it to Voting. Once in the Voting phase, new proposals are no longer accepted. This progression is enforced by the runtime, not by agents. + +**Value normalization**: Recommendation values, vote values, and severity levels are stored in a canonical form (uppercase for recommendations and votes, lowercase for severity) to ensure deterministic comparison during policy evaluation. + +**Commitment readiness**: The runtime requires at least one proposal to exist before accepting a commitment. If governance policies are bound to the session, they impose additional requirements -- vote quorum, confidence thresholds, and veto rules -- that must also be satisfied. + +## Proposal Mode + +**Source**: `src/mode/proposal.rs` | **Identifier**: `macp.mode.proposal.v1` + +The proposal mode handles offer-and-counteroffer negotiation. Its internal state tracks live proposals, per-participant acceptance records, and any terminal rejections. + +**Convergence detection** happens automatically after each message. The `refresh_phase()` method checks the session's acceptance criterion (configurable via policy as `all_parties`, `counterparty`, or `initiator`) and transitions the phase to Converged when the criterion is met. Convergence does not auto-resolve the session -- an explicit commitment is still required. + +**Counter-proposal semantics**: A `CounterProposal` creates a new entry with its own `proposal_id`. The `supersedes_proposal_id` field is informational only -- the original proposal stays live and participants can accept either. Round limits are enforced at counter-proposal submission time, not just at commitment. + +**Terminal rejection**: A `Reject` message with `terminal: true` immediately transitions the session phase to TerminalRejected, making the session eligible for a negative-outcome commitment. + +## Task Mode + +**Source**: `src/mode/task.rs` | **Identifier**: `macp.mode.task.v1` + +The task mode manages bounded work delegation. Its internal state tracks the task request, the currently active assignee, any rejection records, progress updates, and the terminal report (complete or fail). + +**Assignment lifecycle**: After the initiator sends a `TaskRequest`, an eligible participant can accept with `TaskAccept`, which sets them as the active assignee. Only the active assignee can send `TaskUpdate` messages -- this is validated against the authenticated sender, not a payload field. + +**Reassignment**: When the `allow_reassignment_on_reject` policy rule is enabled and the active assignee sends a `TaskReject`, the assignee is cleared. Other eligible participants can then send `TaskAccept` to take over the task. + +**Terminal reports**: Either `TaskComplete` or `TaskFail` records the outcome, but neither resolves the session. An explicit commitment from the initiator is required to bind the result. + +## Handoff Mode + +**Source**: `src/mode/handoff.rs` | **Identifier**: `macp.mode.handoff.v1` + +The handoff mode manages responsibility transfer through serial offers. Its internal state tracks offers and their associated context messages. + +**Serial offer constraint**: Only one outstanding (unresolved) offer may exist at a time. Once an offer is accepted, no further offers can be issued in that session. + +**Late context**: `HandoffContext` messages are accepted even after the offer they reference has been accepted or declined. The protocol allows this as supplementary documentation -- additional context that may be useful to the accepting agent after the transfer. + +## Quorum Mode + +**Source**: `src/mode/quorum.rs` | **Identifier**: `macp.mode.quorum.v1` + +The quorum mode tracks approval requests and ballots against a threshold. Its internal state records the approval request and a map of ballots (approve, reject, or abstain) keyed by sender. + +**Threshold resolution**: The `effective_threshold()` method checks whether a governance policy overrides the `required_approvals` value from the payload. Policy thresholds (percentage or count) replace the payload value entirely rather than supplementing it. + +**Commitment readiness**: The runtime accepts a commitment when either the approval threshold is met or the threshold becomes mathematically unreachable (remaining possible approvals plus current approvals is still below the threshold). + +**Abstention handling**: When the policy specifies abstention rules, the effective voter count is adjusted accordingly. An abstention with `counts_toward_quorum: false` reduces the denominator for percentage-based thresholds. + +## Built-in Extension: Multi-Round Mode + +**Source**: `src/mode/multi_round.rs` | **Identifier**: `ext.multi_round.v1` + +The multi-round mode is a built-in extension for iterative convergence. It is discoverable via `ListExtModes` (not `ListModes`, which returns only standards-track modes). + +Participants send `Contribute` messages with a `value` string. Each contribution overwrites the sender's previous value. When all declared participants have contributed the same value, the runtime marks the session as converged. Convergence does not auto-resolve the session -- an explicit commitment is required. + +Unlike the standards-track modes, multi-round uses JSON-encoded payloads rather than protobuf. + +## Dynamic Extension Modes + +Extensions can be registered at runtime via `RegisterExtMode`. Each registered extension is backed by the passthrough handler (`src/mode/passthrough.rs`), which accepts any message type listed in the extension's descriptor and requires an explicit commitment from the initiator to resolve the session. + +Extension mode names must not use the reserved `macp.mode.*` namespace. Built-in modes cannot be unregistered. Extensions can be promoted to standards-track status via `PromoteMode`, and all registry changes are broadcast to `WatchModeRegistry` subscribers. diff --git a/docs/policy.md b/docs/policy.md index 91a83d9..819d6a9 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -1,78 +1,180 @@ -# Governance Policy Architecture +# Governance Policy + +This page covers the runtime's implementation of the governance policy framework: how to register policies via gRPC, what rule schemas look like in practice, how the evaluation engine works internally, and how errors are surfaced. For the protocol-level policy specification -- identifiers, lifecycle, determinism guarantees, and the full rule schema definitions -- see the [protocol policy documentation](https://www.multiagentcoordinationprotocol.io/docs/policy). + +## Managing policies + +Policies are managed through five gRPC RPCs. Any authenticated sender can perform these operations. + +| RPC | Purpose | +|-----|---------| +| `RegisterPolicy` | Add a new policy to the registry | +| `UnregisterPolicy` | Remove a policy (does not affect sessions already using it) | +| `GetPolicy` | Retrieve a policy by its identifier | +| `ListPolicies` | List all policies, optionally filtered by target mode | +| `WatchPolicies` | Stream notifications when the registry changes | + +The built-in `policy.default` is always present and cannot be registered or removed. + +## Registering a policy + +Here is a complete example of registering a Decision Mode policy that requires majority voting with a confidence threshold: + +```json +{ + "policy_id": "policy.fraud-review.majority-vote", + "mode": "macp.mode.decision.v1", + "description": "Require majority vote with 0.7 confidence threshold", + "schema_version": 1, + "rules": { + "voting": { + "algorithm": "majority", + "threshold": 0.5, + "quorum": { "type": "percentage", "value": 60 } + }, + "evaluation": { + "required_before_voting": true, + "minimum_confidence": 0.7 + }, + "objection_handling": { + "critical_severity_vetoes": true, + "veto_threshold": 1 + }, + "commitment": { + "authority": "initiator_only" + } + } +} +``` + +At registration, the runtime validates the rules against the target mode's schema. It enforces structural constraints: a `weighted` voting algorithm requires a non-empty `weights` map, `supermajority` requires a threshold above 0.5, and `designated_role` commitment authority requires a non-empty `designated_roles` list. The `schema_version` must be `1`. Rules that fail to deserialize into the target mode's Rust struct are rejected with `INVALID_POLICY_DEFINITION`. + +## Rule examples by mode + +### Decision Mode + +```json +{ + "voting": { + "algorithm": "supermajority", + "threshold": 0.67, + "quorum": { "type": "count", "value": 3 }, + "weights": {} + }, + "evaluation": { + "required_before_voting": true, + "minimum_confidence": 0.7 + }, + "objection_handling": { + "critical_severity_vetoes": true, + "veto_threshold": 1 + }, + "commitment": { + "authority": "initiator_only", + "designated_roles": [], + "require_vote_quorum": true + } +} +``` + +Supported voting algorithms: `none`, `majority`, `supermajority`, `unanimous`, `weighted`, `plurality`. + +### Proposal Mode + +```json +{ + "acceptance": { "criterion": "all_parties" }, + "counter_proposal": { "max_rounds": 5 }, + "rejection": { "terminal_on_any_reject": false }, + "commitment": { "authority": "initiator_only" } +} +``` + +Acceptance criteria: `all_parties`, `counterparty`, `initiator`. + +### Task Mode + +```json +{ + "assignment": { "allow_reassignment_on_reject": true }, + "completion": { "require_output": true }, + "commitment": { "authority": "initiator_only" } +} +``` + +### Handoff Mode + +```json +{ + "acceptance": { "implicit_accept_timeout_ms": 30000 }, + "commitment": { "authority": "initiator_only" } +} +``` + +### Quorum Mode + +```json +{ + "threshold": { "threshold_type": "percentage", "value": 66 }, + "abstention": { "counts_toward_quorum": false, "interpretation": "neutral" }, + "commitment": { "authority": "initiator_only" } +} +``` + +Threshold types: `n_of_m`, `percentage`, `count`. Abstention interpretations: `neutral`, `implicit_reject`, `ignored`. + +## How evaluation works + +Each standard mode has a dedicated evaluator in `src/policy/evaluator.rs`. Evaluation runs when a `Commitment` envelope arrives, after the mode's own validation has passed. It is a pure function of three inputs: the resolved policy rules, the accumulated accepted message history, and the session's declared participants. No wall-clock time, external calls, or out-of-session state are involved. + +| Evaluator | What it checks | +|-----------|---------------| +| `evaluate_decision_commitment` | Qualifying evaluations meet the confidence threshold, critical objection count stays below veto threshold, vote quorum is met, voting algorithm threshold is satisfied. REVIEW-type evaluations are excluded from confidence checks. | +| `evaluate_proposal_commitment` | Counter-proposal count is within `max_rounds` | +| `evaluate_task_commitment` | Output is present if `require_output` is set | +| `evaluate_handoff_commitment` | Always allows (implicit timeout is handled by the mode) | +| `evaluate_quorum_commitment` | Effective voter count (adjusted for abstention rules) satisfies the threshold | + +## Commitment authority + +The `commitment.authority` rule determines who can send the terminal commitment. This is enforced in `src/mode/util.rs` and applies across all modes: + +| Value | Who can commit | +|-------|---------------| +| `initiator_only` (default) | The session initiator | +| `any_participant` | Any declared participant or the initiator | +| `designated_role` | Only agents listed in the `designated_roles` array | + +## Error handling + +| Error code | When it occurs | gRPC status | +|-----------|----------------|-------------| +| `UNKNOWN_POLICY_VERSION` | The `policy_version` in SessionStart is not found in the registry | InvalidArgument | +| `POLICY_DENIED` | A commitment is rejected because governance rules are not satisfied | PermissionDenied | +| `INVALID_POLICY_DEFINITION` | A policy fails schema validation at registration time | InvalidArgument | + +When a commitment is denied, the error includes structured reasons explaining which rules were not met: + +```json +{ + "reasons": [ + "vote quorum not met: 1 voters of 3 participants (quorum: 60 percentage)", + "no qualifying evaluation meets minimum confidence threshold: 0.70" + ] +} +``` + +## Default policy + +The default policy (`policy.default`) is always registered with mode `"*"` and no governance constraints: + +```json +{ + "voting": { "algorithm": "none", "quorum": { "type": "count", "value": 0 } }, + "objection_handling": { "critical_severity_vetoes": false, "veto_threshold": 1 }, + "evaluation": { "required_before_voting": false, "minimum_confidence": 0.0 }, + "commitment": { "authority": "initiator_only", "designated_roles": [], "require_vote_quorum": false } +} +``` -This document describes the MACP runtime's governance policy framework, implementing RFC-MACP-0012. - -## Overview - -Governance policies provide declarative, deterministic rules that constrain coordination sessions beyond the built-in mode semantics. Policies are resolved at `SessionStart` and evaluated at `Commitment` time. - -## Policy Registry - -The policy registry (`src/policy/registry.rs`) is an in-memory store of `PolicyDefinition` objects. - -- **Default policy**: `policy.default` is always pre-loaded (mode `*`, empty rules, no constraints) -- **Registration**: `RegisterPolicy` gRPC RPC; validates rules against mode-specific JSON schema -- **Unregistration**: `UnregisterPolicy`; does not affect active sessions -- **Query**: `GetPolicy`, `ListPolicies` (with optional mode filter) -- **Watch**: `WatchPolicies` streams notifications on registry changes - -### Conditional Validation - -At registration time, the registry validates: -- `voting.algorithm == "weighted"` requires non-empty `voting.weights` -- `voting.algorithm == "supermajority"` requires `voting.threshold > 0.5` -- `commitment.authority == "designated_role"` requires non-empty `commitment.designated_roles` - -## Policy Resolution (SessionStart) - -When a `SessionStart` is processed (`src/runtime.rs`): - -1. Extract `policy_version` from `SessionStartPayload` -2. If empty, resolve to `"policy.default"` -3. Look up in policy registry; fail with `UNKNOWN_POLICY_VERSION` if not found -4. Verify mode match: policy `mode` must be `"*"` or match session mode -5. Store the resolved `PolicyDefinition` immutably on the `Session` struct - -## Policy Evaluation (Commitment) - -When a `Commitment` message is processed, each mode calls its evaluator (`src/policy/evaluator.rs`): - -| Mode | Evaluator | Checks | -|------|-----------|--------| -| Decision | `evaluate_decision_commitment()` | Evaluation confidence, objection veto, quorum, voting threshold | -| Proposal | `evaluate_proposal_commitment()` | Counter-proposal round limit | -| Task | `evaluate_task_commitment()` | Output requirement | -| Handoff | `evaluate_handoff_commitment()` | Always allows (implicit timeout handled by mode) | -| Quorum | `evaluate_quorum_commitment()` | Threshold, abstention interpretation | - -### Determinism - -Policy evaluation is a **pure function** of: resolved rules + accepted message history + declared participants. No wall-clock time, external calls, or randomness. - -## Per-Mode Rule Schemas - -Rule schemas are defined in `src/policy/rules.rs` as serde structs: - -- `DecisionPolicyRules`: voting, objection_handling, evaluation, commitment -- `QuorumPolicyRules`: threshold, abstention, commitment -- `ProposalPolicyRules`: acceptance, counter_proposal, rejection, commitment -- `TaskPolicyRules`: assignment, completion, commitment -- `HandoffPolicyRules`: acceptance, commitment - -## Replay Invariant - -The resolved `PolicyDefinition` is persisted in the session snapshot. During replay, the stored descriptor is used — never re-resolved from the registry. This ensures deterministic outcomes across time. - -## Error Codes - -| Code | HTTP | When | -|------|------|------| -| `UNKNOWN_POLICY_VERSION` | 404 | Policy not found at SessionStart | -| `POLICY_DENIED` | 403 | Commitment rejected by governance rules | -| `INVALID_POLICY_DEFINITION` | 400 | Policy fails validation at registration | - -## References - -- RFC-MACP-0012: Governance Policy Framework -- RFC-MACP-0001 Section 7.3: Session lifecycle -- RFC-MACP-0003: Determinism and replay integrity +Sessions with an empty `policy_version` automatically resolve to this default. It allows commitment whenever the mode's own built-in rules are satisfied. diff --git a/docs/protocol.md b/docs/protocol.md deleted file mode 100644 index ad72511..0000000 --- a/docs/protocol.md +++ /dev/null @@ -1,121 +0,0 @@ -# Runtime protocol profile - -This document describes the current implementation profile of `macp-runtime v0.4.0`. - -The RFC/spec repository is the normative source for protocol semantics. This file is intentionally short and only highlights the implementation choices that matter to SDK authors and local operators. - -## Supported protocol version - -- `macp_version = "1.0"` - -Clients should call `Initialize` before using the runtime. - -## Implemented RPCs - -- `Initialize` -- `Send` -- `StreamSession` -- `GetSession` -- `CancelSession` -- `GetManifest` -- `ListModes` -- `ListRoots` -- `WatchModeRegistry` -- `WatchRoots` -- `WatchSignals` -- `ListExtModes` -- `RegisterExtMode` -- `UnregisterExtMode` -- `PromoteMode` - -## Policy RPCs - -- `RegisterPolicy` — register a new governance policy descriptor -- `UnregisterPolicy` — remove a registered policy (does not affect active sessions) -- `GetPolicy` — retrieve a policy descriptor by ID -- `ListPolicies` — list registered policies, optionally filtered by mode -- `WatchPolicies` — stream notifications on policy registry changes - -## Streaming watch RPCs - -- `WatchModeRegistry` — sends the current registry state, then fires `RegistryChanged` on register/unregister/promote -- `WatchRoots` — sends the current roots state, then holds the stream open -- `WatchSignals` — broadcasts ambient Signal envelopes to all subscribers in real time; Signals correlate with sessions via `SignalPayload.correlation_session_id` but do not enter session history - -## Extension mode lifecycle RPCs - -- `ListExtModes` — returns `ModeDescriptor` entries for all extension modes -- `RegisterExtMode` — registers a new extension mode from a `ModeDescriptor`; the runtime creates a passthrough handler that accepts message types listed in the descriptor and requires explicit `Commitment` to resolve -- `UnregisterExtMode` — removes a dynamically registered extension; built-in and standards-track modes cannot be removed -- `PromoteMode` — promotes an extension to standards-track; optionally renames the mode identifier (e.g. `ext.foo.v1` to `macp.mode.foo.v1`) - -## StreamSession profile - -`StreamSession` is session-scoped and authoritative for accepted envelopes: - -- one gRPC stream binds to one non-empty `session_id` -- the server emits only accepted canonical MACP envelopes -- stream attachment observes future accepted envelopes from the bind point; it does not backfill earlier history -- accepted envelope order matches runtime admission order for that session -- mixed-session streams are rejected with `FAILED_PRECONDITION` -- application-level validation errors (e.g. InvalidPayload, PolicyDenied) are sent as inline `MACPError` responses; the stream remains open (RFC-MACP-0001) -- transport-level failures (auth, rate limit, internal) terminate the stream with a gRPC status -- to attach to an existing session without mutating it, send a session-scoped `Signal` envelope with the correct `session_id` and `mode` - -## Strict session start rules - -For these standards-track modes and built-in extensions: - -- `macp.mode.decision.v1` -- `macp.mode.proposal.v1` -- `macp.mode.task.v1` -- `macp.mode.handoff.v1` -- `macp.mode.quorum.v1` -- `ext.multi_round.v1` (built-in extension) - -`SessionStartPayload` must bind: - -- `participants` -- `mode_version` -- `configuration_version` -- `ttl_ms` - -Empty payloads are rejected. Empty `mode` values are rejected. Duplicate participant IDs are rejected. - -## Multi-round mode - -`ext.multi_round.v1` is a built-in extension mode. It uses the same strict `SessionStart` contract as standards-track modes. Convergence is tracked but does not auto-resolve the session — an explicit `Commitment` is required after convergence. - -## Security profile - -Production profile: - -- TLS transport -- sender derived from authenticated identity -- per-request authorization -- payload size caps -- rate limiting - -Local development profile: - -- plaintext allowed only with `MACP_ALLOW_INSECURE=1` -- sender header shortcut allowed only with `MACP_ALLOW_DEV_SENDER_HEADER=1` -- clients attach `x-macp-agent-id` - -## Persistence profile - -By default the runtime persists state via `FileBackend` under `MACP_DATA_DIR`: - -- per-session `session.json` and append-only `log.jsonl` files -- crash recovery reconciles dedup state from the log on startup -- atomic writes (tmp file + rename) prevent partial-write corruption - -This gives restart recovery for session metadata, dedup state, and accepted-history inspection. Corrupt or incompatible files produce a warning on stderr; the runtime falls back to empty state instead of refusing to start. - -## Commitment validation - -For standards-track modes and built-in extensions, `CommitmentPayload` must carry version fields that match the session-bound values. Dynamically registered extension modes use a passthrough handler that also validates commitment version fields. - -## Discovery notes - -`ListModes` returns five standards-track modes. `ListExtModes` returns extension mode descriptors. `GetManifest` exposes all supported modes (standards-track + extensions). `RegisterExtMode`, `UnregisterExtMode`, and `PromoteMode` manage extension lifecycle at runtime. diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md new file mode 100644 index 0000000..1a3995f --- /dev/null +++ b/docs/sdk-guide.md @@ -0,0 +1,170 @@ +# SDK Developer Guide + +This guide is for developers building client libraries that connect to the MACP Runtime. It covers the practical patterns your SDK needs to implement: envelope construction, authentication, error handling, streaming, and retry logic. + +For protocol-level SDK conformance requirements, see the [protocol SDK parity documentation](https://www.multiagentcoordinationprotocol.io/docs/sdk-parity). For transport binding specifications, see the [protocol transports documentation](https://www.multiagentcoordinationprotocol.io/docs/transports). + +## What your SDK should handle + +A well-built MACP SDK takes care of seven concerns so that application code can focus on coordination logic: + +1. **gRPC transport** -- Connection management, TLS configuration, and metadata injection. +2. **Authentication** -- Storing tokens and attaching them to every request. +3. **Envelope construction** -- Building protobuf-encoded envelopes with correct version and mode fields. +4. **Message ID generation** -- Producing unique IDs for deduplication. +5. **Session ID generation** -- Creating IDs in an accepted format (UUID v4/v7 or base64url). +6. **Error handling** -- Distinguishing transient from permanent failures and applying appropriate retry logic. +7. **Streaming** -- Managing `StreamSession` connections, handling inline errors, and recovering from lag. + +## Starting a connection + +Every SDK session should begin with an `Initialize` call: + +``` +-> InitializeRequest { + supported_protocol_versions: ["1.0"], + client_info: { name: "my-sdk", version: "1.0.0" } + } +<- InitializeResponse { + selected_protocol_version: "1.0", + capabilities: { sessions: { stream: true }, ... }, + supported_modes: ["macp.mode.decision.v1", ...] + } +``` + +Cache the response. Use `selected_protocol_version` as the `macp_version` in all subsequent envelopes. Check `capabilities` to determine which features are available and store `supported_modes` for client-side validation before sending. + +## Building envelopes + +Every message to the runtime is wrapped in an `Envelope`: + +```protobuf +message Envelope { + string macp_version = 1; // Always "1.0" + string mode = 2; // Mode identifier (empty for signals) + string message_type = 3; // "SessionStart", "Proposal", etc. + string message_id = 4; // Unique per message + string session_id = 5; // Target session (empty for signals) + string sender = 6; // Set by SDK, overridden by runtime + int64 timestamp_unix_ms = 7; // Current time in milliseconds + bytes payload = 8; // Protobuf-encoded mode-specific payload +} +``` + +The runtime overrides `envelope.sender` with the authenticated identity. If the SDK sets a sender that does not match, the request is rejected. The safest approach is to either leave `sender` empty or set it to the expected authenticated identity. + +**Message IDs** must be unique per sender. UUID v4 is a good default. The runtime deduplicates on `message_id`, so sending the same ID twice returns a duplicate acknowledgement without reprocessing. + +**Session IDs** must be UUID v4/v7 (hyphenated lowercase, 36 characters) or base64url tokens (22+ characters). UUID v4 is the simplest choice. + +**Payloads** are protobuf-encoded bytes. Import the mode-specific `.proto` files, serialize the payload struct, and set `envelope.payload` to the resulting bytes. + +## Error handling + +### Error categories + +Errors fall into five categories, each with different retry semantics: + +| Category | Examples | Retry? | +|----------|---------|--------| +| **Transient** | `RATE_LIMITED`, `INTERNAL_ERROR`, network timeout | Yes, with backoff | +| **Envelope errors** | `INVALID_ENVELOPE`, `INVALID_SESSION_ID`, `PAYLOAD_TOO_LARGE` | No -- fix the request | +| **State errors** | `SESSION_NOT_FOUND`, `SESSION_NOT_OPEN`, `SESSION_ALREADY_EXISTS` | No -- session state is permanent | +| **Auth errors** | `UNAUTHENTICATED`, `FORBIDDEN` | No -- fix credentials or permissions | +| **Policy errors** | `POLICY_DENIED`, `UNKNOWN_POLICY_VERSION` | No -- fix policy or session configuration | + +### Idempotency + +The `Send` RPC is idempotent on `message_id`. If a network error occurs after sending but before receiving the acknowledgement, the SDK can safely retry with the same `message_id`. The runtime returns `Ack { ok: true, duplicate: true }` for already-processed messages. + +### Retry strategies + +For `RATE_LIMITED` errors, wait for the rate window to expire (default: 60-second sliding window) or use exponential backoff starting at 1 second with a 30-second cap. + +For `INTERNAL_ERROR` or network failures, retry with the same `message_id` using exponential backoff (100ms, 200ms, 400ms, 800ms) up to 5 attempts with a 10-second cap. + +For `ResourceExhausted` on a stream (lag detection), reconnect the stream, call `GetSession` to verify the current state, and resume from there. + +## Streaming + +### When to use Send vs StreamSession + +Use `Send` when you need an explicit acknowledgement per message, or for fire-and-forget with retry (idempotent via `message_id`). Use `StreamSession` for real-time observation of a session or high-frequency message exchange. + +### StreamSession lifecycle + +A `StreamSession` connection follows this pattern: + +1. Open a bidirectional stream. +2. Send the first envelope, which binds the stream to that `session_id`. +3. Receive accepted envelopes from all participants in the session. +4. Send additional envelopes as needed. +5. The stream closes on client disconnect, lag overflow, auth failure, or server shutdown. + +All envelopes on a stream must target the same session. The stream only delivers envelopes accepted after the bind point -- there is no backfill of earlier history. + +Application-level errors (validation failures, authorization denials) are delivered as inline `MACPError` messages and the stream stays open. Transport-level errors (unauthenticated, internal) close the stream. + +### Handling stream lag + +The runtime's broadcast buffer holds 256 envelopes per session. If a client falls behind, the stream terminates with `ResourceExhausted`. Your SDK should detect this, call `GetSession` to learn the current session state, reconnect with a new stream, and resume processing. + +## Capability negotiation + +After `Initialize`, check the runtime's capabilities to determine what features are available: + +```python +resp = client.initialize(...) + +if resp.capabilities.sessions.stream: + # StreamSession is available +if resp.capabilities.cancellation.cancel_session: + # CancelSession is available +if resp.capabilities.policy_registry.register_policy: + # Policy management is available +``` + +SDKs should degrade gracefully when capabilities are absent rather than failing. + +## Version negotiation + +Send supported versions in descending preference order. The runtime selects the highest mutual version. If no match exists, it returns `UNSUPPORTED_PROTOCOL_VERSION`. + +Unknown fields in protobuf messages are silently ignored, so SDKs built for protocol version 1.0 will work with a 1.1 runtime -- new fields are always optional. + +## Testing your SDK + +### Against a local runtime + +```bash +MACP_ALLOW_INSECURE=1 MACP_ALLOW_DEV_SENDER_HEADER=1 cargo run +# SDK connects to localhost:50051 using x-macp-agent-id header +``` + +### Test checklist + +- Initialize with version negotiation +- SessionStart with all required fields +- Full message flow through to commitment for each mode +- Duplicate message handling (same `message_id` returns duplicate ack) +- Error paths (invalid payload, forbidden, session not found) +- StreamSession connect, receive, and lag recovery +- GetSession returns correct metadata +- CancelSession by initiator succeeds, by non-initiator fails + +## Proto files + +Proto definitions are available in the `macp-proto` crate: + +``` +macp/v1/envelope.proto -- Envelope, Ack, MACPError +macp/v1/core.proto -- All RPCs, SessionStartPayload, CommitmentPayload +macp/v1/policy.proto -- PolicyDescriptor, policy RPCs +macp/modes/decision/v1/decision.proto -- Decision mode payloads +macp/modes/proposal/v1/proposal.proto -- Proposal mode payloads +macp/modes/task/v1/task.proto -- Task mode payloads +macp/modes/handoff/v1/handoff.proto -- Handoff mode payloads +macp/modes/quorum/v1/quorum.proto -- Quorum mode payloads +``` + +Generate language-specific bindings using `protoc` or `buf`. diff --git a/docs/testing.md b/docs/testing.md index d9c2a45..f80e8b1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,44 +1,47 @@ # Testing -The runtime has three levels of tests, plus a separate integration test crate that exercises the gRPC boundary with real agents. +The runtime has a layered testing strategy that covers unit tests, conformance fixtures, and a separate integration test crate that exercises the full gRPC boundary. This page explains each layer, how to run the tests, and how they fit into CI/CD. ## Unit tests and conformance +The core test suite runs with standard Cargo commands: + ```bash -cargo test --all-targets # unit tests + Rust integration tests +cargo test --all-targets # Unit tests + Rust integration tests make test-conformance # JSON fixture-driven conformance suite -make test-all # fmt → clippy → test → integration → conformance +make test-all # fmt -> clippy -> test -> integration -> conformance ``` -Unit tests live inside `src/` modules (`#[cfg(test)]`). Conformance fixtures are in `tests/conformance/` and exercise each mode's happy path and reject paths from JSON definitions. +Unit tests live inside `src/` modules under `#[cfg(test)]` and cover mode state machines, policy evaluation algorithms, storage backends, replay logic, and error handling. The conformance fixtures in `tests/conformance/` define mode lifecycles as JSON files and verify that each mode's happy path and reject paths produce the expected results. ## Integration test suite -A separate Rust crate at `integration_tests/` tests the runtime through the real gRPC transport boundary. It is **not** part of the main Cargo build — `cargo build --release` ignores it entirely. +A separate Rust crate at `integration_tests/` tests the runtime through the real gRPC transport boundary. It is not part of the main Cargo build -- `cargo build --release` ignores it entirely. + +The crate starts the runtime binary as a subprocess on a free port, connects as a gRPC client, and runs test scenarios against the live server. This ensures that the transport layer, authentication, serialization, and kernel logic all work together correctly. -### Architecture +### Test architecture ``` integration_tests/ - Cargo.toml # Depends on macp-runtime (lib) + rig-core + tonic src/ - config.rs # Test target configuration (local / CI / hosted) - server_manager.rs # Start/stop runtime as a subprocess on a free port - helpers.rs # Envelope builders, payload helpers, gRPC wrappers - macp_tools/ # Rig Tool implementations for all MACP operations + config.rs -- Test target configuration (local / CI / hosted) + server_manager.rs -- Start/stop runtime as subprocess on free port + helpers.rs -- Envelope builders, payload helpers, gRPC wrappers + macp_tools/ -- Rig Tool implementations for MACP operations tests/ - tier1.rs → tier1_protocol/ # Scripted gRPC protocol tests - tier2.rs → tier2_agents/ # Rig agent tool tests (no LLM) - tier3.rs → tier3_e2e/ # Real OpenAI LLM agent tests + tier1.rs -> tier1_protocol/ -- Scripted gRPC protocol tests + tier2.rs -> tier2_agents/ -- Rig agent tool tests + tier3.rs -> tier3_e2e/ -- Real LLM agent tests ``` ### Three tiers -| Tier | What | LLM | Tests | Speed | -|------|------|-----|-------|-------| -| **Tier 1: Protocol** | Scripted gRPC calls testing all modes, error paths, RFC cross-cutting features (signals, dedup, version binding, cancel auth) | None | 47 | <1s | -| **Tier 2: Rig Tools** | MACP operations as Rig `Tool` trait implementations, invoked via `ToolSet::call()` | None | 5 | <1s | -| **Tier 3: E2E** | Real GPT-4o-mini agents coordinating through the runtime. Orchestrator as plain code, specialists as LLM. Parallel execution. Signals on ambient plane. | OpenAI | 3 | ~15s | +**Tier 1: Protocol tests** (47 tests) exercise every mode through scripted gRPC calls. These tests cover the full protocol surface: `Initialize` negotiation, happy-path flows for all five standard modes plus multi-round, signals, deduplication, version binding, cancellation authorization, session lifecycle, mode registry operations, and discovery RPCs. They run in under a second with no external dependencies. + +**Tier 2: Rig agent tools** (5 tests) validate the MACP operations implemented as Rig `Tool` trait objects. These are called through `ToolSet::call()`, the same interface an LLM agent would use. They cover all five standard modes and verify that the tool abstraction correctly maps to gRPC operations. + +**Tier 3: End-to-end with real LLM** (3 tests, marked `#[ignore]`) use real OpenAI GPT-4o-mini agents coordinating through the runtime. The architecture follows the protocol's design: orchestrator operations are deterministic plain code, while specialist reasoning uses real LLM inference. Agents run in parallel and the runtime serializes their contributions by acceptance order. These tests demonstrate both the coordination plane and the ambient plane (signals) working simultaneously. ### Running integration tests @@ -46,27 +49,28 @@ integration_tests/ # Build the runtime first cargo build -# Run Tier 1 + 2 (no API keys needed) +# Tier 1 + 2 (no API keys needed) cd integration_tests MACP_TEST_BINARY=../target/debug/macp-runtime cargo test -- --test-threads=1 -# Run individual tiers +# Individual tiers MACP_TEST_BINARY=../target/debug/macp-runtime cargo test --test tier1 -- --test-threads=1 MACP_TEST_BINARY=../target/debug/macp-runtime cargo test --test tier2 -- --test-threads=1 -# Run Tier 3 E2E (requires OPENAI_API_KEY) -OPENAI_API_KEY=sk-... MACP_TEST_BINARY=../target/debug/macp-runtime cargo test --test tier3 -- --ignored --test-threads=1 +# Tier 3 (requires OpenAI API key) +OPENAI_API_KEY=sk-... MACP_TEST_BINARY=../target/debug/macp-runtime \ + cargo test --test tier3 -- --ignored --test-threads=1 -# Run against a hosted runtime (no local server started) +# Against a hosted runtime (no local server started) MACP_TEST_ENDPOINT=host:50051 cargo test -- --test-threads=1 ``` -Or use Makefile targets from the project root: +Or use the Makefile targets from the project root: ```bash make test-integration-grpc # Tier 1 make test-integration-agents # Tier 2 -make test-integration-e2e # Tier 3 (needs OPENAI_API_KEY) +make test-integration-e2e # Tier 3 make test-integration-hosted # All tiers against MACP_TEST_ENDPOINT ``` @@ -74,67 +78,30 @@ make test-integration-hosted # All tiers against MACP_TEST_ENDPOINT | Variable | Purpose | Default | |----------|---------|---------| -| `MACP_TEST_BINARY` | Path to runtime binary (skip cargo build) | Builds from parent crate | -| `MACP_TEST_ENDPOINT` | Connect to hosted runtime (skip server start) | Start local server | +| `MACP_TEST_BINARY` | Path to the runtime binary | Builds from parent crate | +| `MACP_TEST_ENDPOINT` | Hosted runtime to test against (skips local server) | Starts local server | | `MACP_TEST_TLS` | Use TLS for hosted connection | `0` | | `MACP_TEST_AUTH_TOKEN` | Bearer token for hosted runtime | Dev headers | -| `OPENAI_API_KEY` | Required for Tier 3 E2E tests | Tier 3 tests skip if unset | - -### Tier 1 coverage - -Protocol tests exercise every mode through gRPC: - -- **Initialize**: protocol negotiation, version rejection, runtime info -- **Decision mode**: happy path, duplicate dedup, non-initiator commit rejection -- **Proposal mode**: happy path, premature commitment rejection -- **Task mode**: happy path, non-initiator request rejection, duplicate task rejection -- **Handoff mode**: happy path, accept-without-offer rejection -- **Quorum mode**: happy path, approve-before-request, premature commitment -- **Multi-round mode**: happy path, pre-convergence commit rejection -- **Signals**: valid signal accepted, session_id/mode violations rejected, WatchSignals broadcast -- **Version binding**: commitment with wrong mode_version/config_version rejected -- **Deduplication**: rejected messages don't consume dedup slots, duplicate SessionStart rejected -- **CancelSession**: non-initiator rejection -- **Session lifecycle**: TTL expiry, concurrent sessions, parallel session independence -- **Mode registry**: list/register/unregister extension modes -- **Discovery**: GetManifest returns all modes, Initialize rejects unsupported version +| `OPENAI_API_KEY` | Required for Tier 3 tests | Tier 3 tests skip if unset | -### Tier 2: Rig agent tools - -Each MACP operation (start session, propose, vote, commit, etc.) is implemented as a Rig `Tool` trait. Tier 2 tests validate these tools work correctly by calling them through `ToolSet::call()` — the same interface an LLM agent would use. Tests cover all 5 standard modes. - -### Tier 3: E2E with real LLM +## Policy tests -Three tests use real OpenAI GPT-4o-mini agents: +The policy engine has dedicated coverage across multiple test layers: -1. **Decision with signals**: Orchestrator (code) proposes → 3 specialist LLMs evaluate in parallel → each sends progress/completed Signals on the ambient plane → orchestrator commits. Demonstrates both coordination plane and ambient plane simultaneously. +**Unit tests** in `src/policy/` include approximately 80 tests covering all six voting algorithms, quorum threshold calculations, veto logic, evaluation confidence requirements, registry CRUD operations, schema validation, default policy behavior, and rule deserialization. -2. **Decision**: Same as above without signals — simpler version. +**Mode unit tests** in `src/mode/*.rs` exercise policy denial paths in all five standard modes, verifying that governance policies correctly block commitment when rules are not satisfied. -3. **Task delegation**: Planner (code) creates task → Worker (LLM) accepts and completes → planner commits. +**Conformance fixtures** exercise mode lifecycles with policy version binding to ensure policies are resolved and applied correctly during replay. -Architecture follows the RFC: -- Orchestrator/planner operations are **plain code** (deterministic, no LLM needed) -- Specialist/worker reasoning uses **real LLM** (where domain expertise matters) -- Agents run **in parallel** (runtime serializes by acceptance order) -- LLM reasoning happens **outside the session** (ambient plane) -- Only the resulting Envelope enters the session +**Integration tests** perform gRPC round-trips for all five policy RPCs (`RegisterPolicy`, `GetPolicy`, `ListPolicies`, `UnregisterPolicy`, `WatchPolicies`) and test end-to-end policy enforcement: registering a policy, starting a session bound to it, and verifying that commitment is blocked when rules are not met. -### CI/CD +## CI/CD -Integration tests run via manual GitHub Actions dispatch (not on every PR): +Integration tests run via manual GitHub Actions dispatch rather than on every pull request: ``` -Actions → "Integration Tests" → Run workflow → optionally check "Run Tier 3 E2E" +Actions -> "Integration Tests" -> Run workflow -> optionally check "Run Tier 3 E2E" ``` -Tier 3 E2E requires the `OPENAI_API_KEY` repository secret. - -## Policy tests - -Policy engine coverage spans multiple test layers: - -- **Unit tests** (`src/policy/`): ~80 tests covering evaluator algorithms (all 5 voting types, quorum, veto, evaluation requirements), registry CRUD, schema validation, default policy, and rule deserialization -- **Mode unit tests** (`src/mode/*.rs`): Policy denial paths in all 5 standard modes — verify that governance policies block commitment when rules aren't satisfied -- **Conformance tests**: JSON fixtures exercise mode lifecycles with policy_version binding -- **Integration tests** (`integration_tests/`): gRPC round-trip tests for RegisterPolicy, GetPolicy, ListPolicies, UnregisterPolicy, WatchPolicies RPCs, plus end-to-end policy enforcement (register policy → start session → verify commitment blocked) +Tier 3 tests require the `OPENAI_API_KEY` repository secret. Tiers 1 and 2 run without any secrets. diff --git a/integration_tests/Cargo.lock b/integration_tests/Cargo.lock index fffd5ac..942f61a 100644 --- a/integration_tests/Cargo.lock +++ b/integration_tests/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ [[package]] name = "macp-proto" version = "0.1.0" +source = "git+https://github.com/multiagentcoordinationprotocol/multiagentcoordinationprotocol.git#07d80fc1052cc1c96eaf069a799889c1d204dc95" [[package]] name = "macp-runtime" diff --git a/integration_tests/tests/tier3_e2e/test_e2e_decision.rs b/integration_tests/tests/tier3_e2e/test_e2e_decision.rs index 9d7c299..6ecc122 100644 --- a/integration_tests/tests/tier3_e2e/test_e2e_decision.rs +++ b/integration_tests/tests/tier3_e2e/test_e2e_decision.rs @@ -60,7 +60,7 @@ async fn real_llm_agents_coordinate_decision() { orchestrator_id, session_start_payload( "Review suspicious wire transfer requiring step-up verification", - &[fraud_id, growth_id, compliance_id], + &[orchestrator_id, fraud_id, growth_id, compliance_id], 60_000, ), ), diff --git a/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs b/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs index 28fa4bc..eb2f148 100644 --- a/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs +++ b/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs @@ -131,7 +131,7 @@ async fn decision_with_signals_full_flow() { orch_id, session_start_payload( "Review suspicious $4,800 wire transfer", - &[fraud_id, growth_id, compliance_id], + &[orch_id, fraud_id, growth_id, compliance_id], 60_000, ), ), diff --git a/src/mode/decision.rs b/src/mode/decision.rs index ed01860..c28d9e1 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -206,9 +206,11 @@ impl Mode for DecisionMode { } Self::ensure_can_deliberate(&state)?; Self::ensure_known_proposal(&state, &payload.proposal_id)?; + // RFC-MACP-0007 §4: all enum-like values MUST be stored in UPPER_CASE + let normalized_recommendation = payload.recommendation.to_uppercase(); state.evaluations.push(Evaluation { proposal_id: payload.proposal_id, - recommendation: payload.recommendation, + recommendation: normalized_recommendation, confidence: payload.confidence, reason: payload.reason, sender: env.sender.clone(), diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 1232e45..3bbc661 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -158,9 +158,8 @@ impl Mode for HandoffMode { if offer.offered_by != env.sender { return Err(MacpError::Forbidden); } - if offer.disposition != HandoffDisposition::Offered { - return Err(MacpError::InvalidPayload); - } + // RFC-MACP-0010 §2.1: Late context (sent after accept/decline) is + // permitted as supplementary documentation. No disposition check. state .contexts .entry(payload.handoff_id) @@ -517,6 +516,8 @@ mod tests { ModeResponse::PersistState(data) => { let state: HandoffState = serde_json::from_slice(&data).unwrap(); assert_eq!(state.contexts["h1"].len(), 1); + assert_eq!(state.contexts["h1"][0].content_type, "text/plain"); + assert_eq!(state.contexts["h1"][0].sender, "owner"); } _ => panic!("Expected PersistState"), } @@ -941,7 +942,9 @@ mod tests { } #[test] - fn context_after_accept_is_rejected() { + fn context_after_accept_is_permitted() { + // RFC-MACP-0010 §2.1: Late context after accept/decline is permitted + // as supplementary documentation. let mode = HandoffMode; let mut session = base_session(); let resp = mode @@ -962,14 +965,14 @@ mod tests { ) .unwrap(); apply(&mut session, resp); - assert_eq!( - mode.on_message( - &session, - &env("owner", "HandoffContext", make_context("h1")) - ) - .unwrap_err() - .to_string(), - "InvalidPayload" + // Late context after accept should succeed + let result = mode.on_message( + &session, + &env("owner", "HandoffContext", make_context("h1")), + ); + assert!( + result.is_ok(), + "late HandoffContext should be permitted per RFC" ); } diff --git a/src/mode/task.rs b/src/mode/task.rs index 12ba1d0..e2217b9 100644 --- a/src/mode/task.rs +++ b/src/mode/task.rs @@ -204,18 +204,48 @@ impl Mode for TaskMode { .map_err(|_| MacpError::InvalidPayload)?; let task = state.task.as_ref().ok_or(MacpError::InvalidPayload)?; Self::ensure_task_matches(&payload.task_id, &task.task_id)?; - if state.active_assignee.is_some() { - return Err(MacpError::InvalidPayload); + // RFC-MACP-0009 §5.3b: TaskAccept is irrevocable unless policy + // permits reassignment. §5.3c: when allow_reassignment_on_reject + // is true, the active assignee may send TaskReject to return the + // session to the pre-assignment state. + if let Some(ref active) = state.active_assignee { + if active == &env.sender { + let allow = session.policy_definition.as_ref().is_some_and(|p| { + serde_json::from_value::( + p.rules.clone(), + ) + .unwrap_or_default() + .assignment + .allow_reassignment_on_reject + }); + if !allow { + return Err(MacpError::PolicyDenied { + reasons: vec![ + "active assignee cannot reject without allow_reassignment_on_reject policy".into(), + ], + }); + } + // Policy permits: clear assignee, return to pre-assignment state + } else { + // Someone other than the active assignee trying to reject + return Err(MacpError::InvalidPayload); + } } if !payload.assignee.is_empty() && payload.assignee != env.sender { return Err(MacpError::InvalidPayload); } - if !Self::can_assignee_respond(session, task, &env.sender) { + if state.active_assignee.is_none() + && !Self::can_assignee_respond(session, task, &env.sender) + { return Err(MacpError::Forbidden); } if state.rejections.iter().any(|r| r.assignee == env.sender) { return Err(MacpError::InvalidPayload); } + // If active assignee is rejecting with policy permission, clear assignment + if state.active_assignee.as_deref() == Some(env.sender.as_str()) { + state.active_assignee = None; + } state.rejections.push(TaskRejectRecord { task_id: payload.task_id, assignee: env.sender.clone(), @@ -688,6 +718,9 @@ mod tests { ModeResponse::PersistState(data) => { let state: TaskState = serde_json::from_slice(&data).unwrap(); assert_eq!(state.updates.len(), 1); + assert_eq!(state.updates[0].task_id, "t1"); + assert_eq!(state.updates[0].sender, "worker"); + assert_eq!(state.updates[0].status, "in_progress"); } _ => panic!("Expected PersistState"), } @@ -1354,4 +1387,91 @@ mod tests { .unwrap_err(); assert_eq!(err.to_string(), "Forbidden"); } + + // --- TaskReject with reassignment policy (RFC-MACP-0009 §5.3c) --- + + #[test] + fn active_assignee_can_reject_with_reassignment_policy() { + let mode = TaskMode; + let mut session = base_session(); + session.policy_definition = Some(crate::policy::PolicyDefinition { + policy_id: "test".into(), + mode: "macp.mode.task.v1".into(), + description: "allows reassignment".into(), + rules: serde_json::json!({ + "assignment": { "allow_reassignment_on_reject": true }, + "completion": { "require_output": false } + }), + schema_version: 1, + }); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskAccept", make_task_accept("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Active assignee rejects with policy permission — returns to pre-assignment + let result = mode + .on_message( + &session, + &env("worker", "TaskReject", make_task_reject("t1", "worker")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert!( + state.active_assignee.is_none(), + "should clear active assignee" + ); + assert_eq!(state.rejections.len(), 1); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn active_assignee_cannot_reject_without_reassignment_policy() { + let mode = TaskMode; + let mut session = base_session(); + // No policy = no reassignment + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskAccept", make_task_accept("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + // Active assignee rejects without policy permission — denied + let err = mode + .on_message( + &session, + &env("worker", "TaskReject", make_task_reject("t1", "worker")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "PolicyDenied"); + } } diff --git a/src/runtime.rs b/src/runtime.rs index 5fdeb0f..92732ad 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -340,7 +340,15 @@ impl Runtime { }; let accepted_at = Utc::now().timestamp_millis(); - let ttl_expiry = accepted_at.saturating_add(ttl_ms); + // RFC-MACP-0003 §2: TTL deadline is computed from the SessionStart + // envelope's timestamp_unix_ms, not wall-clock time. This ensures + // deterministic replay. Fall back to accepted_at if envelope has no timestamp. + let ttl_base = if env.timestamp_unix_ms > 0 { + env.timestamp_unix_ms + } else { + accepted_at + }; + let ttl_expiry = ttl_base.saturating_add(ttl_ms); let session = Session { session_id: env.session_id.clone(), state: SessionState::Open, diff --git a/tests/conformance/handoff_reject_paths.json b/tests/conformance/handoff_reject_paths.json index 73246e6..a05fd8b 100644 --- a/tests/conformance/handoff_reject_paths.json +++ b/tests/conformance/handoff_reject_paths.json @@ -54,8 +54,8 @@ "content_type": "text/plain", "context": "late context" }, - "expect": "reject", - "expected_error_code": "INVALID_ENVELOPE" + "expect": "accept", + "_comment": "RFC-MACP-0010 §2.1: late context after accept is permitted as supplementary documentation" } ], "expected_final_state": "Open",