diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81a3016..ce7d706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,7 +185,11 @@ jobs: for proto in \ macp/v1/envelope.proto \ macp/v1/core.proto \ - macp/modes/decision/v1/decision.proto; do + 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 diff --git a/Makefile b/Makefile index f90d9eb..6d0ecbe 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: setup build test fmt clippy check sync-protos sync-protos-local check-protos SPEC_PROTO_DIR := ../multiagentcoordinationprotocol/schemas/proto -PROTO_FILES := macp/v1/envelope.proto macp/v1/core.proto macp/modes/decision/v1/decision.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 ## First-time setup: configure git hooks setup: diff --git a/README.md b/README.md index 4f17a23..456d121 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,11 @@ The MACP Runtime provides session-based message coordination between autonomous - **RFC-0001 Compliant Protocol** — Structured protobuf schema with versioned envelope, typed errors, and capability negotiation - **Initialize Handshake** — Protocol version negotiation and capability discovery before any session work begins - **Pluggable Mode System** — Coordination logic is decoupled from runtime physics; ship new modes without touching the kernel -- **Decision Mode (RFC Lifecycle)** — Full Proposal → Evaluation → Objection → Vote → Commitment workflow with phase tracking and mode-aware authorization +- **Decision Mode** — Full Proposal → Evaluation → Objection → Vote → Commitment workflow with declared participant model and mode-aware authorization +- **Proposal Mode** — Lightweight propose/accept/reject lifecycle with peer participant model +- **Task Mode** — Orchestrated task assignment and completion tracking with structural-only determinism +- **Handoff Mode** — Delegated context transfer between agents with context-frozen semantics +- **Quorum Mode** — Threshold-based voting with quorum participant model and semantic-deterministic resolution - **Multi-Round Convergence Mode (Experimental)** — Participant-based `all_equal` convergence strategy with automatic resolution (not advertised via discovery RPCs) - **Session Cancellation** — Explicit `CancelSession` RPC to terminate sessions with a recorded reason - **Message Deduplication** — Idempotent message handling via `seen_message_ids` tracking @@ -39,6 +43,10 @@ cargo run cargo run --bin client # basic decision mode demo cargo run --bin fuzz_client # all error paths + multi-round + new RPCs cargo run --bin multi_round_client # multi-round convergence demo +cargo run --bin proposal_client # proposal mode demo +cargo run --bin task_client # task mode demo +cargo run --bin handoff_client # handoff mode demo +cargo run --bin quorum_client # quorum mode demo ``` ## Build & Development Commands @@ -71,9 +79,21 @@ runtime/ │ │ ├── envelope.proto # Envelope, Ack, MACPError, SessionState │ │ └── core.proto # Full service definition + all message types │ └── modes/ -│ └── decision/ +│ ├── decision/ +│ │ └── v1/ +│ │ └── decision.proto # Decision mode payload types +│ ├── proposal/ +│ │ └── v1/ +│ │ └── proposal.proto # Proposal mode payload types +│ ├── task/ +│ │ └── v1/ +│ │ └── task.proto # Task mode payload types +│ ├── handoff/ +│ │ └── v1/ +│ │ └── handoff.proto # Handoff mode payload types +│ └── quorum/ │ └── v1/ -│ └── decision.proto # Decision mode payload types +│ └── quorum.proto # Quorum mode payload types ├── src/ │ ├── main.rs # Entry point — wires Runtime + gRPC server │ ├── lib.rs # Library root — proto modules + re-exports @@ -85,12 +105,21 @@ runtime/ │ ├── runtime.rs # Runtime kernel (dispatch + apply ModeResponse) │ ├── mode/ │ │ ├── mod.rs # Mode trait + ModeResponse enum +│ │ ├── util.rs # Shared mode utilities │ │ ├── decision.rs # DecisionMode (RFC lifecycle) +│ │ ├── proposal.rs # ProposalMode (peer propose/accept/reject) +│ │ ├── task.rs # TaskMode (orchestrated task tracking) +│ │ ├── handoff.rs # HandoffMode (delegated context transfer) +│ │ ├── quorum.rs # QuorumMode (threshold-based voting) │ │ └── multi_round.rs # MultiRoundMode (convergence) │ └── bin/ │ ├── client.rs # Basic decision mode demo client │ ├── fuzz_client.rs # Comprehensive error-path test client -│ └── multi_round_client.rs # Multi-round convergence demo client +│ ├── multi_round_client.rs # Multi-round convergence demo client +│ ├── proposal_client.rs # Proposal mode demo client +│ ├── task_client.rs # Task mode demo client +│ ├── handoff_client.rs # Handoff mode demo client +│ └── quorum_client.rs # Quorum mode demo client ├── build.rs # tonic-build proto compilation ├── Cargo.toml # Dependencies and project config ├── Makefile # Development shortcuts diff --git a/build.rs b/build.rs index 4e65bd0..c5fde27 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,10 @@ fn main() -> Result<(), Box> { "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", ], &["proto"], )?; diff --git a/docs/README.md b/docs/README.md index 1c42756..41e83a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,12 +102,16 @@ Sessions follow a strict state machine with three states: **Modes** are pluggable coordination strategies. The runtime provides the "physics" — session invariants, logging, TTL enforcement, routing — while modes provide the "coordination logic" — when to resolve, what state to track, and what convergence criteria to apply. -Two modes are built in: +Five standard modes are built in, plus one experimental mode: -| Mode Name | Aliases | Description | -|-----------|---------|-------------| -| `macp.mode.decision.v1` | `decision` | RFC-compliant decision lifecycle: Proposal → Evaluation → Objection → Vote → Commitment | -| `macp.mode.multi_round.v1` | `multi_round` | Participant-based convergence using `all_equal` strategy (experimental, not on discovery surfaces) | +| Mode Name | Aliases | Participant Model | Determinism | Description | +|-----------|---------|-------------------|-------------|-------------| +| `macp.mode.decision.v1` | `decision` | Declared | Semantic-deterministic | RFC-compliant decision lifecycle: Proposal → Evaluation → Objection → Vote → Commitment | +| `macp.mode.proposal.v1` | — | Peer | Semantic-deterministic | Lightweight propose/accept/reject lifecycle for peer-to-peer coordination | +| `macp.mode.task.v1` | — | Orchestrated | Structural-only | Task assignment and completion tracking with orchestrator-driven workflow | +| `macp.mode.handoff.v1` | — | Delegated | Context-frozen | Context transfer between agents with frozen context semantics | +| `macp.mode.quorum.v1` | — | Quorum | Semantic-deterministic | Threshold-based voting where resolution requires a configurable quorum | +| `macp.mode.multi_round.v1` | `multi_round` | Participant-based | Convergence | Participant-based convergence using `all_equal` strategy (experimental, not on discovery surfaces) | An empty `mode` field defaults to `macp.mode.decision.v1` for backward compatibility. @@ -166,6 +170,10 @@ This runtime consists of: 2. **Basic Client** (`client`) — a demo client exercising the happy path: Initialize, ListModes, SessionStart, Message, Resolve, GetSession. 3. **Fuzz Client** (`fuzz_client`) — a comprehensive test client exercising every error path, every new RPC, participant validation, signal messages, cancellation, and multi-round convergence. 4. **Multi-Round Client** (`multi_round_client`) — a focused demo of multi-round convergence with two participants reaching agreement. +5. **Proposal Client** (`proposal_client`) — a demo of the Proposal mode's peer-based propose/accept/reject workflow. +6. **Task Client** (`task_client`) — a demo of the Task mode's orchestrated assignment and completion tracking. +7. **Handoff Client** (`handoff_client`) — a demo of the Handoff mode's delegated context transfer between agents. +8. **Quorum Client** (`quorum_client`) — a demo of the Quorum mode's threshold-based voting and resolution. --- diff --git a/docs/architecture.md b/docs/architecture.md index bcc1177..34280de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,9 +69,20 @@ This document explains how the MACP Runtime v0.3 is built internally. It walks t │ │ HashMap> │ │ DecisionMode │ │ │ │ │ │ - RFC lifecycle │ │ -│ │ 4 entries: │ │ - Proposal/Eval/Vote/Commit │ │ -│ │ decision (x2) │ │ - Phase tracking │ │ -│ │ multi_round(x2) │ │ │ │ +│ │ 12 entries: │ │ - Proposal/Eval/Vote/Commit │ │ +│ │ decision (x2) │ │ │ │ +│ │ proposal (x2) │ │ ProposalMode │ │ +│ │ task (x2) │ │ - Peer propose/accept/reject│ │ +│ │ handoff (x2) │ │ │ │ +│ │ quorum (x2) │ │ TaskMode │ │ +│ │ multi_round(x2) │ │ - Orchestrated task tracking│ │ +│ │ │ │ │ │ +│ │ │ │ HandoffMode │ │ +│ │ │ │ - Delegated context transfer │ │ +│ │ │ │ │ │ +│ │ │ │ QuorumMode │ │ +│ │ │ │ - Threshold-based voting │ │ +│ │ │ │ │ │ │ │ │ │ MultiRoundMode │ │ │ │ │ │ - Convergence checking │ │ │ │ │ │ - Round counting │ │ @@ -244,7 +255,7 @@ async fn main() -> Result<(), Box> { 1. The address `127.0.0.1:50051` is parsed. 2. A `SessionRegistry` is created — an empty, thread-safe hashmap for storing sessions. 3. A `LogStore` is created — an empty, thread-safe hashmap for storing per-session event logs. -4. A `Runtime` is created — it takes ownership of the registry and log store (via `Arc`), and registers the built-in modes (DecisionMode and MultiRoundMode with both RFC names and backward-compatible aliases). +4. A `Runtime` is created — it takes ownership of the registry and log store (via `Arc`), and registers all six built-in modes (DecisionMode, ProposalMode, TaskMode, HandoffMode, QuorumMode, and MultiRoundMode). Only `decision` and `multi_round` have backward-compatible short aliases; the four new standard modes use canonical names only. 5. A `MacpServer` is created — the gRPC adapter wrapping the runtime. 6. Tonic's gRPC `Server` is started, listening on the configured address. @@ -542,6 +553,58 @@ The mode inspects `envelope.message_type` and dispatches accordingly: - Phase transitions are one-way: `Proposal → Evaluation → Voting → Committed`. - The `Commitment` message is the terminal message — it resolves the session. +### ProposalMode (mode/proposal.rs) + +The Proposal Mode implements a lightweight peer-based propose/accept/reject lifecycle. It maintains a `ProposalState` serialized as JSON in `session.mode_state`. + +**Message routing in `on_message()`:** + +| message_type | Handler | Returns | +|-------------|---------|---------| +| `"Propose"` | Parse proposal payload, record proposal | `PersistState` | +| `"Accept"` | Record acceptance; check if all peers accepted | `PersistState` or `PersistAndResolve` | +| `"Reject"` | Record rejection with reason | `PersistState` | +| Anything else | Ignored | `NoOp` | + +### TaskMode (mode/task.rs) + +The Task Mode implements orchestrated task assignment and completion tracking. It maintains a `TaskState` serialized as JSON in `session.mode_state`. + +**Message routing in `on_message()`:** + +| message_type | Handler | Returns | +|-------------|---------|---------| +| `"Assign"` | Record task assignment to an agent | `PersistState` | +| `"Progress"` | Update task progress | `PersistState` | +| `"Complete"` | Mark task complete; check if all tasks are terminal | `PersistState` or `PersistAndResolve` | +| `"Cancel"` | Mark task as cancelled | `PersistState` | +| Anything else | Ignored | `NoOp` | + +### HandoffMode (mode/handoff.rs) + +The Handoff Mode implements delegated context transfer between agents. It maintains a `HandoffState` serialized as JSON in `session.mode_state`. + +**Message routing in `on_message()`:** + +| message_type | Handler | Returns | +|-------------|---------|---------| +| `"Initiate"` | Record handoff context (frozen at transfer) | `PersistState` | +| `"Acknowledge"` | Resolve session with frozen context | `PersistAndResolve` | +| `"Reject"` | Record rejection, allow re-initiation | `PersistState` | +| Anything else | Ignored | `NoOp` | + +### QuorumMode (mode/quorum.rs) + +The Quorum Mode implements threshold-based voting. It maintains a `QuorumState` serialized as JSON in `session.mode_state`. + +**Message routing in `on_message()`:** + +| message_type | Handler | Returns | +|-------------|---------|---------| +| `"Vote"` | Record vote; check if quorum threshold is met | `PersistState` or `PersistAndResolve` | +| `"Abstain"` | Record abstention (does not count toward quorum) | `PersistState` | +| Anything else | Ignored | `NoOp` | + ### MultiRoundMode (mode/multi_round.rs) The Multi-Round Mode implements participant-based convergence. It maintains a `MultiRoundState` serialized as JSON in `session.mode_state`. @@ -605,10 +668,14 @@ pub struct ProcessResult { ### Mode Registration -On construction, the runtime registers four entries: +On construction, the runtime registers eight entries: ```rust modes.insert("macp.mode.decision.v1", DecisionMode); +modes.insert("macp.mode.proposal.v1", ProposalMode); +modes.insert("macp.mode.task.v1", TaskMode); +modes.insert("macp.mode.handoff.v1", HandoffMode); +modes.insert("macp.mode.quorum.v1", QuorumMode); modes.insert("macp.mode.multi_round.v1", MultiRoundMode); modes.insert("decision", DecisionMode); // backward-compatible alias modes.insert("multi_round", MultiRoundMode); // backward-compatible alias @@ -948,9 +1015,21 @@ runtime/ │ │ ├── envelope.proto # Envelope, Ack, MACPError, SessionState │ │ └── core.proto # Service + all message types │ └── modes/ -│ └── decision/ +│ ├── decision/ +│ │ └── v1/ +│ │ └── decision.proto # Decision mode payload types +│ ├── proposal/ +│ │ └── v1/ +│ │ └── proposal.proto # Proposal mode payload types +│ ├── task/ +│ │ └── v1/ +│ │ └── task.proto # Task mode payload types +│ ├── handoff/ +│ │ └── v1/ +│ │ └── handoff.proto # Handoff mode payload types +│ └── quorum/ │ └── v1/ -│ └── decision.proto # Decision mode payload types +│ └── quorum.proto # Quorum mode payload types ├── src/ │ ├── main.rs # Entry point — server startup │ ├── lib.rs # Library root — proto modules + exports @@ -962,12 +1041,21 @@ runtime/ │ ├── runtime.rs # Runtime kernel │ ├── mode/ │ │ ├── mod.rs # Mode trait + ModeResponse +│ │ ├── util.rs # Shared mode utilities │ │ ├── decision.rs # DecisionMode (RFC lifecycle) +│ │ ├── proposal.rs # ProposalMode (peer propose/accept/reject) +│ │ ├── task.rs # TaskMode (orchestrated task tracking) +│ │ ├── handoff.rs # HandoffMode (delegated context transfer) +│ │ ├── quorum.rs # QuorumMode (threshold-based voting) │ │ └── multi_round.rs # MultiRoundMode (convergence) │ └── bin/ │ ├── client.rs # Basic demo client │ ├── fuzz_client.rs # Comprehensive error-path client -│ └── multi_round_client.rs # Multi-round convergence demo +│ ├── multi_round_client.rs # Multi-round convergence demo +│ ├── proposal_client.rs # Proposal mode demo +│ ├── task_client.rs # Task mode demo +│ ├── handoff_client.rs # Handoff mode demo +│ └── quorum_client.rs # Quorum mode demo ├── build.rs # tonic-build proto compilation ├── Cargo.toml # Dependencies ├── Makefile # Development shortcuts @@ -991,7 +1079,7 @@ runtime/ ## Build Process -1. **`build.rs` runs first** — reads the three `.proto` files from the `proto/` directory and generates Rust code via `tonic-build`. The generated code appears in `target/debug/build/macp-runtime-*/out/`. +1. **`build.rs` runs first** — reads the seven `.proto` files from the `proto/` directory and generates Rust code via `tonic-build`. The generated code appears in `target/debug/build/macp-runtime-*/out/`. 2. **Rust compiler compiles:** - `src/lib.rs` — the library crate with all modules. @@ -999,12 +1087,20 @@ runtime/ - `src/bin/client.rs` — the basic demo client. - `src/bin/fuzz_client.rs` — the comprehensive test client. - `src/bin/multi_round_client.rs` — the convergence demo client. + - `src/bin/proposal_client.rs` — the proposal mode demo client. + - `src/bin/task_client.rs` — the task mode demo client. + - `src/bin/handoff_client.rs` — the handoff mode demo client. + - `src/bin/quorum_client.rs` — the quorum mode demo client. 3. **Output binaries:** - `target/debug/macp-runtime` — the server. - `target/debug/client` — the basic client. - `target/debug/fuzz_client` — the test client. - `target/debug/multi_round_client` — the convergence demo. + - `target/debug/proposal_client` — the proposal mode demo. + - `target/debug/task_client` — the task mode demo. + - `target/debug/handoff_client` — the handoff mode demo. + - `target/debug/quorum_client` — the quorum mode demo. --- diff --git a/docs/examples.md b/docs/examples.md index 494adca..dea11f3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -18,8 +18,12 @@ This document provides step-by-step examples of using the MACP Runtime v0.3. It 10. [Example 9: Signal Messages](#example-9-signal-messages) 11. [Example 10: Session with Custom TTL](#example-10-session-with-custom-ttl) 12. [Example 11: Multi-Agent Scenario](#example-11-multi-agent-scenario) -13. [Common Patterns](#common-patterns) -14. [Common Questions](#common-questions) +13. [Example 12: Proposal Mode — Peer Coordination](#example-12-proposal-mode--peer-coordination) +14. [Example 13: Task Mode — Orchestrated Task Tracking](#example-13-task-mode--orchestrated-task-tracking) +15. [Example 14: Handoff Mode — Context Transfer](#example-14-handoff-mode--context-transfer) +16. [Example 15: Quorum Mode — Threshold Voting](#example-15-quorum-mode--threshold-voting) +17. [Common Patterns](#common-patterns) +18. [Common Questions](#common-questions) --- @@ -57,6 +61,26 @@ cargo run --bin fuzz_client cargo run --bin multi_round_client ``` +**Terminal 2** — Proposal mode: +```bash +cargo run --bin proposal_client +``` + +**Terminal 2** — Task mode: +```bash +cargo run --bin task_client +``` + +**Terminal 2** — Handoff mode: +```bash +cargo run --bin handoff_client +``` + +**Terminal 2** — Quorum mode: +```bash +cargo run --bin quorum_client +``` + --- ## Example 1: Basic Decision Mode Client @@ -962,6 +986,340 @@ The session is now `Resolved` with the commitment as the resolution. --- +## Example 12: Proposal Mode — Peer Coordination + +The Proposal Mode (`macp.mode.proposal.v1`) provides a lightweight peer-based coordination lifecycle. Run the demo: + +```bash +cargo run --bin proposal_client +``` + +### Step 1: Create a Proposal Session + +```rust +let start = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "proposal-001".into(), + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: SessionStartPayload { + intent: "Peer review of API design".into(), + participants: vec!["alice".into(), "bob".into()], + ttl_ms: 60000, + ..Default::default() + }.encode_to_vec(), +}; +``` + +### Step 2: Alice Proposes + +```rust +let propose = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Propose".into(), + message_id: "m1".into(), + session_id: "proposal-001".into(), + sender: "alice".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: serde_json::to_vec(&serde_json::json!({ + "proposal_id": "p1", + "content": "Use REST with OpenAPI 3.1", + "rationale": "Better tooling ecosystem" + })).unwrap(), +}; +``` + +### Step 3: Bob Accepts + +```rust +let accept = Envelope { + // ... + message_type: "Accept".into(), + sender: "bob".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "proposal_id": "p1", + "reason": "Agreed, REST is the right choice" + })).unwrap(), + // ... +}; +``` + +When all declared participants have accepted, the session resolves automatically. + +### Step 3 (Alternative): Bob Rejects + +```rust +let reject = Envelope { + // ... + message_type: "Reject".into(), + sender: "bob".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "proposal_id": "p1", + "reason": "Prefer gRPC for internal services" + })).unwrap(), + // ... +}; +``` + +A rejection is recorded but does not terminate the session. Alice can submit a new proposal to try again. + +--- + +## Example 13: Task Mode — Orchestrated Task Tracking + +The Task Mode (`macp.mode.task.v1`) provides orchestrator-driven task assignment and tracking. Run the demo: + +```bash +cargo run --bin task_client +``` + +### Step 1: Create a Task Session + +```rust +let start = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "task-001".into(), + sender: "orchestrator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: SessionStartPayload { + intent: "Deploy v3.0 to production".into(), + participants: vec!["orchestrator".into(), "builder".into(), "tester".into()], + ttl_ms: 300000, + ..Default::default() + }.encode_to_vec(), +}; +``` + +### Step 2: Orchestrator Assigns Tasks + +```rust +let assign = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "Assign".into(), + message_id: "m1".into(), + session_id: "task-001".into(), + sender: "orchestrator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: serde_json::to_vec(&serde_json::json!({ + "task_id": "t1", + "assignee": "builder", + "description": "Build release artifacts", + "priority": "high" + })).unwrap(), +}; +``` + +### Step 3: Agent Reports Progress + +```rust +let progress = Envelope { + // ... + message_type: "Progress".into(), + sender: "builder".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "task_id": "t1", + "status": "running", + "percent_complete": 75, + "details": "Compiling release binaries" + })).unwrap(), + // ... +}; +``` + +### Step 4: Agent Completes Task + +```rust +let complete = Envelope { + // ... + message_type: "Complete".into(), + sender: "builder".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "task_id": "t1", + "result": "Build artifacts uploaded to S3", + "artifacts": "s3://releases/v3.0/" + })).unwrap(), + // ... +}; +``` + +When all assigned tasks reach a terminal state (completed or cancelled), the session resolves. + +--- + +## Example 14: Handoff Mode — Context Transfer + +The Handoff Mode (`macp.mode.handoff.v1`) enables delegated context transfer between agents. Run the demo: + +```bash +cargo run --bin handoff_client +``` + +### Step 1: Create a Handoff Session + +```rust +let start = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "handoff-001".into(), + sender: "agent-alpha".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: SessionStartPayload { + intent: "Transfer deployment ownership".into(), + participants: vec!["agent-alpha".into(), "agent-beta".into()], + ttl_ms: 60000, + ..Default::default() + }.encode_to_vec(), +}; +``` + +### Step 2: Initiate the Handoff + +```rust +let initiate = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "Initiate".into(), + message_id: "m1".into(), + session_id: "handoff-001".into(), + sender: "agent-alpha".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: serde_json::to_vec(&serde_json::json!({ + "handoff_id": "h1", + "from": "agent-alpha", + "to": "agent-beta", + "context": "{\"pipeline_id\": \"deploy-v3\", \"environment\": \"production\"}", + "reason": "Shift change — transferring deployment ownership" + })).unwrap(), +}; +``` + +The context is frozen at this point and cannot be modified. + +### Step 3: Receiving Agent Acknowledges + +```rust +let ack = Envelope { + // ... + message_type: "Acknowledge".into(), + sender: "agent-beta".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "handoff_id": "h1", + "accepted_by": "agent-beta", + "notes": "Ready to take over deployment pipeline" + })).unwrap(), + // ... +}; +``` + +The session resolves with the frozen context as the resolution payload. + +### Step 3 (Alternative): Receiving Agent Rejects + +```rust +let reject = Envelope { + // ... + message_type: "Reject".into(), + sender: "agent-beta".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "handoff_id": "h1", + "rejected_by": "agent-beta", + "reason": "Not authorized for production deployments" + })).unwrap(), + // ... +}; +``` + +After rejection, the delegating agent may initiate a new handoff within the same session. + +--- + +## Example 15: Quorum Mode — Threshold Voting + +The Quorum Mode (`macp.mode.quorum.v1`) implements threshold-based voting. Run the demo: + +```bash +cargo run --bin quorum_client +``` + +### Step 1: Create a Quorum Session + +```rust +let start = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "quorum-001".into(), + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: SessionStartPayload { + intent: "Vote on release approval".into(), + participants: vec![ + "alice".into(), "bob".into(), "charlie".into(), + "diana".into(), "eve".into(), + ], + ttl_ms: 120000, + ..Default::default() + }.encode_to_vec(), +}; +``` + +With 5 participants, the default quorum threshold is a simple majority (3 approvals needed). + +### Step 2: Participants Vote + +```rust +let vote = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Vote".into(), + message_id: "m1".into(), + session_id: "quorum-001".into(), + sender: "alice".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: serde_json::to_vec(&serde_json::json!({ + "vote": "approve", + "reason": "All acceptance criteria met" + })).unwrap(), +}; +``` + +### Step 3: A Participant Abstains + +```rust +let abstain = Envelope { + // ... + message_type: "Abstain".into(), + sender: "charlie".into(), + payload: serde_json::to_vec(&serde_json::json!({ + "reason": "Conflict of interest" + })).unwrap(), + // ... +}; +``` + +Abstentions are recorded but do not count toward the quorum threshold. + +### Step 4: Quorum Reached + +When enough `approve` votes are cast to meet the threshold (e.g., 3 out of 5 participants approve), the session automatically resolves with the voting results as the resolution payload. + +``` +alice: approve → bob: approve → diana: approve → RESOLVED (3/5 quorum met) +``` + +--- + ## Common Patterns ### Pattern 1: Structured Error Handling @@ -1062,6 +1420,26 @@ let vote = create_envelope("decision", "Vote", "s1", "alice", "proposal_id": "p1", "vote": "approve", "reason": "LGTM" })).unwrap() ); +let propose = create_envelope("proposal", "Propose", "p1", "alice", + &serde_json::to_vec(&serde_json::json!({ + "proposal_id": "p1", "content": "Use REST", "rationale": "Better tooling" + })).unwrap() +); +let assign = create_envelope("task", "Assign", "t1", "orchestrator", + &serde_json::to_vec(&serde_json::json!({ + "task_id": "t1", "assignee": "bob", "description": "Build it", "priority": "high" + })).unwrap() +); +let initiate = create_envelope("handoff", "Initiate", "h1", "alpha", + &serde_json::to_vec(&serde_json::json!({ + "handoff_id": "h1", "from": "alpha", "to": "beta", "context": "{}", "reason": "shift change" + })).unwrap() +); +let quorum_vote = create_envelope("quorum", "Vote", "q1", "alice", + &serde_json::to_vec(&serde_json::json!({ + "vote": "approve", "reason": "LGTM" + })).unwrap() +); let contribute = create_envelope("multi_round", "Contribute", "mr1", "alice", br#"{"value":"option_a"}"# ); @@ -1113,11 +1491,15 @@ let contribute = create_envelope("multi_round", "Contribute", "mr1", "alice", ### Q: What modes are available? -**A:** Two modes: -- `macp.mode.decision.v1` (alias: `decision`) — RFC lifecycle with Proposal/Evaluation/Objection/Vote/Commitment. -- `macp.mode.multi_round.v1` (alias: `multi_round`) — Participant-based convergence. +**A:** Five standard modes plus one experimental: +- `macp.mode.decision.v1` (alias: `decision`) — RFC lifecycle with Proposal/Evaluation/Objection/Vote/Commitment. Declared participant model, semantic-deterministic. +- `macp.mode.proposal.v1` (alias: `proposal`) — Lightweight peer propose/accept/reject. Peer participant model, semantic-deterministic. +- `macp.mode.task.v1` (alias: `task`) — Orchestrated task assignment and completion. Orchestrated participant model, structural-only. +- `macp.mode.handoff.v1` (alias: `handoff`) — Delegated context transfer between agents. Delegated participant model, context-frozen. +- `macp.mode.quorum.v1` (alias: `quorum`) — Threshold-based voting. Quorum participant model, semantic-deterministic. +- `macp.mode.multi_round.v1` (alias: `multi_round`) — Participant-based convergence (experimental, not on discovery surfaces). -Use `ListModes` to discover them at runtime. +Use `ListModes` to discover standard modes at runtime. ### Q: How do Signal messages work? @@ -1135,9 +1517,13 @@ Now that you've seen examples, you can: 1. **Build your own client** — in Python, JavaScript, Go, or any language with a gRPC client. Use the `.proto` files to generate client code. 2. **Explore the Decision Mode lifecycle** — try building a multi-agent voting system. -3. **Implement convergence scenarios** — use Multi-Round Mode for consensus-building. -4. **Extend the protocol** — add new modes by implementing the `Mode` trait. -5. **Integrate with your agent framework** — use the `Initialize` and `ListModes` RPCs for dynamic discovery. +3. **Use Proposal Mode** — for lightweight peer-to-peer coordination. +4. **Use Task Mode** — for orchestrator-driven workflows with task assignment and tracking. +5. **Use Handoff Mode** — for delegating context between agents with frozen-context semantics. +6. **Use Quorum Mode** — for threshold-based voting and approval workflows. +7. **Implement convergence scenarios** — use Multi-Round Mode for consensus-building. +8. **Extend the protocol** — add new modes by implementing the `Mode` trait. +9. **Integrate with your agent framework** — use the `Initialize` and `ListModes` RPCs for dynamic discovery. For deeper understanding: - Read **[architecture.md](./architecture.md)** to see how it's implemented internally. diff --git a/docs/protocol.md b/docs/protocol.md index 0e86fca..a0c70c3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -31,12 +31,16 @@ This document is the authoritative specification of the Multi-Agent Coordination 23. [Session State Machine](#session-state-machine) 24. [Mode System](#mode-system) 25. [Decision Mode Specification](#decision-mode-specification) -26. [Multi-Round Mode Specification](#multi-round-mode-specification) -27. [Validation Rules (Complete)](#validation-rules-complete) -28. [Error Codes (Complete)](#error-codes-complete) -29. [Transport](#transport) -30. [Best Practices](#best-practices) -31. [Future Extensions](#future-extensions) +26. [Proposal Mode Specification](#proposal-mode-specification) +27. [Task Mode Specification](#task-mode-specification) +28. [Handoff Mode Specification](#handoff-mode-specification) +29. [Quorum Mode Specification](#quorum-mode-specification) +30. [Multi-Round Mode Specification](#multi-round-mode-specification) +31. [Validation Rules (Complete)](#validation-rules-complete) +32. [Error Codes (Complete)](#error-codes-complete) +33. [Transport](#transport) +34. [Best Practices](#best-practices) +35. [Future Extensions](#future-extensions) --- @@ -69,7 +73,7 @@ Without a formal protocol, different agents might format messages differently, s ## Protobuf Schema Organization -The protocol is defined across three protobuf files, organized by concern: +The protocol is defined across seven protobuf files, organized by concern: ``` proto/ @@ -81,10 +85,22 @@ proto/ │ # capability messages, session payloads, manifests, │ # mode descriptors, streaming types └── modes/ - └── decision/ + ├── decision/ + │ └── v1/ + │ └── decision.proto # ProposalPayload, EvaluationPayload, + │ # ObjectionPayload, VotePayload + ├── proposal/ + │ └── v1/ + │ └── proposal.proto # Proposal mode payload types + ├── task/ + │ └── v1/ + │ └── task.proto # Task mode payload types + ├── handoff/ + │ └── v1/ + │ └── handoff.proto # Handoff mode payload types + └── quorum/ └── v1/ - └── decision.proto # ProposalPayload, EvaluationPayload, - # ObjectionPayload, VotePayload + └── quorum.proto # Quorum mode payload types ``` **`envelope.proto`** contains the foundational types that every message touches: the `Envelope` wrapper, the `Ack` acknowledgment, the `MACPError` structured error, and the `SessionState` enum. These are imported by `core.proto`. @@ -93,6 +109,14 @@ proto/ **`decision.proto`** contains the mode-specific payload types for the Decision Mode: `ProposalPayload`, `EvaluationPayload`, `ObjectionPayload`, and `VotePayload`. These are not referenced by the core proto — they are domain-level schemas that clients use to structure their payloads. +**`proposal.proto`** contains the payload types for the Proposal Mode's peer-based propose/accept/reject lifecycle. + +**`task.proto`** contains the payload types for the Task Mode's orchestrated task assignment and completion tracking. + +**`handoff.proto`** contains the payload types for the Handoff Mode's delegated context transfer between agents. + +**`quorum.proto`** contains the payload types for the Quorum Mode's threshold-based voting and resolution. + The `buf.yaml` file configures the Buf linter with `STANDARD` lint rules and `FILE`-level breaking-change detection, ensuring the proto schema evolves safely. --- @@ -699,6 +723,10 @@ The runtime registers modes by name in a `HashMap`: | Key | Mode | |-----|------| | `"macp.mode.decision.v1"` | `DecisionMode` | +| `"macp.mode.proposal.v1"` | `ProposalMode` | +| `"macp.mode.task.v1"` | `TaskMode` | +| `"macp.mode.handoff.v1"` | `HandoffMode` | +| `"macp.mode.quorum.v1"` | `QuorumMode` | | `"macp.mode.multi_round.v1"` | `MultiRoundMode` | | `"decision"` | `DecisionMode` (alias) | | `"multi_round"` | `MultiRoundMode` (alias) | @@ -881,6 +909,244 @@ Any other `Message`-type payload returns `NoOp`. --- +## Proposal Mode Specification + +The Proposal Mode (`macp.mode.proposal.v1`) implements a lightweight propose/accept/reject lifecycle for peer-to-peer coordination. Unlike the Decision Mode's formal multi-phase process, the Proposal Mode is designed for simpler scenarios where one agent proposes and peers respond with acceptance or rejection. + +### Participant Model: Peer + +All participants are equal peers. Any participant can propose, and any participant can accept or reject. There is no distinguished orchestrator role. + +### Determinism: Semantic-Deterministic + +The mode produces deterministic outcomes based on the semantic content of messages -- the same sequence of proposals and responses always produces the same resolution. + +### Message Types + +| message_type | Description | Effect | +|-------------|-------------|--------| +| `Propose` | Submit a proposal for peer review | Records the proposal, returns `PersistState` | +| `Accept` | Accept the current proposal | Records acceptance; if acceptance threshold is met, returns `PersistAndResolve` | +| `Reject` | Reject the current proposal with a reason | Records rejection, returns `PersistState` | + +### Lifecycle + +``` +Propose → Accept/Reject → ... → (all peers accept) → Resolved +``` + +A proposal is resolved when all declared participants have accepted it. Any rejection is recorded but does not automatically terminate the session -- a new proposal can be submitted to restart the cycle. + +### Payload Formats + +**Propose:** +```json +{ + "proposal_id": "p1", + "content": "Suggested approach for the task", + "rationale": "Why this approach makes sense" +} +``` + +**Accept:** +```json +{ + "proposal_id": "p1", + "reason": "Looks good" +} +``` + +**Reject:** +```json +{ + "proposal_id": "p1", + "reason": "Does not address requirement X" +} +``` + +--- + +## Task Mode Specification + +The Task Mode (`macp.mode.task.v1`) implements orchestrated task assignment and completion tracking. An orchestrator assigns tasks to agents, and agents report progress and completion. + +### Participant Model: Orchestrated + +A single orchestrator creates and assigns tasks. Assigned agents execute tasks and report back. The orchestrator controls the lifecycle. + +### Determinism: Structural-Only + +The mode enforces structural invariants (task states, assignment rules) but does not interpret the semantic content of task payloads. Two different task contents that follow the same structural flow produce structurally equivalent state transitions. + +### Message Types + +| message_type | Description | Effect | +|-------------|-------------|--------| +| `Assign` | Orchestrator assigns a task to an agent | Records the task assignment, returns `PersistState` | +| `Progress` | Agent reports progress on an assigned task | Updates task progress, returns `PersistState` | +| `Complete` | Agent marks a task as complete | Records completion; if all tasks are complete, returns `PersistAndResolve` | +| `Cancel` | Orchestrator cancels a task | Marks the task as cancelled, returns `PersistState` | + +### Lifecycle + +``` +Assign → Progress (optional, repeatable) → Complete/Cancel +``` + +The session resolves when all assigned tasks reach a terminal state (completed or cancelled). + +### Payload Formats + +**Assign:** +```json +{ + "task_id": "t1", + "assignee": "agent-beta", + "description": "Run integration tests on staging", + "priority": "high" +} +``` + +**Progress:** +```json +{ + "task_id": "t1", + "status": "running", + "percent_complete": 60, + "details": "Tests 120/200 passed so far" +} +``` + +**Complete:** +```json +{ + "task_id": "t1", + "result": "All 200 tests passed", + "artifacts": "" +} +``` + +**Cancel:** +```json +{ + "task_id": "t1", + "reason": "Superseded by new requirements" +} +``` + +--- + +## Handoff Mode Specification + +The Handoff Mode (`macp.mode.handoff.v1`) implements delegated context transfer between agents. One agent hands off responsibility (and context) to another agent, with the context frozen at the point of transfer. + +### Participant Model: Delegated + +A delegating agent initiates the handoff and a receiving agent accepts it. The context is frozen at transfer time -- no further modifications to the handed-off context are permitted. + +### Determinism: Context-Frozen + +Once a handoff is initiated, the context payload is immutable. The receiving agent can acknowledge or reject the handoff, but cannot modify the transferred context. + +### Message Types + +| message_type | Description | Effect | +|-------------|-------------|--------| +| `Initiate` | Delegating agent initiates a handoff with context | Records the handoff context, returns `PersistState` | +| `Acknowledge` | Receiving agent acknowledges the handoff | Records acknowledgment; resolves the session with the frozen context as resolution, returns `PersistAndResolve` | +| `Reject` | Receiving agent rejects the handoff | Records rejection with reason, returns `PersistState` | + +### Lifecycle + +``` +Initiate → Acknowledge (resolved) or Reject +``` + +A handoff session resolves when the receiving agent acknowledges the transfer. If rejected, the delegating agent may initiate a new handoff (to the same or different agent) within the same session. + +### Payload Formats + +**Initiate:** +```json +{ + "handoff_id": "h1", + "from": "agent-alpha", + "to": "agent-beta", + "context": "", + "reason": "Transferring ownership of the deployment pipeline" +} +``` + +**Acknowledge:** +```json +{ + "handoff_id": "h1", + "accepted_by": "agent-beta", + "notes": "Ready to take over" +} +``` + +**Reject:** +```json +{ + "handoff_id": "h1", + "rejected_by": "agent-beta", + "reason": "Not authorized for this resource" +} +``` + +--- + +## Quorum Mode Specification + +The Quorum Mode (`macp.mode.quorum.v1`) implements threshold-based voting where resolution requires a configurable quorum of participants to agree. Unlike the Decision Mode's full lifecycle, the Quorum Mode focuses purely on reaching a voting threshold. + +### Participant Model: Quorum + +All declared participants can vote. Resolution occurs when the number of agreeing votes meets or exceeds the configured quorum threshold. + +### Determinism: Semantic-Deterministic + +The mode produces deterministic outcomes based on the votes cast -- the same set of votes always produces the same resolution. + +### Message Types + +| message_type | Description | Effect | +|-------------|-------------|--------| +| `Vote` | Cast a vote (approve/reject) | Records the vote; if quorum is reached, returns `PersistAndResolve` | +| `Abstain` | Explicitly abstain from voting | Records abstention (does not count toward quorum), returns `PersistState` | + +### Lifecycle + +``` +Vote/Abstain → ... → (quorum reached) → Resolved +``` + +The session resolves when the number of `approve` votes meets or exceeds the quorum threshold. The quorum threshold is configured in the session start payload. If not specified, it defaults to a simple majority (more than half of declared participants). + +### Payload Formats + +**Vote:** +```json +{ + "vote": "approve", + "reason": "Implementation meets all acceptance criteria" +} +``` + +**Abstain:** +```json +{ + "reason": "Conflict of interest" +} +``` + +### Quorum Configuration + +The quorum threshold is set via the session start payload's configuration. For example, a session with 5 participants and a quorum of 3 resolves as soon as 3 participants vote `approve`. + +--- + ## Multi-Round Mode Specification The Multi-Round Mode (`macp.mode.multi_round.v1`) implements participant-based convergence. A set of named participants each submit contributions, and the session resolves automatically when all participants agree on the same value. diff --git a/proto/macp/modes/handoff/v1/handoff.proto b/proto/macp/modes/handoff/v1/handoff.proto new file mode 100644 index 0000000..ee0698d --- /dev/null +++ b/proto/macp/modes/handoff/v1/handoff.proto @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..96b5598 --- /dev/null +++ b/proto/macp/modes/proposal/v1/proposal.proto @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..52b98da --- /dev/null +++ b/proto/macp/modes/quorum/v1/quorum.proto @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..6755f6c --- /dev/null +++ b/proto/macp/modes/task/v1/task.proto @@ -0,0 +1,49 @@ +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 index d8b0788..3964b17 100644 --- a/proto/macp/v1/core.proto +++ b/proto/macp/v1/core.proto @@ -140,6 +140,8 @@ message CancelSessionRequest { 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; } @@ -207,6 +209,8 @@ 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; } diff --git a/src/bin/handoff_client.rs b/src/bin/handoff_client.rs new file mode 100644 index 0000000..7e7f538 --- /dev/null +++ b/src/bin/handoff_client.rs @@ -0,0 +1,165 @@ +use macp_runtime::handoff_pb::{HandoffAcceptPayload, HandoffContextPayload, HandoffOfferPayload}; +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +use prost::Message; + +async fn send( + client: &mut MacpRuntimeServiceClient, + label: &str, + e: Envelope, +) { + match client.send(SendRequest { envelope: Some(e) }).await { + Ok(resp) => { + let ack = resp.into_inner().ack.unwrap(); + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or(""); + println!("[{label}] ok={} error='{}'", ack.ok, err_code); + } + Err(status) => { + println!("[{label}] grpc error: {status}"); + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; + + println!("=== Handoff Mode Demo ===\n"); + + // 1) SessionStart + let start_payload = SessionStartPayload { + intent: "escalate support ticket".into(), + ttl_ms: 60000, + participants: vec!["owner".into(), "target".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + send( + &mut client, + "session_start", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "handoff-1".into(), + sender: "owner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }, + ) + .await; + + // 2) HandoffOffer + let offer = HandoffOfferPayload { + handoff_id: "h1".into(), + target_participant: "target".into(), + scope: "customer-support".into(), + reason: "need specialist help".into(), + }; + send( + &mut client, + "handoff_offer", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "HandoffOffer".into(), + message_id: "m1".into(), + session_id: "handoff-1".into(), + sender: "owner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: offer.encode_to_vec(), + }, + ) + .await; + + // 3) HandoffContext + let context = HandoffContextPayload { + handoff_id: "h1".into(), + content_type: "text/plain".into(), + context: b"Customer issue: billing discrepancy on invoice #1234".to_vec(), + }; + send( + &mut client, + "handoff_context", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "HandoffContext".into(), + message_id: "m2".into(), + session_id: "handoff-1".into(), + sender: "owner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: context.encode_to_vec(), + }, + ) + .await; + + // 4) HandoffAccept + let accept = HandoffAcceptPayload { + handoff_id: "h1".into(), + accepted_by: "target".into(), + reason: "ready to assist".into(), + }; + send( + &mut client, + "handoff_accept", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "HandoffAccept".into(), + message_id: "m3".into(), + session_id: "handoff-1".into(), + sender: "target".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: accept.encode_to_vec(), + }, + ) + .await; + + // 5) Commitment + let commitment = macp_runtime::pb::CommitmentPayload { + commitment_id: "c1".into(), + action: "handoff.accepted".into(), + authority_scope: "support".into(), + reason: "handoff complete".into(), + mode_version: "1.0.0".into(), + policy_version: String::new(), + configuration_version: String::new(), + }; + send( + &mut client, + "commitment", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "Commitment".into(), + message_id: "m4".into(), + session_id: "handoff-1".into(), + sender: "owner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }, + ) + .await; + + // 6) GetSession + match client + .get_session(GetSessionRequest { + session_id: "handoff-1".into(), + }) + .await + { + Ok(resp) => { + let meta = resp.into_inner().metadata.unwrap(); + println!("[get_session] state={} mode={}", meta.state, meta.mode); + } + Err(status) => println!("[get_session] error: {status}"), + } + + println!("\n=== Demo Complete ==="); + Ok(()) +} diff --git a/src/bin/proposal_client.rs b/src/bin/proposal_client.rs new file mode 100644 index 0000000..d97279c --- /dev/null +++ b/src/bin/proposal_client.rs @@ -0,0 +1,167 @@ +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +use macp_runtime::proposal_pb::{AcceptPayload, CounterProposalPayload, ProposalPayload}; +use prost::Message; + +async fn send( + client: &mut MacpRuntimeServiceClient, + label: &str, + e: Envelope, +) { + match client.send(SendRequest { envelope: Some(e) }).await { + Ok(resp) => { + let ack = resp.into_inner().ack.unwrap(); + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or(""); + println!("[{label}] ok={} error='{}'", ack.ok, err_code); + } + Err(status) => { + println!("[{label}] grpc error: {status}"); + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; + + println!("=== Proposal Mode Demo ===\n"); + + // 1) SessionStart + let start_payload = SessionStartPayload { + intent: "negotiate price".into(), + ttl_ms: 60000, + participants: vec!["buyer".into(), "seller".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + send( + &mut client, + "session_start", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "proposal-1".into(), + sender: "buyer".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }, + ) + .await; + + // 2) Seller makes a proposal + let proposal = ProposalPayload { + proposal_id: "offer-1".into(), + title: "Initial offer".into(), + summary: "1200 USD".into(), + details: vec![], + tags: vec![], + }; + send( + &mut client, + "proposal", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Proposal".into(), + message_id: "m1".into(), + session_id: "proposal-1".into(), + sender: "seller".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: proposal.encode_to_vec(), + }, + ) + .await; + + // 3) Buyer counter-proposes + let counter = CounterProposalPayload { + proposal_id: "offer-2".into(), + supersedes_proposal_id: "offer-1".into(), + title: "Counter offer".into(), + summary: "1000 USD".into(), + details: vec![], + }; + send( + &mut client, + "counter_proposal", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "CounterProposal".into(), + message_id: "m2".into(), + session_id: "proposal-1".into(), + sender: "buyer".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: counter.encode_to_vec(), + }, + ) + .await; + + // 4) Seller accepts + let accept = AcceptPayload { + proposal_id: "offer-2".into(), + reason: "agreed".into(), + }; + send( + &mut client, + "accept", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Accept".into(), + message_id: "m3".into(), + session_id: "proposal-1".into(), + sender: "seller".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: accept.encode_to_vec(), + }, + ) + .await; + + // 5) Commitment + let commitment = macp_runtime::pb::CommitmentPayload { + commitment_id: "c1".into(), + action: "proposal.accepted".into(), + authority_scope: "commercial".into(), + reason: "deal at 1000 USD".into(), + mode_version: "1.0.0".into(), + policy_version: String::new(), + configuration_version: String::new(), + }; + send( + &mut client, + "commitment", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Commitment".into(), + message_id: "m4".into(), + session_id: "proposal-1".into(), + sender: "buyer".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }, + ) + .await; + + // 6) GetSession — verify resolved + match client + .get_session(GetSessionRequest { + session_id: "proposal-1".into(), + }) + .await + { + Ok(resp) => { + let meta = resp.into_inner().metadata.unwrap(); + println!("[get_session] state={} mode={}", meta.state, meta.mode); + } + Err(status) => println!("[get_session] error: {status}"), + } + + println!("\n=== Demo Complete ==="); + Ok(()) +} diff --git a/src/bin/quorum_client.rs b/src/bin/quorum_client.rs new file mode 100644 index 0000000..669d487 --- /dev/null +++ b/src/bin/quorum_client.rs @@ -0,0 +1,164 @@ +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +use macp_runtime::quorum_pb::{ApprovalRequestPayload, ApprovePayload}; +use prost::Message; + +async fn send( + client: &mut MacpRuntimeServiceClient, + label: &str, + e: Envelope, +) { + match client.send(SendRequest { envelope: Some(e) }).await { + Ok(resp) => { + let ack = resp.into_inner().ack.unwrap(); + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or(""); + println!("[{label}] ok={} error='{}'", ack.ok, err_code); + } + Err(status) => { + println!("[{label}] grpc error: {status}"); + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; + + println!("=== Quorum Mode Demo ===\n"); + + // 1) SessionStart + let start_payload = SessionStartPayload { + intent: "approve production deploy".into(), + ttl_ms: 60000, + participants: vec!["alice".into(), "bob".into(), "carol".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + send( + &mut client, + "session_start", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "quorum-1".into(), + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }, + ) + .await; + + // 2) ApprovalRequest + let req = ApprovalRequestPayload { + request_id: "deploy-v2".into(), + action: "deploy.production".into(), + summary: "Deploy v2.1 to production".into(), + details: vec![], + required_approvals: 2, + }; + send( + &mut client, + "approval_request", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "ApprovalRequest".into(), + message_id: "m1".into(), + session_id: "quorum-1".into(), + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: req.encode_to_vec(), + }, + ) + .await; + + // 3) Alice approves + let approve1 = ApprovePayload { + request_id: "deploy-v2".into(), + reason: "tests pass".into(), + }; + send( + &mut client, + "alice_approve", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Approve".into(), + message_id: "m2".into(), + session_id: "quorum-1".into(), + sender: "alice".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: approve1.encode_to_vec(), + }, + ) + .await; + + // 4) Bob approves + let approve2 = ApprovePayload { + request_id: "deploy-v2".into(), + reason: "staging looks good".into(), + }; + send( + &mut client, + "bob_approve", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Approve".into(), + message_id: "m3".into(), + session_id: "quorum-1".into(), + sender: "bob".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: approve2.encode_to_vec(), + }, + ) + .await; + + // 5) Commitment (threshold met: 2 of 3) + let commitment = macp_runtime::pb::CommitmentPayload { + commitment_id: "c1".into(), + action: "quorum.approved".into(), + authority_scope: "deploy".into(), + reason: "2 of 3 approved".into(), + mode_version: "1.0.0".into(), + policy_version: String::new(), + configuration_version: String::new(), + }; + send( + &mut client, + "commitment", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Commitment".into(), + message_id: "m4".into(), + session_id: "quorum-1".into(), + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }, + ) + .await; + + // 6) GetSession + match client + .get_session(GetSessionRequest { + session_id: "quorum-1".into(), + }) + .await + { + Ok(resp) => { + let meta = resp.into_inner().metadata.unwrap(); + println!("[get_session] state={} mode={}", meta.state, meta.mode); + } + Err(status) => println!("[get_session] error: {status}"), + } + + println!("\n=== Demo Complete ==="); + Ok(()) +} diff --git a/src/bin/task_client.rs b/src/bin/task_client.rs new file mode 100644 index 0000000..e3209b3 --- /dev/null +++ b/src/bin/task_client.rs @@ -0,0 +1,194 @@ +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +use macp_runtime::task_pb::{ + TaskAcceptPayload, TaskCompletePayload, TaskRequestPayload, TaskUpdatePayload, +}; +use prost::Message; + +async fn send( + client: &mut MacpRuntimeServiceClient, + label: &str, + e: Envelope, +) { + match client.send(SendRequest { envelope: Some(e) }).await { + Ok(resp) => { + let ack = resp.into_inner().ack.unwrap(); + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or(""); + println!("[{label}] ok={} error='{}'", ack.ok, err_code); + } + Err(status) => { + println!("[{label}] grpc error: {status}"); + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; + + println!("=== Task Mode Demo ===\n"); + + // 1) SessionStart + let start_payload = SessionStartPayload { + intent: "summarize document".into(), + ttl_ms: 60000, + participants: vec!["planner".into(), "worker".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + send( + &mut client, + "session_start", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "task-1".into(), + sender: "planner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }, + ) + .await; + + // 2) TaskRequest + let task_req = TaskRequestPayload { + task_id: "t1".into(), + title: "Summarize Q4 report".into(), + instructions: "Summarize the key findings".into(), + requested_assignee: "worker".into(), + input: vec![], + deadline_unix_ms: 0, + }; + send( + &mut client, + "task_request", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskRequest".into(), + message_id: "m1".into(), + session_id: "task-1".into(), + sender: "planner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: task_req.encode_to_vec(), + }, + ) + .await; + + // 3) TaskAccept + let accept = TaskAcceptPayload { + task_id: "t1".into(), + assignee: "worker".into(), + reason: "ready".into(), + }; + send( + &mut client, + "task_accept", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskAccept".into(), + message_id: "m2".into(), + session_id: "task-1".into(), + sender: "worker".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: accept.encode_to_vec(), + }, + ) + .await; + + // 4) TaskUpdate + let update = TaskUpdatePayload { + task_id: "t1".into(), + status: "in_progress".into(), + progress: 0.5, + message: "halfway done".into(), + partial_output: vec![], + }; + send( + &mut client, + "task_update", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskUpdate".into(), + message_id: "m3".into(), + session_id: "task-1".into(), + sender: "worker".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: update.encode_to_vec(), + }, + ) + .await; + + // 5) TaskComplete + let complete = TaskCompletePayload { + task_id: "t1".into(), + assignee: "worker".into(), + output: b"Executive summary: Revenue up 15%".to_vec(), + summary: "completed".into(), + }; + send( + &mut client, + "task_complete", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskComplete".into(), + message_id: "m4".into(), + session_id: "task-1".into(), + sender: "worker".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: complete.encode_to_vec(), + }, + ) + .await; + + // 6) Commitment + let commitment = macp_runtime::pb::CommitmentPayload { + commitment_id: "c1".into(), + action: "task.completed".into(), + authority_scope: "ops".into(), + reason: "task done".into(), + mode_version: "1.0.0".into(), + policy_version: String::new(), + configuration_version: String::new(), + }; + send( + &mut client, + "commitment", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "Commitment".into(), + message_id: "m5".into(), + session_id: "task-1".into(), + sender: "planner".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }, + ) + .await; + + // 7) GetSession + match client + .get_session(GetSessionRequest { + session_id: "task-1".into(), + }) + .await + { + Ok(resp) => { + let meta = resp.into_inner().metadata.unwrap(); + println!("[get_session] state={} mode={}", meta.state, meta.mode); + } + Err(status) => println!("[get_session] error: {status}"), + } + + println!("\n=== Demo Complete ==="); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 85b47d5..67bcdb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,22 @@ pub mod decision_pb { tonic::include_proto!("macp.modes.decision.v1"); } +pub mod proposal_pb { + tonic::include_proto!("macp.modes.proposal.v1"); +} + +pub mod task_pb { + tonic::include_proto!("macp.modes.task.v1"); +} + +pub mod handoff_pb { + tonic::include_proto!("macp.modes.handoff.v1"); +} + +pub mod quorum_pb { + tonic::include_proto!("macp.modes.quorum.v1"); +} + pub mod error; pub mod log_store; pub mod mode; diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs new file mode 100644 index 0000000..192586a --- /dev/null +++ b/src/mode/handoff.rs @@ -0,0 +1,735 @@ +use crate::error::MacpError; +use crate::handoff_pb::{ + HandoffAcceptPayload, HandoffContextPayload, HandoffDeclinePayload, HandoffOfferPayload, +}; +use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::{Mode, ModeResponse}; +use crate::pb::Envelope; +use crate::session::Session; +use prost::Message; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum HandoffDisposition { + Offered, + Accepted, + Declined, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandoffOfferRecord { + pub handoff_id: String, + pub target_participant: String, + pub scope: String, + pub reason: String, + pub offered_by: String, + pub disposition: HandoffDisposition, + pub accepted_by: Option, + pub declined_by: Option, + pub outcome_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandoffContextRecord { + pub content_type: String, + pub context: Vec, + pub sender: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HandoffState { + pub offers: BTreeMap, + pub contexts: BTreeMap>, +} + +pub struct HandoffMode; + +impl HandoffMode { + fn encode_state(state: &HandoffState) -> Vec { + serde_json::to_vec(state).expect("HandoffState is always serializable") + } + + fn decode_state(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState) + } + + fn commitment_ready(state: &HandoffState) -> bool { + state.offers.values().any(|offer| { + offer.disposition == HandoffDisposition::Accepted + || offer.disposition == HandoffDisposition::Declined + }) + } +} + +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), + "HandoffOffer" | "HandoffContext" if env.sender == session.initiator_sender => Ok(()), + _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), + _ => Err(MacpError::Forbidden), + } + } + + fn on_session_start( + &self, + session: &Session, + _env: &Envelope, + ) -> Result { + if session.participants.len() < 2 { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistState(Self::encode_state( + &HandoffState::default(), + ))) + } + + fn on_message(&self, session: &Session, env: &Envelope) -> Result { + let mut state = if session.mode_state.is_empty() { + HandoffState::default() + } else { + Self::decode_state(&session.mode_state)? + }; + + match env.message_type.as_str() { + "HandoffOffer" => { + let payload = HandoffOfferPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + if payload.handoff_id.is_empty() + || payload.target_participant.is_empty() + || state.offers.contains_key(&payload.handoff_id) + || !is_declared_participant(&session.participants, &payload.target_participant) + || payload.target_participant == env.sender + { + return Err(MacpError::InvalidPayload); + } + state.offers.insert( + payload.handoff_id.clone(), + HandoffOfferRecord { + handoff_id: payload.handoff_id, + target_participant: payload.target_participant, + scope: payload.scope, + reason: payload.reason, + offered_by: env.sender.clone(), + disposition: HandoffDisposition::Offered, + accepted_by: None, + declined_by: None, + outcome_reason: None, + }, + ); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "HandoffContext" => { + let payload = HandoffContextPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + let offer = state + .offers + .get(&payload.handoff_id) + .ok_or(MacpError::InvalidPayload)?; + if offer.offered_by != env.sender { + return Err(MacpError::Forbidden); + } + state + .contexts + .entry(payload.handoff_id) + .or_default() + .push(HandoffContextRecord { + content_type: payload.content_type, + context: payload.context, + sender: env.sender.clone(), + }); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "HandoffAccept" => { + let payload = HandoffAcceptPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + let offer = state + .offers + .get_mut(&payload.handoff_id) + .ok_or(MacpError::InvalidPayload)?; + if offer.target_participant != env.sender { + return Err(MacpError::Forbidden); + } + if !payload.accepted_by.is_empty() && payload.accepted_by != env.sender { + return Err(MacpError::InvalidPayload); + } + if offer.disposition != HandoffDisposition::Offered { + return Err(MacpError::InvalidPayload); + } + offer.disposition = HandoffDisposition::Accepted; + offer.accepted_by = Some(env.sender.clone()); + offer.outcome_reason = Some(payload.reason); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "HandoffDecline" => { + let payload = HandoffDeclinePayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + let offer = state + .offers + .get_mut(&payload.handoff_id) + .ok_or(MacpError::InvalidPayload)?; + if offer.target_participant != env.sender { + return Err(MacpError::Forbidden); + } + if !payload.declined_by.is_empty() && payload.declined_by != env.sender { + return Err(MacpError::InvalidPayload); + } + if offer.disposition != HandoffDisposition::Offered { + return Err(MacpError::InvalidPayload); + } + offer.disposition = HandoffDisposition::Declined; + offer.declined_by = Some(env.sender.clone()); + offer.outcome_reason = Some(payload.reason); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Commitment" => { + if env.sender != session.initiator_sender { + return Err(MacpError::Forbidden); + } + let _payload = decode_commitment_payload(&env.payload)?; + if !Self::commitment_ready(&state) { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistAndResolve { + state: Self::encode_state(&state), + resolution: env.payload.clone(), + }) + } + _ => Err(MacpError::InvalidPayload), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pb::CommitmentPayload; + use crate::session::{Session, SessionState}; + use std::collections::HashSet; + + fn base_session() -> Session { + Session { + session_id: "s1".into(), + state: SessionState::Open, + ttl_expiry: i64::MAX, + started_at_unix_ms: 0, + resolution: None, + mode: "macp.mode.handoff.v1".into(), + mode_state: vec![], + participants: vec!["owner".into(), "target".into()], + seen_message_ids: HashSet::new(), + intent: String::new(), + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: "owner".into(), + } + } + + fn env(sender: &str, message_type: &str, payload: Vec) -> Envelope { + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: message_type.into(), + message_id: format!("{}-{}", sender, message_type), + session_id: "s1".into(), + sender: sender.into(), + timestamp_unix_ms: 0, + payload, + } + } + + fn commitment_payload() -> Vec { + CommitmentPayload { + commitment_id: "c1".into(), + action: "handoff.accepted".into(), + authority_scope: "support".into(), + reason: "accepted".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + } + .encode_to_vec() + } + + fn apply(session: &mut Session, result: ModeResponse) { + match result { + ModeResponse::PersistState(data) => session.mode_state = data, + ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state, + _ => {} + } + } + + fn make_offer(handoff_id: &str, target: &str) -> Vec { + HandoffOfferPayload { + handoff_id: handoff_id.into(), + target_participant: target.into(), + scope: "support".into(), + reason: "escalate".into(), + } + .encode_to_vec() + } + + fn make_context(handoff_id: &str) -> Vec { + HandoffContextPayload { + handoff_id: handoff_id.into(), + content_type: "text/plain".into(), + context: b"background info".to_vec(), + } + .encode_to_vec() + } + + fn make_accept(handoff_id: &str, accepted_by: &str) -> Vec { + HandoffAcceptPayload { + handoff_id: handoff_id.into(), + accepted_by: accepted_by.into(), + reason: "ready".into(), + } + .encode_to_vec() + } + + fn make_decline(handoff_id: &str, declined_by: &str) -> Vec { + HandoffDeclinePayload { + handoff_id: handoff_id.into(), + declined_by: declined_by.into(), + reason: "busy".into(), + } + .encode_to_vec() + } + + // --- Session Start --- + + #[test] + fn session_start_initializes_state() { + let mode = HandoffMode; + let session = base_session(); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: HandoffState = serde_json::from_slice(&data).unwrap(); + assert!(state.offers.is_empty()); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn session_start_requires_two_participants() { + let mode = HandoffMode; + let mut session = base_session(); + session.participants = vec!["owner".into()]; // only 1 + let err = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- HandoffOffer --- + + #[test] + fn offer_creates_entry() { + let mode = HandoffMode; + let mut session = base_session(); + 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(); + match result { + ModeResponse::PersistState(data) => { + let state: HandoffState = serde_json::from_slice(&data).unwrap(); + assert!(state.offers.contains_key("h1")); + assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Offered); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn duplicate_offer_id_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "target")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn offer_to_self_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "owner")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn offer_to_non_participant_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("owner", "HandoffOffer", make_offer("h1", "outsider")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- HandoffContext --- + + #[test] + fn context_for_existing_offer() { + let mode = HandoffMode; + let mut session = base_session(); + 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("owner", "HandoffContext", make_context("h1")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: HandoffState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.contexts["h1"].len(), 1); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn context_from_non_offerer_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("target", "HandoffContext", make_context("h1")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- HandoffAccept / HandoffDecline --- + + #[test] + fn target_can_accept() { + let mode = HandoffMode; + let mut session = base_session(); + 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(); + match result { + ModeResponse::PersistState(data) => { + let state: HandoffState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Accepted); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn wrong_target_cannot_accept() { + let mode = HandoffMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("owner", "HandoffAccept", make_accept("h1", "owner")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + #[test] + fn target_can_decline() { + let mode = HandoffMode; + let mut session = base_session(); + 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", "HandoffDecline", make_decline("h1", "target")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: HandoffState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.offers["h1"].disposition, HandoffDisposition::Declined); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn cannot_accept_already_accepted() { + let mode = HandoffMode; + let mut session = base_session(); + 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); + let err = mode + .on_message( + &session, + &env("target", "HandoffAccept", make_accept("h1", "target")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Commitment --- + + #[test] + fn commitment_after_accept() { + let mode = HandoffMode; + let mut session = base_session(); + 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); + let result = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_after_decline() { + let mode = HandoffMode; + let mut session = base_session(); + 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", "HandoffDecline", make_decline("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_without_response_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + 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 err = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn commitment_with_no_offers_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Full lifecycle --- + + #[test] + fn full_handoff_lifecycle() { + let mode = HandoffMode; + let mut session = base_session(); + 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("owner", "HandoffContext", make_context("h1")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("target", "HandoffAccept", make_accept("h1", "target")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message(&session, &env("owner", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + // --- Unknown message type --- + + #[test] + fn unknown_message_type_rejected() { + let mode = HandoffMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("owner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message(&session, &env("owner", "CustomType", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } +} diff --git a/src/mode/mod.rs b/src/mode/mod.rs index ccfabda..40b252a 100644 --- a/src/mode/mod.rs +++ b/src/mode/mod.rs @@ -1,9 +1,24 @@ pub mod decision; +pub mod handoff; pub mod multi_round; +pub mod proposal; +pub mod quorum; +pub mod task; +pub mod util; use crate::error::MacpError; -use crate::pb::Envelope; +use crate::pb::{Envelope, ModeDescriptor}; use crate::session::Session; +use std::collections::HashMap; + +/// The canonical standards-track modes implemented by this runtime. +pub const STANDARD_MODE_NAMES: &[&str] = &[ + "macp.mode.decision.v1", + "macp.mode.proposal.v1", + "macp.mode.task.v1", + "macp.mode.handoff.v1", + "macp.mode.quorum.v1", +]; /// The result of a Mode processing a message. /// The runtime applies this response to mutate session state. @@ -40,3 +55,109 @@ pub trait Mode: Send + Sync { Ok(()) } } + +pub fn standard_mode_names() -> &'static [&'static str] { + STANDARD_MODE_NAMES +} + +pub fn standard_mode_descriptors() -> Vec { + fn schema_map(path: &str) -> HashMap { + HashMap::from([("protobuf".to_string(), path.to_string())]) + } + + vec![ + ModeDescriptor { + mode: "macp.mode.decision.v1".into(), + mode_version: "1.0.0".into(), + title: "Decision Mode".into(), + description: "Structured decision making with proposals, evaluations, objections, votes, and a terminal Commitment.".into(), + determinism_class: "semantic-deterministic".into(), + participant_model: "declared".into(), + message_types: vec![ + "SessionStart".into(), + "Proposal".into(), + "Evaluation".into(), + "Objection".into(), + "Vote".into(), + "Commitment".into(), + ], + terminal_message_types: vec!["Commitment".into()], + schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"), + }, + ModeDescriptor { + mode: "macp.mode.proposal.v1".into(), + mode_version: "1.0.0".into(), + title: "Proposal Mode".into(), + description: "Negotiation with proposals, counterproposals, accepts, rejects, withdrawals, and a terminal Commitment.".into(), + determinism_class: "semantic-deterministic".into(), + participant_model: "peer".into(), + message_types: vec![ + "SessionStart".into(), + "Proposal".into(), + "CounterProposal".into(), + "Accept".into(), + "Reject".into(), + "Withdraw".into(), + "Commitment".into(), + ], + terminal_message_types: vec!["Commitment".into()], + schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"), + }, + ModeDescriptor { + mode: "macp.mode.task.v1".into(), + mode_version: "1.0.0".into(), + title: "Task Mode".into(), + description: "One bounded delegated task with assignee responses, progress, completion/failure reports, and a terminal Commitment.".into(), + determinism_class: "structural-only".into(), + participant_model: "orchestrated".into(), + message_types: vec![ + "SessionStart".into(), + "TaskRequest".into(), + "TaskAccept".into(), + "TaskReject".into(), + "TaskUpdate".into(), + "TaskComplete".into(), + "TaskFail".into(), + "Commitment".into(), + ], + terminal_message_types: vec!["Commitment".into()], + schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"), + }, + ModeDescriptor { + mode: "macp.mode.handoff.v1".into(), + mode_version: "1.0.0".into(), + title: "Handoff Mode".into(), + description: "Scoped responsibility transfer with handoff offers, context, target responses, and a terminal Commitment.".into(), + determinism_class: "context-frozen".into(), + participant_model: "delegated".into(), + message_types: vec![ + "SessionStart".into(), + "HandoffOffer".into(), + "HandoffContext".into(), + "HandoffAccept".into(), + "HandoffDecline".into(), + "Commitment".into(), + ], + terminal_message_types: vec!["Commitment".into()], + schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"), + }, + ModeDescriptor { + mode: "macp.mode.quorum.v1".into(), + mode_version: "1.0.0".into(), + title: "Quorum Mode".into(), + description: "Threshold approval with one approval request, participant ballots, and a terminal Commitment.".into(), + determinism_class: "semantic-deterministic".into(), + participant_model: "quorum".into(), + message_types: vec![ + "SessionStart".into(), + "ApprovalRequest".into(), + "Approve".into(), + "Reject".into(), + "Abstain".into(), + "Commitment".into(), + ], + terminal_message_types: vec!["Commitment".into()], + schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"), + }, + ] +} diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs new file mode 100644 index 0000000..2d45825 --- /dev/null +++ b/src/mode/proposal.rs @@ -0,0 +1,970 @@ +use crate::error::MacpError; +use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::{Mode, ModeResponse}; +use crate::pb::Envelope; +use crate::proposal_pb::{ + AcceptPayload, CounterProposalPayload, ProposalPayload, RejectPayload, WithdrawPayload, +}; +use crate::session::Session; +use prost::Message; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ProposalDisposition { + Live, + Withdrawn, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProposalRecord { + pub proposal_id: String, + pub title: String, + pub summary: String, + pub details: Vec, + pub tags: Vec, + pub proposer: String, + pub supersedes_proposal_id: Option, + pub disposition: ProposalDisposition, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalRejectRecord { + pub proposal_id: String, + pub sender: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProposalState { + pub proposals: BTreeMap, + pub accepts: BTreeMap, + pub terminal_rejections: Vec, +} + +pub struct ProposalMode; + +impl ProposalMode { + fn encode_state(state: &ProposalState) -> Vec { + serde_json::to_vec(state).expect("ProposalState is always serializable") + } + + fn decode_state(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState) + } + + fn live_proposal<'a>( + state: &'a ProposalState, + proposal_id: &str, + ) -> Option<&'a ProposalRecord> { + state + .proposals + .get(proposal_id) + .filter(|record| record.disposition == ProposalDisposition::Live) + } +} + +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), + _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), + _ => Err(MacpError::Forbidden), + } + } + + fn on_session_start( + &self, + session: &Session, + _env: &Envelope, + ) -> Result { + if session.participants.is_empty() { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistState(Self::encode_state( + &ProposalState::default(), + ))) + } + + fn on_message(&self, session: &Session, env: &Envelope) -> Result { + let mut state = if session.mode_state.is_empty() { + ProposalState::default() + } else { + Self::decode_state(&session.mode_state)? + }; + + match env.message_type.as_str() { + "Proposal" => { + let payload = ProposalPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + if payload.proposal_id.is_empty() + || state.proposals.contains_key(&payload.proposal_id) + { + return Err(MacpError::InvalidPayload); + } + state.proposals.insert( + payload.proposal_id.clone(), + ProposalRecord { + proposal_id: payload.proposal_id, + title: payload.title, + summary: payload.summary, + details: payload.details, + tags: payload.tags, + proposer: env.sender.clone(), + supersedes_proposal_id: None, + disposition: ProposalDisposition::Live, + }, + ); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "CounterProposal" => { + let payload = CounterProposalPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + if payload.proposal_id.is_empty() + || payload.supersedes_proposal_id.is_empty() + || state.proposals.contains_key(&payload.proposal_id) + || !state + .proposals + .contains_key(&payload.supersedes_proposal_id) + { + return Err(MacpError::InvalidPayload); + } + state.proposals.insert( + payload.proposal_id.clone(), + ProposalRecord { + proposal_id: payload.proposal_id, + title: payload.title, + summary: payload.summary, + details: payload.details, + tags: Vec::new(), + proposer: env.sender.clone(), + supersedes_proposal_id: Some(payload.supersedes_proposal_id), + disposition: ProposalDisposition::Live, + }, + ); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Accept" => { + let payload = + AcceptPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + if Self::live_proposal(&state, &payload.proposal_id).is_none() { + return Err(MacpError::InvalidPayload); + } + state + .accepts + .insert(env.sender.clone(), payload.proposal_id); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Reject" => { + let payload = + RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + if !state.proposals.contains_key(&payload.proposal_id) { + return Err(MacpError::InvalidPayload); + } + if payload.terminal { + state.terminal_rejections.push(TerminalRejectRecord { + proposal_id: payload.proposal_id, + sender: env.sender.clone(), + reason: payload.reason, + }); + return Ok(ModeResponse::PersistState(Self::encode_state(&state))); + } + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Withdraw" => { + let payload = WithdrawPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + let record = state + .proposals + .get_mut(&payload.proposal_id) + .ok_or(MacpError::InvalidPayload)?; + if record.proposer != env.sender { + return Err(MacpError::Forbidden); + } + if record.disposition == ProposalDisposition::Withdrawn { + return Err(MacpError::InvalidPayload); + } + record.disposition = ProposalDisposition::Withdrawn; + // Clear acceptances for withdrawn proposal + state.accepts.retain(|_, pid| pid != &payload.proposal_id); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Commitment" => { + if env.sender != session.initiator_sender { + return Err(MacpError::Forbidden); + } + let _payload = decode_commitment_payload(&env.payload)?; + if state.proposals.is_empty() { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistAndResolve { + state: Self::encode_state(&state), + resolution: env.payload.clone(), + }) + } + _ => Err(MacpError::InvalidPayload), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pb::CommitmentPayload; + use crate::session::{Session, SessionState}; + use std::collections::HashSet; + + fn base_session() -> Session { + Session { + session_id: "s1".into(), + state: SessionState::Open, + ttl_expiry: i64::MAX, + started_at_unix_ms: 0, + resolution: None, + mode: "macp.mode.proposal.v1".into(), + mode_state: vec![], + participants: vec!["buyer".into(), "seller".into()], + seen_message_ids: HashSet::new(), + intent: String::new(), + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: "buyer".into(), + } + } + + fn env(sender: &str, message_type: &str, payload: Vec) -> Envelope { + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: message_type.into(), + message_id: format!("{}-{}", sender, message_type), + session_id: "s1".into(), + sender: sender.into(), + timestamp_unix_ms: 0, + payload, + } + } + + fn commitment_payload() -> Vec { + CommitmentPayload { + commitment_id: "c1".into(), + action: "proposal.accepted".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + } + .encode_to_vec() + } + + fn apply(session: &mut Session, result: ModeResponse) { + match result { + ModeResponse::PersistState(data) => session.mode_state = data, + ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state, + _ => {} + } + } + + fn make_proposal(id: &str, title: &str, summary: &str) -> Vec { + ProposalPayload { + proposal_id: id.into(), + title: title.into(), + summary: summary.into(), + details: vec![], + tags: vec![], + } + .encode_to_vec() + } + + fn make_counter(id: &str, supersedes: &str, title: &str, summary: &str) -> Vec { + CounterProposalPayload { + proposal_id: id.into(), + supersedes_proposal_id: supersedes.into(), + title: title.into(), + summary: summary.into(), + details: vec![], + } + .encode_to_vec() + } + + fn make_accept(proposal_id: &str, reason: &str) -> Vec { + AcceptPayload { + proposal_id: proposal_id.into(), + reason: reason.into(), + } + .encode_to_vec() + } + + fn make_reject(proposal_id: &str, terminal: bool, reason: &str) -> Vec { + RejectPayload { + proposal_id: proposal_id.into(), + terminal, + reason: reason.into(), + } + .encode_to_vec() + } + + fn make_withdraw(proposal_id: &str, reason: &str) -> Vec { + WithdrawPayload { + proposal_id: proposal_id.into(), + reason: reason.into(), + } + .encode_to_vec() + } + + // --- Session Start --- + + #[test] + fn session_start_initializes_state() { + let mode = ProposalMode; + let session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: ProposalState = serde_json::from_slice(&data).unwrap(); + assert!(state.proposals.is_empty()); + assert!(state.accepts.is_empty()); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn session_start_requires_participants() { + let mode = ProposalMode; + let mut session = base_session(); + session.participants.clear(); + let err = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Proposal --- + + #[test] + fn proposal_creates_entry() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: ProposalState = serde_json::from_slice(&data).unwrap(); + assert!(state.proposals.contains_key("p1")); + assert_eq!(state.proposals["p1"].proposer, "seller"); + assert_eq!(state.proposals["p1"].disposition, ProposalDisposition::Live); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn proposal_with_empty_id_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("", "Offer", "1200")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn duplicate_proposal_id_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("buyer", "Proposal", make_proposal("p1", "Same", "1300")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- CounterProposal --- + + #[test] + fn counter_proposal_works() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env( + "buyer", + "CounterProposal", + make_counter("p2", "p1", "Counter", "1000"), + ), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: ProposalState = serde_json::from_slice(&data).unwrap(); + assert!(state.proposals.contains_key("p2")); + assert_eq!( + state.proposals["p2"].supersedes_proposal_id, + Some("p1".into()) + ); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn counter_proposal_missing_supersedes_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env( + "buyer", + "CounterProposal", + make_counter("p2", "nonexistent", "Counter", "1000"), + ), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Accept --- + + #[test] + fn accept_live_proposal() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("buyer", "Accept", make_accept("p1", "agree")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: ProposalState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.accepts.get("buyer"), Some(&"p1".to_string())); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn accept_supersedes_previous() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer A", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("buyer", "Proposal", make_proposal("p2", "Offer B", "1000")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message(&session, &env("buyer", "Accept", make_accept("p1", "ok"))) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("buyer", "Accept", make_accept("p2", "changed mind")), + ) + .unwrap(); + apply(&mut session, result); + let state: ProposalState = serde_json::from_slice(&session.mode_state).unwrap(); + assert_eq!(state.accepts.get("buyer"), Some(&"p2".to_string())); + } + + #[test] + fn withdrawn_proposal_cannot_be_accepted() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Withdraw", make_withdraw("p1", "withdrawn")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("buyer", "Accept", make_accept("p1", "too late")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Reject --- + + #[test] + fn reject_existing_proposal() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("buyer", "Reject", make_reject("p1", false, "too expensive")), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistState(_))); + } + + #[test] + fn terminal_reject_sets_flag() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("buyer", "Reject", make_reject("p1", true, "deal breaker")), + ) + .unwrap(); + apply(&mut session, result); + let state: ProposalState = serde_json::from_slice(&session.mode_state).unwrap(); + assert_eq!(state.terminal_rejections.len(), 1); + } + + #[test] + fn reject_nonexistent_proposal_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("buyer", "Reject", make_reject("nope", false, "bad")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Withdraw --- + + #[test] + fn withdraw_own_proposal() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Withdraw", make_withdraw("p1", "changed mind")), + ) + .unwrap(); + apply(&mut session, result); + let state: ProposalState = serde_json::from_slice(&session.mode_state).unwrap(); + assert_eq!( + state.proposals["p1"].disposition, + ProposalDisposition::Withdrawn + ); + } + + #[test] + fn cannot_withdraw_others_proposal() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("buyer", "Withdraw", make_withdraw("p1", "nope")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + #[test] + fn withdraw_clears_acceptances() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message(&session, &env("buyer", "Accept", make_accept("p1", "ok"))) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Withdraw", make_withdraw("p1", "changed mind")), + ) + .unwrap(); + apply(&mut session, result); + let state: ProposalState = serde_json::from_slice(&session.mode_state).unwrap(); + assert!(state.accepts.is_empty()); + } + + #[test] + fn double_withdraw_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Withdraw", make_withdraw("p1", "withdrawn")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("seller", "Withdraw", make_withdraw("p1", "again")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Commitment --- + + #[test] + fn commitment_resolves_session() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message(&session, &env("buyer", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_without_proposals_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message(&session, &env("buyer", "Commitment", commitment_payload())) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn non_initiator_commitment_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message(&session, &env("seller", "Commitment", commitment_payload())) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- Authorization --- + + #[test] + fn non_participant_rejected() { + let mode = ProposalMode; + let session = base_session(); + let err = mode + .authorize_sender( + &session, + &env("outsider", "Proposal", make_proposal("p1", "Offer", "1200")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- Unknown message type --- + + #[test] + fn unknown_message_type_rejected() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message(&session, &env("seller", "CustomType", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Full lifecycle --- + + #[test] + fn full_proposal_lifecycle() { + let mode = ProposalMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + + // Seller makes proposal + let result = mode + .on_message( + &session, + &env("seller", "Proposal", make_proposal("p1", "Initial", "1200")), + ) + .unwrap(); + apply(&mut session, result); + + // Buyer counter-proposes + let result = mode + .on_message( + &session, + &env( + "buyer", + "CounterProposal", + make_counter("p2", "p1", "Counter", "1000"), + ), + ) + .unwrap(); + apply(&mut session, result); + + // Both accept p2 + let result = mode + .on_message(&session, &env("buyer", "Accept", make_accept("p2", "good"))) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("seller", "Accept", make_accept("p2", "agreed")), + ) + .unwrap(); + apply(&mut session, result); + + // Commitment + let result = mode + .on_message(&session, &env("buyer", "Commitment", commitment_payload())) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn deterministic_serialization() { + let mut state1 = ProposalState::default(); + state1.proposals.insert( + "z".into(), + ProposalRecord { + proposal_id: "z".into(), + title: "z".into(), + summary: "".into(), + details: vec![], + tags: vec![], + proposer: "".into(), + supersedes_proposal_id: None, + disposition: ProposalDisposition::Live, + }, + ); + state1.proposals.insert( + "a".into(), + ProposalRecord { + proposal_id: "a".into(), + title: "a".into(), + summary: "".into(), + details: vec![], + tags: vec![], + proposer: "".into(), + supersedes_proposal_id: None, + disposition: ProposalDisposition::Live, + }, + ); + + let mut state2 = ProposalState::default(); + state2.proposals.insert( + "a".into(), + ProposalRecord { + proposal_id: "a".into(), + title: "a".into(), + summary: "".into(), + details: vec![], + tags: vec![], + proposer: "".into(), + supersedes_proposal_id: None, + disposition: ProposalDisposition::Live, + }, + ); + state2.proposals.insert( + "z".into(), + ProposalRecord { + proposal_id: "z".into(), + title: "z".into(), + summary: "".into(), + details: vec![], + tags: vec![], + proposer: "".into(), + supersedes_proposal_id: None, + disposition: ProposalDisposition::Live, + }, + ); + + assert_eq!( + ProposalMode::encode_state(&state1), + ProposalMode::encode_state(&state2) + ); + } +} diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs new file mode 100644 index 0000000..31be6aa --- /dev/null +++ b/src/mode/quorum.rs @@ -0,0 +1,879 @@ +use crate::error::MacpError; +use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::{Mode, ModeResponse}; +use crate::pb::Envelope; +use crate::quorum_pb::{AbstainPayload, ApprovalRequestPayload, ApprovePayload, RejectPayload}; +use crate::session::Session; +use prost::Message; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum BallotChoice { + Approve, + Reject, + Abstain, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalRequestRecord { + pub request_id: String, + pub action: String, + pub summary: String, + pub details: Vec, + pub required_approvals: u32, + pub requested_by: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BallotRecord { + pub request_id: String, + pub choice: BallotChoice, + pub sender: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct QuorumState { + pub request: Option, + pub ballots: BTreeMap, +} + +pub struct QuorumMode; + +impl QuorumMode { + fn encode_state(state: &QuorumState) -> Vec { + serde_json::to_vec(state).expect("QuorumState is always serializable") + } + + fn decode_state(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState) + } + + fn commitment_ready(session: &Session, state: &QuorumState) -> bool { + let request = match &state.request { + Some(request) => request, + None => return false, + }; + let approvals = state + .ballots + .values() + .filter(|ballot| ballot.choice == BallotChoice::Approve) + .count() as u32; + let total_eligible = session.participants.len() as u32; + 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 + } +} + +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), + _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), + _ => Err(MacpError::Forbidden), + } + } + + fn on_session_start( + &self, + session: &Session, + _env: &Envelope, + ) -> Result { + if session.participants.is_empty() { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistState(Self::encode_state( + &QuorumState::default(), + ))) + } + + fn on_message(&self, session: &Session, env: &Envelope) -> Result { + let mut state = if session.mode_state.is_empty() { + QuorumState::default() + } else { + Self::decode_state(&session.mode_state)? + }; + + match env.message_type.as_str() { + "ApprovalRequest" => { + if env.sender != session.initiator_sender { + return Err(MacpError::Forbidden); + } + let payload = ApprovalRequestPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + if state.request.is_some() + || payload.request_id.is_empty() + || payload.required_approvals == 0 + || payload.required_approvals > session.participants.len() as u32 + { + return Err(MacpError::InvalidPayload); + } + state.request = Some(ApprovalRequestRecord { + request_id: payload.request_id, + action: payload.action, + summary: payload.summary, + details: payload.details, + required_approvals: payload.required_approvals, + requested_by: env.sender.clone(), + }); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Approve" => { + let payload = + ApprovePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?; + if payload.request_id != request.request_id + || state.ballots.contains_key(&env.sender) + { + return Err(MacpError::InvalidPayload); + } + state.ballots.insert( + env.sender.clone(), + BallotRecord { + request_id: payload.request_id, + choice: BallotChoice::Approve, + sender: env.sender.clone(), + reason: payload.reason, + }, + ); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Reject" => { + let payload = + RejectPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?; + if payload.request_id != request.request_id + || state.ballots.contains_key(&env.sender) + { + return Err(MacpError::InvalidPayload); + } + state.ballots.insert( + env.sender.clone(), + BallotRecord { + request_id: payload.request_id, + choice: BallotChoice::Reject, + sender: env.sender.clone(), + reason: payload.reason, + }, + ); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Abstain" => { + let payload = + AbstainPayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; + let request = state.request.as_ref().ok_or(MacpError::InvalidPayload)?; + if payload.request_id != request.request_id + || state.ballots.contains_key(&env.sender) + { + return Err(MacpError::InvalidPayload); + } + state.ballots.insert( + env.sender.clone(), + BallotRecord { + request_id: payload.request_id, + choice: BallotChoice::Abstain, + sender: env.sender.clone(), + reason: payload.reason, + }, + ); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Commitment" => { + if env.sender != session.initiator_sender { + return Err(MacpError::Forbidden); + } + let _payload = decode_commitment_payload(&env.payload)?; + if !Self::commitment_ready(session, &state) { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistAndResolve { + state: Self::encode_state(&state), + resolution: env.payload.clone(), + }) + } + _ => Err(MacpError::InvalidPayload), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pb::CommitmentPayload; + use crate::session::{Session, SessionState}; + use std::collections::HashSet; + + fn base_session() -> Session { + Session { + session_id: "s1".into(), + state: SessionState::Open, + ttl_expiry: i64::MAX, + started_at_unix_ms: 0, + resolution: None, + mode: "macp.mode.quorum.v1".into(), + mode_state: vec![], + participants: vec!["alice".into(), "bob".into(), "carol".into()], + seen_message_ids: HashSet::new(), + intent: String::new(), + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: "coordinator".into(), + } + } + + fn env(sender: &str, message_type: &str, payload: Vec) -> Envelope { + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: message_type.into(), + message_id: format!("{}-{}", sender, message_type), + session_id: "s1".into(), + sender: sender.into(), + timestamp_unix_ms: 0, + payload, + } + } + + fn commitment_payload() -> Vec { + CommitmentPayload { + commitment_id: "c1".into(), + action: "quorum.approved".into(), + authority_scope: "deploy".into(), + reason: "threshold met".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + } + .encode_to_vec() + } + + fn apply(session: &mut Session, result: ModeResponse) { + match result { + ModeResponse::PersistState(data) => session.mode_state = data, + ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state, + _ => {} + } + } + + fn make_approval_request(request_id: &str, required: u32) -> Vec { + ApprovalRequestPayload { + request_id: request_id.into(), + action: "deploy.production".into(), + summary: "Deploy v2".into(), + details: vec![], + required_approvals: required, + } + .encode_to_vec() + } + + fn make_approve(request_id: &str, reason: &str) -> Vec { + ApprovePayload { + request_id: request_id.into(), + reason: reason.into(), + } + .encode_to_vec() + } + + fn make_reject(request_id: &str, reason: &str) -> Vec { + RejectPayload { + request_id: request_id.into(), + reason: reason.into(), + } + .encode_to_vec() + } + + fn make_abstain(request_id: &str, reason: &str) -> Vec { + AbstainPayload { + request_id: request_id.into(), + reason: reason.into(), + } + .encode_to_vec() + } + + // --- Session Start --- + + #[test] + fn session_start_initializes_state() { + let mode = QuorumMode; + let session = base_session(); + let result = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: QuorumState = serde_json::from_slice(&data).unwrap(); + assert!(state.request.is_none()); + assert!(state.ballots.is_empty()); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn session_start_requires_participants() { + let mode = QuorumMode; + let mut session = base_session(); + session.participants.clear(); + let err = mode + .on_session_start(&session, &env("coordinator", "SessionStart", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- ApprovalRequest --- + + #[test] + fn approval_request_from_coordinator() { + 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(); + match result { + ModeResponse::PersistState(data) => { + let state: QuorumState = serde_json::from_slice(&data).unwrap(); + assert!(state.request.is_some()); + assert_eq!(state.request.unwrap().required_approvals, 2); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn duplicate_approval_request_rejected() { + 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); + let err = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r2", 1), + ), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn required_approvals_exceeds_participants_rejected() { + 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 err = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 4), + ), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn required_approvals_zero_rejected() { + 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 err = mode + .on_message( + &session, + &env( + "coordinator", + "ApprovalRequest", + make_approval_request("r1", 0), + ), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn non_coordinator_approval_request_rejected() { + 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 err = mode + .on_message( + &session, + &env("alice", "ApprovalRequest", make_approval_request("r1", 2)), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- Ballots --- + + #[test] + fn participant_can_approve() { + 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); + let result = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "looks good")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: QuorumState = serde_json::from_slice(&data).unwrap(); + assert!(state.ballots.contains_key("alice")); + assert_eq!(state.ballots["alice"].choice, BallotChoice::Approve); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn participant_can_reject() { + 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); + let result = mode + .on_message( + &session, + &env("alice", "Reject", make_reject("r1", "not ready")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: QuorumState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.ballots["alice"].choice, BallotChoice::Reject); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn participant_can_abstain() { + 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); + let result = mode + .on_message( + &session, + &env("alice", "Abstain", make_abstain("r1", "no opinion")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: QuorumState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.ballots["alice"].choice, BallotChoice::Abstain); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn duplicate_ballot_rejected() { + 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); + 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("alice", "Approve", make_approve("r1", "again")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn ballot_before_request_rejected() { + 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 err = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "premature")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn wrong_request_id_rejected() { + 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); + let err = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r2", "wrong id")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- Commitment --- + + #[test] + fn commitment_when_threshold_reached() { + 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); + 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); + let result = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_when_threshold_unreachable() { + 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", 3), + ), + ) + .unwrap(); + apply(&mut session, result); + // All 3 must approve for threshold=3, but alice rejects + let result = mode + .on_message(&session, &env("alice", "Reject", make_reject("r1", "no"))) + .unwrap(); + apply(&mut session, result); + // Threshold is now unreachable (need 3, max possible = 2) + let result = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_before_threshold_rejected() { + 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); + let result = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "yes")), + ) + .unwrap(); + apply(&mut session, result); + // Only 1 approval, threshold is 2, and 2 participants left can still vote + let err = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn non_coordinator_commitment_rejected() { + 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); + 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); + let err = mode + .on_message(&session, &env("alice", "Commitment", commitment_payload())) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- Full lifecycle --- + + #[test] + fn full_quorum_approve_lifecycle() { + 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); + let result = mode + .on_message( + &session, + &env("alice", "Approve", make_approve("r1", "green")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("bob", "Approve", make_approve("r1", "ready")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn full_quorum_reject_lifecycle() { + 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", 3), + ), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("alice", "Reject", make_reject("r1", "not ready")), + ) + .unwrap(); + apply(&mut session, result); + // Threshold unreachable: need 3, 1 rejected, only 2 left, max possible = 2 + let result = mode + .on_message( + &session, + &env("coordinator", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + // --- Unknown message type --- + + #[test] + fn unknown_message_type_rejected() { + 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 err = mode + .on_message(&session, &env("alice", "CustomType", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } +} diff --git a/src/mode/task.rs b/src/mode/task.rs new file mode 100644 index 0000000..9c31e40 --- /dev/null +++ b/src/mode/task.rs @@ -0,0 +1,942 @@ +use crate::error::MacpError; +use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::{Mode, ModeResponse}; +use crate::pb::Envelope; +use crate::session::Session; +use crate::task_pb::{ + TaskAcceptPayload, TaskCompletePayload, TaskFailPayload, TaskRejectPayload, TaskRequestPayload, + TaskUpdatePayload, +}; +use prost::Message; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskRecord { + pub task_id: String, + pub title: String, + pub instructions: String, + pub requested_assignee: String, + pub input: Vec, + pub deadline_unix_ms: i64, + pub requester: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskRejectRecord { + pub task_id: String, + pub assignee: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskUpdateRecord { + pub task_id: String, + pub status: String, + pub progress: f64, + pub message: String, + pub partial_output: Vec, + pub sender: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCompleteRecord { + pub task_id: String, + pub assignee: String, + pub output: Vec, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskFailRecord { + pub task_id: String, + pub assignee: String, + pub error_code: String, + pub reason: String, + pub retryable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TaskTerminalReport { + Complete(TaskCompleteRecord), + Fail(TaskFailRecord), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TaskState { + pub task: Option, + pub active_assignee: Option, + pub rejections: Vec, + pub updates: Vec, + pub terminal_report: Option, +} + +pub struct TaskMode; + +impl TaskMode { + fn encode_state(state: &TaskState) -> Vec { + serde_json::to_vec(state).expect("TaskState is always serializable") + } + + fn decode_state(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|_| MacpError::InvalidModeState) + } + + fn ensure_task_matches(expected_task_id: &str, actual_task_id: &str) -> Result<(), MacpError> { + if expected_task_id.is_empty() || expected_task_id != actual_task_id { + return Err(MacpError::InvalidPayload); + } + Ok(()) + } + + fn can_assignee_respond(session: &Session, task: &TaskRecord, sender: &str) -> bool { + if !task.requested_assignee.is_empty() { + sender == task.requested_assignee + } else { + is_declared_participant(&session.participants, sender) + && sender != session.initiator_sender + } + } +} + +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), + _ if is_declared_participant(&session.participants, &env.sender) => Ok(()), + _ => Err(MacpError::Forbidden), + } + } + + fn on_session_start( + &self, + session: &Session, + _env: &Envelope, + ) -> Result { + if session.participants.is_empty() { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistState(Self::encode_state( + &TaskState::default(), + ))) + } + + fn on_message(&self, session: &Session, env: &Envelope) -> Result { + let mut state = if session.mode_state.is_empty() { + TaskState::default() + } else { + Self::decode_state(&session.mode_state)? + }; + + match env.message_type.as_str() { + "TaskRequest" => { + if env.sender != session.initiator_sender { + return Err(MacpError::Forbidden); + } + let payload = TaskRequestPayload::decode(&*env.payload) + .map_err(|_| MacpError::InvalidPayload)?; + if state.task.is_some() || payload.task_id.is_empty() { + return Err(MacpError::InvalidPayload); + } + if !payload.requested_assignee.is_empty() + && !is_declared_participant(&session.participants, &payload.requested_assignee) + { + return Err(MacpError::InvalidPayload); + } + state.task = Some(TaskRecord { + task_id: payload.task_id, + title: payload.title, + instructions: payload.instructions, + requested_assignee: payload.requested_assignee, + input: payload.input, + deadline_unix_ms: payload.deadline_unix_ms, + requester: env.sender.clone(), + }); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "TaskAccept" => { + let payload = TaskAcceptPayload::decode(&*env.payload) + .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); + } + if !payload.assignee.is_empty() && payload.assignee != env.sender { + return Err(MacpError::InvalidPayload); + } + if !Self::can_assignee_respond(session, task, &env.sender) { + return Err(MacpError::Forbidden); + } + state.active_assignee = Some(env.sender.clone()); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "TaskReject" => { + let payload = TaskRejectPayload::decode(&*env.payload) + .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); + } + if !payload.assignee.is_empty() && payload.assignee != env.sender { + return Err(MacpError::InvalidPayload); + } + if !Self::can_assignee_respond(session, task, &env.sender) { + return Err(MacpError::Forbidden); + } + state.rejections.push(TaskRejectRecord { + task_id: payload.task_id, + assignee: env.sender.clone(), + reason: payload.reason, + }); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "TaskUpdate" => { + let payload = TaskUpdatePayload::decode(&*env.payload) + .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.terminal_report.is_some() + || state.active_assignee.as_deref() != Some(env.sender.as_str()) + { + return Err(MacpError::Forbidden); + } + state.updates.push(TaskUpdateRecord { + task_id: payload.task_id, + status: payload.status, + progress: payload.progress, + message: payload.message, + partial_output: payload.partial_output, + sender: env.sender.clone(), + }); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "TaskComplete" => { + let payload = TaskCompletePayload::decode(&*env.payload) + .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.terminal_report.is_some() + || state.active_assignee.as_deref() != Some(env.sender.as_str()) + { + return Err(MacpError::Forbidden); + } + if !payload.assignee.is_empty() && payload.assignee != env.sender { + return Err(MacpError::InvalidPayload); + } + state.terminal_report = Some(TaskTerminalReport::Complete(TaskCompleteRecord { + task_id: payload.task_id, + assignee: env.sender.clone(), + output: payload.output, + summary: payload.summary, + })); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "TaskFail" => { + let payload = TaskFailPayload::decode(&*env.payload) + .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.terminal_report.is_some() + || state.active_assignee.as_deref() != Some(env.sender.as_str()) + { + return Err(MacpError::Forbidden); + } + if !payload.assignee.is_empty() && payload.assignee != env.sender { + return Err(MacpError::InvalidPayload); + } + state.terminal_report = Some(TaskTerminalReport::Fail(TaskFailRecord { + task_id: payload.task_id, + assignee: env.sender.clone(), + error_code: payload.error_code, + reason: payload.reason, + retryable: payload.retryable, + })); + Ok(ModeResponse::PersistState(Self::encode_state(&state))) + } + "Commitment" => { + if env.sender != session.initiator_sender { + return Err(MacpError::Forbidden); + } + let _payload = decode_commitment_payload(&env.payload)?; + if state.terminal_report.is_none() { + return Err(MacpError::InvalidPayload); + } + Ok(ModeResponse::PersistAndResolve { + state: Self::encode_state(&state), + resolution: env.payload.clone(), + }) + } + _ => Err(MacpError::InvalidPayload), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pb::CommitmentPayload; + use crate::session::{Session, SessionState}; + use std::collections::HashSet; + + fn base_session() -> Session { + Session { + session_id: "s1".into(), + state: SessionState::Open, + ttl_expiry: i64::MAX, + started_at_unix_ms: 0, + resolution: None, + mode: "macp.mode.task.v1".into(), + mode_state: vec![], + participants: vec!["planner".into(), "worker".into()], + seen_message_ids: HashSet::new(), + intent: String::new(), + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: "planner".into(), + } + } + + fn env(sender: &str, message_type: &str, payload: Vec) -> Envelope { + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: message_type.into(), + message_id: format!("{}-{}", sender, message_type), + session_id: "s1".into(), + sender: sender.into(), + timestamp_unix_ms: 0, + payload, + } + } + + fn commitment_payload() -> Vec { + CommitmentPayload { + commitment_id: "c1".into(), + action: "task.completed".into(), + authority_scope: "ops".into(), + reason: "done".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + } + .encode_to_vec() + } + + fn apply(session: &mut Session, result: ModeResponse) { + match result { + ModeResponse::PersistState(data) => session.mode_state = data, + ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state, + _ => {} + } + } + + fn make_task_request(task_id: &str, assignee: &str) -> Vec { + TaskRequestPayload { + task_id: task_id.into(), + title: "Test Task".into(), + instructions: "Do the thing".into(), + requested_assignee: assignee.into(), + input: vec![], + deadline_unix_ms: 0, + } + .encode_to_vec() + } + + fn make_task_accept(task_id: &str, assignee: &str) -> Vec { + TaskAcceptPayload { + task_id: task_id.into(), + assignee: assignee.into(), + reason: "ready".into(), + } + .encode_to_vec() + } + + fn make_task_reject(task_id: &str, assignee: &str) -> Vec { + TaskRejectPayload { + task_id: task_id.into(), + assignee: assignee.into(), + reason: "busy".into(), + } + .encode_to_vec() + } + + fn make_task_update(task_id: &str) -> Vec { + TaskUpdatePayload { + task_id: task_id.into(), + status: "in_progress".into(), + progress: 0.5, + message: "halfway done".into(), + partial_output: vec![], + } + .encode_to_vec() + } + + fn make_task_complete(task_id: &str, assignee: &str) -> Vec { + TaskCompletePayload { + task_id: task_id.into(), + assignee: assignee.into(), + output: b"result".to_vec(), + summary: "done".into(), + } + .encode_to_vec() + } + + fn make_task_fail(task_id: &str, assignee: &str) -> Vec { + TaskFailPayload { + task_id: task_id.into(), + assignee: assignee.into(), + error_code: "E001".into(), + reason: "failed".into(), + retryable: true, + } + .encode_to_vec() + } + + // --- Session Start --- + + #[test] + fn session_start_initializes_state() { + let mode = TaskMode; + let session = base_session(); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert!(state.task.is_none()); + assert!(state.active_assignee.is_none()); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn session_start_requires_participants() { + let mode = TaskMode; + let mut session = base_session(); + session.participants.clear(); + let err = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + // --- TaskRequest --- + + #[test] + fn task_request_from_initiator() { + let mode = TaskMode; + let mut session = base_session(); + 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(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert!(state.task.is_some()); + assert_eq!(state.task.unwrap().task_id, "t1"); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn duplicate_task_request_rejected() { + let mode = TaskMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t2", "worker")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn non_initiator_task_request_rejected() { + let mode = TaskMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("worker", "TaskRequest", make_task_request("t1", "worker")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- TaskAccept / TaskReject --- + + #[test] + fn correct_assignee_can_accept() { + let mode = TaskMode; + let mut session = base_session(); + 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(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.active_assignee, Some("worker".into())); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn wrong_assignee_cannot_accept() { + let mode = TaskMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("planner", "TaskAccept", make_task_accept("t1", "planner")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + #[test] + fn assignee_can_reject() { + let mode = TaskMode; + let mut session = base_session(); + 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", "TaskReject", make_task_reject("t1", "worker")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.rejections.len(), 1); + assert!(state.active_assignee.is_none()); // still unassigned + } + _ => panic!("Expected PersistState"), + } + } + + // --- TaskUpdate --- + + #[test] + fn active_assignee_can_update() { + let mode = TaskMode; + let mut session = base_session(); + 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", "TaskUpdate", make_task_update("t1")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.updates.len(), 1); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn non_assignee_cannot_update() { + let mode = TaskMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("planner", "TaskUpdate", make_task_update("t1")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- TaskComplete / TaskFail --- + + #[test] + fn assignee_can_complete() { + let mode = TaskMode; + let mut session = base_session(); + 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(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert!(matches!( + state.terminal_report, + Some(TaskTerminalReport::Complete(_)) + )); + } + _ => panic!("Expected PersistState"), + } + } + + #[test] + fn assignee_can_fail() { + let mode = TaskMode; + let mut session = base_session(); + 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", "TaskFail", make_task_fail("t1", "worker")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert!(matches!( + state.terminal_report, + Some(TaskTerminalReport::Fail(_)) + )); + } + _ => panic!("Expected PersistState"), + } + } + + // --- Commitment --- + + #[test] + fn commitment_after_complete() { + let mode = TaskMode; + let mut session = base_session(); + 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); + let result = mode + .on_message( + &session, + &env("planner", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_after_fail() { + let mode = TaskMode; + let mut session = base_session(); + 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", "TaskFail", make_task_fail("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + #[test] + fn commitment_before_terminal_report_rejected() { + let mode = TaskMode; + let mut session = base_session(); + 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 err = mode + .on_message( + &session, + &env("planner", "Commitment", commitment_payload()), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn non_initiator_commitment_rejected() { + let mode = TaskMode; + let mut session = base_session(); + 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); + let err = mode + .on_message(&session, &env("worker", "Commitment", commitment_payload())) + .unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + // --- Full lifecycle --- + + #[test] + fn full_task_lifecycle() { + let mode = TaskMode; + let mut session = base_session(); + 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", "TaskUpdate", make_task_update("t1")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("worker", "TaskComplete", make_task_complete("t1", "worker")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "Commitment", commitment_payload()), + ) + .unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + + // --- Unknown message type --- + + #[test] + fn unknown_message_type_rejected() { + let mode = TaskMode; + let mut session = base_session(); + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message(&session, &env("worker", "CustomType", vec![])) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } +} diff --git a/src/mode/util.rs b/src/mode/util.rs new file mode 100644 index 0000000..081b71a --- /dev/null +++ b/src/mode/util.rs @@ -0,0 +1,11 @@ +use crate::error::MacpError; +use crate::pb::CommitmentPayload; +use prost::Message; + +pub fn decode_commitment_payload(payload: &[u8]) -> Result { + CommitmentPayload::decode(payload).map_err(|_| MacpError::InvalidPayload) +} + +pub fn is_declared_participant(participants: &[String], sender: &str) -> bool { + participants.iter().any(|participant| participant == sender) +} diff --git a/src/runtime.rs b/src/runtime.rs index ea9a3a4..3ef471a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -5,8 +5,12 @@ use std::sync::Arc; use crate::error::MacpError; use crate::log_store::{EntryKind, LogEntry, LogStore}; use crate::mode::decision::DecisionMode; +use crate::mode::handoff::HandoffMode; use crate::mode::multi_round::MultiRoundMode; -use crate::mode::{Mode, ModeResponse}; +use crate::mode::proposal::ProposalMode; +use crate::mode::quorum::QuorumMode; +use crate::mode::task::TaskMode; +use crate::mode::{standard_mode_names, Mode, ModeResponse}; use crate::pb::Envelope; use crate::registry::SessionRegistry; use crate::session::{extract_ttl_ms, parse_session_start_payload, Session, SessionState}; @@ -27,10 +31,18 @@ pub struct Runtime { impl Runtime { pub fn new(registry: Arc, log_store: Arc) -> Self { let mut modes: HashMap> = HashMap::new(); - // RFC-compliant names + + // Standards-track canonical mode names modes.insert("macp.mode.decision.v1".into(), Box::new(DecisionMode)); + modes.insert("macp.mode.proposal.v1".into(), Box::new(ProposalMode)); + modes.insert("macp.mode.task.v1".into(), Box::new(TaskMode)); + modes.insert("macp.mode.handoff.v1".into(), Box::new(HandoffMode)); + modes.insert("macp.mode.quorum.v1".into(), Box::new(QuorumMode)); + + // Experimental mode (not advertised via ListModes but functional) modes.insert("macp.mode.multi_round.v1".into(), Box::new(MultiRoundMode)); - // Short aliases for backward compatibility + + // Short aliases for backward compatibility (legacy only for decision/multi_round) modes.insert("decision".into(), Box::new(DecisionMode)); modes.insert("multi_round".into(), Box::new(MultiRoundMode)); @@ -41,12 +53,12 @@ impl Runtime { } } - /// Returns the list of RFC-registered mode names (excludes experimental modes). + /// Returns the standards-track mode names in canonical registry order. pub fn registered_mode_names(&self) -> Vec { - self.modes - .keys() - .filter(|k| k.starts_with("macp.mode.") && !k.contains("multi_round")) - .cloned() + standard_mode_names() + .iter() + .filter(|mode_name| self.modes.contains_key(**mode_name)) + .map(|mode_name| (*mode_name).to_string()) .collect() } @@ -312,7 +324,12 @@ impl Runtime { mod tests { use super::*; use crate::decision_pb::ProposalPayload; + use crate::handoff_pb as hpb; + use crate::pb::CommitmentPayload; use crate::pb::SessionStartPayload; + use crate::proposal_pb as ppb; + use crate::quorum_pb as qpb; + use crate::task_pb as tpb; use prost::Message; fn make_runtime() -> Runtime { @@ -1475,4 +1492,391 @@ mod tests { ); rt.process(&e).await.unwrap(); } + + #[tokio::test] + async fn proposal_mode_full_flow() { + let rt = make_runtime(); + + // SessionStart + let start_payload = encode_session_start(0, vec!["buyer".into(), "seller".into()]); + let e = env( + "macp.mode.proposal.v1", + "SessionStart", + "m0", + "s_prop", + "buyer", + &start_payload, + ); + rt.process(&e).await.unwrap(); + + // Proposal from seller + let proposal = ppb::ProposalPayload { + proposal_id: "p1".into(), + title: "Offer".into(), + summary: "$1200".into(), + details: vec![], + tags: vec![], + }; + let e = env( + "macp.mode.proposal.v1", + "Proposal", + "m1", + "s_prop", + "seller", + &proposal.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Accept from buyer + let accept = ppb::AcceptPayload { + proposal_id: "p1".into(), + reason: "agreed".into(), + }; + let e = env( + "macp.mode.proposal.v1", + "Accept", + "m2", + "s_prop", + "buyer", + &accept.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Commitment from buyer (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "proposal.accepted".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let e = env( + "macp.mode.proposal.v1", + "Commitment", + "m3", + "s_prop", + "buyer", + &commitment.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Assert Resolved state + { + let guard = rt.registry.sessions.read().await; + assert_eq!(guard["s_prop"].state, SessionState::Resolved); + } + + // Post-resolution message should fail + let e = env( + "macp.mode.proposal.v1", + "Proposal", + "m4", + "s_prop", + "seller", + b"nope", + ); + let err = rt.process(&e).await.unwrap_err(); + assert_eq!(err.to_string(), "SessionNotOpen"); + } + + #[tokio::test] + async fn task_mode_full_flow() { + let rt = make_runtime(); + + // SessionStart + let start_payload = encode_session_start(0, vec!["planner".into(), "worker".into()]); + let e = env( + "macp.mode.task.v1", + "SessionStart", + "m0", + "s_task", + "planner", + &start_payload, + ); + rt.process(&e).await.unwrap(); + + // TaskRequest from planner + let task_req = tpb::TaskRequestPayload { + task_id: "t1".into(), + title: "Build widget".into(), + instructions: "Do it".into(), + requested_assignee: "worker".into(), + input: vec![], + deadline_unix_ms: 0, + }; + let e = env( + "macp.mode.task.v1", + "TaskRequest", + "m1", + "s_task", + "planner", + &task_req.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // TaskAccept from worker + let task_accept = tpb::TaskAcceptPayload { + task_id: "t1".into(), + assignee: "worker".into(), + reason: "ready".into(), + }; + let e = env( + "macp.mode.task.v1", + "TaskAccept", + "m2", + "s_task", + "worker", + &task_accept.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // TaskComplete from worker + let task_complete = tpb::TaskCompletePayload { + task_id: "t1".into(), + assignee: "worker".into(), + output: b"result".to_vec(), + summary: "done".into(), + }; + let e = env( + "macp.mode.task.v1", + "TaskComplete", + "m3", + "s_task", + "worker", + &task_complete.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Commitment from planner (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "task.completed".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let e = env( + "macp.mode.task.v1", + "Commitment", + "m4", + "s_task", + "planner", + &commitment.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Assert Resolved state + { + let guard = rt.registry.sessions.read().await; + assert_eq!(guard["s_task"].state, SessionState::Resolved); + } + + // Post-resolution message should fail + let e = env( + "macp.mode.task.v1", + "TaskRequest", + "m5", + "s_task", + "planner", + b"nope", + ); + let err = rt.process(&e).await.unwrap_err(); + assert_eq!(err.to_string(), "SessionNotOpen"); + } + + #[tokio::test] + async fn handoff_mode_full_flow() { + let rt = make_runtime(); + + // SessionStart + let start_payload = encode_session_start(0, vec!["owner".into(), "target".into()]); + let e = env( + "macp.mode.handoff.v1", + "SessionStart", + "m0", + "s_hand", + "owner", + &start_payload, + ); + rt.process(&e).await.unwrap(); + + // HandoffOffer from owner + let offer = hpb::HandoffOfferPayload { + handoff_id: "h1".into(), + target_participant: "target".into(), + scope: "support".into(), + reason: "escalate".into(), + }; + let e = env( + "macp.mode.handoff.v1", + "HandoffOffer", + "m1", + "s_hand", + "owner", + &offer.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // HandoffAccept from target + let accept = hpb::HandoffAcceptPayload { + handoff_id: "h1".into(), + accepted_by: "target".into(), + reason: "ready".into(), + }; + let e = env( + "macp.mode.handoff.v1", + "HandoffAccept", + "m2", + "s_hand", + "target", + &accept.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Commitment from owner (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "handoff.accepted".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let e = env( + "macp.mode.handoff.v1", + "Commitment", + "m3", + "s_hand", + "owner", + &commitment.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Assert Resolved state + { + let guard = rt.registry.sessions.read().await; + assert_eq!(guard["s_hand"].state, SessionState::Resolved); + } + + // Post-resolution message should fail + let e = env( + "macp.mode.handoff.v1", + "HandoffOffer", + "m4", + "s_hand", + "owner", + b"nope", + ); + let err = rt.process(&e).await.unwrap_err(); + assert_eq!(err.to_string(), "SessionNotOpen"); + } + + #[tokio::test] + async fn quorum_mode_full_flow() { + let rt = make_runtime(); + + // SessionStart + let start_payload = + encode_session_start(0, vec!["alice".into(), "bob".into(), "carol".into()]); + let e = env( + "macp.mode.quorum.v1", + "SessionStart", + "m0", + "s_quorum", + "coordinator", + &start_payload, + ); + rt.process(&e).await.unwrap(); + + // ApprovalRequest from coordinator + let approval_req = qpb::ApprovalRequestPayload { + request_id: "r1".into(), + action: "deploy".into(), + summary: "Deploy v2".into(), + details: vec![], + required_approvals: 2, + }; + let e = env( + "macp.mode.quorum.v1", + "ApprovalRequest", + "m1", + "s_quorum", + "coordinator", + &approval_req.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Approve from alice + let approve_alice = qpb::ApprovePayload { + request_id: "r1".into(), + reason: "looks good".into(), + }; + let e = env( + "macp.mode.quorum.v1", + "Approve", + "m2", + "s_quorum", + "alice", + &approve_alice.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Approve from bob + let approve_bob = qpb::ApprovePayload { + request_id: "r1".into(), + reason: "ready".into(), + }; + let e = env( + "macp.mode.quorum.v1", + "Approve", + "m3", + "s_quorum", + "bob", + &approve_bob.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Commitment from coordinator (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "quorum.approved".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let e = env( + "macp.mode.quorum.v1", + "Commitment", + "m4", + "s_quorum", + "coordinator", + &commitment.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + // Assert Resolved state + { + let guard = rt.registry.sessions.read().await; + assert_eq!(guard["s_quorum"].state, SessionState::Resolved); + } + + // Post-resolution message should fail + let e = env( + "macp.mode.quorum.v1", + "ApprovalRequest", + "m5", + "s_quorum", + "coordinator", + b"nope", + ); + let err = rt.process(&e).await.unwrap_err(); + assert_eq!(err.to_string(), "SessionNotOpen"); + } } diff --git a/src/server.rs b/src/server.rs index d368897..56a95bd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,14 +1,15 @@ use macp_runtime::error::MacpError; +use macp_runtime::mode::standard_mode_descriptors; 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, ListModesRequest, ListModesResponse, ListRootsRequest, - ListRootsResponse, MacpError as PbMacpError, ManifestCapability, ModeDescriptor, - ModeRegistryCapability, ProgressCapability, RootsCapability, RuntimeInfo, SendRequest, - SendResponse, SessionMetadata, SessionState as PbSessionState, SessionsCapability, - StreamSessionRequest, StreamSessionResponse, WatchModeRegistryRequest, - WatchModeRegistryResponse, WatchRootsRequest, WatchRootsResponse, + ListRootsResponse, MacpError as PbMacpError, ManifestCapability, ModeRegistryCapability, + ProgressCapability, RootsCapability, RuntimeInfo, SendRequest, SendResponse, SessionMetadata, + SessionState as PbSessionState, SessionsCapability, StreamSessionRequest, + StreamSessionResponse, WatchModeRegistryRequest, WatchModeRegistryResponse, WatchRootsRequest, + WatchRootsResponse, }; use macp_runtime::runtime::Runtime; use macp_runtime::session::SessionState; @@ -257,29 +258,9 @@ impl MacpRuntimeService for MacpServer { &self, _request: Request, ) -> Result, Status> { - let modes = vec![ModeDescriptor { - mode: "macp.mode.decision.v1".into(), - mode_version: "1.0.0".into(), - title: "Decision Mode".into(), - description: "Proposal-based decision making with voting".into(), - determinism_class: "semantic-deterministic".into(), - participant_model: "declared".into(), - message_types: vec![ - "SessionStart".into(), - "Proposal".into(), - "Evaluation".into(), - "Objection".into(), - "Vote".into(), - "Commitment".into(), - ], - terminal_message_types: vec!["Commitment".into()], - schema_uris: HashMap::from([( - "protobuf".into(), - "buf.build/multiagentcoordinationprotocol/macp".into(), - )]), - }]; - - Ok(Response::new(ListModesResponse { modes })) + Ok(Response::new(ListModesResponse { + modes: standard_mode_descriptors(), + })) } async fn list_roots( @@ -392,10 +373,15 @@ impl MacpRuntimeService for MacpServer { mod tests { use super::*; use chrono::Utc; + use macp_runtime::handoff_pb; use macp_runtime::log_store::LogStore; + use macp_runtime::pb::CommitmentPayload; use macp_runtime::pb::SessionStartPayload; + use macp_runtime::proposal_pb; + use macp_runtime::quorum_pb; use macp_runtime::registry::SessionRegistry; use macp_runtime::session::Session; + use macp_runtime::task_pb; use prost::Message; use std::collections::HashSet; @@ -1293,8 +1279,17 @@ mod tests { .await .unwrap(); let modes = resp.into_inner().modes; - assert_eq!(modes.len(), 1); - assert_eq!(modes[0].mode, "macp.mode.decision.v1"); + let mode_names: Vec = modes.iter().map(|m| m.mode.clone()).collect(); + assert_eq!( + mode_names, + vec![ + "macp.mode.decision.v1", + "macp.mode.proposal.v1", + "macp.mode.task.v1", + "macp.mode.handoff.v1", + "macp.mode.quorum.v1", + ] + ); } // --- GetManifest --- @@ -1512,4 +1507,494 @@ mod tests { assert!(!ack.ok); assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); } + + // --- Full proposal flow through server --- + + #[tokio::test] + async fn full_proposal_flow_through_server() { + let (server, _) = make_server(); + + // SessionStart + let start_payload = SessionStartPayload { + intent: String::new(), + ttl_ms: 60_000, + participants: vec!["buyer".into(), "seller".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "s_prop".into(), + sender: "buyer".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Proposal from seller + let proposal = proposal_pb::ProposalPayload { + proposal_id: "p1".into(), + title: "Offer".into(), + summary: "$1200".into(), + details: vec![], + tags: vec![], + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Proposal".into(), + message_id: "m1".into(), + session_id: "s_prop".into(), + sender: "seller".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: proposal.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Accept from buyer + let accept = proposal_pb::AcceptPayload { + proposal_id: "p1".into(), + reason: "agreed".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Accept".into(), + message_id: "m2".into(), + session_id: "s_prop".into(), + sender: "buyer".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: accept.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Commitment from buyer (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "proposal.accepted".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Commitment".into(), + message_id: "m3".into(), + session_id: "s_prop".into(), + sender: "buyer".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Resolved as i32); + + // Post-resolution message should fail + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.proposal.v1".into(), + message_type: "Proposal".into(), + message_id: "m4".into(), + session_id: "s_prop".into(), + sender: "seller".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: b"too late".to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, "SESSION_NOT_OPEN"); + } + + // --- Full task flow through server --- + + #[tokio::test] + async fn full_task_flow_through_server() { + let (server, _) = make_server(); + + // SessionStart + let start_payload = SessionStartPayload { + intent: String::new(), + ttl_ms: 60_000, + participants: vec!["planner".into(), "worker".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "s_task".into(), + sender: "planner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // TaskRequest from planner + let task_req = task_pb::TaskRequestPayload { + task_id: "t1".into(), + title: "Build widget".into(), + instructions: "Do the thing".into(), + requested_assignee: "worker".into(), + input: vec![], + deadline_unix_ms: 0, + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskRequest".into(), + message_id: "m1".into(), + session_id: "s_task".into(), + sender: "planner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: task_req.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // TaskAccept from worker + let task_accept = task_pb::TaskAcceptPayload { + task_id: "t1".into(), + assignee: "worker".into(), + reason: "ready".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskAccept".into(), + message_id: "m2".into(), + session_id: "s_task".into(), + sender: "worker".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: task_accept.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // TaskComplete from worker + let task_complete = task_pb::TaskCompletePayload { + task_id: "t1".into(), + assignee: "worker".into(), + output: b"result".to_vec(), + summary: "done".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskComplete".into(), + message_id: "m3".into(), + session_id: "s_task".into(), + sender: "worker".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: task_complete.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Commitment from planner (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "task.completed".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "Commitment".into(), + message_id: "m4".into(), + session_id: "s_task".into(), + sender: "planner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Resolved as i32); + + // Post-resolution message should fail + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.task.v1".into(), + message_type: "TaskRequest".into(), + message_id: "m5".into(), + session_id: "s_task".into(), + sender: "planner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: b"too late".to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, "SESSION_NOT_OPEN"); + } + + // --- Full handoff flow through server --- + + #[tokio::test] + async fn full_handoff_flow_through_server() { + let (server, _) = make_server(); + + // SessionStart + let start_payload = SessionStartPayload { + intent: String::new(), + ttl_ms: 60_000, + participants: vec!["owner".into(), "target".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "s_hand".into(), + sender: "owner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // HandoffOffer from owner + let offer = handoff_pb::HandoffOfferPayload { + handoff_id: "h1".into(), + target_participant: "target".into(), + scope: "support".into(), + reason: "escalate".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "HandoffOffer".into(), + message_id: "m1".into(), + session_id: "s_hand".into(), + sender: "owner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: offer.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // HandoffAccept from target + let accept = handoff_pb::HandoffAcceptPayload { + handoff_id: "h1".into(), + accepted_by: "target".into(), + reason: "ready".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "HandoffAccept".into(), + message_id: "m2".into(), + session_id: "s_hand".into(), + sender: "target".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: accept.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Commitment from owner (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "handoff.accepted".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "Commitment".into(), + message_id: "m3".into(), + session_id: "s_hand".into(), + sender: "owner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Resolved as i32); + + // Post-resolution message should fail + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.handoff.v1".into(), + message_type: "HandoffOffer".into(), + message_id: "m4".into(), + session_id: "s_hand".into(), + sender: "owner".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: b"too late".to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, "SESSION_NOT_OPEN"); + } + + // --- Full quorum flow through server --- + + #[tokio::test] + async fn full_quorum_flow_through_server() { + let (server, _) = make_server(); + + // SessionStart + let start_payload = SessionStartPayload { + intent: String::new(), + ttl_ms: 60_000, + participants: vec!["alice".into(), "bob".into(), "carol".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "s_quorum".into(), + sender: "coordinator".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // ApprovalRequest from coordinator + let approval_req = quorum_pb::ApprovalRequestPayload { + request_id: "r1".into(), + action: "deploy".into(), + summary: "Deploy v2".into(), + details: vec![], + required_approvals: 2, + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "ApprovalRequest".into(), + message_id: "m1".into(), + session_id: "s_quorum".into(), + sender: "coordinator".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: approval_req.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Approve from alice + let approve_alice = quorum_pb::ApprovePayload { + request_id: "r1".into(), + reason: "looks good".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Approve".into(), + message_id: "m2".into(), + session_id: "s_quorum".into(), + sender: "alice".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: approve_alice.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Approve from bob + let approve_bob = quorum_pb::ApprovePayload { + request_id: "r1".into(), + reason: "ready".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Approve".into(), + message_id: "m3".into(), + session_id: "s_quorum".into(), + sender: "bob".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: approve_bob.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Open as i32); + + // Commitment from coordinator (initiator) + let commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "quorum.approved".into(), + authority_scope: "commercial".into(), + reason: "bound".into(), + mode_version: "1.0.0".into(), + policy_version: "policy".into(), + configuration_version: "config".into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Commitment".into(), + message_id: "m4".into(), + session_id: "s_quorum".into(), + sender: "coordinator".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: commitment.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + assert_eq!(ack.session_state, PbSessionState::Resolved as i32); + + // Post-resolution message should fail + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.quorum.v1".into(), + message_type: "Approve".into(), + message_id: "m5".into(), + session_id: "s_quorum".into(), + sender: "carol".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: b"too late".to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(!ack.ok); + assert_eq!(ack.error.unwrap().code, "SESSION_NOT_OPEN"); + } }