diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7d706..4324272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,8 @@ jobs: - name: Run tests run: cargo test --all-targets + env: + MACP_MEMORY_ONLY: "1" build: name: Build diff --git a/.gitignore b/.gitignore index 74ef82e..7fe477a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,11 +16,12 @@ Cargo.lock # Claude Code .claude/ -CLAUDE.md /plans/ /tmp/ /temp/ +CLAUDE.md # OS .DS_Store Thumbs.db +.macp-data/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 08c2d22..f7218da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,13 @@ [package] name = "macp-runtime" -version = "0.3.0" +version = "0.4.0" edition = "2021" [dependencies] tokio = { version = "1", features = ["full"] } -tonic = "0.11" +tonic = { version = "0.11", features = ["transport", "tls"] } prost = "0.12" prost-types = "0.12" -uuid = { version = "1", features = ["v4"] } thiserror = "1" chrono = "0.4" serde = { version = "1", features = ["derive"] } @@ -17,5 +16,8 @@ tokio-stream = "0.1" futures-core = "0.3" async-stream = "0.3" +[dev-dependencies] +tempfile = "3" + [build-dependencies] tonic-build = "0.11" diff --git a/README.md b/README.md index 456d121..a26987c 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,209 @@ -# macp-runtime v0.3 +# macp-runtime v0.4.0 -**Minimal Coordination Runtime (MCR)** — an RFC-0001-compliant gRPC server implementing the Multi-Agent Coordination Protocol (MACP). +Reference runtime for the Multi-Agent Coordination Protocol (MACP). -The MACP Runtime provides session-based message coordination between autonomous agents. It manages session lifecycles, enforces protocol invariants, routes messages through a pluggable Mode system, and ensures deterministic state transitions — so that agents can focus on coordination logic rather than infrastructure plumbing. +This runtime implements the current MACP core/service surface, the five standards-track modes in the main RFC repository, and one experimental `multi_round` mode that remains available only by explicit canonical name. The focus of this release is freeze-readiness for SDKs and real-world unary integrations: strict `SessionStart`, mode-semantic correctness, authenticated senders, bounded resources, and durable restart recovery. -## Features +## What changed in v0.4.0 -- **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** — 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 -- **Mode-Aware Authorization** — Sender authorization delegated to modes; Decision Mode allows orchestrator Commitment bypass per RFC -- **Participant Validation** — Sender membership enforcement when a participant list is configured -- **Signal Messages** — Ambient, session-less messages for out-of-band coordination signals -- **Mode & Manifest Discovery** — `ListModes` and `GetManifest` RPCs for runtime introspection -- **Structured Errors** — `MACPError` with RFC error codes, session/message correlation, and detail payloads -- **Append-Only Audit Log** — Log-before-mutate ordering for every session event -- **CI/CD Pipeline** — GitHub Actions workflow with formatting, linting, and test gates +- **Strict canonical `SessionStart` for standard modes** + - no empty payloads + - no implicit default mode + - explicit `mode_version`, `configuration_version`, and positive `ttl_ms` + - explicit unique participants for standards-track modes +- **Decision Mode authority clarified** + - initiator/coordinator may emit `Proposal` and `Commitment` + - participants emit `Evaluation`, `Objection`, and `Vote` + - duplicate `proposal_id` values are rejected + - votes are tracked per proposal, per sender +- **Proposal Mode commitment gating fixed** + - `Commitment` is accepted only after acceptance convergence or a terminal rejection +- **Security boundary added** + - TLS-capable startup + - authenticated sender derivation via bearer token or dev header mode + - per-request authorization + - payload size limits + - rate limiting +- **Durable local persistence** + - session registry snapshots + - accepted-history log snapshots + - dedup state survives restart +- **Unary freeze profile** + - `StreamSession` is intentionally disabled in this profile + - `WatchModeRegistry` and `WatchRoots` remain unimplemented -## Prerequisites +## Implemented modes -- [Rust](https://www.rust-lang.org/tools/install) (stable toolchain) -- [Protocol Buffers compiler (`protoc`)](https://grpc.io/docs/protoc-installation/) +Standards-track modes: -## Quick Start +- `macp.mode.decision.v1` +- `macp.mode.proposal.v1` +- `macp.mode.task.v1` +- `macp.mode.handoff.v1` +- `macp.mode.quorum.v1` -```bash -# Build the project -cargo build +Experimental mode: -# Run the server (listens on 127.0.0.1:50051) -cargo run +- `macp.mode.multi_round.v1` + +## Runtime behavior that SDKs should assume + +### Session bootstrap + +For the five standards-track modes, `SessionStartPayload` must include: + +- `participants` +- `mode_version` +- `configuration_version` +- `ttl_ms` + +`policy_version` is optional unless your policy requires it. Empty `mode` is rejected. Empty `SessionStartPayload` is rejected. -# Run test clients (server must be running in another terminal) -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 +### Security + +In production, requests should be authenticated with a bearer token. The runtime derives `Envelope.sender` from the authenticated identity and rejects spoofed sender values. + +For local development, you may opt into insecure/dev mode with: + +```bash +MACP_ALLOW_INSECURE=1 +MACP_ALLOW_DEV_SENDER_HEADER=1 ``` -## Build & Development Commands +When dev header mode is enabled, clients can set `x-macp-agent-id` metadata instead of bearer tokens. + +### Persistence + +Unless `MACP_MEMORY_ONLY=1` is set, the runtime persists session and log snapshots under `MACP_DATA_DIR` (default: `.macp-data`). If a persistence file contains corrupt or incompatible JSON on startup, the runtime logs a warning to stderr and starts with empty state rather than failing. + +## Configuration + +### Core server configuration + +| Variable | Meaning | Default | +|---|---|---| +| `MACP_BIND_ADDR` | bind address | `127.0.0.1:50051` | +| `MACP_DATA_DIR` | persistence directory | `.macp-data` | +| `MACP_MEMORY_ONLY` | disable persistence when set to `1` | unset | +| `MACP_ALLOW_INSECURE` | allow plaintext transport when set to `1` | unset | +| `MACP_TLS_CERT_PATH` | PEM certificate for TLS | unset | +| `MACP_TLS_KEY_PATH` | PEM private key for TLS | unset | + +### Authentication and authorization + +| Variable | Meaning | Default | +|---|---|---| +| `MACP_AUTH_TOKENS_JSON` | inline auth config JSON | unset | +| `MACP_AUTH_TOKENS_FILE` | path to auth config JSON | unset | +| `MACP_ALLOW_DEV_SENDER_HEADER` | allow `x-macp-agent-id` for local dev | unset | + +Token JSON may be either a raw list or an object with a `tokens` array. Example: + +```json +{ + "tokens": [ + { + "token": "demo-coordinator-token", + "sender": "coordinator", + "allowed_modes": [ + "macp.mode.decision.v1", + "macp.mode.quorum.v1" + ], + "can_start_sessions": true, + "max_open_sessions": 25 + }, + { + "token": "demo-worker-token", + "sender": "worker", + "allowed_modes": [ + "macp.mode.task.v1" + ], + "can_start_sessions": false + } + ] +} +``` + +### Resource limits + +| Variable | Meaning | Default | +|---|---|---| +| `MACP_MAX_PAYLOAD_BYTES` | max envelope payload size | `1048576` | +| `MACP_SESSION_START_LIMIT_PER_MINUTE` | per-sender session start limit | `60` | +| `MACP_MESSAGE_LIMIT_PER_MINUTE` | per-sender message limit | `600` | + +## Quick start + +### Production-style startup with TLS ```bash -cargo build # compile the project -cargo run # start the runtime server -cargo test # run the test suite -cargo check # type-check without building -cargo fmt # format all code -cargo clippy # run the linter - -# Or use the Makefile: -make setup # configure git hooks -make build # cargo build -make test # cargo test -make fmt # cargo fmt -make clippy # cargo clippy with -D warnings -make check # fmt + clippy + test +export MACP_TLS_CERT_PATH=/path/to/server.crt +export MACP_TLS_KEY_PATH=/path/to/server.key +export MACP_AUTH_TOKENS_FILE=/path/to/tokens.json +cargo run ``` -## Project Structure +### Local development startup +```bash +export MACP_ALLOW_INSECURE=1 +export MACP_ALLOW_DEV_SENDER_HEADER=1 +cargo run ``` -runtime/ -├── proto/ -│ ├── buf.yaml # Buf linter configuration -│ └── macp/ -│ ├── v1/ -│ │ ├── envelope.proto # Envelope, Ack, MACPError, SessionState -│ │ └── core.proto # Full service definition + all message types -│ └── modes/ -│ ├── 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/ -│ └── quorum.proto # Quorum mode payload types -├── src/ -│ ├── main.rs # Entry point — wires Runtime + gRPC server -│ ├── lib.rs # Library root — proto modules + re-exports -│ ├── server.rs # gRPC adapter (MacpRuntimeService impl) -│ ├── error.rs # MacpError enum + RFC error codes -│ ├── session.rs # Session struct, SessionState, TTL parsing -│ ├── registry.rs # SessionRegistry (thread-safe session store) -│ ├── log_store.rs # Append-only LogStore for audit trails -│ ├── 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 -│ ├── 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 -└── .github/ - └── workflows/ - └── ci.yml # CI/CD pipeline + +### Running the example clients + +The example clients in `src/bin` assume the local development startup shown above. + +```bash +cargo run --bin client +cargo run --bin proposal_client +cargo run --bin task_client +cargo run --bin handoff_client +cargo run --bin quorum_client +cargo run --bin multi_round_client +cargo run --bin fuzz_client ``` -## gRPC Service +## Freeze-profile capability summary -The runtime exposes `MACPRuntimeService` on `127.0.0.1:50051` with the following RPCs: +| RPC | Status | +|---|---| +| `Initialize` | implemented | +| `Send` | implemented | +| `GetSession` | implemented | +| `CancelSession` | implemented | +| `GetManifest` | implemented | +| `ListModes` | implemented | +| `ListRoots` | implemented | +| `StreamSession` | intentionally disabled in freeze profile | +| `WatchModeRegistry` | unimplemented | +| `WatchRoots` | unimplemented | -| RPC | Description | -|-----|-------------| -| `Initialize` | Protocol version negotiation and capability exchange | -| `Send` | Send an Envelope, receive an Ack | -| `StreamSession` | Bidirectional streaming for session events (not yet fully implemented) | -| `GetSession` | Query session metadata by ID | -| `CancelSession` | Cancel an active session with a reason | -| `GetManifest` | Retrieve agent manifest and supported modes | -| `ListModes` | Discover registered mode descriptors | -| `ListRoots` | List resource roots | -| `WatchModeRegistry` | Stream mode registry change notifications | -| `WatchRoots` | Stream root change notifications | +## Project structure -## Documentation +```text +runtime/ +├── proto/ # protobuf schemas copied from the RFC/spec repository +├── src/ +│ ├── main.rs # server startup, TLS, persistence, auth wiring +│ ├── server.rs # gRPC adapter and request authentication +│ ├── runtime.rs # coordination kernel and mode dispatch +│ ├── security.rs # auth config, sender derivation, rate limiting +│ ├── session.rs # canonical SessionStart validation and session model +│ ├── registry.rs # session store with optional persistence +│ ├── log_store.rs # accepted-history log store with optional persistence +│ ├── mode/ # mode implementations +│ └── bin/ # local development example clients +├── docs/ +└── build.rs +``` -- **[docs/README.md](./docs/README.md)** — Getting started guide and key concepts -- **[docs/protocol.md](./docs/protocol.md)** — Full MACP v1.0 protocol specification -- **[docs/architecture.md](./docs/architecture.md)** — Internal architecture and design principles -- **[docs/examples.md](./docs/examples.md)** — Step-by-step usage examples and common patterns +## Development notes -## License +- The RFC/spec repository remains the normative source for protocol semantics. +- This runtime only accepts the canonical standards-track mode identifiers for the five main modes. +- `multi_round` remains experimental and is not advertised by discovery RPCs. +- `StreamSession` is intentionally not part of the freeze surface for the first SDKs. -See the repository root for license information. +See `docs/README.md` and `docs/examples.md` for the updated local development and usage guidance. diff --git a/docs/README.md b/docs/README.md index 41e83a1..382f103 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,215 +1,86 @@ -# MACP Runtime Documentation +# MACP Runtime documentation -Welcome to the Multi-Agent Coordination Protocol (MACP) Runtime documentation. This guide explains everything about the system in plain language, whether you are an experienced distributed-systems engineer or someone encountering multi-agent coordination for the first time. +This directory documents the runtime implementation profile for `macp-runtime v0.4.0`. ---- +The RFC/spec repository is still the normative source for MACP semantics. These runtime docs focus on how this implementation behaves today: startup configuration, security model, persistence profile, mode surface, and local-development examples. -## What Is This Project? +## What is in this runtime profile -The MACP Runtime — also called the **Minimal Coordination Runtime (MCR)** — is a **gRPC server** that helps multiple AI agents or programs coordinate with each other. Think of it as a traffic controller for structured conversations between autonomous agents: it manages who can speak, tracks the state of each conversation, enforces time limits, and determines when a conversation has reached its conclusion. +- unary-first MACP server over gRPC +- five standards-track modes from the main RFC repository +- one experimental `macp.mode.multi_round.v1` mode kept off discovery surfaces +- strict canonical `SessionStart` for standards-track modes +- authenticated sender derivation +- payload limits and rate limiting +- optional file-backed persistence for sessions and accepted-history logs -Version **0.3** of the runtime implements **RFC-0001**, introducing a formal protocol handshake, structured error reporting, a rich Decision Mode lifecycle, session cancellation, message deduplication, participant validation, mode-aware authorization, and a host of new RPCs for runtime introspection. +## Standards-track modes -### Real-World Analogy +- `macp.mode.decision.v1` +- `macp.mode.proposal.v1` +- `macp.mode.task.v1` +- `macp.mode.handoff.v1` +- `macp.mode.quorum.v1` -Imagine you are chairing a formal committee meeting: +## Freeze profile -1. **Someone opens the meeting** — a `SessionStart` message creates a new coordination session. -2. **The chair announces the rules** — the `Initialize` handshake negotiates which protocol version everyone speaks and what capabilities the runtime supports. -3. **Participants discuss and propose** — agents send `Proposal`, `Evaluation`, `Objection`, and `Vote` messages through the Decision Mode, or `Contribute` messages through the Multi-Round Mode. -4. **The committee reaches a decision** — when the mode's convergence criteria are met, the session transitions to **Resolved** and the resolution is recorded. -5. **After the gavel falls, no more motions are accepted** — once a session is resolved or expired, no further messages can be sent to it. -6. **The chair can also adjourn early** — a `CancelSession` call terminates the session before natural resolution. +The current runtime is intended to be the freeze candidate for unary SDKs and reference examples. -The MACP Runtime manages this entire lifecycle automatically and enforces the rules at every step. +Implemented and supported: ---- +- `Initialize` +- `Send` +- `GetSession` +- `CancelSession` +- `GetManifest` +- `ListModes` +- `ListRoots` -## What Problem Does It Solve? +Not part of the freeze surface: -When multiple AI agents or programs need to work together, they need a way to: +- `StreamSession` is intentionally disabled in this profile +- `WatchModeRegistry` is unimplemented +- `WatchRoots` is unimplemented -- **Negotiate a common protocol** — agree on version, capabilities, and supported modes before any real work begins. -- **Start a conversation** — create a session with a declared intent, participant list, and time-to-live. -- **Exchange messages safely** — with deduplication, participant validation, and ordered logging. -- **Track the state** of the conversation — know whether it is open, resolved, or expired at any moment. -- **Reach a decision** — through a structured lifecycle (proposals, evaluations, votes, commitments) or through iterative convergence. -- **Know when it is done** — terminal states are enforced; resolved or expired sessions reject further messages. -- **Cancel gracefully** — terminate sessions explicitly with a recorded reason. -- **Discover capabilities** — query which modes are available, inspect manifests, and watch for registry changes. +## Security model -Without a coordination runtime, each agent would need to implement all of this logic independently, leading to subtle bugs, inconsistent state machines, and fragile integrations. The MACP Runtime centralizes these concerns so that agents can focus on their domain logic. +Production expectations: ---- +- TLS transport +- bearer-token authentication +- runtime-derived `Envelope.sender` +- per-request authorization +- payload size limits +- rate limiting -## Key Concepts +Local development shortcut: -### Protocol Version - -The current protocol version is **`1.0`**. Every `Envelope` must carry `macp_version: "1.0"` or the message will be rejected with `UNSUPPORTED_PROTOCOL_VERSION`. Before sending any session messages, clients should call the `Initialize` RPC to negotiate the protocol version and discover runtime capabilities. - -### Sessions - -A **session** is a bounded coordination context — like a conversation thread with rules. Each session has: - -- A unique **session ID** chosen by the creator. -- A **mode** that defines the coordination logic (e.g., `macp.mode.decision.v1` or `macp.mode.multi_round.v1`). -- A current **state**: `Open`, `Resolved`, or `Expired`. -- A **time-to-live (TTL)** — how long the session remains open before automatic expiry (default 60 seconds, max 24 hours). -- An optional **participant list** — if provided, only listed senders may contribute. -- An optional **resolution** — the final outcome, recorded when the mode resolves the session. -- **Version metadata** — intent, mode_version, configuration_version, and policy_version carried from the `SessionStartPayload`. - -### Messages (Envelopes) - -Every message is wrapped in an **Envelope** — a structured protobuf container that carries: - -- **macp_version** — protocol version (`"1.0"`). -- **mode** — which coordination mode handles this message. -- **message_type** — the semantic type (`SessionStart`, `Message`, `Proposal`, `Vote`, `Contribute`, `Signal`, etc.). -- **message_id** — a unique identifier for deduplication and tracing. -- **session_id** — which session this belongs to (may be empty for `Signal` messages). -- **sender** — who is sending the message. -- **timestamp_unix_ms** — informational timestamp. -- **payload** — the actual content (protobuf-encoded or JSON, depending on the mode and message type). - -### Acknowledgments (Ack) - -Every `Send` call returns an **Ack** — a structured response that tells you: - -- **ok** — `true` if accepted, `false` if rejected. -- **duplicate** — `true` if this was an idempotent replay of a previously accepted message. -- **message_id** and **session_id** — echoed back for correlation. -- **accepted_at_unix_ms** — server-side acceptance timestamp. -- **session_state** — the session's state after processing (OPEN, RESOLVED, EXPIRED). -- **error** — a structured `MACPError` with an RFC error code, human-readable message, and optional details. - -### Session States - -Sessions follow a strict state machine with three states: - -| State | Can receive messages? | Transitions to | -|-------|----------------------|----------------| -| **Open** | Yes | Resolved, Expired | -| **Resolved** | No (terminal) | — | -| **Expired** | No (terminal) | — | - -- **Open** — the session is active and accepting messages. This is the initial state after `SessionStart`. -- **Resolved** — a mode returned a `Resolve` or `PersistAndResolve` response, recording the final outcome. No further messages are accepted. -- **Expired** — the session's TTL elapsed (detected lazily on the next message), or the session was explicitly cancelled via `CancelSession`. No further messages are accepted. - -### Modes - -**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. - -Five standard modes are built in, plus one experimental mode: - -| 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. - -### Signals - -**Signal** messages are ambient, session-less messages. They can be sent with an empty `session_id` and do not create or modify any session. They are useful for out-of-band coordination hints, heartbeats, or cross-session correlation. - ---- - -## How It Works (High Level) - -``` -Client MACP Runtime - | | - |--- Initialize(["1.0"]) ------------>| - |<-- InitializeResponse(v=1.0) -------| (handshake complete) - | | - |--- Send(SessionStart, s1) --------->| - |<-- Ack(ok=true, state=OPEN) --------| (session created) - | | - |--- Send(Proposal, s1) ------------->| - |<-- Ack(ok=true, state=OPEN) --------| (proposal recorded) - | | - |--- Send(Vote, s1) ----------------->| - |<-- Ack(ok=true, state=OPEN) --------| (vote recorded) - | | - |--- Send(Commitment, s1) ----------->| - |<-- Ack(ok=true, state=RESOLVED) ----| (session resolved) - | | - |--- Send(Message, s1) -------------->| - |<-- Ack(ok=false, SESSION_NOT_OPEN) -| (rejected: terminal) - | | - |--- GetSession(s1) ----------------->| - |<-- SessionMetadata(RESOLVED) -------| (query state) +```bash +export MACP_ALLOW_INSECURE=1 +export MACP_ALLOW_DEV_SENDER_HEADER=1 +cargo run ``` ---- - -## What Is Built With - -- **gRPC over HTTP/2** — high-performance, type-safe RPC framework with streaming support. -- **Protocol Buffers (protobuf)** — binary serialization for efficient, schema-enforced message exchange. -- **Rust** — memory-safe, concurrent systems language with zero-cost abstractions. -- **Tonic** — Rust's async gRPC framework built on Tokio. -- **Buf** — protobuf linting and breaking-change detection. - -You do not need to know Rust to understand the protocol or use the runtime — any language with a gRPC client can connect. - ---- - -## Components - -This runtime consists of: +In dev mode, example clients attach `x-macp-agent-id` metadata and may use plaintext transport. -1. **Runtime Server** (`macp-runtime`) — the main coordination server managing sessions, modes, and protocol enforcement. -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. +## Persistence model ---- +By default the runtime persists snapshots under `.macp-data/`: -## Documentation Structure +- `sessions.json` +- `logs.json` -| Document | What It Covers | -|----------|---------------| -| **[protocol.md](./protocol.md)** | Full MACP v1.0 protocol specification — message types, validation rules, error codes, mode specifications | -| **[architecture.md](./architecture.md)** | Internal architecture — component design, data flow, concurrency model, design principles | -| **[examples.md](./examples.md)** | Step-by-step usage examples — client walkthroughs, common patterns, FAQ | +If a snapshot file contains corrupt or incompatible JSON, the runtime logs a warning to stderr and starts with empty state. ---- +Disable persistence with: -## Quick Start - -**Terminal 1** — Start the server: -```bash -cargo run -``` - -You should see: -``` -macp-runtime v0.3.0 (RFC-0001) listening on 127.0.0.1:50051 -``` - -**Terminal 2** — Run a test client: ```bash -cargo run --bin client +export MACP_MEMORY_ONLY=1 ``` -You will see the client negotiate the protocol version, discover modes, create a session, send messages, resolve the session, and verify the final state. - ---- - -## Next Steps +## Document map -1. Read **[protocol.md](./protocol.md)** to understand the full MACP v1.0 protocol specification. -2. Read **[architecture.md](./architecture.md)** to understand how the runtime is built internally. -3. Read **[examples.md](./examples.md)** for practical, step-by-step usage examples. +- `../README.md` — root-level quick start and configuration reference +- `examples.md` — updated local-development examples and canonical message patterns +- `protocol.md` — implementation notes and protocol surface summary +- `architecture.md` — runtime component layout diff --git a/docs/architecture.md b/docs/architecture.md index 34280de..b16ff3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,1175 +1,97 @@ -# Architecture +# Runtime architecture -This document explains how the MACP Runtime v0.3 is built internally. It walks through every component, every data structure, every flow, and every design decision in narrative detail. You do not need to know Rust to follow along — the documentation explains concepts in plain language, with code excerpts for precision where it matters. +`macp-runtime v0.4.0` is organized as a small set of explicit layers. ---- +## 1. Transport adapter (`src/server.rs`) -## Table of Contents +Responsibilities: -1. [System Overview](#system-overview) -2. [Protobuf Schema Layer](#protobuf-schema-layer) -3. [Build System](#build-system) -4. [Entry Point (main.rs)](#entry-point-mainrs) -5. [Library Root (lib.rs)](#library-root-librs) -6. [Error Types (error.rs)](#error-types-errorrs) -7. [Session Types (session.rs)](#session-types-sessionrs) -8. [Session Registry (registry.rs)](#session-registry-registryrs) -9. [Log Store (log_store.rs)](#log-store-log_storers) -10. [Mode System (mode/)](#mode-system-mode) -11. [Runtime Kernel (runtime.rs)](#runtime-kernel-runtimers) -12. [gRPC Server Adapter (server.rs)](#grpc-server-adapter-serverrs) -13. [Data Flow: Complete Message Processing](#data-flow-complete-message-processing) -14. [Data Flow: Session Cancellation](#data-flow-session-cancellation) -15. [Data Flow: Initialize Handshake](#data-flow-initialize-handshake) -16. [Concurrency Model](#concurrency-model) -17. [File Structure](#file-structure) -18. [Build Process](#build-process) -19. [CI/CD Pipeline](#cicd-pipeline) -20. [Design Principles](#design-principles) +- receive gRPC requests +- authenticate request metadata +- derive the runtime sender identity +- enforce payload limits and rate limits +- translate runtime errors into gRPC responses and MACP `Ack` values ---- +## 2. Coordination kernel (`src/runtime.rs`) -## System Overview +Responsibilities: -``` -┌──────────────────────────────────────────────────────────────┐ -│ Clients │ -│ (AI agents, test programs, any gRPC-capable application) │ -└─────────┬──────────────────┬──────────────────┬──────────────┘ - │ Initialize │ Send / Stream │ GetSession / - │ │ │ CancelSession / - │ │ │ ListModes / ... - ▼ ▼ ▼ -┌──────────────────────────────────────────────────────────────┐ -│ MACP Runtime Server (v0.2) │ -│ │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ MacpServer (gRPC Adapter Layer) │ │ -│ │ - Implements MACPRuntimeService (10 RPCs) │ │ -│ │ - Validates transport-level fields (version, IDs) │ │ -│ │ - Builds structured Ack responses with MACPError │ │ -│ │ - Delegates all coordination logic to Runtime │ │ -│ └───────────────────────┬────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ Runtime (Coordination Kernel) │ │ -│ │ - Routes messages by type (SessionStart/Signal/other) │ │ -│ │ - Resolves mode names to implementations │ │ -│ │ - Enforces TTL, session state, participant validation │ │ -│ │ - Handles message deduplication │ │ -│ │ - Dispatches to Mode implementations │ │ -│ │ - Applies ModeResponse as single mutation point │ │ -│ │ - Manages session cancellation │ │ -│ └──────┬────────────────────────────┬────────────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────────────┐ ┌──────────────────────────────┐ │ -│ │ Mode Registry │ │ Mode Implementations │ │ -│ │ HashMap> │ │ DecisionMode │ │ -│ │ │ │ - RFC lifecycle │ │ -│ │ 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 │ │ -│ └──────────────────┘ └──────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ SessionRegistry LogStore │ │ -│ │ RwLock> String, Vec>>│ │ -│ │ │ │ -│ │ Thread-safe session Append-only per-session │ │ -│ │ storage with read/write event log with Incoming │ │ -│ │ locking and Internal entries │ │ -│ └────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` +- route envelopes by message type +- validate and create sessions +- apply mode authorization and mode transitions +- enforce accepted-history ordering +- enforce lazy TTL expiry on reads and writes +- persist updated session snapshots -The architecture follows a strict layered design: +## 3. Mode layer (`src/mode/*`) -1. **Transport layer** (`MacpServer`) — handles gRPC protocol concerns, validates transport-level fields, builds structured responses. -2. **Coordination layer** (`Runtime`) — enforces protocol invariants, routes messages, manages session lifecycle, dispatches to modes. -3. **Logic layer** (`Mode` implementations) — provides coordination-specific behavior, returns declarative `ModeResponse` values. -4. **Storage layer** (`SessionRegistry`, `LogStore`) — provides thread-safe state persistence. +Responsibilities: -Each layer has a single responsibility and communicates through well-defined interfaces. +- encode coordination semantics per mode +- validate mode-specific payloads +- authorize mode-specific message types +- return declarative `ModeResponse` values for the kernel to apply ---- +Implemented modes: -## Protobuf Schema Layer +- Decision +- Proposal +- Task +- Handoff +- Quorum +- MultiRound (experimental) -### Schema Organization +## 4. Storage layer -The protocol schema has been restructured from a single `macp.proto` file (v0.1) into a modular, concern-separated layout: +### Session registry (`src/registry.rs`) -``` -proto/ -├── buf.yaml # Linting and breaking-change config -└── macp/ - ├── v1/ - │ ├── envelope.proto # Foundational types - │ └── core.proto # Service + all message types - └── modes/ - └── decision/ - └── v1/ - └── decision.proto # Decision mode payloads -``` +Stores: -**`envelope.proto`** defines the four foundational types that everything builds on: +- session metadata +- bound versions +- participants +- dedup state +- current session state -- **`Envelope`** — the universal message wrapper with 8 fields (macp_version, mode, message_type, message_id, session_id, sender, timestamp_unix_ms, payload). -- **`Ack`** — the structured acknowledgment with 7 fields (ok, duplicate, message_id, session_id, accepted_at_unix_ms, session_state, error). -- **`MACPError`** — the structured error type with 5 fields (code, message, session_id, message_id, details). -- **`SessionState`** — the enum with 4 values (UNSPECIFIED, OPEN, RESOLVED, EXPIRED). +Supports: -**`core.proto`** imports `envelope.proto` and defines everything else: +- in-memory mode +- file-backed snapshot persistence -- **Capability messages** — `ClientInfo`, `RuntimeInfo`, `Capabilities` (with sub-capabilities for sessions, cancellation, progress, manifest, mode registry, roots, and experimental features). -- **Initialize** — `InitializeRequest` and `InitializeResponse` for protocol handshake. -- **Session payloads** — `SessionStartPayload` (with intent, participants, versions, ttl_ms, context, roots), `SessionCancelPayload`, `CommitmentPayload`. -- **Coordination payloads** — `SignalPayload`, `ProgressPayload`. -- **Session metadata** — `SessionMetadata` with typed state and version fields. -- **Introspection** — `AgentManifest`, `ModeDescriptor`. -- **Request/Response wrappers** — `SendRequest`/`SendResponse`, `GetSessionRequest`/`GetSessionResponse`, `CancelSessionRequest`/`CancelSessionResponse`, etc. -- **Streaming types** — `StreamSessionRequest`/`StreamSessionResponse`. -- **Watch types** — `RegistryChanged`, `RootsChanged`. -- **Service definition** — `MACPRuntimeService` with 10 RPCs. +Both stores log a warning and fall back to empty state if snapshot deserialization fails. -**`decision.proto`** defines mode-specific payload types: +### Log store (`src/log_store.rs`) -- **`ProposalPayload`** — proposal_id, option, rationale, supporting_data. -- **`EvaluationPayload`** — proposal_id, recommendation, confidence, reason. -- **`ObjectionPayload`** — proposal_id, reason, severity. -- **`VotePayload`** — proposal_id, vote, reason. +Stores: -These types are not referenced by the core proto — they exist as domain schemas for clients and the Decision Mode implementation. The `CommitmentPayload` is defined in `core.proto` because it is reused across modes. +- accepted incoming envelopes +- runtime-generated internal events such as TTL expiry and session cancellation -### Buf Configuration +Supports: -The `buf.yaml` file configures: +- in-memory mode +- file-backed snapshot persistence -- **Lint rules:** `STANDARD` — enforces naming conventions, field numbering, and other best practices. -- **Breaking-change detection:** `FILE` — detects breaking changes at the file level, ensuring backward compatibility as the schema evolves. +## 5. Security layer (`src/security.rs`) ---- +Responsibilities: -## Build System +- load token-to-identity mappings +- derive sender identities from metadata +- enforce allowed-mode policy +- enforce session-start policy +- enforce per-sender rate limits -### build.rs +## Request path summary -The `build.rs` script runs before compilation and uses `tonic-build` to generate Rust code from the protobuf files: +1. gRPC request arrives in `MacpServer` +2. request metadata is authenticated +3. sender identity is derived and envelope spoofing is rejected +4. runtime processes the envelope +5. accepted messages mutate state and log history +6. updated session snapshots are persisted +7. an `Ack` is returned to the caller -```rust -fn main() -> Result<(), Box> { - tonic_build::configure().build_server(true).compile( - &[ - "macp/v1/envelope.proto", - "macp/v1/core.proto", - "macp/modes/decision/v1/decision.proto", - ], - &["proto"], - )?; - Ok(()) -} -``` +## Freeze-profile design choice -This generates two Rust modules: -- `macp.v1` — all core types and the gRPC service server/client stubs. -- `macp.modes.decision.v1` — decision mode payload types. - -The generated code is included in the binary via `tonic::include_proto!()` macros in `lib.rs`. - -### Cargo.toml Dependencies - -| Dependency | Purpose | -|------------|---------| -| `tokio` | Async runtime (full features) | -| `tonic` | gRPC framework | -| `prost` | Protobuf serialization/deserialization | -| `prost-types` | Well-known protobuf types | -| `uuid` | UUID generation (v4) | -| `thiserror` | Ergonomic error type derivation | -| `chrono` | Date/time handling | -| `serde` + `serde_json` | JSON serialization for mode state and payloads | -| `tokio-stream` | Stream utilities for async streaming | -| `futures-core` | Core future/stream traits | -| `async-stream` | Macro for creating async streams | - -### Makefile - -The `Makefile` provides development shortcuts: - -```makefile -setup: # Configure git hooks (points to .githooks/) -build: # cargo build -test: # cargo test -fmt: # cargo fmt --all -clippy: # cargo clippy --all-targets -- -D warnings -check: # fmt + clippy + test (full CI check locally) -``` - ---- - -## Entry Point (main.rs) - -The `main.rs` file is deliberately minimal — it wires up the three core components and starts the gRPC server: - -```rust -#[tokio::main] -async fn main() -> Result<(), Box> { - let addr = "127.0.0.1:50051".parse()?; - let registry = Arc::new(SessionRegistry::new()); - let log_store = Arc::new(LogStore::new()); - let runtime = Arc::new(Runtime::new(registry, log_store)); - let svc = MacpServer::new(runtime); - - println!("macp-runtime v0.2 (RFC-0001) listening on {}", addr); - - Server::builder() - .add_service(pb::macp_runtime_service_server::MacpRuntimeServiceServer::new(svc)) - .serve(addr) - .await?; - - Ok(()) -} -``` - -**What happens on startup:** - -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 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. - -The `Arc` wrapper allows the runtime to be shared across all async tasks spawned by the Tokio runtime — each gRPC handler receives a clone of the `Arc`. - ---- - -## Library Root (lib.rs) - -```rust -pub mod pb { - tonic::include_proto!("macp.v1"); -} - -pub mod decision_pb { - tonic::include_proto!("macp.modes.decision.v1"); -} - -pub mod error; -pub mod log_store; -pub mod mode; -pub mod registry; -pub mod runtime; -pub mod session; -``` - -The library root serves two purposes: - -1. **Proto module inclusion** — The `pb` module contains all generated code from `envelope.proto` and `core.proto`. The `decision_pb` module contains generated code from `decision.proto`. These are available to both the server binary and the client binaries. - -2. **Module re-exports** — All internal modules are made public so that client binaries (in `src/bin/`) can import types like `SessionStartPayload`, `Envelope`, etc. - ---- - -## Error Types (error.rs) - -The error system is designed to provide both internal precision (distinct Rust error variants for each failure mode) and external clarity (RFC-compliant error codes for clients). - -### MacpError Enum - -```rust -pub enum MacpError { - InvalidMacpVersion, // Protocol version mismatch - InvalidEnvelope, // Missing required fields or invalid encoding - DuplicateSession, // SessionStart for existing session - UnknownSession, // Message for non-existent session - SessionNotOpen, // Message to resolved/expired session - TtlExpired, // Session TTL has elapsed - InvalidTtl, // TTL value out of range - UnknownMode, // Mode not registered - InvalidModeState, // Mode state deserialization failure - InvalidPayload, // Payload doesn't match mode's expectations - Forbidden, // Operation not permitted - Unauthenticated, // Authentication required - DuplicateMessage, // Explicit duplicate detection - PayloadTooLarge, // Payload exceeds size limits - RateLimited, // Too many requests -} -``` - -### RFC Error Code Mapping - -Each variant maps to an RFC-compliant string code via the `error_code()` method: - -| Variant | RFC Code | -|---------|----------| -| `InvalidMacpVersion` | `"UNSUPPORTED_PROTOCOL_VERSION"` | -| `InvalidEnvelope` | `"INVALID_ENVELOPE"` | -| `DuplicateSession` | `"INVALID_ENVELOPE"` | -| `UnknownSession` | `"SESSION_NOT_FOUND"` | -| `SessionNotOpen` | `"SESSION_NOT_OPEN"` | -| `TtlExpired` | `"SESSION_NOT_OPEN"` | -| `InvalidTtl` | `"INVALID_ENVELOPE"` | -| `UnknownMode` | `"MODE_NOT_SUPPORTED"` | -| `InvalidModeState` | `"INVALID_ENVELOPE"` | -| `InvalidPayload` | `"INVALID_ENVELOPE"` | -| `Forbidden` | `"FORBIDDEN"` | -| `Unauthenticated` | `"UNAUTHENTICATED"` | -| `DuplicateMessage` | `"DUPLICATE_MESSAGE"` | -| `PayloadTooLarge` | `"PAYLOAD_TOO_LARGE"` | -| `RateLimited` | `"RATE_LIMITED"` | - -**Design rationale:** Multiple internal variants map to `INVALID_ENVELOPE` because, from a client's perspective, these are all "your request was malformed." The distinct internal variants allow for precise logging, metrics, and debugging. The `Display` trait implementation uses the variant names directly (e.g., `"InvalidMacpVersion"`), providing human-readable error messages in log output. - ---- - -## Session Types (session.rs) - -### SessionState Enum - -```rust -pub enum SessionState { - Open, // Active — accepting messages - Resolved, // Terminal — mode resolved the session - Expired, // Terminal — TTL elapsed or cancelled -} -``` - -### Session Struct - -```rust -pub struct Session { - pub session_id: String, - pub state: SessionState, - pub ttl_expiry: i64, // Unix ms when session expires - pub started_at_unix_ms: i64, // Unix ms when session was created - pub resolution: Option>, // Final outcome (if Resolved) - pub mode: String, // Mode name - pub mode_state: Vec, // Mode-specific serialized state - pub participants: Vec, // Allowed senders (empty = open) - pub seen_message_ids: HashSet, // For deduplication - - // RFC version fields from SessionStartPayload - pub intent: String, - pub mode_version: String, - pub configuration_version: String, - pub policy_version: String, -} -``` - -**Key fields explained:** - -- **`mode_state`** — Opaque bytes owned by the mode. The runtime never inspects these; it simply stores whatever the mode returns in `PersistState`. Each mode serializes/deserializes its own state format (JSON for both built-in modes). - -- **`seen_message_ids`** — A `HashSet` tracking every `message_id` that has been accepted for this session. Used for deduplication — if a message arrives with a `message_id` already in this set, it is returned as a duplicate without re-processing. - -- **`participants`** — If non-empty, only senders in this list may send messages to the session. This is populated from `SessionStartPayload.participants`. - -- **`started_at_unix_ms`** — Records when the session was created (server-side timestamp). Used in `GetSession` responses. - -- **Version fields** (`intent`, `mode_version`, `configuration_version`, `policy_version`) — Carried from the `SessionStartPayload` and returned in `GetSession` responses. The runtime does not interpret these; they exist for client-side versioning and policy tracking. - -### TTL Parsing - -Two functions handle TTL extraction from the protobuf payload: - -**`parse_session_start_payload(payload: &[u8])`** — Decodes the raw bytes as a protobuf `SessionStartPayload`. If the payload is empty, returns a default `SessionStartPayload` (all fields at their protobuf defaults). - -**`extract_ttl_ms(payload: &SessionStartPayload)`** — Returns the `ttl_ms` field, or the default `60,000 ms` if the field is `0`. Validates that the value is in range `[1, 86,400,000]`. Returns `Err(MacpError::InvalidTtl)` if out of range. - -**Constants:** -- `DEFAULT_TTL_MS: i64 = 60_000` (60 seconds) -- `MAX_TTL_MS: i64 = 86_400_000` (24 hours) - ---- - -## Session Registry (registry.rs) - -```rust -pub struct SessionRegistry { - pub(crate) sessions: RwLock>, -} -``` - -The `SessionRegistry` is the in-memory session store. It wraps a `HashMap` in a Tokio `RwLock` for thread-safe concurrent access. - -**Methods:** - -- **`new()`** — Creates an empty registry. -- **`get_session(session_id: &str) -> Option`** — Acquires a read lock and returns a clone of the session if found. -- **`insert_session_for_test(session: Session)`** — Acquires a write lock and inserts a session. Used only in tests. - -**Important note:** The registry's `RwLock` is also directly accessed by the `Runtime` for atomic read-modify-write operations. During `process_session_start()` and `process_message()`, the runtime acquires a **write lock** on the registry and holds it for the entire processing sequence (including mode dispatch). This ensures atomicity but creates a potential concurrency bottleneck for high-throughput scenarios. - ---- - -## Log Store (log_store.rs) - -The `LogStore` maintains an append-only audit log per session, providing a complete history of every event that occurred within a session. - -### LogEntry - -```rust -pub struct LogEntry { - pub message_id: String, - pub received_at_ms: i64, - pub sender: String, - pub message_type: String, - pub raw_payload: Vec, - pub entry_kind: EntryKind, -} - -pub enum EntryKind { - Incoming, // Message from a client - Internal, // Runtime-generated event -} -``` - -**Entry kinds:** - -- **Incoming** — Every client message (SessionStart, Proposal, Vote, Contribute, etc.) is logged as an `Incoming` entry before the mode processes it. This is the "log-before-mutate" guarantee. -- **Internal** — Runtime-generated events like `TtlExpired` (when TTL expiry is detected) and `SessionCancel` (when `CancelSession` is called) are logged as `Internal` entries. These have synthetic `message_id` values like `"__ttl_expired__"` or `"__session_cancel__"`. - -### LogStore Structure - -```rust -pub struct LogStore { - logs: RwLock>>, -} -``` - -**Methods:** - -- **`new()`** — Creates an empty store. -- **`create_session_log(session_id: &str)`** — Creates an empty log vector for a session. Idempotent — if the log already exists, this is a no-op. -- **`append(session_id: &str, entry: LogEntry)`** — Appends an entry to the session's log. If no log exists for the session, one is auto-created. -- **`get_log(session_id: &str) -> Option>`** — Returns a cloned copy of the session's log entries. - -**Design properties:** -- Entries are never deleted or modified — the log is strictly append-only. -- Entries are appended in strict chronological order per session. -- The log persists for the lifetime of the server process (no cleanup). -- Future extensions (replay engine, GetSessionLog RPC) will build on this foundation. - ---- - -## Mode System (mode/) - -### Mode Trait (mode/mod.rs) - -```rust -pub trait Mode: Send + Sync { - fn on_session_start(&self, session: &Session, env: &Envelope) - -> Result; - fn on_message(&self, session: &Session, env: &Envelope) - -> Result; -} -``` - -The `Mode` trait is the extension point for coordination logic. It is designed around three principles: - -1. **Immutability** — Modes receive `&Session` (immutable reference). They cannot directly mutate state. -2. **Declarative responses** — Modes return `ModeResponse` values that describe *what should change*, not *how to change it*. -3. **Thread safety** — The `Send + Sync` bounds ensure modes can be shared across async tasks. - -### ModeResponse (mode/mod.rs) - -```rust -pub enum ModeResponse { - NoOp, - PersistState(Vec), - Resolve(Vec), - PersistAndResolve { state: Vec, resolution: Vec }, -} -``` - -The runtime's `apply_mode_response()` is the single mutation point that interprets these responses: - -- **`NoOp`** — Nothing happens. The message was accepted but produced no state change. -- **`PersistState(bytes)`** — `session.mode_state = bytes`. The mode's internal state is updated. -- **`Resolve(bytes)`** — `session.state = Resolved` and `session.resolution = Some(bytes)`. The session terminates with a resolution. -- **`PersistAndResolve { state, resolution }`** — Both of the above in a single atomic operation. - -### DecisionMode (mode/decision.rs) - -The Decision Mode implements the RFC-0001 decision lifecycle. It maintains a `DecisionState` serialized as JSON in `session.mode_state`. - -**Internal state:** - -```rust -pub struct DecisionState { - pub proposals: HashMap, - pub evaluations: Vec, - pub objections: Vec, - pub votes: HashMap, // sender → Vote - pub phase: DecisionPhase, -} - -pub enum DecisionPhase { - Proposal, // Initial — waiting for proposals - Evaluation, // At least one proposal exists - Voting, // Votes being cast - Committed, // Terminal — decision finalized -} -``` - -**Message routing in `on_message()`:** - -The mode inspects `envelope.message_type` and dispatches accordingly: - -| message_type | Handler | Returns | -|-------------|---------|---------| -| `"Proposal"` | Parse `ProposalPayload`, validate `proposal_id`, store proposal, advance to `Evaluation` | `PersistState` | -| `"Evaluation"` | Parse `EvaluationPayload`, validate `proposal_id` exists, append evaluation | `PersistState` | -| `"Objection"` | Parse `ObjectionPayload`, validate `proposal_id` exists, append objection | `PersistState` | -| `"Vote"` | Parse `VotePayload`, validate proposals exist, store vote (keyed by sender — overwrites), advance to `Voting` | `PersistState` | -| `"Commitment"` | Parse `CommitmentPayload`, validate votes exist, advance to `Committed` | `PersistAndResolve` | -| `"Message"` with payload `b"resolve"` | Legacy backward compatibility | `Resolve(b"resolve")` | -| Anything else | Ignored | `NoOp` | - -**Key behaviors:** - -- Proposals are stored in a `HashMap` keyed by `proposal_id`. Submitting a new proposal with the same ID overwrites the previous one. -- Votes are stored in a `HashMap` keyed by sender. If the same sender votes again, the previous vote is replaced. -- 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`. - -**Internal state:** - -```rust -pub struct MultiRoundState { - pub round: u64, - pub participants: Vec, - pub contributions: BTreeMap, // sender → value - pub convergence_type: String, // "all_equal" -} -``` - -The `BTreeMap` is used instead of `HashMap` for **deterministic serialization ordering** — this ensures that the same state always produces the same JSON bytes, enabling reliable comparison and replay. - -**`on_session_start()` flow:** - -1. Reads `session.participants` (populated from `SessionStartPayload.participants`). -2. If participants is empty, returns `Err(MacpError::InvalidPayload)` — multi-round mode requires at least one participant. -3. Creates initial `MultiRoundState` with `round: 0`, the participant list, empty contributions, and `convergence_type: "all_equal"`. -4. Serializes and returns `PersistState`. - -**`on_message()` flow for `Contribute` messages:** - -1. Deserializes `mode_state` into `MultiRoundState`. -2. Parses the JSON payload `{"value": ""}`. -3. Checks if the sender's value has changed: - - **New contribution** (sender not in `contributions`) → insert and increment round. - - **Changed value** (sender's previous value differs) → update and increment round. - - **Same value** (sender resubmits identical value) → update without incrementing round. -4. Checks convergence: - - All participants have contributed (every participant in the list has an entry in `contributions`). - - All contribution values are identical. -5. If converged → `PersistAndResolve` with resolution `{"converged_value": "...", "round": N, "final_values": {...}}`. -6. If not converged → `PersistState` with updated state. - -**Participant extraction note:** The mode tries to read participants from the session first. If the session's participant list is empty (which shouldn't normally happen for multi_round), the runtime attempts to extract participants from the `mode_state` as a fallback. - ---- - -## Runtime Kernel (runtime.rs) - -The `Runtime` is the coordination kernel — the central orchestrator that ties everything together. It holds the session registry, the log store, and the registered modes. - -### Structure - -```rust -pub struct Runtime { - pub registry: Arc, - pub log_store: Arc, - modes: HashMap>, -} - -pub struct ProcessResult { - pub session_state: SessionState, - pub duplicate: bool, -} -``` - -### Mode Registration - -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 -``` - -The `mode_names()` method returns the canonical mode names (the RFC-compliant ones, not the aliases). - -### Message Routing: process() - -The `process()` method is the main entry point. It inspects `envelope.message_type` and routes to the appropriate handler: - -```rust -pub async fn process(&self, env: &Envelope) -> Result { - match env.message_type.as_str() { - "SessionStart" => self.process_session_start(env).await, - "Signal" => self.process_signal(env).await, - _ => self.process_message(env).await, - } -} -``` - -### process_session_start() - -This is the most complex handler. Here is the complete flow: - -1. **Resolve mode** — Empty mode field → `"macp.mode.decision.v1"`. Look up mode in registry → `MODE_NOT_SUPPORTED` if not found. -2. **Parse payload** — Decode bytes as protobuf `SessionStartPayload` → `INVALID_ENVELOPE` if decode fails. -3. **Validate TTL** — Extract `ttl_ms`, validate range → `INVALID_ENVELOPE` if out of range. -4. **Compute TTL expiry** — `current_time + ttl_ms`. -5. **Acquire write lock** on session registry. -6. **Check for duplicate session:** - - If session exists and `message_id` is in `seen_message_ids` → return `ProcessResult { state, duplicate: true }`. - - If session exists with different `message_id` → return `Err(MacpError::DuplicateSession)`. -7. **Create session log** — `log_store.create_session_log(session_id)`. -8. **Log incoming entry** — Append `Incoming` entry with the SessionStart details. -9. **Create Session object** — state=Open, computed TTL expiry, participants, version metadata, `message_id` in `seen_message_ids`. -10. **Call mode.on_session_start()** — Mode may return `PersistState` with initial state. -11. **Apply ModeResponse** — Mutate session according to the response. -12. **Insert session** into registry. -13. **Return ProcessResult** — state=Open (or Resolved if mode immediately resolved), duplicate=false. - -### process_message() - -Handles all non-SessionStart, non-Signal messages: - -1. **Acquire write lock** on session registry. -2. **Find session** → `SESSION_NOT_FOUND` if not found. -3. **Deduplication check** — If `message_id` in `seen_message_ids` → return `ProcessResult { state, duplicate: true }`. -4. **TTL check** — If session is Open and `now > ttl_expiry`: - - Log internal `TtlExpired` entry. - - Transition session to `Expired`. - - Return `Err(MacpError::TtlExpired)`. -5. **State check** — If session is not `Open` → `Err(MacpError::SessionNotOpen)`. -6. **Participant check** — If `participants` is non-empty and `sender` not in list → `Err(MacpError::InvalidEnvelope)`. -7. **Record message_id** in `seen_message_ids`. -8. **Log incoming entry**. -9. **Look up mode** → `MODE_NOT_SUPPORTED` if not found (should not happen for valid sessions). -10. **Participant extraction fallback** — For multi_round mode, if session participants is empty, try to extract from mode_state. -11. **Call mode.on_message()**. -12. **Apply ModeResponse**. -13. **Return ProcessResult** with current session state. - -### process_signal() - -Signal handling is deliberately simple: - -1. Accept the signal (no session lookup, no state mutation). -2. Return `ProcessResult { state: Open, duplicate: false }`. - -### apply_mode_response() - -The single mutation point for all session state changes: - -```rust -fn apply_mode_response(session: &mut Session, response: ModeResponse) { - match response { - ModeResponse::NoOp => {} - ModeResponse::PersistState(s) => { - session.mode_state = s; - } - ModeResponse::Resolve(r) => { - session.state = SessionState::Resolved; - session.resolution = Some(r); - } - ModeResponse::PersistAndResolve { state, resolution } => { - session.mode_state = state; - session.state = SessionState::Resolved; - session.resolution = Some(resolution); - } - } -} -``` - -### cancel_session() - -Session cancellation flow: - -1. **Acquire write lock** on session registry. -2. **Find session** → `Err(MacpError::UnknownSession)` if not found. -3. **Check state:** - - If already `Resolved` or `Expired` → idempotent, return `Ok(())`. - - If `Open` → log internal `SessionCancel` entry, transition to `Expired`. -4. **Return `Ok(())`**. - ---- - -## gRPC Server Adapter (server.rs) - -The `MacpServer` struct implements the `MACPRuntimeService` gRPC trait generated by tonic. It is a thin adapter layer that translates between gRPC types and the runtime's internal types. - -### Structure - -```rust -pub struct MacpServer { - runtime: Arc, -} -``` - -### Key Methods - -**`validate(env: &Envelope)`** — Transport-level validation: -- Checks `macp_version == "1.0"` → `UNSUPPORTED_PROTOCOL_VERSION`. -- For non-Signal messages: checks `session_id` and `message_id` are non-empty → `INVALID_ENVELOPE`. -- For Signal messages: checks only `message_id` is non-empty. - -**`session_state_to_pb(state: &SessionState) -> i32`** — Maps internal session state to protobuf enum values. - -**`make_error_ack(err: &MacpError, env: &Envelope) -> Ack`** — Constructs a structured `Ack` with: -- `ok: false` -- `message_id` and `session_id` from the envelope -- `accepted_at_unix_ms` set to current time -- `error` containing a `MACPError` with the RFC error code and the error's display string - -### RPC Implementations - -| RPC | Behavior | -|-----|----------| -| **Initialize** | Checks `supported_protocol_versions` for `"1.0"`, returns `RuntimeInfo`, `Capabilities`, supported modes, and instructions. Returns `INVALID_ARGUMENT` gRPC status if no supported version. | -| **Send** | Validates envelope, delegates to `runtime.process()`, constructs `Ack` with ok/duplicate/session_state/error. All protocol errors are in-band (gRPC status is always OK). | -| **GetSession** | Looks up session in registry, returns `SessionMetadata` with state, timestamps, and version fields. Returns `NOT_FOUND` gRPC status if session doesn't exist. | -| **CancelSession** | Delegates to `runtime.cancel_session()`, returns `Ack`. | -| **GetManifest** | Returns `AgentManifest` with runtime identity and supported modes. | -| **ListModes** | Returns two `ModeDescriptor` entries (decision and multi_round) with message types, determinism class, and participant model. | -| **StreamSession** | Bidirectional streaming — processes each incoming envelope and echoes an envelope with updated message_type. | -| **ListRoots** | Returns empty roots list. | -| **WatchModeRegistry** | Returns `UNIMPLEMENTED`. | -| **WatchRoots** | Returns `UNIMPLEMENTED`. | - ---- - -## Data Flow: Complete Message Processing - -Let's trace the complete path of a `Vote` message through the system: - -``` -1. Client sends SendRequest { envelope: Vote message } - │ - ▼ -2. MacpServer::send() receives the gRPC request - │ - ▼ -3. MacpServer::validate(&envelope) - - Checks macp_version == "1.0" ✓ - - Checks session_id non-empty ✓ - - Checks message_id non-empty ✓ - │ - ▼ -4. runtime.process(&envelope) - │ - ▼ -5. message_type == "Vote" → process_message() - │ - ▼ -6. Acquire write lock on session registry - │ - ▼ -7. Find session by session_id ✓ found - │ - ▼ -8. Check seen_message_ids for message_id ✓ not a duplicate - │ - ▼ -9. Check TTL: now < ttl_expiry ✓ not expired - │ - ▼ -10. Check state == Open ✓ - │ - ▼ -11. Check sender in participants ✓ (or empty list) - │ - ▼ -12. Add message_id to seen_message_ids - │ - ▼ -13. log_store.append(session_id, Incoming entry) - │ - ▼ -14. Look up mode by session.mode → DecisionMode - │ - ▼ -15. DecisionMode::on_message(&session, &envelope) - - Deserialize mode_state → DecisionState - - Parse payload as VotePayload - - Validate: proposals exist, phase != Committed - - Store vote in state.votes (keyed by sender) - - Advance phase to Voting - - Serialize updated state - - Return PersistState(serialized_state) - │ - ▼ -16. apply_mode_response(&mut session, PersistState(bytes)) - - session.mode_state = bytes - │ - ▼ -17. Return ProcessResult { state: Open, duplicate: false } - │ - ▼ -18. MacpServer builds Ack { - ok: true, - duplicate: false, - message_id: "...", - session_id: "...", - accepted_at_unix_ms: , - session_state: SESSION_STATE_OPEN, - error: None - } - │ - ▼ -19. Client receives SendResponse { ack } -``` - ---- - -## Data Flow: Session Cancellation - -``` -1. Client sends CancelSessionRequest { session_id, reason } - │ - ▼ -2. MacpServer::cancel_session() receives the gRPC request - │ - ▼ -3. runtime.cancel_session(session_id, reason) - │ - ▼ -4. Acquire write lock on session registry - │ - ▼ -5. Find session by session_id - - Not found → return Err(UnknownSession) - - Found, state == Resolved or Expired → idempotent, return Ok - - Found, state == Open → continue - │ - ▼ -6. log_store.append(session_id, Internal { - message_id: "__session_cancel__", - message_type: "SessionCancel", - sender: "", - raw_payload: reason.as_bytes() - }) - │ - ▼ -7. session.state = Expired - │ - ▼ -8. Return Ok(()) - │ - ▼ -9. MacpServer builds Ack { ok: true, ... } -``` - ---- - -## Data Flow: Initialize Handshake - -``` -1. Client sends InitializeRequest { - supported_protocol_versions: ["1.0"], - client_info: { name: "my-agent", ... }, - capabilities: { ... } - } - │ - ▼ -2. MacpServer::initialize() receives the request - │ - ▼ -3. Check if "1.0" is in supported_protocol_versions - - Not found → return INVALID_ARGUMENT gRPC status - - Found → continue - │ - ▼ -4. Build InitializeResponse { - selected_protocol_version: "1.0", - runtime_info: { name: "macp-runtime", version: "0.2.0", ... }, - capabilities: { sessions, cancellation, progress, manifest, ... }, - supported_modes: ["macp.mode.decision.v1", "macp.mode.multi_round.v1"], - instructions: "MACP Runtime v0.2 ..." - } - │ - ▼ -5. Client receives the response, caches capabilities and modes -``` - ---- - -## Concurrency Model - -**Question:** What happens when two clients send messages to the same session simultaneously? - -**Answer:** They are serialized safely through the `RwLock`: - -1. Both gRPC handlers enter `send()` simultaneously. -2. Both call `validate()` independently — no shared state here. -3. Both call `runtime.process()` which attempts to acquire the write lock. -4. **First request** acquires the write lock. -5. First request processes through the full pipeline (dedup check, TTL check, state check, participant check, logging, mode dispatch, apply response). -6. First request releases the write lock. -7. **Second request** acquires the write lock. -8. Second request processes through the same pipeline, but now sees the state changes from the first request (e.g., if the first request resolved the session, the second will get `SessionNotOpen`). - -**Read concurrency:** The `RwLock` allows multiple concurrent readers. `GetSession` calls acquire read locks and can execute simultaneously without blocking each other. - -**Write serialization:** All write operations (SessionStart, regular messages, CancelSession) acquire exclusive write locks. This ensures atomicity but means that high write throughput to the same registry will be serialized. In practice, this is acceptable for the coordination use case where message rates are modest. - -**No background tasks:** There are no background cleanup threads. Expired sessions remain in memory until the server restarts. This is a deliberate simplification — the coordination use case typically involves a bounded number of sessions, and memory pressure from expired sessions is negligible. - ---- - -## File Structure - -``` -runtime/ -├── proto/ -│ ├── buf.yaml # Buf linter configuration -│ └── macp/ -│ ├── v1/ -│ │ ├── envelope.proto # Envelope, Ack, MACPError, SessionState -│ │ └── core.proto # Service + all message types -│ └── modes/ -│ ├── 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/ -│ └── quorum.proto # Quorum mode payload types -├── src/ -│ ├── main.rs # Entry point — server startup -│ ├── lib.rs # Library root — proto modules + exports -│ ├── server.rs # gRPC adapter (MacpRuntimeService) -│ ├── error.rs # MacpError enum + RFC error codes -│ ├── session.rs # Session struct, SessionState, TTL parsing -│ ├── registry.rs # SessionRegistry (RwLock) -│ ├── log_store.rs # Append-only LogStore -│ ├── 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 -│ ├── 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 -├── .githooks/ -│ └── pre-commit # Pre-commit hook (fmt + clippy) -├── .github/ -│ ├── workflows/ -│ │ └── ci.yml # CI/CD pipeline -│ ├── ISSUE_TEMPLATE/ -│ │ ├── bug_report.yml # Bug report template -│ │ └── rfc_proposal.yml # RFC proposal template -│ └── PULL_REQUEST_TEMPLATE.md # PR template -└── docs/ - ├── README.md # Getting started guide - ├── protocol.md # Protocol specification - ├── architecture.md # This document - └── examples.md # Usage examples -``` - ---- - -## Build Process - -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. - - `src/main.rs` — the server binary. - - `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. - ---- - -## CI/CD Pipeline - -The GitHub Actions workflow (`.github/workflows/ci.yml`) runs on every push and pull request: - -1. **Checkout** — fetches the code. -2. **Install protoc** — installs the Protocol Buffers compiler. -3. **Install Buf** — installs the Buf CLI for proto linting. -4. **Buf lint** — lints the proto files against the `STANDARD` rules. -5. **Cargo fmt** — checks that all code is formatted. -6. **Cargo clippy** — runs the linter with `-D warnings` (warnings are errors). -7. **Cargo test** — runs the full test suite. -8. **Cargo build** — verifies the project compiles cleanly. - ---- - -## Design Principles - -### 1. Separation of Concerns - -Each layer has exactly one job: -- **Proto schema** — defines the wire format. -- **MacpServer** — handles gRPC transport concerns. -- **Runtime** — enforces protocol invariants. -- **Modes** — provide coordination logic. -- **Registry/LogStore** — provide storage. - -### 2. Pluggable Coordination - -The runtime provides "physics" (invariants, TTL, logging, routing, deduplication, participant validation). Modes provide "coordination logic" (when to resolve, what state to track). New modes can be added by implementing the `Mode` trait and registering them in the runtime — no changes to the kernel or transport layer required. - -### 3. Fail-Safe Design - -- Invalid messages are rejected, never ignored. -- No partial state updates — `apply_mode_response()` is atomic. -- Errors are explicit and structured — every failure has an RFC error code. -- Validation occurs before state mutation — the system never enters an inconsistent state. -- Log-before-mutate ordering — events are logged before the mode processes them. - -### 4. Idempotent Operations - -- Duplicate messages (same `message_id`) are safely handled as no-ops. -- Duplicate SessionStart with the same `message_id` returns success without re-creating. -- CancelSession on an already-terminal session is idempotent. - -### 5. Minimal Coordination - -- The runtime does not interpret payload contents (that's the mode's job). -- Sessions are independent — no cross-session coordination. -- Modes receive immutable state and return declarative responses. -- The server is stateless except for the in-memory registry and log store. - -### 6. Structural Invariants - -The system enforces protocol-level rules that cannot be violated: -- Cannot start a session twice (with a different message_id). -- Cannot send to a non-existent session. -- Cannot send to a resolved or expired session. -- Must use the correct protocol version. -- Must reference a registered mode. -- Must be a listed participant (if participant list is configured). - -These are **protocol-level** invariants, not domain-specific business rules. - ---- - -## Next Steps - -- Read **[protocol.md](./protocol.md)** for the full protocol specification. -- Read **[examples.md](./examples.md)** for practical usage examples with the new v0.2 RPCs. +The runtime intentionally prioritizes unary correctness over streaming completeness. `StreamSession` is therefore disabled in this release profile so SDKs can target a stable, explicit surface. diff --git a/docs/examples.md b/docs/examples.md index dea11f3..c019557 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,1530 +1,196 @@ -# Examples and Usage +# Examples and local development guide -This document provides step-by-step examples of using the MACP Runtime v0.3. It covers the full lifecycle — from protocol handshake to session creation, decision-making, convergence, cancellation, and error handling — with detailed explanations of what happens at each step. +These examples target `macp-runtime v0.4.0` and the unary freeze profile. ---- +They intentionally use the local-development security shortcut: -## Table of Contents - -1. [Quick Start](#quick-start) -2. [Example 1: Basic Decision Mode Client](#example-1-basic-decision-mode-client) -3. [Example 2: Full Decision Mode Lifecycle](#example-2-full-decision-mode-lifecycle) -4. [Example 3: Multi-Round Convergence](#example-3-multi-round-convergence) -5. [Example 4: Fuzz Client — Testing Every Error Path](#example-4-fuzz-client--testing-every-error-path) -6. [Example 5: Using the New RPCs](#example-5-using-the-new-rpcs) -7. [Example 6: Session Cancellation](#example-6-session-cancellation) -8. [Example 7: Message Deduplication](#example-7-message-deduplication) -9. [Example 8: Participant Validation](#example-8-participant-validation) -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. [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) - ---- - -## Quick Start - -### Running the Server - -**Terminal 1:** ```bash -cd /path/to/runtime +export MACP_ALLOW_INSECURE=1 +export MACP_ALLOW_DEV_SENDER_HEADER=1 cargo run ``` -You should see: -``` -macp-runtime v0.3.0 (RFC-0001) listening on 127.0.0.1:50051 -``` - -The server is now ready to accept connections on port 50051. - -### Running the Test Clients - -**Terminal 2** — Basic demo: -```bash -cargo run --bin client -``` - -**Terminal 2** — Comprehensive error testing: -```bash -cargo run --bin fuzz_client -``` - -**Terminal 2** — Multi-round convergence: -```bash -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 - -The basic client (`src/bin/client.rs`) demonstrates the core happy path: Initialize, ListModes, SessionStart, Message, Resolve, post-resolve rejection, and GetSession. - -### Step 1: Connect to the Server - -```rust -let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; -``` - -This creates a gRPC client and connects to the runtime. If the server isn't running, this will fail with a connection error. - -### Step 2: Initialize — Negotiate Protocol Version - -```rust -let init_resp = client - .initialize(InitializeRequest { - supported_protocol_versions: vec!["1.0".into()], - client_info: None, - capabilities: None, - }) - .await? - .into_inner(); -println!( - "Initialize: version={} runtime={}", - init_resp.selected_protocol_version, - init_resp.runtime_info.as_ref().map(|r| r.name.as_str()).unwrap_or("?") -); -``` - -**What happens:** The client proposes protocol version `"1.0"`. The server confirms it and returns runtime info (name, version), capabilities, and the list of supported modes. - -**Expected output:** -``` -Initialize: version=1.0 runtime=macp-runtime -``` - -### Step 3: Discover Available Modes - -```rust -let modes_resp = client.list_modes(ListModesRequest {}).await?.into_inner(); -println!( - "ListModes: {:?}", - modes_resp.modes.iter().map(|m| &m.mode).collect::>() -); -``` - -**Expected output:** -``` -ListModes: ["macp.mode.decision.v1"] -``` - -### Step 4: Create a Session (SessionStart) - -```rust -let start = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: 1_700_000_000_000, - payload: vec![], -}; - -let ack = client - .send(SendRequest { envelope: Some(start) }) - .await? - .into_inner() - .ack - .unwrap(); -println!("SessionStart ack: ok={} error={:?}", ack.ok, ack.error.as_ref().map(|e| &e.code)); -``` - -**What each field means:** -- `macp_version: "1.0"` — Protocol version (must be exactly `"1.0"`). -- `mode: "decision"` — Use the Decision Mode (alias for `"macp.mode.decision.v1"`). -- `message_type: "SessionStart"` — This creates a new session. -- `message_id: "m1"` — Unique ID for this message. -- `session_id: "s1"` — The session ID we're creating. -- `sender: "ajit"` — Who is sending this message. -- `payload: vec![]` — Empty payload means default TTL (60s), no participants. - -**Expected output:** -``` -SessionStart ack: ok=true error=None -``` - -### Step 5: Send a Normal Message +The example binaries in `src/bin` attach `x-macp-agent-id` metadata so the runtime can derive the authenticated sender. -```rust -let msg = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), - session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: 1_700_000_000_001, - payload: b"hello".to_vec(), -}; -``` +## Ground rules for every standards-track session -In the Decision Mode, a `Message` with a non-`"resolve"` payload returns `NoOp` — the message is accepted but produces no state change. This is the backward-compatible behavior from v0.1. +For these modes: -**Expected output:** -``` -Message ack: ok=true error=None -``` +- `macp.mode.decision.v1` +- `macp.mode.proposal.v1` +- `macp.mode.task.v1` +- `macp.mode.handoff.v1` +- `macp.mode.quorum.v1` -### Step 6: Resolve the Session (Legacy) +`SessionStartPayload` must include all of the following: -```rust -let resolve = Envelope { - // ... - message_type: "Message".into(), - message_id: "m3".into(), - payload: b"resolve".to_vec(), // Legacy resolve trigger -}; -``` +- `participants` +- `mode_version` +- `configuration_version` +- positive `ttl_ms` -When the Decision Mode sees `message_type: "Message"` with `payload == b"resolve"`, it resolves the session immediately. This is the backward-compatible resolution mechanism from v0.1. +The example clients use: -**Expected output:** -``` -Resolve ack: ok=true error=None -``` +- `mode_version = "1.0.0"` +- `configuration_version = "config.default"` +- `policy_version = "policy.default"` -### Step 7: Attempt Message After Resolution - -```rust -let after = Envelope { - // ... - message_type: "Message".into(), - message_id: "m4".into(), - payload: b"should-fail".to_vec(), -}; -``` - -The session is now `Resolved` (terminal state). Any further message is rejected with `SESSION_NOT_OPEN`. - -**Expected output:** -``` -After-resolve ack: ok=false error=Some("SESSION_NOT_OPEN") -``` - -### Step 8: Verify State with GetSession - -```rust -let resp = client - .get_session(GetSessionRequest { session_id: "s1".into() }) - .await? - .into_inner(); -let meta = resp.metadata.unwrap(); -println!("GetSession: state={} mode={}", meta.state, meta.mode); -``` - -**Expected output:** -``` -GetSession: state=2 mode=decision -``` +## Example 1: Decision Mode -(State `2` is `SESSION_STATE_RESOLVED` in the protobuf enum.) - ---- - -## Example 2: Full Decision Mode Lifecycle - -The Decision Mode in v0.3 supports a rich lifecycle: Proposal, Evaluation, Objection, Vote, and Commitment. Here is how a complete decision process flows: - -### Step 1: Create a Session - -```rust -let start = Envelope { - macp_version: "1.0".into(), - mode: "macp.mode.decision.v1".into(), // RFC-compliant name - message_type: "SessionStart".into(), - message_id: uuid::Uuid::new_v4().to_string(), - session_id: "decision-001".into(), - sender: "coordinator".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: vec![], -}; -``` - -### Step 2: Submit a Proposal - -```rust -let proposal = Envelope { - macp_version: "1.0".into(), - mode: "macp.mode.decision.v1".into(), - message_type: "Proposal".into(), - message_id: uuid::Uuid::new_v4().to_string(), - session_id: "decision-001".into(), - sender: "agent-alpha".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "p1", - "option": "Deploy v2.1 to production", - "rationale": "All integration tests pass, staging validation complete", - "supporting_data": "" - })).unwrap(), -}; -``` - -After this message, the Decision Mode's phase advances from `Proposal` to `Evaluation`. - -### Step 3: Submit an Evaluation - -```rust -let evaluation = Envelope { - // ... - message_type: "Evaluation".into(), - sender: "agent-beta".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "p1", - "recommendation": "APPROVE", - "confidence": 0.92, - "reason": "Performance metrics look excellent" - })).unwrap(), -}; -``` - -Evaluations are appended to the state — multiple agents can evaluate the same proposal. - -### Step 4: Raise an Objection (Optional) - -```rust -let objection = Envelope { - // ... - message_type: "Objection".into(), - sender: "agent-gamma".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "p1", - "reason": "Security audit pending for the new auth module", - "severity": "medium" - })).unwrap(), -}; -``` - -Objections are recorded but do not block the decision process — they are informational. - -### Step 5: Cast Votes - -```rust -let vote = Envelope { - // ... - message_type: "Vote".into(), - sender: "agent-alpha".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "p1", - "vote": "approve", - "reason": "Objection addressed in patch v2.1.1" - })).unwrap(), -}; -``` - -Votes are keyed by sender — if the same sender votes again, the previous vote is overwritten. The phase advances to `Voting`. - -### Step 6: Commit the Decision - -```rust -let commitment = Envelope { - // ... - message_type: "Commitment".into(), - sender: "coordinator".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "commitment_id": "c1", - "action": "deploy-v2.1.1", - "authority_scope": "team-alpha", - "reason": "Unanimous approval with addressed objection" - })).unwrap(), -}; -``` - -The `Commitment` message finalizes the decision: -- The phase advances to `Committed`. -- The session resolves with the commitment payload as the resolution. -- No further messages are accepted. - -### Full Phase Progression - -``` -Proposal → Evaluation → Voting → Committed (resolved) -``` - ---- - -## Example 3: Multi-Round Convergence - -The multi-round client (`src/bin/multi_round_client.rs`) demonstrates participant-based convergence. - -### Run the Demo +Run: ```bash -cargo run --bin multi_round_client -``` - -### Expected Output - -``` -=== Multi-Round Convergence Demo === - -[session_start] ok=true error='' -[alice_contributes_a] ok=true error='' -[bob_contributes_b] ok=true error='' -[get_session] state=1 mode=multi_round -[bob_revises_to_a] ok=true error='' -[get_session] state=2 mode_version= -[after_convergence] ok=false error='SESSION_NOT_OPEN' - -=== Demo Complete === -``` - -### Step-by-Step Walkthrough - -#### 1. Create a Multi-Round Session - -```rust -let start_payload = SessionStartPayload { - intent: "convergence test".into(), - ttl_ms: 60000, - participants: vec!["alice".into(), "bob".into()], - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), - context: vec![], - roots: vec![], -}; - -let start = Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "SessionStart".into(), - message_id: "m0".into(), - session_id: "mr1".into(), - sender: "coordinator".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), // Protobuf-encoded -}; -``` - -**Key points:** -- The `participants` field declares that `alice` and `bob` are the expected contributors. -- The payload is protobuf-encoded (using `prost::Message::encode_to_vec()`), not JSON. -- The `intent` field provides a human-readable description. - -#### 2. Alice Contributes "option_a" - -```rust -let contribute = Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m1".into(), - session_id: "mr1".into(), - sender: "alice".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: br#"{"value":"option_a"}"#.to_vec(), -}; -``` - -**State after:** Round 1. Contributions: `{alice: "option_a"}`. Bob hasn't contributed yet — no convergence. - -#### 3. Bob Contributes "option_b" (Divergence) - -```rust -let contribute = Envelope { - // ... - sender: "bob".into(), - payload: br#"{"value":"option_b"}"#.to_vec(), -}; -``` - -**State after:** Round 2. Contributions: `{alice: "option_a", bob: "option_b"}`. All participants have contributed but values differ — no convergence. - -#### 4. Query Session State — Still Open - -```rust -let resp = client.get_session(GetSessionRequest { session_id: "mr1".into() }).await?; -// state=1 (SESSION_STATE_OPEN) -``` - -#### 5. Bob Revises to "option_a" (Convergence!) - -```rust -let contribute = Envelope { - // ... - sender: "bob".into(), - payload: br#"{"value":"option_a"}"#.to_vec(), -}; -``` - -**State after:** Round 3. Contributions: `{alice: "option_a", bob: "option_a"}`. All participants have contributed and all values are identical — **convergence reached!** The session auto-resolves with: - -```json -{ - "converged_value": "option_a", - "round": 3, - "final_values": { - "alice": "option_a", - "bob": "option_a" - } -} -``` - -#### 6. Attempt After Convergence — Rejected - -```rust -let contribute = Envelope { - // ... - sender: "alice".into(), - payload: br#"{"value":"option_c"}"#.to_vec(), -}; -// Ack: ok=false, error="SESSION_NOT_OPEN" -``` - -The session is resolved — no further contributions are accepted. - -### Why Round 3? - -- Round 0: Session starts with no contributions. -- Round 1: Alice contributes "option_a" (new contribution). -- Round 2: Bob contributes "option_b" (new contribution). -- Round 3: Bob revises to "option_a" (value changed from "option_b"). - -Re-submitting the same value does **not** increment the round. Only substantive changes count. - ---- - -## Example 4: Fuzz Client — Testing Every Error Path - -The fuzz client (`src/bin/fuzz_client.rs`) is a comprehensive test that exercises every error code, every new RPC, and every edge case. Here's what it tests and what each test proves: - -### Expected Output - -``` -[initialize] version=1.0 -[initialize_bad_version] error: ...INVALID_ARGUMENT... -[wrong_version] ok=false duplicate=false error='UNSUPPORTED_PROTOCOL_VERSION' -[missing_fields] ok=false duplicate=false error='INVALID_ENVELOPE' -[unknown_session_message] ok=false duplicate=false error='SESSION_NOT_FOUND' -[session_start_ok] ok=true duplicate=false error='' -[session_start_duplicate] ok=false duplicate=false error='INVALID_ENVELOPE' -[session_start_idempotent] ok=true duplicate=true error='' -[message_ok] ok=true duplicate=false error='' -[message_duplicate] ok=true duplicate=true error='' -[resolve] ok=true duplicate=false error='' -[after_resolve] ok=false duplicate=false error='SESSION_NOT_OPEN' -[ttl_session_start] ok=true duplicate=false error='' -[ttl_expired_message] ok=false duplicate=false error='SESSION_NOT_OPEN' -[invalid_ttl_negative] ok=false duplicate=false error='INVALID_ENVELOPE' -[invalid_ttl_exceeds_max] ok=false duplicate=false error='INVALID_ENVELOPE' -[multi_round_start] ok=true duplicate=false error='' -[multi_round_alice] ok=true duplicate=false error='' -[multi_round_bob_diff] ok=true duplicate=false error='' -[multi_round_bob_converge] ok=true duplicate=false error='' -[multi_round_after_resolve] ok=false duplicate=false error='SESSION_NOT_OPEN' -[cancel_session_start] ok=true duplicate=false error='' -[cancel_session] ok=true -[after_cancel] ok=false duplicate=false error='SESSION_NOT_OPEN' -[participant_session_start] ok=true duplicate=false error='' -[unauthorized_sender] ok=false duplicate=false error='INVALID_ENVELOPE' -[authorized_sender] ok=true duplicate=false error='' -[signal] ok=true duplicate=false error='' -[get_session] state=2 mode=decision -[list_modes] count=1 modes=["macp.mode.decision.v1"] -[get_manifest] agent_id=macp-runtime modes=["macp.mode.decision.v1"] -[list_roots] count=0 -``` - -### What Each Test Proves - -| Test | What It Proves | -|------|---------------| -| `initialize` | Protocol handshake with version "1.0" succeeds | -| `initialize_bad_version` | Unsupported version "2.0" returns gRPC INVALID_ARGUMENT | -| `wrong_version` | Envelope with `macp_version: "v0"` is rejected | -| `missing_fields` | Empty `message_id` is rejected | -| `unknown_session_message` | Message to non-existent session returns SESSION_NOT_FOUND | -| `session_start_ok` | Valid SessionStart creates a session | -| `session_start_duplicate` | Second SessionStart with different message_id is rejected | -| `session_start_idempotent` | Same SessionStart with same message_id is idempotent (duplicate=true) | -| `message_ok` | Valid message to open session succeeds | -| `message_duplicate` | Same message_id is idempotent (duplicate=true) | -| `resolve` | Legacy `payload="resolve"` resolves the session | -| `after_resolve` | Message to resolved session returns SESSION_NOT_OPEN | -| `ttl_session_start` | Session with 1-second TTL is created | -| `ttl_expired_message` | Message after TTL expiry returns SESSION_NOT_OPEN | -| `invalid_ttl_negative` | Negative TTL is rejected | -| `invalid_ttl_exceeds_max` | TTL > 24h is rejected | -| `multi_round_*` | Full multi-round convergence cycle works | -| `cancel_session` | CancelSession transitions session to Expired | -| `after_cancel` | Message to cancelled session returns SESSION_NOT_OPEN | -| `participant_*` | Unauthorized sender is rejected, authorized sender succeeds | -| `signal` | Signal message with empty session_id succeeds | -| `get_session` | GetSession returns correct state | -| `list_modes` | ListModes returns both registered modes | -| `get_manifest` | GetManifest returns runtime identity and modes | -| `list_roots` | ListRoots returns empty list | - ---- - -## Example 5: Using the New RPCs - -### Initialize - -```rust -let init_resp = client - .initialize(InitializeRequest { - supported_protocol_versions: vec!["1.0".into()], - client_info: Some(ClientInfo { - name: "my-agent".into(), - title: "My Coordination Agent".into(), - version: "1.0.0".into(), - description: "An agent that coordinates deployments".into(), - website_url: String::new(), - }), - capabilities: None, - }) - .await? - .into_inner(); - -println!("Selected version: {}", init_resp.selected_protocol_version); -println!("Runtime: {:?}", init_resp.runtime_info); -println!("Supported modes: {:?}", init_resp.supported_modes); -``` - -### ListModes - -```rust -let modes = client.list_modes(ListModesRequest {}).await?.into_inner().modes; -for mode in &modes { - println!("Mode: {} (v{})", mode.mode, mode.mode_version); - println!(" Title: {}", mode.title); - println!(" Message types: {:?}", mode.message_types); - println!(" Terminal types: {:?}", mode.terminal_message_types); - println!(" Determinism: {}", mode.determinism_class); - println!(" Participant model: {}", mode.participant_model); -} -``` - -### GetManifest - -```rust -let manifest = client - .get_manifest(GetManifestRequest { agent_id: String::new() }) - .await? - .into_inner() - .manifest - .unwrap(); - -println!("Runtime: {} - {}", manifest.agent_id, manifest.description); -println!("Supported modes: {:?}", manifest.supported_modes); -``` - -### GetSession - -```rust -let metadata = client - .get_session(GetSessionRequest { session_id: "s1".into() }) - .await? - .into_inner() - .metadata - .unwrap(); - -println!("Session: {}", metadata.session_id); -println!("Mode: {}", metadata.mode); -println!("State: {} (1=Open, 2=Resolved, 3=Expired)", metadata.state); -println!("Started at: {}", metadata.started_at_unix_ms); -println!("Expires at: {}", metadata.expires_at_unix_ms); -println!("Mode version: {}", metadata.mode_version); -``` - ---- - -## Example 6: Session Cancellation - -```rust -// Create a session -let start = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m_c1".into(), - session_id: "s_cancel".into(), - sender: "ajit".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: vec![], -}; -client.send(SendRequest { envelope: Some(start) }).await?; - -// Cancel the session -let cancel_resp = client - .cancel_session(CancelSessionRequest { - session_id: "s_cancel".into(), - reason: "User requested cancellation".into(), - }) - .await? - .into_inner(); - -let ack = cancel_resp.ack.unwrap(); -println!("Cancel: ok={}", ack.ok); // ok=true - -// Try to send a message — rejected -let msg = Envelope { - // ... - session_id: "s_cancel".into(), - message_id: "m_c2".into(), - payload: b"should-fail".to_vec(), - // ... -}; -let ack = client.send(SendRequest { envelope: Some(msg) }).await?.into_inner().ack.unwrap(); -println!("After cancel: ok={} error={}", ack.ok, ack.error.unwrap().code); -// ok=false error=SESSION_NOT_OPEN -``` - -**Key behaviors:** -- Cancelling an open session transitions it to `Expired` and logs the reason. -- Cancelling an already resolved or expired session is idempotent — returns `ok: true`. -- Messages to a cancelled session are rejected with `SESSION_NOT_OPEN`. - ---- - -## Example 7: Message Deduplication - -The runtime deduplicates messages by `message_id` within a session: - -```rust -// Send a message -let msg = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m_dedup".into(), - session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), -}; - -// First send — accepted normally -let ack1 = client.send(SendRequest { envelope: Some(msg.clone()) }).await?.into_inner().ack.unwrap(); -println!("First: ok={} duplicate={}", ack1.ok, ack1.duplicate); -// ok=true duplicate=false - -// Second send (same message_id) — idempotent duplicate -let ack2 = client.send(SendRequest { envelope: Some(msg.clone()) }).await?.into_inner().ack.unwrap(); -println!("Second: ok={} duplicate={}", ack2.ok, ack2.duplicate); -// ok=true duplicate=true -``` - -**Why this matters:** Network retries are safe. If a client isn't sure whether a message was received (e.g., timeout on the response), it can safely resend with the same `message_id`. The runtime will recognize it as a duplicate and return success without re-processing. - -This also works for `SessionStart`: - -```rust -// Same SessionStart with same message_id is idempotent -let start = Envelope { - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - // ... -}; - -// If s1 was already created with message_id "m1": -let ack = client.send(SendRequest { envelope: Some(start) }).await?.into_inner().ack.unwrap(); -// ok=true, duplicate=true -``` - ---- - -## Example 8: Participant Validation - -Sessions can restrict which senders are allowed: - -```rust -// Create a session with a participant list -let start_payload = SessionStartPayload { - participants: vec!["alice".into(), "bob".into()], - ttl_ms: 60000, - ..Default::default() -}; - -let start = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m_p1".into(), - session_id: "s_participant".into(), - sender: "alice".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), -}; -client.send(SendRequest { envelope: Some(start) }).await?; - -// Charlie tries to send — REJECTED (not a participant) -let msg = Envelope { - // ... - session_id: "s_participant".into(), - sender: "charlie".into(), - message_id: "m_p2".into(), - // ... -}; -let ack = client.send(SendRequest { envelope: Some(msg) }).await?.into_inner().ack.unwrap(); -println!("Charlie: ok={}", ack.ok); // ok=false (INVALID_ENVELOPE) - -// Alice sends — ACCEPTED (is a participant) -let msg = Envelope { - // ... - session_id: "s_participant".into(), - sender: "alice".into(), - message_id: "m_p3".into(), - // ... -}; -let ack = client.send(SendRequest { envelope: Some(msg) }).await?.into_inner().ack.unwrap(); -println!("Alice: ok={}", ack.ok); // ok=true -``` - ---- - -## Example 9: Signal Messages - -Signal messages are ambient, session-less messages: - -```rust -let signal = Envelope { - macp_version: "1.0".into(), - mode: String::new(), // mode is optional for signals - message_type: "Signal".into(), - message_id: "sig1".into(), - session_id: String::new(), // session_id can be empty! - sender: "alice".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: vec![], -}; - -let ack = client.send(SendRequest { envelope: Some(signal) }).await?.into_inner().ack.unwrap(); -println!("Signal: ok={}", ack.ok); // ok=true -``` - -**Key points:** -- `session_id` may be empty — signals don't belong to any session. -- No session is created or modified. -- The runtime simply acknowledges receipt. -- Useful for heartbeats, coordination hints, or cross-session correlation. - ---- - -## Example 10: Session with Custom TTL - -```rust -// Create a session with a 1-second TTL -let start_payload = SessionStartPayload { - ttl_ms: 1000, // 1 second - ..Default::default() -}; - -let start = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m_ttl1".into(), - session_id: "s_ttl".into(), - sender: "ajit".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), -}; - -client.send(SendRequest { envelope: Some(start) }).await?; - -// Wait for TTL to expire -tokio::time::sleep(Duration::from_millis(1200)).await; - -// This message will be rejected — session expired -let msg = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m_ttl2".into(), - session_id: "s_ttl".into(), - sender: "ajit".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: b"too-late".to_vec(), -}; - -let ack = client.send(SendRequest { envelope: Some(msg) }).await?.into_inner().ack.unwrap(); -println!("After TTL: ok={} error={}", ack.ok, ack.error.unwrap().code); -// ok=false error=SESSION_NOT_OPEN -``` - -**TTL rules:** -- `ttl_ms: 0` (or absent) → default 60 seconds. -- `ttl_ms: 1` to `86,400,000` → custom TTL. -- Negative or > 24h → rejected with `INVALID_ENVELOPE`. -- TTL is enforced lazily on the next message — no background cleanup. - ---- - -## Example 11: Multi-Agent Scenario - -Imagine three agents coordinating a deployment decision using the full Decision Mode lifecycle: - -### Phase 1: Setup - -```rust -// Coordinator creates a decision session with participant list -let start_payload = SessionStartPayload { - intent: "Decide on v3.0 release strategy".into(), - participants: vec!["lead".into(), "security".into(), "ops".into()], - ttl_ms: 300000, // 5 minutes - ..Default::default() -}; - -// ... send SessionStart ... -``` - -### Phase 2: Proposal - -```rust -// Lead agent proposes a deployment strategy -let proposal = Envelope { - message_type: "Proposal".into(), - sender: "lead".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "release-v3", - "option": "Blue-green deployment with 10% canary", - "rationale": "Minimizes risk while allowing quick rollback" - })).unwrap(), - // ... other fields ... -}; -``` - -### Phase 3: Evaluation + Objection - -```rust -// Security agent evaluates -let eval = Envelope { - message_type: "Evaluation".into(), - sender: "security".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "release-v3", - "recommendation": "REVIEW", - "confidence": 0.75, - "reason": "Need to verify WAF rules for new endpoints" - })).unwrap(), - // ... -}; - -// Security agent raises objection -let objection = Envelope { - message_type: "Objection".into(), - sender: "security".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "release-v3", - "reason": "New /admin endpoint lacks rate limiting", - "severity": "high" - })).unwrap(), - // ... -}; -``` - -### Phase 4: Voting - -```rust -// All agents vote after objection is addressed -for (sender, vote) in [("lead", "approve"), ("security", "approve"), ("ops", "approve")] { - let vote_msg = Envelope { - message_type: "Vote".into(), - sender: sender.into(), - payload: serde_json::to_vec(&serde_json::json!({ - "proposal_id": "release-v3", - "vote": vote, - "reason": "Objection addressed in hotfix" - })).unwrap(), - // ... - }; - client.send(SendRequest { envelope: Some(vote_msg) }).await?; -} +cargo run --bin client ``` -### Phase 5: Commitment +Flow: -```rust -// Lead commits the decision — session resolves -let commitment = Envelope { - message_type: "Commitment".into(), - sender: "lead".into(), - payload: serde_json::to_vec(&serde_json::json!({ - "commitment_id": "release-v3-commit", - "action": "deploy-blue-green-canary", - "authority_scope": "release-team", - "reason": "Unanimous approval after security review" - })).unwrap(), - // ... -}; -``` +1. `Initialize` +2. `ListModes` +3. `SessionStart` by `coordinator` +4. `Proposal` by `coordinator` +5. `Evaluation` by a participant +6. `Vote` by a participant +7. `Commitment` by `coordinator` +8. `GetSession` -The session is now `Resolved` with the commitment as the resolution. +Important runtime behavior: ---- +- initiator/coordinator may emit `Proposal` and `Commitment` +- participants emit `Evaluation`, `Objection`, and `Vote` +- duplicate proposal IDs are rejected +- votes are tracked per proposal, per sender +- `CommitmentPayload` version fields must match the bound session versions -## Example 12: Proposal Mode — Peer Coordination +## Example 2: Proposal Mode -The Proposal Mode (`macp.mode.proposal.v1`) provides a lightweight peer-based coordination lifecycle. Run the demo: +Run: ```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 +Flow: -```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(), - // ... -}; -``` +1. buyer starts the session +2. seller creates a proposal +3. buyer counters +4. both required participants accept the same live proposal +5. buyer emits `Commitment` -A rejection is recorded but does not terminate the session. Alice can submit a new proposal to try again. +Important runtime behavior: ---- +- a proposal session does **not** resolve merely because a proposal exists +- `Commitment` is accepted only after acceptance convergence or a terminal rejection -## Example 13: Task Mode — Orchestrated Task Tracking +## Example 3: Task Mode -The Task Mode (`macp.mode.task.v1`) provides orchestrator-driven task assignment and tracking. Run the demo: +Run: ```bash cargo run --bin task_client ``` -### Step 1: Create a Task Session +Flow: -```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 +1. planner starts the session +2. planner sends `TaskRequest` +3. worker sends `TaskAccept` +4. worker sends `TaskUpdate` +5. worker sends `TaskComplete` +6. planner emits `Commitment` -```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(), -}; -``` +## Example 4: Handoff Mode -### 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: +Run: ```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 +Flow: -```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. - ---- +1. owner starts the session +2. owner sends `HandoffOffer` +3. owner sends `HandoffContext` +4. target sends `HandoffAccept` +5. owner emits `Commitment` -## Example 15: Quorum Mode — Threshold Voting +## Example 5: Quorum Mode -The Quorum Mode (`macp.mode.quorum.v1`) implements threshold-based voting. Run the demo: +Run: ```bash cargo run --bin quorum_client ``` -### Step 1: Create a Quorum Session +Flow: -```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). +1. coordinator starts the session +2. coordinator sends `ApprovalRequest` +3. participants send ballots +4. coordinator emits `Commitment` after threshold is satisfied -### Step 2: Participants Vote +## Example 6: Experimental multi-round mode -```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(), -}; -``` +Run: -### 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(), - // ... -}; +```bash +cargo run --bin multi_round_client ``` -Abstentions are recorded but do not count toward the quorum threshold. +This mode is still experimental. It remains callable by the explicit canonical name `macp.mode.multi_round.v1`, but it is not advertised by discovery RPCs and it does not use the strict standards-track `SessionStart` contract. -### Step 4: Quorum Reached +## Example 7: Freeze-check / error-path client -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. +Run: -``` -alice: approve → bob: approve → diana: approve → RESOLVED (3/5 quorum met) +```bash +cargo run --bin fuzz_client ``` ---- +This client exercises common failure paths for the freeze profile, including: -## Common Patterns +- invalid protocol version +- empty mode +- invalid payloads +- duplicate messages +- unauthorized sender spoofing +- payload too large +- session access without membership -### Pattern 1: Structured Error Handling +## Common troubleshooting -```rust -let ack = client.send(SendRequest { envelope: Some(env) }).await?.into_inner().ack.unwrap(); +### `UNAUTHENTICATED` -if ack.ok { - if ack.duplicate { - println!("Idempotent duplicate — already processed"); - } else { - println!("Success! Session state: {:?}", ack.session_state); - } -} else { - let err = ack.error.unwrap(); - match err.code.as_str() { - "UNSUPPORTED_PROTOCOL_VERSION" => println!("Use macp_version: 1.0"), - "INVALID_ENVELOPE" => println!("Check required fields and payload format"), - "SESSION_NOT_FOUND" => println!("Session doesn't exist — send SessionStart first"), - "SESSION_NOT_OPEN" => println!("Session is resolved or expired"), - "MODE_NOT_SUPPORTED" => println!("Use a registered mode name"), - code => println!("Error {}: {}", code, err.message), - } -} -``` +Either send a bearer token that exists in the configured auth map, or start the runtime with: -### Pattern 2: Protobuf-Encoded SessionStart Payload - -```rust -use macp_runtime::pb::SessionStartPayload; -use prost::Message; - -let payload = SessionStartPayload { - intent: "My coordination task".into(), - participants: vec!["agent-a".into(), "agent-b".into()], - ttl_ms: 120000, // 2 minutes - mode_version: "1.0.0".into(), - configuration_version: String::new(), - policy_version: String::new(), - context: vec![], - roots: vec![], -}; - -let start = Envelope { - // ... - payload: payload.encode_to_vec(), -}; +```bash +export MACP_ALLOW_DEV_SENDER_HEADER=1 ``` -### Pattern 3: Unique Message IDs with UUIDs +and ensure the client sets `x-macp-agent-id`. -```rust -use uuid::Uuid; +### `INVALID_ENVELOPE` on `SessionStart` -let envelope = Envelope { - message_id: Uuid::new_v4().to_string(), - // ... other fields -}; -``` +For a standards-track mode, check that: -### Pattern 4: Current Timestamp +- the mode name is canonical +- the payload is not empty +- `mode_version` is present +- `configuration_version` is present +- `ttl_ms > 0` +- participants are present and unique -```rust -use chrono::Utc; +### `SESSION_NOT_OPEN` -let envelope = Envelope { - timestamp_unix_ms: Utc::now().timestamp_millis(), - // ... other fields -}; -``` +The session is already resolved or expired. Use `GetSession` to confirm the terminal state. -### Pattern 5: Helper Function +### `RATE_LIMITED` -```rust -fn create_envelope( - mode: &str, - message_type: &str, - session_id: &str, - sender: &str, - payload: &[u8], -) -> Envelope { - Envelope { - macp_version: "1.0".into(), - mode: mode.into(), - message_type: message_type.into(), - message_id: uuid::Uuid::new_v4().to_string(), - session_id: session_id.into(), - sender: sender.into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: payload.to_vec(), - } -} +Increase the limits only if you understand the operational impact: -// Usage: -let start = create_envelope("decision", "SessionStart", "s1", "alice", b""); -let vote = create_envelope("decision", "Vote", "s1", "alice", - &serde_json::to_vec(&serde_json::json!({ - "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"}"# -); +```bash +export MACP_SESSION_START_LIMIT_PER_MINUTE=120 +export MACP_MESSAGE_LIMIT_PER_MINUTE=2000 ``` - ---- - -## Common Questions - -### Q: What protocol version should I use? - -**A:** Use `macp_version: "1.0"`. This is the only supported version in v0.3. Always call `Initialize` first to confirm. - -### Q: How do I encode the SessionStart payload? - -**A:** Use protobuf encoding. In Rust, create a `SessionStartPayload` and call `.encode_to_vec()`. In other languages, use the generated protobuf code for `macp.v1.SessionStartPayload`. An empty payload (zero bytes) is also valid and uses all defaults. - -### Q: What's the difference between "decision" and "macp.mode.decision.v1"? - -**A:** They refer to the same mode. `"decision"` is a backward-compatible alias; `"macp.mode.decision.v1"` is the RFC-compliant canonical name. Both work identically. The same applies to `"multi_round"` and `"macp.mode.multi_round.v1"`. - -### Q: Can I send messages from different senders to the same session? - -**A:** Yes, if the session has no participant list (open participation). If the session has a participant list, only listed senders can send. - -### Q: What if I use the same message_id twice? - -**A:** The runtime treats it as an idempotent duplicate — it returns `ok: true, duplicate: true` without re-processing. This is by design for safe retries. - -### Q: Can I create multiple sessions with the same ID? - -**A:** No. The second SessionStart (with a different message_id) will be rejected with `INVALID_ENVELOPE`. However, resending the same SessionStart (same message_id) is idempotent and returns success. - -### Q: How long do sessions last? - -**A:** Sessions have a configurable TTL (default 60 seconds, max 24 hours). Set `ttl_ms` in the `SessionStartPayload`. - -### Q: Can I cancel a session? - -**A:** Yes. Use the `CancelSession` RPC with a session_id and reason. The session transitions to Expired and the reason is logged. - -### Q: Can I "unresolve" a session? - -**A:** No. Resolved and Expired are terminal states. Create a new session if you need to continue coordination. - -### Q: What happens if the server crashes? - -**A:** All session state is lost (it's in-memory only). Clients would need to reconnect and restart sessions. Future versions may add persistent storage. - -### Q: What modes are available? - -**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 standard modes at runtime. - -### Q: How do Signal messages work? - -**A:** Signals are fire-and-forget messages that don't require a session_id. They don't create or modify sessions. They're useful for heartbeats, hints, or cross-session correlation. - -### Q: What's the difference between the old "resolve" payload and the new Commitment message? - -**A:** The old mechanism (sending `payload: b"resolve"` with `message_type: "Message"`) still works for backward compatibility. The new `Commitment` message type is richer — it carries a `CommitmentPayload` with fields like `commitment_id`, `action`, `authority_scope`, and `reason`. Both resolve the session, but the new mechanism provides much more context in the resolution. - ---- - -## Next Steps - -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. **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. -- Read **[protocol.md](./protocol.md)** for the complete protocol specification. diff --git a/docs/protocol.md b/docs/protocol.md index a0c70c3..155366a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,1425 +1,83 @@ -# MACP Protocol Specification (v1.0 — RFC-0001) +# Runtime protocol profile -This document is the authoritative specification of the Multi-Agent Coordination Protocol (MACP) as implemented by `macp-runtime` v0.3. It describes every message type, every field, every validation rule, every error code, and every behavioral guarantee in narrative detail. Whether you are building a client, implementing a new mode, or auditing the protocol for correctness, this document is your reference. +This document describes the current implementation profile of `macp-runtime v0.4.0`. ---- +The RFC/spec repository is the normative source for protocol semantics. This file is intentionally short and only highlights the implementation choices that matter to SDK authors and local operators. -## Table of Contents +## Supported protocol version -1. [Protocol Version](#protocol-version) -2. [Core Concepts](#core-concepts) -3. [Protobuf Schema Organization](#protobuf-schema-organization) -4. [The Envelope](#the-envelope) -5. [The Ack Response](#the-ack-response) -6. [Structured Errors (MACPError)](#structured-errors-macperror) -7. [Session State Enum](#session-state-enum) -8. [gRPC Service Definition](#grpc-service-definition) -9. [RPC: Initialize](#rpc-initialize) -10. [RPC: Send](#rpc-send) -11. [RPC: GetSession](#rpc-getsession) -12. [RPC: CancelSession](#rpc-cancelsession) -13. [RPC: GetManifest](#rpc-getmanifest) -14. [RPC: ListModes](#rpc-listmodes) -15. [RPC: StreamSession](#rpc-streamsession) -16. [RPC: ListRoots, WatchModeRegistry, WatchRoots](#rpc-listrootswatchmoderegistrywatchroots) -17. [Message Type: SessionStart](#message-type-sessionstart) -18. [Message Type: Regular Message](#message-type-regular-message) -19. [Message Type: Signal](#message-type-signal) -20. [TTL Configuration](#ttl-configuration) -21. [Message Deduplication](#message-deduplication) -22. [Participant Validation](#participant-validation) -23. [Session State Machine](#session-state-machine) -24. [Mode System](#mode-system) -25. [Decision Mode Specification](#decision-mode-specification) -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) +- `macp_version = "1.0"` ---- +Clients should call `Initialize` before using the runtime. -## Protocol Version +## Implemented unary RPCs -Current version: **`1.0`** +- `Initialize` +- `Send` +- `GetSession` +- `CancelSession` +- `GetManifest` +- `ListModes` +- `ListRoots` -All messages must carry `macp_version: "1.0"`. The `Initialize` RPC is the mechanism by which client and server agree on a protocol version. The server currently supports only `"1.0"` — if the client proposes only unsupported versions, the `Initialize` call returns a gRPC `INVALID_ARGUMENT` error. +## Not in the freeze surface -> **Migration note from v0.1:** The previous protocol used `macp_version: "v1"`. Version 0.2 uses `"1.0"`. Old clients sending `"v1"` will receive `UNSUPPORTED_PROTOCOL_VERSION`. +- `StreamSession` exists in the protobuf surface but is intentionally disabled in this runtime profile +- `WatchModeRegistry` is unimplemented +- `WatchRoots` is unimplemented ---- +## Standards-track mode rules -## Core Concepts +For these modes: -### What Is a Protocol? +- `macp.mode.decision.v1` +- `macp.mode.proposal.v1` +- `macp.mode.task.v1` +- `macp.mode.handoff.v1` +- `macp.mode.quorum.v1` -A protocol is a set of rules that all participants agree to follow. Just as HTTP defines how browsers and servers exchange web pages, the MACP protocol defines how agents exchange coordination messages: what information must be included, what order things must happen, what is allowed, and what is forbidden. +`SessionStartPayload` must bind: -### Why a Formal Protocol? +- `participants` +- `mode_version` +- `configuration_version` +- `ttl_ms` -Without a formal protocol, different agents might format messages differently, state transitions could be inconsistent, errors would be ambiguous, and debugging would be nearly impossible. With MACP: +Empty payloads are rejected. Empty `mode` values are rejected. Duplicate participant IDs are rejected. -- Everyone speaks the same structured language. -- Behavior is predictable and deterministic. -- Tools and clients can be built for any MACP-compliant runtime. -- Audit logs are meaningful because every event follows a known schema. +## Experimental mode rule ---- +`macp.mode.multi_round.v1` remains available as an explicit experimental mode. It is not advertised by discovery RPCs and retains a more permissive bootstrap path for backward compatibility. -## Protobuf Schema Organization +## Security profile -The protocol is defined across seven protobuf files, organized by concern: +Production profile: -``` -proto/ -├── buf.yaml # Buf linter config (STANDARD lint, FILE breaking) -└── macp/ - ├── v1/ - │ ├── envelope.proto # Envelope, Ack, MACPError, SessionState enum - │ └── core.proto # Service definition, all request/response types, - │ # capability messages, session payloads, manifests, - │ # mode descriptors, streaming types - └── modes/ - ├── 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/ - └── quorum.proto # Quorum mode payload types -``` +- TLS transport +- sender derived from authenticated identity +- per-request authorization +- payload size caps +- rate limiting -**`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`. +Local development profile: -**`core.proto`** contains everything else: the `MACPRuntimeService` definition with all ten RPCs, the request/response wrappers, capability negotiation messages (`ClientInfo`, `RuntimeInfo`, `Capabilities` and its sub-capabilities), session lifecycle payloads (`SessionStartPayload`, `SessionCancelPayload`, `CommitmentPayload`), introspection types (`AgentManifest`, `ModeDescriptor`), and streaming types. +- plaintext allowed only with `MACP_ALLOW_INSECURE=1` +- sender header shortcut allowed only with `MACP_ALLOW_DEV_SENDER_HEADER=1` +- clients attach `x-macp-agent-id` -**`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. +## Persistence profile -**`proposal.proto`** contains the payload types for the Proposal Mode's peer-based propose/accept/reject lifecycle. +By default the runtime persists snapshots of: -**`task.proto`** contains the payload types for the Task Mode's orchestrated task assignment and completion tracking. +- session registry +- accepted-history log store -**`handoff.proto`** contains the payload types for the Handoff Mode's delegated context transfer between agents. +This gives restart recovery for session metadata, dedup state, and accepted-history inspection. Corrupt or incompatible snapshot files produce a warning on stderr; the runtime falls back to empty state instead of refusing to start. -**`quorum.proto`** contains the payload types for the Quorum Mode's threshold-based voting and resolution. +## Commitment validation -The `buf.yaml` file configures the Buf linter with `STANDARD` lint rules and `FILE`-level breaking-change detection, ensuring the proto schema evolves safely. +For standards-track modes, `CommitmentPayload` must carry version fields that match the session-bound values. ---- +## Discovery notes -## The Envelope - -Every message sent through the `Send` or `StreamSession` RPC is wrapped in an **Envelope**. The Envelope is the universal container — it carries both the routing metadata and the actual payload. - -```protobuf -message Envelope { - string macp_version = 1; // Must be "1.0" - string mode = 2; // Coordination mode (e.g., "decision", "macp.mode.decision.v1") - string message_type = 3; // Semantic type: "SessionStart", "Message", "Proposal", etc. - string message_id = 4; // Unique ID for this message (used for deduplication) - string session_id = 5; // Session this belongs to (empty for Signal messages) - string sender = 6; // Who is sending - int64 timestamp_unix_ms = 7; // Informational client-side timestamp - bytes payload = 8; // The actual content -} -``` - -**Field-by-field narrative:** - -- **`macp_version`** — The protocol version. The server checks this first. If it is not `"1.0"`, the message is immediately rejected with `UNSUPPORTED_PROTOCOL_VERSION`. This is a hard gate — no further processing occurs. - -- **`mode`** — The name of the coordination mode that should handle this message. Accepted values include RFC-compliant names (`macp.mode.decision.v1`, `macp.mode.multi_round.v1`) and backward-compatible aliases (`decision`, `multi_round`). An empty string defaults to `macp.mode.decision.v1`. If the name does not match any registered mode, the message is rejected with `MODE_NOT_SUPPORTED`. - -- **`message_type`** — Determines how the runtime routes the message. Three routing categories exist: - - `"SessionStart"` — creates a new session. - - `"Signal"` — ambient message that does not require a session. - - Everything else (`"Message"`, `"Proposal"`, `"Evaluation"`, `"Objection"`, `"Vote"`, `"Commitment"`, `"Contribute"`, etc.) — dispatched to the mode's `on_message()` handler within an existing session. - -- **`message_id`** — A client-chosen unique identifier. The runtime uses this for deduplication: if a message with the same `message_id` has already been accepted for a given session, the runtime returns `ok: true, duplicate: true` without re-processing. Clients should use UUIDs or similarly unique values. - -- **`session_id`** — Identifies the session. Required for all message types except `Signal`. For `SessionStart`, this becomes the ID of the newly created session. For subsequent messages, the runtime looks up this session in the registry. - -- **`sender`** — Identifies who is sending the message. If the session has a non-empty participant list, the sender must be a member of that list or the message is rejected with `INVALID_ENVELOPE`. - -- **`timestamp_unix_ms`** — An informational timestamp set by the client. The runtime does not use this for any logic — it records its own `accepted_at_unix_ms` in the Ack. This field exists for client-side tracing and ordering. - -- **`payload`** — The actual content of the message, encoded as raw bytes. The interpretation depends on the `message_type` and the mode: - - For `SessionStart`: a protobuf-encoded `SessionStartPayload` (or empty bytes for defaults). - - For Decision Mode messages: JSON-encoded payloads matching `ProposalPayload`, `EvaluationPayload`, etc. - - For Multi-Round `Contribute` messages: JSON `{"value": ""}`. - - For `Signal`: arbitrary bytes. - ---- - -## The Ack Response - -Every `Send` call returns an `Ack` — a structured acknowledgment that provides complete information about what happened. - -```protobuf -message Ack { - bool ok = 1; // true if accepted - bool duplicate = 2; // true if this was an idempotent replay - string message_id = 3; // echoed from the request - string session_id = 4; // echoed from the request - int64 accepted_at_unix_ms = 5; // server-side timestamp - SessionState session_state = 6; // session state after processing - MACPError error = 7; // structured error (if ok == false) -} -``` - -**Understanding the Ack fields:** - -- **`ok`** — The primary success indicator. `true` means the message was accepted and processed. `false` means it was rejected — consult the `error` field for details. - -- **`duplicate`** — Set to `true` when the runtime recognizes a previously-accepted `message_id` for the same session. The message is not reprocessed; the Ack simply confirms idempotent acceptance. This allows clients to safely retry without side effects. - -- **`message_id`** and **`session_id`** — Echoed back from the request for client-side correlation, especially useful in asynchronous or batched workflows. - -- **`accepted_at_unix_ms`** — The server-side timestamp (milliseconds since Unix epoch) at the moment the message was accepted. This is authoritative — clients should use this rather than their own `timestamp_unix_ms` for ordering guarantees. - -- **`session_state`** — The session's state *after* the message was processed. This tells the client whether the session is still `OPEN`, has been `RESOLVED` (e.g., after a `Commitment` message in Decision Mode), or has `EXPIRED`. For messages that don't touch a session (e.g., `Signal`), this is `SESSION_STATE_OPEN`. - -- **`error`** — A structured `MACPError` object present when `ok == false`. Contains the RFC error code, a human-readable message, and optional correlation fields. See the next section for details. - -> **Migration note from v0.1:** The old `Ack` had only `accepted: bool` and `error: string`. The new Ack is significantly richer — clients should update to read the structured `error` field and the `duplicate` and `session_state` fields. - ---- - -## Structured Errors (MACPError) - -When a message is rejected, the `Ack.error` field contains a structured error: - -```protobuf -message MACPError { - string code = 1; // RFC error code (e.g., "INVALID_ENVELOPE") - string message = 2; // Human-readable description - string session_id = 3; // Correlated session (if applicable) - string message_id = 4; // Correlated message (if applicable) - bytes details = 5; // Optional additional detail payload -} -``` - -The `code` field uses a fixed vocabulary of RFC-compliant error codes (see [Error Codes](#error-codes-complete) below). The `message` field provides a human-readable explanation. The `session_id` and `message_id` fields echo back the relevant identifiers for correlation. The `details` field is reserved for future use (e.g., structured error payloads for specific modes). - ---- - -## Session State Enum - -Session state is represented as a protobuf enum: - -```protobuf -enum SessionState { - SESSION_STATE_UNSPECIFIED = 0; - SESSION_STATE_OPEN = 1; - SESSION_STATE_RESOLVED = 2; - SESSION_STATE_EXPIRED = 3; -} -``` - -The `UNSPECIFIED` value is the protobuf default and should not be set intentionally. The runtime maps its internal `SessionState` enum (`Open`, `Resolved`, `Expired`) to these wire values. - ---- - -## gRPC Service Definition - -The `MACPRuntimeService` is the single gRPC service exposed by the runtime: - -```protobuf -service MACPRuntimeService { - rpc Initialize(InitializeRequest) returns (InitializeResponse); - rpc Send(SendRequest) returns (SendResponse); - rpc StreamSession(stream StreamSessionRequest) returns (stream StreamSessionResponse); - rpc GetSession(GetSessionRequest) returns (GetSessionResponse); - rpc CancelSession(CancelSessionRequest) returns (CancelSessionResponse); - rpc GetManifest(GetManifestRequest) returns (GetManifestResponse); - rpc ListModes(ListModesRequest) returns (ListModesResponse); - rpc WatchModeRegistry(WatchModeRegistryRequest) returns (stream WatchModeRegistryResponse); - rpc ListRoots(ListRootsRequest) returns (ListRootsResponse); - rpc WatchRoots(WatchRootsRequest) returns (stream WatchRootsResponse); -} -``` - -> **Migration note from v0.1:** The old service was named `MACPService` with only two RPCs (`SendMessage` and `GetSession`). The v0.2 service is `MACPRuntimeService` with ten RPCs. The `SendMessage` RPC has been replaced by `Send` (which wraps the `Envelope` in a `SendRequest`). - ---- - -## RPC: Initialize - -The `Initialize` RPC is a protocol handshake that should be called before any session work begins. It negotiates the protocol version and exchanges capability information. - -**Request:** -```protobuf -message InitializeRequest { - repeated string supported_protocol_versions = 1; // e.g., ["1.0"] - ClientInfo client_info = 2; // optional client metadata - Capabilities capabilities = 3; // optional client capabilities -} -``` - -**Response:** -```protobuf -message InitializeResponse { - string selected_protocol_version = 1; // "1.0" - RuntimeInfo runtime_info = 2; // server name, version, description - Capabilities capabilities = 3; // server capabilities - repeated string supported_modes = 4; // registered mode names - string instructions = 5; // human-readable usage instructions -} -``` - -**Behavior:** - -1. The server inspects the client's `supported_protocol_versions` list. -2. If `"1.0"` is in the list, it is selected. If not, the RPC returns a gRPC `INVALID_ARGUMENT` status with a descriptive message. -3. The response includes the runtime's identity (`RuntimeInfo` with name `"macp-runtime"`, version `"0.2.0"`), its capabilities (sessions with streaming, cancellation, progress, manifest, mode registry, and roots), and the list of supported modes. -4. The `instructions` field provides a brief human-readable note about the runtime. - -**Capabilities advertised:** - -| Capability | Value | Description | -|------------|-------|-------------| -| `sessions.stream` | `true` | StreamSession RPC is available | -| `cancellation.cancel_session` | `true` | CancelSession RPC is available | -| `progress.progress` | `true` | Progress tracking is supported | -| `manifest.get_manifest` | `true` | GetManifest RPC is available | -| `mode_registry.list_modes` | `true` | ListModes RPC is available | -| `mode_registry.list_changed` | `true` | WatchModeRegistry RPC is available | -| `roots.list_roots` | `true` | ListRoots RPC is available | -| `roots.list_changed` | `true` | WatchRoots RPC is available | - ---- - -## RPC: Send - -The `Send` RPC is the primary message ingestion point. It accepts a `SendRequest` containing an `Envelope` and returns a `SendResponse` containing an `Ack`. - -**Request:** -```protobuf -message SendRequest { - Envelope envelope = 1; -} -``` - -**Response:** -```protobuf -message SendResponse { - Ack ack = 1; -} -``` - -**Processing flow:** - -1. **Validate the Envelope** — check `macp_version == "1.0"`, check that `session_id` and `message_id` are non-empty (except for `Signal` messages where `session_id` may be empty). -2. **Delegate to the Runtime** — the `Runtime::process()` method routes to `process_session_start()`, `process_signal()`, or `process_message()` based on `message_type`. -3. **Build the Ack** — the server constructs a full `Ack` with `ok`, `duplicate`, echoed IDs, server timestamp, session state, and any error. - -All errors are returned in the Ack — the gRPC status is always `OK` for protocol-level errors. Only infrastructure-level failures (e.g., missing `Envelope` in the request) return non-OK gRPC statuses. - ---- - -## RPC: GetSession - -Retrieves metadata for a specific session. - -**Request:** -```protobuf -message GetSessionRequest { - string session_id = 1; -} -``` - -**Response:** -```protobuf -message GetSessionResponse { - SessionMetadata metadata = 1; -} - -message SessionMetadata { - string session_id = 1; - string mode = 2; - SessionState state = 3; - int64 started_at_unix_ms = 4; - int64 expires_at_unix_ms = 5; - string mode_version = 6; - string configuration_version = 7; - string policy_version = 8; -} -``` - -**Behavior:** - -If the session exists, its metadata is returned — including mode name, current state (as a `SessionState` enum value), creation timestamp, TTL expiry timestamp, and the version fields from the original `SessionStartPayload`. - -If the session does not exist, the RPC returns a gRPC `NOT_FOUND` status. - -> **Migration note from v0.1:** The old `GetSession` returned a `SessionInfo` with fields like `state` (as a string), `resolution`, `mode_state`, and `participants`. The new response uses `SessionMetadata` with typed `SessionState` enum and version metadata fields. - ---- - -## RPC: CancelSession - -Explicitly cancels an active session, transitioning it to `Expired` state. - -**Request:** -```protobuf -message CancelSessionRequest { - string session_id = 1; - string reason = 2; -} -``` - -**Response:** -```protobuf -message CancelSessionResponse { - Ack ack = 1; -} -``` - -**Behavior:** - -1. If the session does not exist, returns `ok: false` with `SESSION_NOT_FOUND`. -2. If the session is already `Resolved` or `Expired`, the cancellation is idempotent — returns `ok: true` without modification. -3. If the session is `Open`, logs an internal `SessionCancel` entry with the provided reason, transitions the session to `Expired`, and returns `ok: true`. - -The cancellation reason is persisted in the session's audit log, providing a clear record of why the session was terminated. - ---- - -## RPC: GetManifest - -Retrieves the agent manifest — a description of the runtime's identity and capabilities. - -**Request:** -```protobuf -message GetManifestRequest { - string agent_id = 1; // currently unused -} -``` - -**Response:** -```protobuf -message GetManifestResponse { - AgentManifest manifest = 1; -} - -message AgentManifest { - string agent_id = 1; - string title = 2; - string description = 3; - repeated string supported_modes = 4; - repeated string input_content_types = 5; - repeated string output_content_types = 6; - map metadata = 7; -} -``` - -The response includes the runtime's identity (`"macp-runtime"`, `"MACP Coordination Runtime"`), a description, and the list of supported mode names. - ---- - -## RPC: ListModes - -Discovers the coordination modes registered in the runtime. - -**Request:** `ListModesRequest {}` (empty) - -**Response:** -```protobuf -message ListModesResponse { - repeated ModeDescriptor modes = 1; -} - -message ModeDescriptor { - string mode = 1; - string mode_version = 2; - string title = 3; - string description = 4; - string determinism_class = 5; - string participant_model = 6; - repeated string message_types = 7; - repeated string terminal_message_types = 8; - map schema_uris = 9; -} -``` - -**Currently returned descriptors:** - -1. **Decision Mode:** - - `mode`: `"macp.mode.decision.v1"` - - `mode_version`: `"1.0.0"` - - `title`: `"Decision Mode"` - - `determinism_class`: `"deterministic"` - - `participant_model`: `"open"` - - `message_types`: `["Proposal", "Evaluation", "Objection", "Vote", "Commitment"]` - - `terminal_message_types`: `["Commitment"]` - -2. **Multi-Round Mode:** - - `mode`: `"macp.mode.multi_round.v1"` - - `mode_version`: `"1.0.0"` - - `title`: `"Multi-Round Convergence Mode"` - - `determinism_class`: `"deterministic"` - - `participant_model`: `"closed"` - - `message_types`: `["Contribute"]` - - `terminal_message_types`: `["Contribute"]` (the final Contribute that triggers convergence) - ---- - -## RPC: StreamSession - -Bidirectional streaming RPC for real-time session interaction. - -```protobuf -rpc StreamSession(stream StreamSessionRequest) returns (stream StreamSessionResponse); -``` - -The client sends a stream of `StreamSessionRequest` messages (each wrapping an `Envelope`), and the server responds with a stream of `StreamSessionResponse` messages (each wrapping an echoed `Envelope` with an updated `message_type` reflecting the processing result). This enables real-time, interactive coordination without polling. - ---- - -## RPC: ListRoots, WatchModeRegistry, WatchRoots - -- **ListRoots** — Returns an empty list of `Root` objects. Reserved for future resource-root discovery. -- **WatchModeRegistry** — Server-streaming RPC for mode registry change notifications. Currently returns `UNIMPLEMENTED`. -- **WatchRoots** — Server-streaming RPC for root change notifications. Currently returns `UNIMPLEMENTED`. - ---- - -## Message Type: SessionStart - -A `SessionStart` message creates a new coordination session. - -**Required fields:** -- `message_type`: `"SessionStart"` -- `session_id`: Must be unique — no session with this ID may already exist. -- `message_id`: Must be non-empty. -- `mode`: Must reference a registered mode (or be empty for the default `macp.mode.decision.v1`). - -**Payload:** - -The payload should be a protobuf-encoded `SessionStartPayload`: - -```protobuf -message SessionStartPayload { - string intent = 1; // human-readable purpose - repeated string participants = 2; // participant IDs (empty = open participation) - string mode_version = 3; // version of the mode to use - string configuration_version = 4; // configuration version identifier - string policy_version = 5; // policy version identifier - int64 ttl_ms = 6; // TTL in milliseconds (0 = default 60s) - bytes context = 7; // arbitrary context data - repeated Root roots = 8; // resource roots -} -``` - -An empty payload (zero bytes) is valid — the runtime uses defaults (60s TTL, no participants, empty version strings). - -**Processing sequence:** - -1. Runtime resolves the mode name (empty → `"macp.mode.decision.v1"`). -2. Looks up the mode in the registry — rejects with `MODE_NOT_SUPPORTED` if not found. -3. Decodes the payload as a protobuf `SessionStartPayload` — rejects with `INVALID_ENVELOPE` if decoding fails. -4. Extracts and validates TTL — rejects with `INVALID_ENVELOPE` if out of range (see [TTL Configuration](#ttl-configuration)). -5. Acquires write lock on the session registry. -6. Checks for duplicate session ID: - - If the session exists and the `message_id` matches the session's `seen_message_ids`, returns `ok: true, duplicate: true` (idempotent). - - If the session exists with a different `message_id`, rejects with `INVALID_ENVELOPE` (duplicate session). -7. Creates a session log and appends an `Incoming` entry. -8. Calls `mode.on_session_start()` — the mode may return `PersistState` with initial mode state. -9. Creates a `Session` object with state `Open`, computed TTL expiry, participants, version metadata, and the message_id recorded in `seen_message_ids`. -10. Applies the `ModeResponse` to mutate the session (e.g., storing initial mode state). -11. Inserts the session into the registry. - ---- - -## Message Type: Regular Message - -Any message with a `message_type` other than `"SessionStart"` or `"Signal"` is treated as a regular message dispatched to the session's mode. - -**Required fields:** -- `session_id`: Must reference an existing session. -- `message_id`: Must be non-empty. - -**Processing sequence:** - -1. Acquires write lock on the session registry. -2. Finds the session — rejects with `SESSION_NOT_FOUND` if not found. -3. **Deduplication check** — if `message_id` is already in the session's `seen_message_ids`, returns `ok: true, duplicate: true` without re-processing. -4. **TTL check** — if the session is `Open` and the current time exceeds `ttl_expiry`, logs an internal `TtlExpired` entry, transitions the session to `Expired`, and rejects with `SESSION_NOT_OPEN`. -5. **State check** — if the session is not `Open` (already `Resolved` or `Expired`), rejects with `SESSION_NOT_OPEN`. -6. **Participant check** — if the session has a non-empty `participants` list and the `sender` is not in it, rejects with `INVALID_ENVELOPE`. -7. Records `message_id` in `seen_message_ids`. -8. Appends an `Incoming` log entry. -9. Calls `mode.on_message(session, envelope)`. -10. Applies the `ModeResponse` to mutate session state. - ---- - -## Message Type: Signal - -`Signal` messages are ambient, session-less messages. They are fire-and-forget coordination hints. - -**Special rules:** -- `session_id` may be empty. -- `message_id` must be non-empty. -- No session is created, modified, or looked up. -- The runtime simply acknowledges receipt. - -**Use cases:** -- Heartbeats between agents. -- Out-of-band coordination hints. -- Cross-session correlation signals (using the `SignalPayload.correlation_session_id` field). - ---- - -## TTL Configuration - -Session TTL (time-to-live) determines how long a session remains open before it is considered expired. - -**Encoding:** TTL is specified in the `SessionStartPayload.ttl_ms` field (protobuf int64). - -| `ttl_ms` value | Behavior | -|----------------|----------| -| `0` (or field absent) | Default TTL: **60,000 ms** (60 seconds) | -| `1` to `86,400,000` | Custom TTL in milliseconds | -| Negative | Rejected with `INVALID_ENVELOPE` | -| `> 86,400,000` (> 24h) | Rejected with `INVALID_ENVELOPE` | - -**TTL enforcement:** TTL is enforced **lazily** — the runtime checks `current_time > ttl_expiry` on each non-SessionStart message. When expiry is detected: - -1. An internal `TtlExpired` log entry is appended. -2. The session transitions to `Expired`. -3. The message is rejected with error code `SESSION_NOT_OPEN`. - -There is no background cleanup thread — expired sessions remain in memory until the server is restarted. This is a deliberate simplification; future versions may add background eviction. - -> **Migration note from v0.1:** TTL was previously specified as a JSON payload `{"ttl_ms": }`. It is now a field in the protobuf `SessionStartPayload`. - ---- - -## Message Deduplication - -The runtime provides **at-least-once** delivery with idempotent acceptance via message deduplication. - -Each session maintains a `seen_message_ids: HashSet`. When a message arrives: - -1. If `message_id` is already in `seen_message_ids`, the runtime returns `ok: true, duplicate: true` without re-processing the message or calling the mode. -2. If `message_id` is new, it is added to `seen_message_ids` before processing. - -This applies to both `SessionStart` and regular messages: - -- **SessionStart deduplication:** If a `SessionStart` arrives for a session that already exists and the `message_id` matches one in the session's `seen_message_ids`, it is treated as an idempotent retry. If the `message_id` is different, it is rejected as a duplicate session. -- **Regular message deduplication:** If a regular message's `message_id` matches a previously accepted message for that session, it is returned as a duplicate. - -This design allows clients to safely retry failed network requests without causing double-processing. - ---- - -## Participant Validation - -Sessions can optionally restrict which senders are allowed to contribute. - -**Configuration:** The `SessionStartPayload.participants` field is a list of participant identifiers. If this list is non-empty, only senders whose name appears in the list may send messages to the session. - -**Enforcement:** - -- For regular messages (not `SessionStart` or `Signal`), the runtime checks whether `envelope.sender` is in `session.participants`. -- If the participant list is non-empty and the sender is not in it, the message is rejected with error code `INVALID_ENVELOPE`. -- If the participant list is empty, any sender is allowed (open participation). - -**Mode-specific behavior:** - -- In **Multi-Round Mode**, participants are essential — convergence is checked against the participant list. All listed participants must contribute for convergence to trigger. -- In **Decision Mode**, participants are optional — the mode works with or without a restricted participant list. - ---- - -## Session State Machine - -Sessions follow a strict state machine with three states and two terminal transitions: - -``` - SessionStart - │ - ▼ - ┌────────────┐ - │ OPEN │ ← Initial state - └────────────┘ - │ │ - (mode returns (TTL expires or - Resolve or CancelSession) - PersistAndResolve) - │ │ - ▼ ▼ - ┌──────────┐ ┌─────────┐ - │ RESOLVED │ │ EXPIRED │ - └──────────┘ └─────────┘ - (terminal) (terminal) -``` - -**Transition rules:** - -| From | To | Trigger | -|------|----|---------| -| Open | Resolved | Mode returns `ModeResponse::Resolve` or `ModeResponse::PersistAndResolve` | -| Open | Expired | TTL check fails on next message, or `CancelSession` RPC called | -| Resolved | — | Terminal — no transitions allowed | -| Expired | — | Terminal — no transitions allowed | - -Once a session reaches a terminal state, any subsequent message to that session is rejected with `SESSION_NOT_OPEN`. - ---- - -## Mode System - -The Mode system is the heart of MACP's extensibility. The runtime provides "physics" — session invariants, TTL enforcement, logging, routing, participant validation — while Modes provide "coordination logic" — when to resolve, what intermediate state to track, and what convergence criteria to apply. - -### The Mode Trait - -```rust -pub trait Mode: Send + Sync { - fn on_session_start(&self, session: &Session, env: &Envelope) - -> Result; - fn on_message(&self, session: &Session, env: &Envelope) - -> Result; -} -``` - -Both methods receive **immutable** references to the session and envelope. They cannot directly mutate state — they return a `ModeResponse` that the runtime applies as a single atomic mutation. - -### ModeResponse - -```rust -pub enum ModeResponse { - NoOp, // No state change - PersistState(Vec), // Update mode_state bytes - Resolve(Vec), // Set resolution, transition to Resolved - PersistAndResolve { state: Vec, resolution: Vec }, // Both -} -``` - -- **NoOp** — The mode has nothing to do. The message is accepted but no state changes. -- **PersistState** — The mode wants to update its internal state (e.g., record a vote, update a contribution). The bytes are stored in `session.mode_state`. -- **Resolve** — The mode has determined that the session should resolve. The resolution bytes are stored in `session.resolution` and the session transitions to `Resolved`. -- **PersistAndResolve** — Both state update and resolution in a single atomic operation. - -### Mode Registration - -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) | - -An empty `mode` field in the Envelope defaults to `"macp.mode.decision.v1"`. - ---- - -## Decision Mode Specification - -The Decision Mode (`macp.mode.decision.v1`) implements a structured decision-making lifecycle following RFC-0001. It models the flow from initial proposal through evaluation, optional objection, voting, and final commitment. - -### Decision State - -```rust -pub struct DecisionState { - pub proposals: HashMap, // proposal_id → Proposal - pub evaluations: Vec, - pub objections: Vec, - pub votes: HashMap, // sender → Vote (last vote wins) - pub phase: DecisionPhase, -} - -pub enum DecisionPhase { - Proposal, // Initial phase — waiting for proposals - Evaluation, // At least one proposal exists — accepting evaluations - Voting, // Votes are being cast - Committed, // Terminal — commitment recorded -} -``` - -### Message Types and Lifecycle - -The Decision Mode accepts five message types, each with a corresponding protobuf payload type defined in `decision.proto`: - -#### 1. Proposal - -Creates a new proposal within the session. - -**Payload (JSON-encoded `ProposalPayload`):** -```json -{ - "proposal_id": "p1", - "option": "Deploy to production", - "rationale": "All tests pass and staging looks good", - "supporting_data": "" -} -``` - -**Validation:** -- `proposal_id` must be non-empty — rejected with `InvalidPayload` if empty. -- A proposal with the same `proposal_id` overwrites the previous one. - -**Effect:** -- Records the proposal in `state.proposals`. -- Advances the phase to `Evaluation` (enabling evaluations and votes). -- Returns `PersistState` with the updated state. - -#### 2. Evaluation - -Evaluates an existing proposal with a recommendation. - -**Payload (JSON-encoded `EvaluationPayload`):** -```json -{ - "proposal_id": "p1", - "recommendation": "APPROVE", - "confidence": 0.95, - "reason": "Implementation looks solid" -} -``` - -**Validation:** -- `proposal_id` must reference an existing proposal — rejected with `InvalidPayload` if not found. - -**Recommendations:** `APPROVE`, `REVIEW`, `BLOCK`, `REJECT` - -**Effect:** -- Appends the evaluation to `state.evaluations`. -- Returns `PersistState`. - -#### 3. Objection - -Raises an objection against a proposal. - -**Payload (JSON-encoded `ObjectionPayload`):** -```json -{ - "proposal_id": "p1", - "reason": "Security review not completed", - "severity": "high" -} -``` - -**Validation:** -- `proposal_id` must reference an existing proposal — rejected with `InvalidPayload` if not found. - -**Severities:** `low`, `medium`, `high`, `critical` - -**Effect:** -- Appends the objection to `state.objections`. -- Returns `PersistState`. - -#### 4. Vote - -Casts a vote on the current proposals. - -**Payload (JSON-encoded `VotePayload`):** -```json -{ - "proposal_id": "p1", - "vote": "approve", - "reason": "Looks good to me" -} -``` - -**Validation:** -- At least one proposal must exist — rejected with `InvalidPayload` if no proposals. -- Cannot vote when phase is `Committed` — rejected with `InvalidPayload`. - -**Votes:** `approve`, `reject`, `abstain` - -**Effect:** -- Records the vote in `state.votes`, keyed by sender. If the same sender votes again, the previous vote is overwritten. -- Advances the phase to `Voting`. -- Returns `PersistState`. - -#### 5. Commitment - -Finalizes the decision and resolves the session. - -**Payload (JSON-encoded `CommitmentPayload`):** -```json -{ - "commitment_id": "c1", - "action": "deploy-v2.1", - "authority_scope": "team-alpha", - "reason": "Unanimous approval" -} -``` - -**Validation:** -- At least one vote must exist — rejected with `InvalidPayload` if no votes. -- Phase must not already be `Committed` — rejected with `InvalidPayload` if so. - -**Effect:** -- Advances the phase to `Committed`. -- Returns `PersistAndResolve` with the commitment payload as resolution bytes and the updated state. -- The session transitions to `Resolved`. - -### Backward Compatibility (Legacy Resolve) - -For backward compatibility with v0.1 clients, the Decision Mode also supports the legacy resolution mechanism: if the `message_type` is `"Message"` and the `payload` equals the bytes `b"resolve"`, the session is immediately resolved with `"resolve"` as the resolution payload. This allows old clients to continue working without modification. - -Any other `Message`-type payload returns `NoOp`. - -### Phase Transitions - -``` - Proposal received - │ - ┌──────────┐ ▼ ┌──────────────┐ - │ Proposal │ ──────────────────→│ Evaluation │ - └──────────┘ └──────────────┘ - │ ↑ - Vote │ │ Evaluation/Objection - received │ │ received - ▼ │ - ┌────────┐ - │ Voting │ - └────────┘ - │ - Commitment received - │ - ▼ - ┌───────────┐ - │ Committed │ (terminal) - └───────────┘ -``` - ---- - -## 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. - -### Multi-Round State - -```rust -pub struct MultiRoundState { - pub round: u64, // Current round number - pub participants: Vec, // Expected participant IDs - pub contributions: BTreeMap, // sender → current value - pub convergence_type: String, // "all_equal" -} -``` - -The `BTreeMap` is used instead of `HashMap` for deterministic serialization ordering. - -### SessionStart - -On `SessionStart`, the mode: - -1. Reads the `participants` list from the session (populated from `SessionStartPayload.participants`). -2. Validates that the participant list is non-empty — returns `InvalidPayload` if empty. -3. Initializes the state with `round: 0`, `convergence_type: "all_equal"`, and empty contributions. -4. Returns `PersistState` with the serialized initial state. - -### Contribute Messages - -The mode processes messages with `message_type: "Contribute"`. - -**Payload (JSON):** -```json -{"value": "option_a"} -``` - -**Processing:** - -1. Deserializes the current `mode_state` into `MultiRoundState`. -2. Parses the JSON payload to extract the `value` field. -3. Checks if the sender's value has changed from their previous contribution: - - If this is a new contribution or the value differs from the previous one → **increment the round counter** and update the contribution. - - If the value is identical to the previous one → update without incrementing the round (no change in substance). -4. Checks convergence: **all** listed participants have submitted at least one contribution, **and** all contribution values are identical. -5. If converged → returns `PersistAndResolve` with: - - `state`: the final `MultiRoundState` serialized to JSON. - - `resolution`: a JSON payload containing: - ```json - { - "converged_value": "option_a", - "round": 3, - "final_values": { - "alice": "option_a", - "bob": "option_a" - } - } - ``` -6. If not converged → returns `PersistState` with the updated state. - -Non-`Contribute` messages return `NoOp`. - -### Convergence Strategy: `all_equal` - -The only currently supported convergence strategy. Resolution triggers when: - -1. Every participant in the session's participant list has made at least one contribution. -2. All contribution values are identical. - -If any participant has not contributed, or if any two contributions differ, convergence has not been reached and the session remains open. - -### Round Counting - -- Round starts at `0`. -- Each time a participant submits a **new or changed** value, the round increments by 1. -- Re-submitting the **same** value does not increment the round — this prevents artificial round inflation. -- The final round number in the resolution tells you how many substantive value changes occurred across all participants. - ---- - -## Validation Rules (Complete) - -The following validation rules are applied in order. The first failing rule produces the error; subsequent rules are not checked. - -### 1. Protocol Version - -``` -IF macp_version != "1.0" -THEN reject with UNSUPPORTED_PROTOCOL_VERSION -``` - -This is checked in the gRPC adapter before any runtime processing. - -### 2. Required Fields - -``` -IF message_type != "Signal": - IF session_id is empty OR message_id is empty - THEN reject with INVALID_ENVELOPE - -IF message_type == "Signal": - IF message_id is empty - THEN reject with INVALID_ENVELOPE - (session_id may be empty) -``` - -### 3. Mode Resolution (SessionStart) - -``` -IF message_type == "SessionStart": - Resolve mode name (empty → "macp.mode.decision.v1") - IF mode not in registered modes - THEN reject with MODE_NOT_SUPPORTED -``` - -### 4. SessionStart Payload Parsing - -``` -IF message_type == "SessionStart": - Decode payload as protobuf SessionStartPayload - IF decode fails THEN reject with INVALID_ENVELOPE - - Extract ttl_ms from payload - IF ttl_ms < 0 THEN reject with INVALID_ENVELOPE - IF ttl_ms > 86,400,000 THEN reject with INVALID_ENVELOPE - IF ttl_ms == 0 THEN use default (60,000 ms) -``` - -### 5. Session Existence (SessionStart) - -``` -IF message_type == "SessionStart": - IF session already exists: - IF message_id matches existing session's seen_message_ids - THEN return ok=true, duplicate=true (idempotent) - ELSE reject with INVALID_ENVELOPE (duplicate session) -``` - -### 6. Session Existence (Regular Messages) - -``` -IF message_type is not "SessionStart" and not "Signal": - IF session does not exist - THEN reject with SESSION_NOT_FOUND -``` - -### 7. Message Deduplication (Regular Messages) - -``` -IF message_id is in session.seen_message_ids -THEN return ok=true, duplicate=true (idempotent) -``` - -### 8. TTL Expiry Check - -``` -IF session.state == Open AND current_time > session.ttl_expiry: - Log internal TtlExpired entry - Transition session to Expired - reject with SESSION_NOT_OPEN -``` - -### 9. Session State Check - -``` -IF session.state != Open -THEN reject with SESSION_NOT_OPEN -``` - -### 10. Participant Validation - -``` -IF session.participants is non-empty AND sender not in session.participants -THEN reject with INVALID_ENVELOPE -``` - -### 11. Mode Dispatch - -``` -Call mode.on_message(session, envelope) -IF mode returns Err(e) THEN reject with corresponding error code -ELSE apply ModeResponse -``` - ---- - -## Error Codes (Complete) - -| RFC Error Code | Internal Error | When It Occurs | -|----------------|---------------|----------------| -| `UNSUPPORTED_PROTOCOL_VERSION` | `InvalidMacpVersion` | `macp_version` is not `"1.0"` | -| `INVALID_ENVELOPE` | `InvalidEnvelope` | Missing required fields, or invalid payload encoding | -| `INVALID_ENVELOPE` | `DuplicateSession` | SessionStart for existing session (different message_id) | -| `INVALID_ENVELOPE` | `InvalidTtl` | TTL value out of range (< 0 or > 24h) | -| `INVALID_ENVELOPE` | `InvalidModeState` | Internal mode state cannot be deserialized | -| `INVALID_ENVELOPE` | `InvalidPayload` | Payload does not match mode's expected format | -| `SESSION_NOT_FOUND` | `UnknownSession` | Message for non-existent session | -| `SESSION_NOT_OPEN` | `SessionNotOpen` | Message to resolved or expired session | -| `SESSION_NOT_OPEN` | `TtlExpired` | Session TTL has elapsed | -| `MODE_NOT_SUPPORTED` | `UnknownMode` | Mode field references unregistered mode | -| `FORBIDDEN` | `Forbidden` | Operation not permitted | -| `UNAUTHENTICATED` | `Unauthenticated` | Authentication required | -| `DUPLICATE_MESSAGE` | `DuplicateMessage` | Explicit duplicate detection (distinct from idempotent dedup) | -| `PAYLOAD_TOO_LARGE` | `PayloadTooLarge` | Payload exceeds size limits | -| `RATE_LIMITED` | `RateLimited` | Too many requests | - -Note that several internal error variants map to `INVALID_ENVELOPE` — this groups related validation failures under a single client-facing code while preserving distinct internal error variants for logging and debugging. - ---- - -## Transport - -The protocol uses **gRPC over HTTP/2**: - -- **Binary protocol** — efficient serialization via protobuf. -- **Type-safe** — schema enforcement at compile time. -- **Streaming support** — bidirectional streaming via `StreamSession`. -- **Wide language support** — gRPC clients available for Python, JavaScript, Go, Java, C++, and more. -- **Built-in TLS** — secure transport via standard gRPC TLS configuration. - -**Default address:** `127.0.0.1:50051` (hardcoded in `src/main.rs`). - ---- - -## Best Practices - -### For Clients - -1. **Always call Initialize first** — Negotiate the protocol version and discover capabilities before sending session messages. - -2. **Check `Ack.ok` and `Ack.error`** — Don't just check the boolean; inspect the `MACPError.code` for specific error handling. - -3. **Use unique message IDs** — UUIDs are recommended. This enables safe retries via the deduplication mechanism. - -4. **Handle duplicates gracefully** — If `Ack.duplicate` is `true`, the message was already processed. Treat this as success. - -5. **Send SessionStart first** — Before any other messages for a session. - -6. **Respect terminal states** — Once a session is `RESOLVED` or `EXPIRED`, don't send more messages. Cache the state locally. - -7. **Use CancelSession for cleanup** — Don't let sessions hang until TTL expiry if you know the coordination is over. - -8. **Use ListModes for discovery** — Query available modes and their message types before creating sessions. - -9. **Use GetSession to check state** — Useful for resuming after disconnection or verifying session state. - -10. **Declare participants when appropriate** — Use the `participants` field in `SessionStartPayload` to restrict who can contribute, especially for convergence-based modes. - ---- - -## Future Extensions - -### 1. Background TTL Cleanup -Currently, TTL is enforced lazily. Future versions will run a background eviction task. - -### 2. Replay Engine -Replay session logs to reconstruct state for debugging and auditing. - -### 3. GetSessionLog RPC -Query session event logs for audit trails. - -### 4. Additional Convergence Strategies -- `majority` — resolve when a majority of participants agree. -- `threshold` — resolve when N participants agree. -- `weighted` — resolve based on weighted votes. - -### 5. Persistent Storage -Durable session state and log storage (e.g., to SQLite or Postgres). - -### 6. Authentication and Authorization -Token-based authentication and role-based access control for sessions. - ---- - -## Next Steps - -- Read **[architecture.md](./architecture.md)** to understand how this is implemented internally. -- Read **[examples.md](./examples.md)** for practical code examples with the new v0.2 RPCs. +`ListModes` returns the five standards-track modes. `GetManifest` exposes a freeze-profile manifest that matches the implemented unary capabilities. diff --git a/src/bin/client.rs b/src/bin/client.rs index c10c44a..fc4198e 100644 --- a/src/bin/client.rs +++ b/src/bin/client.rs @@ -1,177 +1,132 @@ -use macp_runtime::decision_pb::ProposalPayload; -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; -use macp_runtime::pb::{ - Envelope, GetSessionRequest, InitializeRequest, ListModesRequest, SendRequest, - SessionStartPayload, +#[path = "support/common.rs"] +mod common; + +use common::{ + canonical_commitment_payload, canonical_start_payload, envelope, get_session_as, initialize, + list_modes, print_ack, send_as, }; +use macp_runtime::decision_pb::{EvaluationPayload, ProposalPayload, VotePayload}; use prost::Message; #[tokio::main] async fn main() -> Result<(), Box> { - let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; + let mut client = common::connect_client().await?; - // 1) Initialize — negotiate protocol version - let init_resp = client - .initialize(InitializeRequest { - supported_protocol_versions: vec!["1.0".into()], - client_info: None, - capabilities: None, - }) - .await? - .into_inner(); + let init = initialize(&mut client).await?; println!( "Initialize: version={} runtime={}", - init_resp.selected_protocol_version, - init_resp - .runtime_info + init.selected_protocol_version, + init.runtime_info .as_ref() - .map(|r| r.name.as_str()) + .map(|info| info.name.as_str()) .unwrap_or("?") ); - // 2) ListModes — discover available modes - let modes_resp = client.list_modes(ListModesRequest {}).await?.into_inner(); + let modes = list_modes(&mut client).await?; println!( "ListModes: {:?}", - modes_resp.modes.iter().map(|m| &m.mode).collect::>() + modes + .modes + .iter() + .map(|m| m.mode.as_str()) + .collect::>() ); - // 3) SessionStart with participants (canonical mode) - let start_payload = SessionStartPayload { - intent: "demo canonical lifecycle".into(), - participants: vec!["alice".into(), "bob".into()], - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), - ttl_ms: 60_000, - context: vec![], - roots: vec![], - }; - - let start = Envelope { - macp_version: "1.0".into(), - mode: "macp.mode.decision.v1".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "coordinator".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), - }; - - let ack = client - .send(SendRequest { - envelope: Some(start), - }) - .await? - .into_inner() - .ack - .unwrap(); - println!( - "SessionStart ack: ok={} error={:?}", - ack.ok, - ack.error.as_ref().map(|e| &e.code) + let start = envelope( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "decision-demo-1", + "coordinator", + canonical_start_payload("select the deployment plan", &["alice", "bob"], 60_000), ); + let ack = send_as(&mut client, "coordinator", start).await?; + print_ack("session_start", &ack); - // 4) Proposal (protobuf-encoded) let proposal = ProposalPayload { proposal_id: "p1".into(), - option: "Deploy v2.1 to production".into(), - rationale: "All integration tests pass".into(), + option: "deploy v2.1 to production".into(), + rationale: "integration tests and canary checks passed".into(), supporting_data: vec![], }; - let proposal_env = Envelope { - macp_version: "1.0".into(), - mode: "macp.mode.decision.v1".into(), - message_type: "Proposal".into(), - message_id: "m2".into(), - session_id: "s1".into(), - sender: "alice".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: proposal.encode_to_vec(), - }; + let ack = send_as( + &mut client, + "coordinator", + envelope( + "macp.mode.decision.v1", + "Proposal", + "m2", + "decision-demo-1", + "coordinator", + proposal.encode_to_vec(), + ), + ) + .await?; + print_ack("proposal", &ack); - let ack = client - .send(SendRequest { - envelope: Some(proposal_env), - }) - .await? - .into_inner() - .ack - .unwrap(); - println!( - "Proposal ack: ok={} error={:?}", - ack.ok, - ack.error.as_ref().map(|e| &e.code) - ); - - // 5) Evaluation (protobuf-encoded) - let eval = macp_runtime::decision_pb::EvaluationPayload { + let evaluation = EvaluationPayload { proposal_id: "p1".into(), - recommendation: "APPROVE".into(), - confidence: 0.95, - reason: "Looks good".into(), - }; - let eval_env = Envelope { - macp_version: "1.0".into(), - mode: "macp.mode.decision.v1".into(), - message_type: "Evaluation".into(), - message_id: "m3".into(), - session_id: "s1".into(), - sender: "bob".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: eval.encode_to_vec(), + recommendation: "approve".into(), + confidence: 0.94, + reason: "operational risk is low".into(), }; + let ack = send_as( + &mut client, + "alice", + envelope( + "macp.mode.decision.v1", + "Evaluation", + "m3", + "decision-demo-1", + "alice", + evaluation.encode_to_vec(), + ), + ) + .await?; + print_ack("evaluation", &ack); - let ack = client - .send(SendRequest { - envelope: Some(eval_env), - }) - .await? - .into_inner() - .ack - .unwrap(); - println!( - "Evaluation ack: ok={} error={:?}", - ack.ok, - ack.error.as_ref().map(|e| &e.code) - ); - - // 6) Commitment (votes not required per RFC — orchestrator bypass) - let commitment = Envelope { - macp_version: "1.0".into(), - mode: "macp.mode.decision.v1".into(), - message_type: "Commitment".into(), - message_id: "m4".into(), - session_id: "s1".into(), - sender: "coordinator".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: b"deploy-approved".to_vec(), + let vote = VotePayload { + proposal_id: "p1".into(), + vote: "yes".into(), + reason: "approved for release".into(), }; + let ack = send_as( + &mut client, + "bob", + envelope( + "macp.mode.decision.v1", + "Vote", + "m4", + "decision-demo-1", + "bob", + vote.encode_to_vec(), + ), + ) + .await?; + print_ack("vote", &ack); - let ack = client - .send(SendRequest { - envelope: Some(commitment), - }) - .await? - .into_inner() - .ack - .unwrap(); - println!( - "Commitment ack: ok={} state={} error={:?}", - ack.ok, - ack.session_state, - ack.error.as_ref().map(|e| &e.code) - ); + let ack = send_as( + &mut client, + "coordinator", + envelope( + "macp.mode.decision.v1", + "Commitment", + "m5", + "decision-demo-1", + "coordinator", + canonical_commitment_payload( + "c1", + "deployment.approved", + "release-management", + "decision session reached commitment", + ), + ), + ) + .await?; + print_ack("commitment", &ack); - // 7) GetSession — verify resolved state - let resp = client - .get_session(GetSessionRequest { - session_id: "s1".into(), - }) - .await? - .into_inner(); - let meta = resp.metadata.unwrap(); + let session = get_session_as(&mut client, "alice", "decision-demo-1").await?; + let meta = session.metadata.expect("metadata"); println!("GetSession: state={} mode={}", meta.state, meta.mode); Ok(()) diff --git a/src/bin/fuzz_client.rs b/src/bin/fuzz_client.rs index cd517fd..24b88f3 100644 --- a/src/bin/fuzz_client.rs +++ b/src/bin/fuzz_client.rs @@ -1,581 +1,138 @@ -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; -use macp_runtime::pb::{ - CancelSessionRequest, Envelope, GetManifestRequest, GetSessionRequest, InitializeRequest, - ListModesRequest, ListRootsRequest, SendRequest, SessionStartPayload, +#[path = "support/common.rs"] +mod common; + +use common::{ + cancel_session_as, canonical_start_payload, envelope, get_manifest, get_session_as, initialize, + list_modes, list_roots, print_ack, send_as, }; +use macp_runtime::task_pb::TaskRequestPayload; use prost::Message; -use tokio::time::{sleep, Duration}; - -#[allow(clippy::too_many_arguments)] -fn env( - macp_version: &str, - mode: &str, - message_type: &str, - message_id: &str, - session_id: &str, - sender: &str, - ts: i64, - payload: &[u8], -) -> Envelope { - Envelope { - macp_version: macp_version.into(), - mode: mode.into(), - message_type: message_type.into(), - message_id: message_id.into(), - session_id: session_id.into(), - sender: sender.into(), - timestamp_unix_ms: ts, - payload: payload.to_vec(), - } -} - -fn encode_session_start(ttl_ms: i64, participants: Vec) -> Vec { - let payload = SessionStartPayload { - intent: String::new(), - participants, - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), - ttl_ms, - context: vec![], - roots: vec![], - }; - payload.encode_to_vec() -} - -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={} duplicate={} error='{}'", - ack.ok, ack.duplicate, 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?; - - // --- 0) Initialize - match client - .initialize(InitializeRequest { - supported_protocol_versions: vec!["1.0".into()], - client_info: None, - capabilities: None, - }) - .await - { - Ok(resp) => { - let init = resp.into_inner(); - println!("[initialize] version={}", init.selected_protocol_version); - } - Err(status) => println!("[initialize] error: {status}"), - } - - // --- 0b) Initialize with bad version - match client - .initialize(InitializeRequest { - supported_protocol_versions: vec!["2.0".into()], - client_info: None, - capabilities: None, - }) - .await - { - Ok(_) => println!("[initialize_bad_version] unexpected success"), - Err(status) => println!("[initialize_bad_version] error: {status}"), - } - - // --- 1) Wrong MACP version (should reject) - send( - &mut client, - "wrong_version", - env( - "v0", - "decision", - "SessionStart", - "badv1", - "sv", - "ajit", - 1_700_000_000_100, - b"", - ), - ) - .await; - - // --- 2) Missing required fields (should reject InvalidEnvelope) - send( - &mut client, - "missing_fields", - env( - "1.0", - "decision", - "SessionStart", - "", - "s_missing", - "", - 0, - b"", - ), - ) - .await; - - // --- 3) Message to unknown session (should reject UnknownSession) - send( - &mut client, - "unknown_session_message", - env( - "1.0", - "decision", - "Message", - "m_unknown", - "no_such_session", - "ajit", - 1_700_000_000_101, - b"hello", - ), - ) - .await; - - // --- 4) Valid SessionStart (should accept) - send( - &mut client, - "session_start_ok", - env( - "1.0", - "decision", - "SessionStart", - "m1", - "s1", - "ajit", - 1_700_000_000_102, - b"", - ), - ) - .await; - - // --- 5) Duplicate SessionStart (should reject → INVALID_ENVELOPE) - send( - &mut client, - "session_start_duplicate", - env( - "1.0", - "decision", - "SessionStart", - "m1_dup", - "s1", - "ajit", - 1_700_000_000_103, - b"", - ), - ) - .await; - - // --- 5b) Duplicate SessionStart with SAME message_id (idempotent) - send( - &mut client, - "session_start_idempotent", - env( - "1.0", - "decision", - "SessionStart", - "m1", - "s1", - "ajit", - 1_700_000_000_104, - b"", - ), - ) - .await; - - // --- 6) Valid Message (should accept) - send( - &mut client, - "message_ok", - env( - "1.0", - "decision", - "Message", - "m2", - "s1", - "ajit", - 1_700_000_000_104, - b"hello", - ), - ) - .await; - - // --- 6b) Duplicate message (same message_id) - send( - &mut client, - "message_duplicate", - env( - "1.0", - "decision", - "Message", - "m2", - "s1", - "ajit", - 1_700_000_000_105, - b"hello", - ), - ) - .await; - - // --- 7) Resolve (payload == "resolve" => session becomes RESOLVED) - send( - &mut client, - "resolve", - env( - "1.0", - "decision", - "Message", - "m3", - "s1", - "ajit", - 1_700_000_000_105, - b"resolve", - ), - ) - .await; - - // --- 8) Message after resolved (should reject SESSION_NOT_OPEN) - send( - &mut client, - "after_resolve", - env( - "1.0", - "decision", - "Message", - "m4", - "s1", - "ajit", - 1_700_000_000_106, - b"should_fail", - ), - ) - .await; - - // --- 9) TTL Expiry - let ttl_payload = encode_session_start(1000, vec![]); - send( - &mut client, - "ttl_session_start", - env( - "1.0", - "decision", - "SessionStart", - "m_ttl1", - "s_ttl", - "ajit", - 1_700_000_000_200, - &ttl_payload, - ), - ) - .await; - - sleep(Duration::from_millis(1200)).await; - - send( - &mut client, - "ttl_expired_message", - env( - "1.0", - "decision", - "Message", - "m_ttl2", - "s_ttl", - "ajit", - 1_700_000_000_201, - b"should_expire", - ), - ) - .await; - - // --- 10) Invalid TTL values - let bad_ttl = encode_session_start(-5000, vec![]); - send( - &mut client, - "invalid_ttl_negative", - env( - "1.0", - "decision", - "SessionStart", - "m_bad_ttl2", - "s_bad_ttl2", - "ajit", - 1_700_000_000_301, - &bad_ttl, - ), - ) - .await; - - let bad_ttl = encode_session_start(86_400_001, vec![]); - send( - &mut client, - "invalid_ttl_exceeds_max", - env( - "1.0", - "decision", - "SessionStart", - "m_bad_ttl3", - "s_bad_ttl3", - "ajit", - 1_700_000_000_302, - &bad_ttl, - ), - ) - .await; - - // --- 11) Multi-round convergence test - let mr_payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - send( - &mut client, - "multi_round_start", - env( - "1.0", - "multi_round", - "SessionStart", - "m_mr0", - "s_mr", - "creator", - 1_700_000_000_400, - &mr_payload, - ), - ) - .await; - - send( - &mut client, - "multi_round_alice", - env( - "1.0", - "multi_round", - "Contribute", - "m_mr1", - "s_mr", - "alice", - 1_700_000_000_401, - br#"{"value":"option_a"}"#, - ), - ) - .await; - send( - &mut client, - "multi_round_bob_diff", - env( - "1.0", - "multi_round", - "Contribute", - "m_mr2", - "s_mr", - "bob", - 1_700_000_000_402, - br#"{"value":"option_b"}"#, - ), - ) - .await; - send( - &mut client, - "multi_round_bob_converge", - env( - "1.0", - "multi_round", - "Contribute", - "m_mr3", - "s_mr", - "bob", - 1_700_000_000_403, - br#"{"value":"option_a"}"#, - ), - ) - .await; - send( - &mut client, - "multi_round_after_resolve", - env( - "1.0", - "multi_round", - "Contribute", - "m_mr4", - "s_mr", - "alice", - 1_700_000_000_404, - br#"{"value":"option_c"}"#, - ), - ) - .await; - - // --- 12) CancelSession - send( - &mut client, - "cancel_session_start", - env( - "1.0", - "decision", - "SessionStart", - "m_c1", - "s_cancel", - "ajit", - 1_700_000_000_500, - b"", - ), - ) - .await; - - match client - .cancel_session(CancelSessionRequest { - session_id: "s_cancel".into(), - reason: "test cancellation".into(), - }) - .await - { - Ok(resp) => { - let ack = resp.into_inner().ack.unwrap(); - println!("[cancel_session] ok={}", ack.ok); - } - Err(status) => println!("[cancel_session] error: {status}"), - } - - send( - &mut client, - "after_cancel", - env( - "1.0", - "decision", - "Message", - "m_c2", - "s_cancel", - "ajit", - 1_700_000_000_501, - b"should_fail", - ), - ) - .await; - - // --- 13) Participant validation - let p_payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - send( - &mut client, - "participant_session_start", - env( - "1.0", - "decision", - "SessionStart", - "m_p1", - "s_participant", - "alice", - 1_700_000_000_600, - &p_payload, - ), - ) - .await; - send( - &mut client, - "unauthorized_sender", - env( - "1.0", - "decision", - "Message", - "m_p2", - "s_participant", - "charlie", - 1_700_000_000_601, - b"hello", - ), - ) - .await; - send( - &mut client, - "authorized_sender", - env( - "1.0", - "decision", - "Message", - "m_p3", - "s_participant", - "alice", - 1_700_000_000_602, - b"hello", - ), - ) - .await; - - // --- 14) Signal - send( - &mut client, - "signal", - env( - "1.0", - "", - "Signal", - "sig1", - "", - "alice", - 1_700_000_000_700, - b"", - ), - ) - .await; - - // --- 15) GetSession - match client - .get_session(GetSessionRequest { - session_id: "s1".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}"), - } - - // --- 16) ListModes - match client.list_modes(ListModesRequest {}).await { - Ok(resp) => { - let modes = resp.into_inner().modes; - println!( - "[list_modes] count={} modes={:?}", - modes.len(), - modes.iter().map(|m| &m.mode).collect::>() - ); - } - Err(status) => println!("[list_modes] error: {status}"), - } - - // --- 17) GetManifest - match client - .get_manifest(GetManifestRequest { - agent_id: String::new(), - }) - .await - { - Ok(resp) => { - let manifest = resp.into_inner().manifest.unwrap(); - println!( - "[get_manifest] agent_id={} modes={:?}", - manifest.agent_id, manifest.supported_modes - ); - } - Err(status) => println!("[get_manifest] error: {status}"), + let mut client = common::connect_client().await?; + + println!("=== Freeze-Profile Error Path Demo ===\n"); + + let init = initialize(&mut client).await?; + println!("[initialize] version={}", init.selected_protocol_version); + + let modes = list_modes(&mut client).await?; + println!( + "[list_modes] {:?}", + modes + .modes + .iter() + .map(|m| m.mode.as_str()) + .collect::>() + ); + + let roots = list_roots(&mut client).await?; + println!("[list_roots] count={}", roots.roots.len()); + + let manifest = get_manifest(&mut client, "demo-agent").await?; + println!( + "[get_manifest] agent_id={} modes={}", + manifest + .manifest + .as_ref() + .map(|m| m.agent_id.as_str()) + .unwrap_or("?"), + manifest + .manifest + .as_ref() + .map(|m| m.supported_modes.len()) + .unwrap_or(0) + ); + + let mut invalid_version = envelope( + "macp.mode.decision.v1", + "SessionStart", + "bad-1", + "freeze-check-1", + "coordinator", + canonical_start_payload("bad version", &["alice", "bob"], 60_000), + ); + invalid_version.macp_version = "2.0".into(); + let ack = send_as(&mut client, "coordinator", invalid_version).await?; + print_ack("invalid_version", &ack); + + let empty_mode = envelope( + "", + "SessionStart", + "bad-2", + "freeze-check-2", + "coordinator", + canonical_start_payload("empty mode", &["alice", "bob"], 60_000), + ); + let ack = send_as(&mut client, "coordinator", empty_mode).await?; + print_ack("empty_mode", &ack); + + let start = envelope( + "macp.mode.task.v1", + "SessionStart", + "m0", + "freeze-task-1", + "planner", + canonical_start_payload("freeze checks", &["planner", "worker"], 60_000), + ); + let ack = send_as(&mut client, "planner", start).await?; + print_ack("session_start", &ack); + + let duplicate_request = TaskRequestPayload { + task_id: "dup-task".into(), + title: "check duplicate handling".into(), + instructions: "noop".into(), + requested_assignee: "worker".into(), + input: vec![], + deadline_unix_ms: 0, + }; + let duplicate = envelope( + "macp.mode.task.v1", + "TaskRequest", + "dup-1", + "freeze-task-1", + "planner", + duplicate_request.encode_to_vec(), + ); + let ack = send_as(&mut client, "planner", duplicate.clone()).await?; + print_ack("duplicate_first", &ack); + let ack = send_as(&mut client, "planner", duplicate).await?; + print_ack("duplicate_second", &ack); + + let spoofed = envelope( + "macp.mode.task.v1", + "TaskRequest", + "spoof-1", + "freeze-task-1", + "mallory", + vec![1, 2, 3], + ); + let ack = send_as(&mut client, "planner", spoofed).await?; + print_ack("spoofed_sender", &ack); + + let oversized = envelope( + "macp.mode.task.v1", + "TaskUpdate", + "big-1", + "freeze-task-1", + "worker", + vec![b'x'; 2_000_000], + ); + let ack = send_as(&mut client, "worker", oversized).await?; + print_ack("payload_too_large", &ack); + + match get_session_as(&mut client, "outsider", "freeze-task-1").await { + Ok(resp) => println!( + "[forbidden_get_session] unexpected success: {:?}", + resp.metadata + ), + Err(status) => println!("[forbidden_get_session] grpc error: {status}"), } - // --- 18) ListRoots - match client.list_roots(ListRootsRequest {}).await { - Ok(resp) => println!("[list_roots] count={}", resp.into_inner().roots.len()), - Err(status) => println!("[list_roots] error: {status}"), + let cancelled = cancel_session_as(&mut client, "planner", "freeze-task-1", "end demo").await?; + if let Some(ack) = cancelled.ack.as_ref() { + print_ack("cancel_session", ack); } Ok(()) diff --git a/src/bin/handoff_client.rs b/src/bin/handoff_client.rs index 7e7f538..e878b89 100644 --- a/src/bin/handoff_client.rs +++ b/src/bin/handoff_client.rs @@ -1,165 +1,118 @@ +#[path = "support/common.rs"] +mod common; + +use common::{ + canonical_commitment_payload, canonical_start_payload, envelope, get_session_as, print_ack, + send_as, +}; 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?; + let mut client = common::connect_client().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( + let ack = send_as( &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(), - }, + "owner", + envelope( + "macp.mode.handoff.v1", + "SessionStart", + "m0", + "handoff-demo-1", + "owner", + canonical_start_payload("escalate support ticket", &["owner", "target"], 60_000), + ), ) - .await; + .await?; + print_ack("session_start", &ack); - // 2) HandoffOffer let offer = HandoffOfferPayload { handoff_id: "h1".into(), target_participant: "target".into(), scope: "customer-support".into(), - reason: "need specialist help".into(), + reason: "specialist attention required".into(), }; - send( + let ack = send_as( &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(), - }, + "owner", + envelope( + "macp.mode.handoff.v1", + "HandoffOffer", + "m1", + "handoff-demo-1", + "owner", + offer.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("handoff_offer", &ack); - // 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(), + context: b"customer issue: invoice mismatch".to_vec(), }; - send( + let ack = send_as( &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(), - }, + "owner", + envelope( + "macp.mode.handoff.v1", + "HandoffContext", + "m2", + "handoff-demo-1", + "owner", + context.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("handoff_context", &ack); - // 4) HandoffAccept let accept = HandoffAcceptPayload { handoff_id: "h1".into(), accepted_by: "target".into(), - reason: "ready to assist".into(), + reason: "taking ownership".into(), }; - send( + let ack = send_as( &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(), - }, + "target", + envelope( + "macp.mode.handoff.v1", + "HandoffAccept", + "m3", + "handoff-demo-1", + "target", + accept.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("handoff_accept", &ack); - // 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( + let ack = send_as( &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(), - }, + "owner", + envelope( + "macp.mode.handoff.v1", + "Commitment", + "m4", + "handoff-demo-1", + "owner", + canonical_commitment_payload( + "c1", + "handoff.accepted", + "workflow", + "target accepted handoff h1", + ), + ), ) - .await; + .await?; + print_ack("commitment", &ack); - // 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}"), - } + let session = get_session_as(&mut client, "target", "handoff-demo-1").await?; + let meta = session.metadata.expect("metadata"); + println!("[get_session] state={} mode={}", meta.state, meta.mode); - println!("\n=== Demo Complete ==="); Ok(()) } diff --git a/src/bin/multi_round_client.rs b/src/bin/multi_round_client.rs index d8f4cd3..8821a52 100644 --- a/src/bin/multi_round_client.rs +++ b/src/bin/multi_round_client.rs @@ -1,156 +1,93 @@ -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; -use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; -use prost::Message; +#[path = "support/common.rs"] +mod common; -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}"); - } - } -} +use common::{envelope, get_session_as, print_ack, send_as}; +use macp_runtime::pb::SessionStartPayload; +use prost::Message; #[tokio::main] async fn main() -> Result<(), Box> { - let mut client = MacpRuntimeServiceClient::connect("http://127.0.0.1:50051").await?; + let mut client = common::connect_client().await?; println!("=== Multi-Round Convergence Demo ===\n"); - // 1) SessionStart with multi_round mode let start_payload = SessionStartPayload { intent: "convergence test".into(), - ttl_ms: 60000, + ttl_ms: 60_000, participants: vec!["alice".into(), "bob".into()], - mode_version: String::new(), - configuration_version: String::new(), + mode_version: "experimental".into(), + configuration_version: "legacy".into(), policy_version: String::new(), context: vec![], roots: vec![], }; - send( + let ack = send_as( &mut client, - "session_start", - Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "SessionStart".into(), - message_id: "m0".into(), - session_id: "mr1".into(), - sender: "coordinator".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), - }, + "coordinator", + envelope( + "macp.mode.multi_round.v1", + "SessionStart", + "m0", + "multi-round-demo-1", + "coordinator", + start_payload.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("session_start", &ack); - // 2) Alice contributes "option_a" - send( + let ack = send_as( &mut client, - "alice_contributes_a", - Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m1".into(), - session_id: "mr1".into(), - sender: "alice".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: br#"{"value":"option_a"}"#.to_vec(), - }, + "alice", + envelope( + "macp.mode.multi_round.v1", + "Contribute", + "m1", + "multi-round-demo-1", + "alice", + br#"{"value":"option_a"}"#.to_vec(), + ), ) - .await; + .await?; + print_ack("alice_contributes", &ack); - // 3) Bob contributes "option_b" (no convergence yet) - send( + let ack = send_as( &mut client, - "bob_contributes_b", - Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m2".into(), - session_id: "mr1".into(), - sender: "bob".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: br#"{"value":"option_b"}"#.to_vec(), - }, + "bob", + envelope( + "macp.mode.multi_round.v1", + "Contribute", + "m2", + "multi-round-demo-1", + "bob", + br#"{"value":"option_b"}"#.to_vec(), + ), ) - .await; + .await?; + print_ack("bob_contributes_b", &ack); - // Query session state — should be Open - match client - .get_session(GetSessionRequest { - session_id: "mr1".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}"), - } + let session = get_session_as(&mut client, "alice", "multi-round-demo-1").await?; + let meta = session.metadata.expect("metadata"); + println!("[get_session] state={} mode={}", meta.state, meta.mode); - // 4) Bob revises to "option_a" (convergence -> auto-resolved) - send( + let ack = send_as( &mut client, - "bob_revises_to_a", - Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m3".into(), - session_id: "mr1".into(), - sender: "bob".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: br#"{"value":"option_a"}"#.to_vec(), - }, + "bob", + envelope( + "macp.mode.multi_round.v1", + "Contribute", + "m3", + "multi-round-demo-1", + "bob", + br#"{"value":"option_a"}"#.to_vec(), + ), ) - .await; + .await?; + print_ack("bob_revises", &ack); - // Query session state — should be Resolved - match client - .get_session(GetSessionRequest { - session_id: "mr1".into(), - }) - .await - { - Ok(resp) => { - let meta = resp.into_inner().metadata.unwrap(); - println!( - "[get_session] state={} mode_version={}", - meta.state, meta.mode_version - ); - } - Err(status) => println!("[get_session] error: {status}"), - } - - // 5) Another message — should be rejected: SessionNotOpen - send( - &mut client, - "after_convergence", - Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m4".into(), - session_id: "mr1".into(), - sender: "alice".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: br#"{"value":"option_c"}"#.to_vec(), - }, - ) - .await; + let session = get_session_as(&mut client, "alice", "multi-round-demo-1").await?; + let meta = session.metadata.expect("metadata"); + println!("[get_session] state={} mode={}", meta.state, meta.mode); - println!("\n=== Demo Complete ==="); Ok(()) } diff --git a/src/bin/proposal_client.rs b/src/bin/proposal_client.rs index d97279c..3842367 100644 --- a/src/bin/proposal_client.rs +++ b/src/bin/proposal_client.rs @@ -1,59 +1,34 @@ -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; -use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +#[path = "support/common.rs"] +mod common; + +use common::{ + canonical_commitment_payload, canonical_start_payload, envelope, get_session_as, print_ack, + send_as, +}; 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?; + let mut client = common::connect_client().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( + let ack = send_as( &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(), - }, + "buyer", + envelope( + "macp.mode.proposal.v1", + "SessionStart", + "m0", + "proposal-demo-1", + "buyer", + canonical_start_payload("negotiate price", &["buyer", "seller"], 60_000), + ), ) - .await; + .await?; + print_ack("session_start", &ack); - // 2) Seller makes a proposal let proposal = ProposalPayload { proposal_id: "offer-1".into(), title: "Initial offer".into(), @@ -61,23 +36,21 @@ async fn main() -> Result<(), Box> { details: vec![], tags: vec![], }; - send( + let ack = send_as( &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(), - }, + "seller", + envelope( + "macp.mode.proposal.v1", + "Proposal", + "m1", + "proposal-demo-1", + "seller", + proposal.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("proposal", &ack); - // 3) Buyer counter-proposes let counter = CounterProposalPayload { proposal_id: "offer-2".into(), supersedes_proposal_id: "offer-1".into(), @@ -85,83 +58,82 @@ async fn main() -> Result<(), Box> { summary: "1000 USD".into(), details: vec![], }; - send( + let ack = send_as( &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(), - }, + "buyer", + envelope( + "macp.mode.proposal.v1", + "CounterProposal", + "m2", + "proposal-demo-1", + "buyer", + counter.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("counter_proposal", &ack); - // 4) Seller accepts let accept = AcceptPayload { proposal_id: "offer-2".into(), reason: "agreed".into(), }; - send( + let ack = send_as( &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(), - }, + "buyer", + envelope( + "macp.mode.proposal.v1", + "Accept", + "m3", + "proposal-demo-1", + "buyer", + accept.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("buyer_accept", &ack); - // 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(), + let accept = AcceptPayload { + proposal_id: "offer-2".into(), + reason: "confirmed".into(), }; - send( + let ack = send_as( + &mut client, + "seller", + envelope( + "macp.mode.proposal.v1", + "Accept", + "m4", + "proposal-demo-1", + "seller", + accept.encode_to_vec(), + ), + ) + .await?; + print_ack("seller_accept", &ack); + + let ack = send_as( &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(), - }, + "buyer", + envelope( + "macp.mode.proposal.v1", + "Commitment", + "m5", + "proposal-demo-1", + "buyer", + canonical_commitment_payload( + "c1", + "proposal.accepted", + "commercial", + "all required participants accepted offer-2", + ), + ), ) - .await; + .await?; + print_ack("commitment", &ack); - // 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}"), - } + let session = get_session_as(&mut client, "seller", "proposal-demo-1").await?; + let meta = session.metadata.expect("metadata"); + println!("[get_session] state={} mode={}", meta.state, meta.mode); - println!("\n=== Demo Complete ==="); Ok(()) } diff --git a/src/bin/quorum_client.rs b/src/bin/quorum_client.rs index 669d487..041f635 100644 --- a/src/bin/quorum_client.rs +++ b/src/bin/quorum_client.rs @@ -1,164 +1,121 @@ -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; -use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +#[path = "support/common.rs"] +mod common; + +use common::{ + canonical_commitment_payload, canonical_start_payload, envelope, get_session_as, print_ack, + send_as, +}; 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?; + let mut client = common::connect_client().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( + let ack = send_as( &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(), - }, + "coordinator", + envelope( + "macp.mode.quorum.v1", + "SessionStart", + "m0", + "quorum-demo-1", + "coordinator", + canonical_start_payload( + "approve production deploy", + &["alice", "bob", "carol"], + 60_000, + ), + ), ) - .await; + .await?; + print_ack("session_start", &ack); - // 2) ApprovalRequest - let req = ApprovalRequestPayload { + let request = ApprovalRequestPayload { request_id: "deploy-v2".into(), action: "deploy.production".into(), summary: "Deploy v2.1 to production".into(), details: vec![], required_approvals: 2, }; - send( + let ack = send_as( &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(), - }, + "coordinator", + envelope( + "macp.mode.quorum.v1", + "ApprovalRequest", + "m1", + "quorum-demo-1", + "coordinator", + request.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("approval_request", &ack); - // 3) Alice approves - let approve1 = ApprovePayload { + let approve = ApprovePayload { request_id: "deploy-v2".into(), reason: "tests pass".into(), }; - send( + let ack = send_as( &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(), - }, + "alice", + envelope( + "macp.mode.quorum.v1", + "Approve", + "m2", + "quorum-demo-1", + "alice", + approve.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("alice_approve", &ack); - // 4) Bob approves - let approve2 = ApprovePayload { + let approve = ApprovePayload { request_id: "deploy-v2".into(), reason: "staging looks good".into(), }; - send( + let ack = send_as( &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(), - }, + "bob", + envelope( + "macp.mode.quorum.v1", + "Approve", + "m3", + "quorum-demo-1", + "bob", + approve.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("bob_approve", &ack); - // 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( + let ack = send_as( &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(), - }, + "coordinator", + envelope( + "macp.mode.quorum.v1", + "Commitment", + "m4", + "quorum-demo-1", + "coordinator", + canonical_commitment_payload( + "c1", + "quorum.approved", + "release-management", + "required approvals reached for deploy-v2", + ), + ), ) - .await; + .await?; + print_ack("commitment", &ack); - // 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}"), - } + let session = get_session_as(&mut client, "carol", "quorum-demo-1").await?; + let meta = session.metadata.expect("metadata"); + println!("[get_session] state={} mode={}", meta.state, meta.mode); - println!("\n=== Demo Complete ==="); Ok(()) } diff --git a/src/bin/support/common.rs b/src/bin/support/common.rs new file mode 100644 index 0000000..032c903 --- /dev/null +++ b/src/bin/support/common.rs @@ -0,0 +1,184 @@ +#![allow(dead_code)] + +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::{ + Ack, CancelSessionRequest, CancelSessionResponse, CommitmentPayload, Envelope, + GetManifestRequest, GetManifestResponse, GetSessionRequest, GetSessionResponse, + InitializeRequest, InitializeResponse, ListModesRequest, ListModesResponse, ListRootsRequest, + ListRootsResponse, SendRequest, SessionStartPayload, +}; +use prost::Message; +use tonic::transport::Channel; +use tonic::Request; + +pub const DEV_ENDPOINT: &str = "http://127.0.0.1:50051"; +pub const MODE_VERSION: &str = "1.0.0"; +pub const CONFIG_VERSION: &str = "config.default"; +pub const POLICY_VERSION: &str = "policy.default"; + +pub async fn connect_client( +) -> Result, Box> { + Ok(MacpRuntimeServiceClient::connect(DEV_ENDPOINT).await?) +} + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +pub fn canonical_start_payload(intent: &str, participants: &[&str], ttl_ms: i64) -> Vec { + SessionStartPayload { + intent: intent.into(), + participants: participants.iter().map(|p| (*p).to_string()).collect(), + mode_version: MODE_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + policy_version: POLICY_VERSION.into(), + ttl_ms, + context: vec![], + roots: vec![], + } + .encode_to_vec() +} + +pub fn canonical_commitment_payload( + commitment_id: &str, + action: &str, + authority_scope: &str, + reason: &str, +) -> Vec { + CommitmentPayload { + commitment_id: commitment_id.into(), + action: action.into(), + authority_scope: authority_scope.into(), + reason: reason.into(), + mode_version: MODE_VERSION.into(), + policy_version: POLICY_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + } + .encode_to_vec() +} + +pub fn envelope( + mode: &str, + message_type: &str, + message_id: &str, + session_id: &str, + sender: &str, + payload: Vec, +) -> Envelope { + Envelope { + macp_version: "1.0".into(), + mode: mode.into(), + message_type: message_type.into(), + message_id: message_id.into(), + session_id: session_id.into(), + sender: sender.into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload, + } +} + +pub async fn initialize( + client: &mut MacpRuntimeServiceClient, +) -> Result { + client + .initialize(InitializeRequest { + supported_protocol_versions: vec!["1.0".into()], + client_info: None, + capabilities: None, + }) + .await + .map(|r| r.into_inner()) +} + +pub async fn list_modes( + client: &mut MacpRuntimeServiceClient, +) -> Result { + client + .list_modes(ListModesRequest {}) + .await + .map(|r| r.into_inner()) +} + +pub async fn list_roots( + client: &mut MacpRuntimeServiceClient, +) -> Result { + client + .list_roots(ListRootsRequest {}) + .await + .map(|r| r.into_inner()) +} + +pub async fn get_manifest( + client: &mut MacpRuntimeServiceClient, + agent_id: &str, +) -> Result { + client + .get_manifest(GetManifestRequest { + agent_id: agent_id.into(), + }) + .await + .map(|r| r.into_inner()) +} + +pub async fn send_as( + client: &mut MacpRuntimeServiceClient, + sender: &str, + env: Envelope, +) -> Result { + client + .send(with_sender( + sender, + SendRequest { + envelope: Some(env), + }, + )) + .await + .map(|r| r.into_inner().ack.expect("ack present")) +} + +pub async fn get_session_as( + client: &mut MacpRuntimeServiceClient, + sender: &str, + session_id: &str, +) -> Result { + client + .get_session(with_sender( + sender, + GetSessionRequest { + session_id: session_id.into(), + }, + )) + .await + .map(|r| r.into_inner()) +} + +pub async fn cancel_session_as( + client: &mut MacpRuntimeServiceClient, + sender: &str, + session_id: &str, + reason: &str, +) -> Result { + client + .cancel_session(with_sender( + sender, + CancelSessionRequest { + session_id: session_id.into(), + reason: reason.into(), + }, + )) + .await + .map(|r| r.into_inner()) +} + +pub fn print_ack(label: &str, ack: &Ack) { + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or(""); + println!( + "[{label}] ok={} duplicate={} state={} error='{}'", + ack.ok, ack.duplicate, ack.session_state, err_code + ); +} diff --git a/src/bin/task_client.rs b/src/bin/task_client.rs index e3209b3..7b9f141 100644 --- a/src/bin/task_client.rs +++ b/src/bin/task_client.rs @@ -1,194 +1,145 @@ -use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; -use macp_runtime::pb::{Envelope, GetSessionRequest, SendRequest, SessionStartPayload}; +#[path = "support/common.rs"] +mod common; + +use common::{ + canonical_commitment_payload, canonical_start_payload, envelope, get_session_as, print_ack, + send_as, +}; 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?; + let mut client = common::connect_client().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( + let ack = send_as( &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(), - }, + "planner", + envelope( + "macp.mode.task.v1", + "SessionStart", + "m0", + "task-demo-1", + "planner", + canonical_start_payload("summarize quarterly report", &["planner", "worker"], 60_000), + ), ) - .await; + .await?; + print_ack("session_start", &ack); - // 2) TaskRequest - let task_req = TaskRequestPayload { + let task_request = TaskRequestPayload { task_id: "t1".into(), title: "Summarize Q4 report".into(), - instructions: "Summarize the key findings".into(), + instructions: "Produce a concise executive summary".into(), requested_assignee: "worker".into(), input: vec![], deadline_unix_ms: 0, }; - send( + let ack = send_as( &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(), - }, + "planner", + envelope( + "macp.mode.task.v1", + "TaskRequest", + "m1", + "task-demo-1", + "planner", + task_request.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("task_request", &ack); - // 3) TaskAccept let accept = TaskAcceptPayload { task_id: "t1".into(), assignee: "worker".into(), reason: "ready".into(), }; - send( + let ack = send_as( &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(), - }, + "worker", + envelope( + "macp.mode.task.v1", + "TaskAccept", + "m2", + "task-demo-1", + "worker", + accept.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("task_accept", &ack); - // 4) TaskUpdate let update = TaskUpdatePayload { task_id: "t1".into(), status: "in_progress".into(), progress: 0.5, - message: "halfway done".into(), + message: "draft summary complete".into(), partial_output: vec![], }; - send( + let ack = send_as( &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(), - }, + "worker", + envelope( + "macp.mode.task.v1", + "TaskUpdate", + "m3", + "task-demo-1", + "worker", + update.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("task_update", &ack); - // 5) TaskComplete let complete = TaskCompletePayload { task_id: "t1".into(), assignee: "worker".into(), - output: b"Executive summary: Revenue up 15%".to_vec(), - summary: "completed".into(), + output: b"Q4 summary".to_vec(), + summary: "Report summarized successfully".into(), }; - send( + let ack = send_as( &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(), - }, + "worker", + envelope( + "macp.mode.task.v1", + "TaskComplete", + "m4", + "task-demo-1", + "worker", + complete.encode_to_vec(), + ), ) - .await; + .await?; + print_ack("task_complete", &ack); - // 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( + let ack = send_as( &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(), - }, + "planner", + envelope( + "macp.mode.task.v1", + "Commitment", + "m5", + "task-demo-1", + "planner", + canonical_commitment_payload( + "c1", + "task.completed", + "workflow", + "worker completed task t1", + ), + ), ) - .await; + .await?; + print_ack("commitment", &ack); - // 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}"), - } + let session = get_session_as(&mut client, "worker", "task-demo-1").await?; + let meta = session.metadata.expect("metadata"); + println!("[get_session] state={} mode={}", meta.state, meta.mode); - println!("\n=== Demo Complete ==="); Ok(()) } diff --git a/src/error.rs b/src/error.rs index bb3a364..cd3f341 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,9 @@ pub enum MacpError { Forbidden, #[error("Unauthenticated")] Unauthenticated, + /// Kept for RFC error-code completeness. The runtime currently represents + /// duplicate detection via `ProcessResult { duplicate: true }` at the Ack + /// level rather than returning this as an error. #[error("DuplicateMessage")] DuplicateMessage, #[error("PayloadTooLarge")] diff --git a/src/lib.rs b/src/lib.rs index 67bcdb8..f20f263 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,3 +28,5 @@ pub mod mode; pub mod registry; pub mod runtime; pub mod session; + +pub mod security; diff --git a/src/log_store.rs b/src/log_store.rs index 65de2d7..458bb2d 100644 --- a/src/log_store.rs +++ b/src/log_store.rs @@ -1,13 +1,15 @@ use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; use tokio::sync::RwLock; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum EntryKind { Incoming, Internal, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct LogEntry { pub message_id: String, pub received_at_ms: i64, @@ -19,6 +21,7 @@ pub struct LogEntry { pub struct LogStore { logs: RwLock>>, + persistence_path: Option, } impl Default for LogStore { @@ -31,22 +34,62 @@ impl LogStore { pub fn new() -> Self { Self { logs: RwLock::new(HashMap::new()), + persistence_path: None, } } - /// Create an empty log for a session. + pub fn with_persistence>(dir: P) -> std::io::Result { + let dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&dir)?; + let path = dir.join("logs.json"); + let logs = if path.exists() { + match serde_json::from_slice(&fs::read(&path)?) { + Ok(v) => v, + Err(e) => { + eprintln!("warning: failed to deserialize logs from {}: {e}; starting with empty state", path.display()); + HashMap::new() + } + } + } else { + HashMap::new() + }; + Ok(Self { + logs: RwLock::new(logs), + persistence_path: Some(path), + }) + } + + fn persist_map(path: &Path, logs: &HashMap>) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(logs)?; + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, bytes)?; + fs::rename(&tmp_path, path) + } + + async fn persist_locked(&self, logs: &HashMap>) -> std::io::Result<()> { + if let Some(path) = &self.persistence_path { + Self::persist_map(path, logs)?; + } + Ok(()) + } + + pub async fn persist_snapshot(&self) -> std::io::Result<()> { + let guard = self.logs.read().await; + self.persist_locked(&guard).await + } + pub async fn create_session_log(&self, session_id: &str) { let mut guard = self.logs.write().await; guard.entry(session_id.to_string()).or_default(); + let _ = self.persist_locked(&guard).await; } - /// Append a log entry. Auto-creates the session log if it doesn't exist. pub async fn append(&self, session_id: &str, entry: LogEntry) { let mut guard = self.logs.write().await; guard.entry(session_id.to_string()).or_default().push(entry); + let _ = self.persist_locked(&guard).await; } - /// Get the log for a session. Returns None if session was never logged. pub async fn get_log(&self, session_id: &str) -> Option> { let guard = self.logs.read().await; guard.get(session_id).cloned() @@ -56,6 +99,7 @@ impl LogStore { #[cfg(test)] mod tests { use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; fn entry(id: &str, kind: EntryKind) -> LogEntry { LogEntry { @@ -82,33 +126,20 @@ mod tests { } #[tokio::test] - async fn auto_create_on_append() { - let store = LogStore::new(); - // No explicit create_session_log call - store.append("s2", entry("m1", EntryKind::Incoming)).await; + async fn persistent_log_store_round_trip() { + let base = std::env::temp_dir().join(format!( + "macp-log-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let store = LogStore::with_persistence(&base).unwrap(); + store.append("s1", entry("m1", EntryKind::Incoming)).await; - let log = store.get_log("s2").await.unwrap(); + let reopened = LogStore::with_persistence(&base).unwrap(); + let log = reopened.get_log("s1").await.unwrap(); assert_eq!(log.len(), 1); - } - - #[tokio::test] - async fn ordering_preserved() { - let store = LogStore::new(); - for i in 0..5 { - store - .append("s1", entry(&format!("m{}", i), EntryKind::Incoming)) - .await; - } - - let log = store.get_log("s1").await.unwrap(); - for (i, e) in log.iter().enumerate() { - assert_eq!(e.message_id, format!("m{}", i)); - } - } - - #[tokio::test] - async fn unknown_session_returns_none() { - let store = LogStore::new(); - assert!(store.get_log("nonexistent").await.is_none()); + assert_eq!(log[0].message_id, "m1"); } } diff --git a/src/main.rs b/src/main.rs index 14e827f..0ef966b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,91 @@ mod server; use macp_runtime::log_store::LogStore; +use macp_runtime::pb; use macp_runtime::registry::SessionRegistry; use macp_runtime::runtime::Runtime; +use macp_runtime::security::SecurityLayer; use server::MacpServer; +use std::path::PathBuf; use std::sync::Arc; -use tonic::transport::Server; - -use macp_runtime::pb; +use tonic::transport::{Identity, Server, ServerTlsConfig}; #[tokio::main] async fn main() -> Result<(), Box> { - let addr = "127.0.0.1:50051".parse()?; - let registry = Arc::new(SessionRegistry::new()); - let log_store = Arc::new(LogStore::new()); + let addr = std::env::var("MACP_BIND_ADDR") + .unwrap_or_else(|_| "127.0.0.1:50051".into()) + .parse()?; + + let memory_only = std::env::var("MACP_MEMORY_ONLY").ok().as_deref() == Some("1"); + let data_dir = + PathBuf::from(std::env::var("MACP_DATA_DIR").unwrap_or_else(|_| ".macp-data".into())); + + let registry = Arc::new(if memory_only { + SessionRegistry::new() + } else { + SessionRegistry::with_persistence(&data_dir)? + }); + let log_store = Arc::new(if memory_only { + LogStore::new() + } else { + LogStore::with_persistence(&data_dir)? + }); + + let registry_ref = Arc::clone(®istry); + let log_store_ref = Arc::clone(&log_store); + let runtime = Arc::new(Runtime::new(registry, log_store)); - let svc = MacpServer::new(runtime); + let security = SecurityLayer::from_env()?; + let svc = MacpServer::new(runtime, security); + + let allow_insecure = std::env::var("MACP_ALLOW_INSECURE").ok().as_deref() == Some("1"); + let tls_cert = std::env::var("MACP_TLS_CERT_PATH").ok(); + let tls_key = std::env::var("MACP_TLS_KEY_PATH").ok(); + + println!("macp-runtime v0.4.0 listening on {}", addr); - println!("macp-runtime v0.3.0 (RFC-0001) listening on {}", addr); + let builder = Server::builder(); + let mut builder = match (tls_cert, tls_key) { + (Some(cert_path), Some(key_path)) => { + let cert = tokio::fs::read(cert_path).await?; + let key = tokio::fs::read(key_path).await?; + builder.tls_config(ServerTlsConfig::new().identity(Identity::from_pem(cert, key)))? + } + _ if allow_insecure => { + eprintln!( + "warning: starting without TLS because MACP_ALLOW_INSECURE=1; this is not RFC-compliant" + ); + builder + } + _ => { + return Err( + "TLS is required unless MACP_ALLOW_INSECURE=1 and MACP_ALLOW_DEV_SENDER_HEADER=1" + .into(), + ) + } + }; - Server::builder() + let server_future = builder .add_service(pb::macp_runtime_service_server::MacpRuntimeServiceServer::new(svc)) - .serve(addr) - .await?; + .serve(addr); + + tokio::select! { + result = server_future => { + result?; + } + _ = tokio::signal::ctrl_c() => { + println!("\nShutting down gracefully..."); + } + } + + // Persist final state on shutdown + if let Err(e) = registry_ref.persist_snapshot().await { + eprintln!("warning: failed to persist session registry: {}", e); + } + if let Err(e) = log_store_ref.persist_snapshot().await { + eprintln!("warning: failed to persist log store: {}", e); + } + println!("State persisted. Goodbye."); Ok(()) } diff --git a/src/mode/decision.rs b/src/mode/decision.rs index 9dbe986..1fbfdd9 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -1,5 +1,6 @@ use crate::decision_pb::{EvaluationPayload, ObjectionPayload, ProposalPayload, VotePayload}; use crate::error::MacpError; +use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::session::Session; @@ -7,7 +8,6 @@ use prost::Message; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -/// Phase of the decision lifecycle. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum DecisionPhase { Proposal, @@ -16,13 +16,12 @@ pub enum DecisionPhase { Committed, } -/// Internal state tracked across the decision lifecycle. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DecisionState { pub proposals: BTreeMap, pub evaluations: Vec, pub objections: Vec, - pub votes: BTreeMap, + pub votes: BTreeMap>, pub phase: DecisionPhase, } @@ -59,12 +58,19 @@ pub struct Vote { pub sender: String, } -/// DecisionMode implements the RFC-compliant Proposal -> Evaluation -> Vote -> Commitment lifecycle. -/// Payloads are protobuf-encoded using types from `decision.proto`. -/// Also supports the legacy `payload == b"resolve"` behavior for backward compatibility. pub struct DecisionMode; impl DecisionMode { + fn default_state() -> DecisionState { + DecisionState { + proposals: BTreeMap::new(), + evaluations: Vec::new(), + objections: Vec::new(), + votes: BTreeMap::new(), + phase: DecisionPhase::Proposal, + } + } + fn encode_state(state: &DecisionState) -> Vec { serde_json::to_vec(state).expect("DecisionState is always serializable") } @@ -76,17 +82,16 @@ impl DecisionMode { impl Mode for DecisionMode { fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { - if session.participants.is_empty() { - return Ok(()); - } - // Commitment allowed from session initiator (designated orchestrator) - if env.message_type == "Commitment" && env.sender == session.initiator_sender { - return Ok(()); - } - if !session.participants.contains(&env.sender) { - return Err(MacpError::Forbidden); + match env.message_type.as_str() { + "Proposal" | "Commitment" if env.sender == session.initiator_sender => Ok(()), + "Proposal" | "Evaluation" | "Objection" | "Vote" + if is_declared_participant(&session.participants, &env.sender) => + { + Ok(()) + } + "Commitment" => Err(MacpError::Forbidden), + _ => Err(MacpError::Forbidden), } - Ok(()) } fn on_session_start( @@ -94,47 +99,17 @@ impl Mode for DecisionMode { session: &Session, _env: &Envelope, ) -> Result { - // Enforce declared participant model for canonical mode name - if session.mode == "macp.mode.decision.v1" && session.participants.is_empty() { + if session.participants.is_empty() { return Err(MacpError::InvalidPayload); } - let state = DecisionState { - proposals: BTreeMap::new(), - evaluations: Vec::new(), - objections: Vec::new(), - votes: BTreeMap::new(), - phase: DecisionPhase::Proposal, - }; - Ok(ModeResponse::PersistState(Self::encode_state(&state))) + Ok(ModeResponse::PersistState(Self::encode_state( + &Self::default_state(), + ))) } fn on_message(&self, session: &Session, env: &Envelope) -> Result { - // Legacy backward compatibility: payload == "resolve" ONLY on alias "decision" - if session.mode == "decision" && env.message_type == "Message" && env.payload == b"resolve" - { - return Ok(ModeResponse::Resolve(env.payload.clone())); - } - - match env.message_type.as_str() { - "Proposal" | "Evaluation" | "Objection" | "Vote" | "Commitment" => {} - _ => { - // Canonical mode rejects unknown message types - if session.mode == "macp.mode.decision.v1" { - return Err(MacpError::InvalidPayload); - } - // Legacy alias allows unknown message types as NoOp - return Ok(ModeResponse::NoOp); - } - } - let mut state = if session.mode_state.is_empty() { - DecisionState { - proposals: BTreeMap::new(), - evaluations: Vec::new(), - objections: Vec::new(), - votes: BTreeMap::new(), - phase: DecisionPhase::Proposal, - } + Self::default_state() } else { Self::decode_state(&session.mode_state)? }; @@ -147,7 +122,10 @@ impl Mode for DecisionMode { "Proposal" => { let payload = ProposalPayload::decode(&*env.payload) .map_err(|_| MacpError::InvalidPayload)?; - if payload.proposal_id.is_empty() { + if payload.proposal_id.trim().is_empty() + || payload.option.trim().is_empty() + || state.proposals.contains_key(&payload.proposal_id) + { return Err(MacpError::InvalidPayload); } state.proposals.insert( @@ -196,15 +174,16 @@ impl Mode for DecisionMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Vote" => { - if state.proposals.is_empty() { - return Err(MacpError::InvalidPayload); - } let payload = VotePayload::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?; if !state.proposals.contains_key(&payload.proposal_id) { return Err(MacpError::InvalidPayload); } - state.votes.insert( + let proposal_votes = state.votes.entry(payload.proposal_id.clone()).or_default(); + if proposal_votes.contains_key(&env.sender) { + return Err(MacpError::InvalidPayload); + } + proposal_votes.insert( env.sender.clone(), Vote { proposal_id: payload.proposal_id, @@ -217,6 +196,7 @@ impl Mode for DecisionMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Commitment" => { + validate_commitment_payload_for_session(session, &env.payload)?; if state.proposals.is_empty() { return Err(MacpError::InvalidPayload); } @@ -226,7 +206,7 @@ impl Mode for DecisionMode { resolution: env.payload.clone(), }) } - _ => Ok(ModeResponse::NoOp), + _ => Err(MacpError::InvalidPayload), } } } @@ -234,6 +214,7 @@ impl Mode for DecisionMode { #[cfg(test)] mod tests { use super::*; + use crate::pb::CommitmentPayload; use crate::session::SessionState; use std::collections::HashSet; @@ -244,573 +225,215 @@ mod tests { ttl_expiry: i64::MAX, started_at_unix_ms: 0, resolution: None, - mode: "decision".into(), + mode: "macp.mode.decision.v1".into(), mode_state: vec![], - participants: vec![], + participants: vec!["agent://fraud".into(), "agent://growth".into()], seen_message_ids: HashSet::new(), intent: String::new(), - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: "policy-1".into(), context: vec![], roots: vec![], - initiator_sender: String::new(), + initiator_sender: "agent://orchestrator".into(), } } - fn test_envelope(message_type: &str, payload: &[u8]) -> Envelope { + fn env(sender: &str, message_type: &str, payload: Vec) -> Envelope { Envelope { macp_version: "1.0".into(), - mode: "decision".into(), + mode: "macp.mode.decision.v1".into(), message_type: message_type.into(), - message_id: "m1".into(), + message_id: format!("{}-{}", sender, message_type), session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: 1_700_000_000_000, - payload: payload.to_vec(), + sender: sender.into(), + timestamp_unix_ms: 0, + payload, } } - fn session_with_state(state: &DecisionState) -> Session { - let mut s = test_session(); - s.mode_state = DecisionMode::encode_state(state); - s - } - - fn empty_state() -> DecisionState { - DecisionState { - proposals: BTreeMap::new(), - evaluations: Vec::new(), - objections: Vec::new(), - votes: BTreeMap::new(), - phase: DecisionPhase::Proposal, - } - } - - fn state_with_proposal() -> DecisionState { - let mut state = empty_state(); - state.proposals.insert( - "p1".into(), - Proposal { - proposal_id: "p1".into(), - option: "option_a".into(), - rationale: "because".into(), - sender: "alice".into(), - }, - ); - state.phase = DecisionPhase::Evaluation; - state - } - - fn state_with_vote() -> DecisionState { - let mut state = state_with_proposal(); - state.votes.insert( - "alice".into(), - Vote { - proposal_id: "p1".into(), - vote: "approve".into(), - reason: String::new(), - sender: "alice".into(), - }, - ); - state.phase = DecisionPhase::Voting; - state - } - - // Helper to encode protobuf payloads - fn encode_proposal(proposal_id: &str, option: &str, rationale: &str) -> Vec { + fn proposal(id: &str) -> Vec { ProposalPayload { - proposal_id: proposal_id.into(), - option: option.into(), - rationale: rationale.into(), + proposal_id: id.into(), + option: format!("option-{id}"), + rationale: "because".into(), supporting_data: vec![], } .encode_to_vec() } - fn encode_evaluation( - proposal_id: &str, - recommendation: &str, - confidence: f64, - reason: &str, - ) -> Vec { - EvaluationPayload { - proposal_id: proposal_id.into(), - recommendation: recommendation.into(), - confidence, - reason: reason.into(), - } - .encode_to_vec() - } - - fn encode_objection(proposal_id: &str, reason: &str, severity: &str) -> Vec { - ObjectionPayload { - proposal_id: proposal_id.into(), - reason: reason.into(), - severity: severity.into(), - } - .encode_to_vec() - } - - fn encode_vote(proposal_id: &str, vote: &str, reason: &str) -> Vec { + fn vote(id: &str, value: &str) -> Vec { VotePayload { - proposal_id: proposal_id.into(), - vote: vote.into(), - reason: reason.into(), + proposal_id: id.into(), + vote: value.into(), + reason: String::new(), } .encode_to_vec() } - #[test] - fn session_start_initializes_state() { - let mode = DecisionMode; - let session = test_session(); - let env = test_envelope("SessionStart", b""); - - let result = mode.on_session_start(&session, &env).unwrap(); - match result { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.phase, DecisionPhase::Proposal); - assert!(state.proposals.is_empty()); - } - _ => panic!("Expected PersistState"), - } - } - - // --- Legacy backward compatibility --- - - #[test] - fn legacy_resolve_payload_still_works() { - let mode = DecisionMode; - let session = test_session(); - let env = test_envelope("Message", b"resolve"); - - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::Resolve(_))); - } - - #[test] - fn other_message_payload_returns_noop() { - let mode = DecisionMode; - let session = test_session(); - let env = test_envelope("Message", b"hello world"); - - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::NoOp)); - } - - // --- Proposal --- - - #[test] - fn proposal_creates_entry_and_advances_phase() { - let mode = DecisionMode; - let session = session_with_state(&empty_state()); - let payload = encode_proposal("p1", "option_a", "it's the best"); - let env = test_envelope("Proposal", &payload); - - let result = mode.on_message(&session, &env).unwrap(); - match result { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.phase, DecisionPhase::Evaluation); - assert!(state.proposals.contains_key("p1")); - let p = &state.proposals["p1"]; - assert_eq!(p.option, "option_a"); - assert_eq!(p.sender, "test"); - } - _ => panic!("Expected PersistState"), - } - } - - #[test] - fn proposal_with_empty_id_rejected() { - let mode = DecisionMode; - let session = session_with_state(&empty_state()); - let payload = encode_proposal("", "opt", ""); - let env = test_envelope("Proposal", &payload); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn proposal_with_bad_payload_rejected() { - let mode = DecisionMode; - let session = session_with_state(&empty_state()); - let env = test_envelope("Proposal", b"not protobuf"); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - // --- Evaluation --- - - #[test] - fn evaluation_for_existing_proposal() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_evaluation("p1", "APPROVE", 0.9, "looks good"); - let env = test_envelope("Evaluation", &payload); - - let result = mode.on_message(&session, &env).unwrap(); - match result { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.evaluations.len(), 1); - assert_eq!(state.evaluations[0].recommendation, "APPROVE"); - } - _ => panic!("Expected PersistState"), - } - } - - #[test] - fn evaluation_for_nonexistent_proposal_rejected() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_evaluation("nonexistent", "APPROVE", 0.9, ""); - let env = test_envelope("Evaluation", &payload); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - // --- Objection --- - - #[test] - fn objection_for_existing_proposal() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_objection("p1", "too risky", "high"); - let env = test_envelope("Objection", &payload); - - let result = mode.on_message(&session, &env).unwrap(); - match result { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.objections.len(), 1); - assert_eq!(state.objections[0].severity, "high"); - } - _ => panic!("Expected PersistState"), - } - } - - #[test] - fn objection_for_nonexistent_proposal_rejected() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_objection("nope", "bad", "medium"); - let env = test_envelope("Objection", &payload); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - // --- Vote --- - - #[test] - fn vote_for_existing_proposal() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_vote("p1", "approve", "I agree"); - let env = test_envelope("Vote", &payload); - - let result = mode.on_message(&session, &env).unwrap(); - match result { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.phase, DecisionPhase::Voting); - assert!(state.votes.contains_key("test")); - assert_eq!(state.votes["test"].vote, "approve"); - } - _ => panic!("Expected PersistState"), - } - } - - #[test] - fn vote_before_proposal_rejected() { - let mode = DecisionMode; - let session = session_with_state(&empty_state()); - let payload = encode_vote("p1", "approve", ""); - let env = test_envelope("Vote", &payload); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn vote_for_nonexistent_proposal_rejected() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_vote("nope", "approve", ""); - let env = test_envelope("Vote", &payload); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn vote_overwrites_previous_vote_by_sender() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let payload = encode_vote("p1", "approve", ""); - let env = test_envelope("Vote", &payload); - let result = mode.on_message(&session, &env).unwrap(); - let data = match result { - ModeResponse::PersistState(d) => d, - _ => panic!("Expected PersistState"), - }; - - // Second vote by same sender - let mut session2 = test_session(); - session2.mode_state = data; - let payload2 = encode_vote("p1", "reject", ""); - let env2 = test_envelope("Vote", &payload2); - let result2 = mode.on_message(&session2, &env2).unwrap(); - match result2 { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.votes.len(), 1); - assert_eq!(state.votes["test"].vote, "reject"); - } - _ => panic!("Expected PersistState"), + fn commitment(session: &Session) -> Vec { + CommitmentPayload { + commitment_id: "c1".into(), + action: "decision.selected".into(), + authority_scope: "payments".into(), + reason: "bound".into(), + mode_version: session.mode_version.clone(), + policy_version: session.policy_version.clone(), + configuration_version: session.configuration_version.clone(), } + .encode_to_vec() } - // --- Commitment --- - - #[test] - fn commitment_resolves_session() { - let mode = DecisionMode; - let session = session_with_state(&state_with_vote()); - let payload = b"commitment-data"; - let env = test_envelope("Commitment", payload); - - let result = mode.on_message(&session, &env).unwrap(); - match result { - ModeResponse::PersistAndResolve { state, resolution } => { - let final_state: DecisionState = serde_json::from_slice(&state).unwrap(); - assert_eq!(final_state.phase, DecisionPhase::Committed); - assert!(!resolution.is_empty()); - } - _ => panic!("Expected PersistAndResolve"), + fn apply(session: &mut Session, response: ModeResponse) { + match response { + ModeResponse::PersistState(data) => session.mode_state = data, + ModeResponse::PersistAndResolve { state, .. } => session.mode_state = state, + _ => {} } } #[test] - fn commitment_without_proposals_rejected() { - let mode = DecisionMode; - let session = session_with_state(&empty_state()); - let env = test_envelope("Commitment", b"commit-data"); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn commitment_with_proposal_no_votes_succeeds() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - let env = test_envelope("Commitment", b"commit-data"); - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); - } - - // --- Full lifecycle --- - - #[test] - fn full_decision_lifecycle() { + fn session_start_requires_declared_participants() { let mode = DecisionMode; let mut session = test_session(); - - // SessionStart - let env = test_envelope("SessionStart", b""); - let result = mode.on_session_start(&session, &env).unwrap(); - if let ModeResponse::PersistState(data) = result { - session.mode_state = data; - } - - // Proposal - let payload = encode_proposal("p1", "option_a", "best choice"); - let env = test_envelope("Proposal", &payload); - let result = mode.on_message(&session, &env).unwrap(); - if let ModeResponse::PersistState(data) = result { - session.mode_state = data; - } - - // Evaluation - let payload = encode_evaluation("p1", "APPROVE", 0.95, ""); - let env = test_envelope("Evaluation", &payload); - let result = mode.on_message(&session, &env).unwrap(); - if let ModeResponse::PersistState(data) = result { - session.mode_state = data; - } - - // Vote - let payload = encode_vote("p1", "approve", "agreed"); - let env = test_envelope("Vote", &payload); - let result = mode.on_message(&session, &env).unwrap(); - if let ModeResponse::PersistState(data) = result { - session.mode_state = data; - } - - // Commitment - let env = test_envelope("Commitment", b"final-commitment"); - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); - } - - #[test] - fn message_after_commitment_rejected() { - let mode = DecisionMode; - let mut state = state_with_vote(); - state.phase = DecisionPhase::Committed; - let session = session_with_state(&state); - - let payload = encode_proposal("p1", "option_b", ""); - let env = test_envelope("Proposal", &payload); - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "SessionNotOpen"); - } - - #[test] - fn objection_default_severity() { - let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); - // Encode with empty severity -- should default to "medium" - let payload = encode_objection("p1", "bad idea", ""); - let env = test_envelope("Objection", &payload); - - let result = mode.on_message(&session, &env).unwrap(); - match result { - ModeResponse::PersistState(data) => { - let state: DecisionState = serde_json::from_slice(&data).unwrap(); - assert_eq!(state.objections[0].severity, "medium"); - } - _ => panic!("Expected PersistState"), - } - } - - #[test] - fn btreemap_deterministic_serialization() { - // Verify BTreeMap produces deterministic output - let mut state1 = empty_state(); - state1.proposals.insert( - "z".into(), - Proposal { - proposal_id: "z".into(), - option: "z".into(), - rationale: "".into(), - sender: "".into(), - }, + session.participants.clear(); + assert_eq!( + mode.on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]) + ) + .unwrap_err() + .to_string(), + "InvalidPayload" ); - state1.proposals.insert( - "a".into(), - Proposal { - proposal_id: "a".into(), - option: "a".into(), - rationale: "".into(), - sender: "".into(), - }, - ); - - let mut state2 = empty_state(); - state2.proposals.insert( - "a".into(), - Proposal { - proposal_id: "a".into(), - option: "a".into(), - rationale: "".into(), - sender: "".into(), - }, - ); - state2.proposals.insert( - "z".into(), - Proposal { - proposal_id: "z".into(), - option: "z".into(), - rationale: "".into(), - sender: "".into(), - }, - ); - - let enc1 = DecisionMode::encode_state(&state1); - let enc2 = DecisionMode::encode_state(&state2); - assert_eq!(enc1, enc2); } - // --- Participant enforcement tests --- - #[test] - fn canonical_mode_requires_participants() { + fn initiator_can_propose_without_being_declared_participant() { let mode = DecisionMode; - let mut session = test_session(); - session.mode = "macp.mode.decision.v1".into(); - session.participants = vec![]; // empty - let env = test_envelope("SessionStart", b""); - - let err = mode.on_session_start(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn canonical_mode_with_participants_succeeds() { - let mode = DecisionMode; - let mut session = test_session(); - session.mode = "macp.mode.decision.v1".into(); - session.participants = vec!["alice".into(), "bob".into()]; - let env = test_envelope("SessionStart", b""); - - let result = mode.on_session_start(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::PersistState(_))); + let session = test_session(); + mode.authorize_sender( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); } #[test] - fn legacy_alias_allows_empty_participants() { + fn duplicate_proposal_id_is_rejected() { let mode = DecisionMode; let mut session = test_session(); - session.mode = "decision".into(); - session.participants = vec![]; // empty -- should be allowed for legacy alias - let env = test_envelope("SessionStart", b""); - - let result = mode.on_session_start(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::PersistState(_))); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + assert_eq!( + mode.on_message(&session, &env("agent://fraud", "Proposal", proposal("p1"))) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); } - // --- Phase 4: Legacy scoping + canonical strictness --- - #[test] - fn canonical_mode_rejects_legacy_resolve() { + fn vote_is_scoped_per_proposal() { let mode = DecisionMode; let mut session = test_session(); - session.mode = "macp.mode.decision.v1".into(); - let env = test_envelope("Message", b"resolve"); - - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message(&session, &env("agent://fraud", "Proposal", proposal("p2"))) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://fraud", "Vote", vote("p1", "approve")), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://fraud", "Vote", vote("p2", "approve")), + ) + .unwrap(); + apply(&mut session, resp); + assert_eq!( + mode.on_message( + &session, + &env("agent://fraud", "Vote", vote("p1", "reject")) + ) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); } #[test] - fn canonical_mode_rejects_unknown_message_type() { + fn commitment_versions_must_match_session_bindings() { let mode = DecisionMode; let mut session = test_session(); - session.mode = "macp.mode.decision.v1".into(); - let env = test_envelope("CustomType", b"data"); - - let err = mode.on_message(&session, &env).unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn legacy_alias_allows_legacy_resolve() { - let mode = DecisionMode; - let session = test_session(); // mode = "decision" - let env = test_envelope("Message", b"resolve"); - - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::Resolve(_))); - } - - #[test] - fn legacy_alias_allows_unknown_message_noop() { - let mode = DecisionMode; - let session = test_session(); // mode = "decision" - let env = test_envelope("CustomType", b"data"); + let resp = mode + .on_session_start( + &session, + &env("agent://orchestrator", "SessionStart", vec![]), + ) + .unwrap(); + apply(&mut session, resp); + let resp = mode + .on_message( + &session, + &env("agent://orchestrator", "Proposal", proposal("p1")), + ) + .unwrap(); + apply(&mut session, resp); + + let mut bad = CommitmentPayload { + commitment_id: "c1".into(), + action: "decision.selected".into(), + authority_scope: "payments".into(), + reason: "bound".into(), + mode_version: "wrong".into(), + policy_version: session.policy_version.clone(), + configuration_version: session.configuration_version.clone(), + } + .encode_to_vec(); + + assert_eq!( + mode.on_message( + &session, + &env("agent://orchestrator", "Commitment", bad.clone()) + ) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); - let result = mode.on_message(&session, &env).unwrap(); - assert!(matches!(result, ModeResponse::NoOp)); + bad = commitment(&session); + mode.on_message(&session, &env("agent://orchestrator", "Commitment", bad)) + .unwrap(); } } diff --git a/src/mode/handoff.rs b/src/mode/handoff.rs index 192586a..800e18e 100644 --- a/src/mode/handoff.rs +++ b/src/mode/handoff.rs @@ -2,7 +2,7 @@ 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::util::{is_declared_participant, validate_commitment_payload_for_session}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::session::Session; @@ -188,7 +188,7 @@ impl Mode for HandoffMode { if env.sender != session.initiator_sender { return Err(MacpError::Forbidden); } - let _payload = decode_commitment_payload(&env.payload)?; + validate_commitment_payload_for_session(session, &env.payload)?; if !Self::commitment_ready(&state) { return Err(MacpError::InvalidPayload); } @@ -221,9 +221,9 @@ mod tests { 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(), + mode_version: "1.0.0".into(), + configuration_version: "config".into(), + policy_version: "policy".into(), context: vec![], roots: vec![], initiator_sender: "owner".into(), diff --git a/src/mode/proposal.rs b/src/mode/proposal.rs index 2d45825..eaaea1d 100644 --- a/src/mode/proposal.rs +++ b/src/mode/proposal.rs @@ -1,5 +1,7 @@ use crate::error::MacpError; -use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::util::{ + is_declared_participant, participants_all_accept, validate_commitment_payload_for_session, +}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::proposal_pb::{ @@ -62,6 +64,24 @@ impl ProposalMode { .get(proposal_id) .filter(|record| record.disposition == ProposalDisposition::Live) } + + fn commitment_ready(session: &Session, state: &ProposalState) -> bool { + if !state.terminal_rejections.is_empty() { + return true; + } + + state + .proposals + .values() + .filter(|proposal| proposal.disposition == ProposalDisposition::Live) + .any(|proposal| { + participants_all_accept( + &session.participants, + &state.accepts, + &proposal.proposal_id, + ) + }) + } } impl Mode for ProposalMode { @@ -168,7 +188,6 @@ impl Mode for ProposalMode { sender: env.sender.clone(), reason: payload.reason, }); - return Ok(ModeResponse::PersistState(Self::encode_state(&state))); } Ok(ModeResponse::PersistState(Self::encode_state(&state))) } @@ -186,16 +205,18 @@ impl Mode for ProposalMode { return Err(MacpError::InvalidPayload); } record.disposition = ProposalDisposition::Withdrawn; - // Clear acceptances for withdrawn proposal state.accepts.retain(|_, pid| pid != &payload.proposal_id); + state + .terminal_rejections + .retain(|r| r.proposal_id != 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() { + validate_commitment_payload_for_session(session, &env.payload)?; + if !Self::commitment_ready(session, &state) { return Err(MacpError::InvalidPayload); } Ok(ModeResponse::PersistAndResolve { @@ -224,15 +245,15 @@ mod tests { resolution: None, mode: "macp.mode.proposal.v1".into(), mode_state: vec![], - participants: vec!["buyer".into(), "seller".into()], + participants: vec!["agent://buyer".into(), "agent://seller".into()], seen_message_ids: HashSet::new(), intent: String::new(), - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: "policy-1".into(), context: vec![], roots: vec![], - initiator_sender: "buyer".into(), + initiator_sender: "agent://buyer".into(), } } @@ -249,15 +270,15 @@ mod tests { } } - fn commitment_payload() -> Vec { + fn commitment(session: &Session, action: &str) -> Vec { CommitmentPayload { commitment_id: "c1".into(), - action: "proposal.accepted".into(), + action: action.into(), authority_scope: "commercial".into(), reason: "bound".into(), - mode_version: "1.0.0".into(), - policy_version: "policy".into(), - configuration_version: "config".into(), + mode_version: session.mode_version.clone(), + policy_version: session.policy_version.clone(), + configuration_version: session.configuration_version.clone(), } .encode_to_vec() } @@ -270,701 +291,242 @@ mod tests { } } - fn make_proposal(id: &str, title: &str, summary: &str) -> Vec { + fn make_proposal(id: &str) -> Vec { ProposalPayload { proposal_id: id.into(), - title: title.into(), - summary: summary.into(), + title: format!("offer-{id}"), + 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 { + fn make_accept(id: &str) -> Vec { AcceptPayload { - proposal_id: proposal_id.into(), - reason: reason.into(), + proposal_id: id.into(), + reason: String::new(), } .encode_to_vec() } - fn make_reject(proposal_id: &str, terminal: bool, reason: &str) -> Vec { + fn make_reject(id: &str, terminal: bool) -> Vec { RejectPayload { - proposal_id: proposal_id.into(), + proposal_id: id.into(), terminal, - reason: reason.into(), + reason: "no".into(), } .encode_to_vec() } - fn make_withdraw(proposal_id: &str, reason: &str) -> Vec { + fn make_withdraw(id: &str) -> Vec { WithdrawPayload { - proposal_id: proposal_id.into(), - reason: reason.into(), + proposal_id: id.into(), + reason: "changed mind".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"); + assert_eq!( + mode.on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); } - // --- CounterProposal --- - #[test] - fn counter_proposal_works() { + fn commitment_requires_acceptance_convergence() { 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")), - ) + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + let resp = mode .on_message( &session, - &env( - "buyer", - "CounterProposal", - make_counter("p2", "p1", "Counter", "1000"), - ), + &env("agent://seller", "Proposal", make_proposal("p1")), ) .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"), - } - } + apply(&mut session, resp); - #[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( + assert_eq!( + mode.on_message( &session, &env( - "buyer", - "CounterProposal", - make_counter("p2", "nonexistent", "Counter", "1000"), + "agent://buyer", + "Commitment", + commitment(&session, "proposal.accepted"), ), ) - .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 --- + .unwrap_err() + .to_string(), + "InvalidPayload" + ); - #[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")), - ) + let resp = mode + .on_message(&session, &env("agent://buyer", "Accept", make_accept("p1"))) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + let resp = mode .on_message( &session, - &env("buyer", "Reject", make_reject("p1", false, "too expensive")), + &env("agent://seller", "Accept", make_accept("p1")), ) .unwrap(); - assert!(matches!(result, ModeResponse::PersistState(_))); + apply(&mut session, resp); + mode.on_message( + &session, + &env( + "agent://buyer", + "Commitment", + commitment(&session, "proposal.accepted"), + ), + ) + .unwrap(); } #[test] - fn terminal_reject_sets_flag() { + fn terminal_rejection_allows_negative_commitment() { let mode = ProposalMode; let mut session = base_session(); - let result = mode - .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + let resp = mode .on_message( &session, - &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + &env("agent://seller", "Proposal", make_proposal("p1")), ) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + let resp = mode .on_message( &session, - &env("buyer", "Reject", make_reject("p1", true, "deal breaker")), + &env("agent://buyer", "Reject", make_reject("p1", true)), ) .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"); + apply(&mut session, resp); + mode.on_message( + &session, + &env( + "agent://buyer", + "Commitment", + commitment(&session, "proposal.rejected"), + ), + ) + .unwrap(); } - // --- Withdraw --- - #[test] - fn withdraw_own_proposal() { + fn withdraw_clears_terminal_rejections() { let mode = ProposalMode; let mut session = base_session(); - let result = mode - .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + // Seller proposes p1 + let resp = mode .on_message( &session, - &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + &env("agent://seller", "Proposal", make_proposal("p1")), ) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + // Buyer terminally rejects p1 + let resp = mode .on_message( &session, - &env("seller", "Withdraw", make_withdraw("p1", "changed mind")), + &env("agent://buyer", "Reject", make_reject("p1", true)), ) .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 + apply(&mut session, resp); + // Seller withdraws p1 — terminal rejection should be cleared + let resp = mode .on_message( &session, - &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + &env("agent://seller", "Withdraw", make_withdraw("p1")), ) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + // Seller proposes p2 + let resp = mode .on_message( &session, - &env("seller", "Withdraw", make_withdraw("p1", "withdrawn")), + &env("agent://seller", "Proposal", make_proposal("p2")), ) .unwrap(); - apply(&mut session, result); + apply(&mut session, resp); + // Commitment should NOT be ready (no acceptance convergence, no terminal rejection) let err = mode .on_message( &session, - &env("seller", "Withdraw", make_withdraw("p1", "again")), + &env( + "agent://buyer", + "Commitment", + commitment(&session, "proposal.rejected"), + ), ) .unwrap_err(); assert_eq!(err.to_string(), "InvalidPayload"); } - // --- Commitment --- - #[test] - fn commitment_resolves_session() { + fn terminal_rejection_on_different_proposal_survives_withdraw() { let mode = ProposalMode; let mut session = base_session(); - let result = mode - .on_session_start(&session, &env("buyer", "SessionStart", vec![])) + let resp = mode + .on_session_start(&session, &env("agent://buyer", "SessionStart", vec![])) .unwrap(); - apply(&mut session, result); - let result = mode + apply(&mut session, resp); + // Seller proposes p1 and p2 + let resp = mode .on_message( &session, - &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + &env("agent://seller", "Proposal", make_proposal("p1")), ) .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 + apply(&mut session, resp); + let resp = mode .on_message( &session, - &env("seller", "Proposal", make_proposal("p1", "Offer", "1200")), + &env("agent://seller", "Proposal", make_proposal("p2")), ) .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 + apply(&mut session, resp); + // Buyer terminally rejects p2 + let resp = mode .on_message( &session, - &env("seller", "Proposal", make_proposal("p1", "Initial", "1200")), + &env("agent://buyer", "Reject", make_reject("p2", true)), ) .unwrap(); - apply(&mut session, result); - - // Buyer counter-proposes - let result = mode + apply(&mut session, resp); + // Seller withdraws p1 — p2's terminal rejection survives + let resp = mode .on_message( &session, - &env( - "buyer", - "CounterProposal", - make_counter("p2", "p1", "Counter", "1000"), - ), + &env("agent://seller", "Withdraw", make_withdraw("p1")), ) .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) - ); + apply(&mut session, resp); + // Commitment should be ready because p2 still has a terminal rejection + mode.on_message( + &session, + &env( + "agent://buyer", + "Commitment", + commitment(&session, "proposal.rejected"), + ), + ) + .unwrap(); } } diff --git a/src/mode/quorum.rs b/src/mode/quorum.rs index 31be6aa..2dd35bb 100644 --- a/src/mode/quorum.rs +++ b/src/mode/quorum.rs @@ -1,5 +1,5 @@ use crate::error::MacpError; -use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::quorum_pb::{AbstainPayload, ApprovalRequestPayload, ApprovePayload, RejectPayload}; @@ -187,7 +187,7 @@ impl Mode for QuorumMode { if env.sender != session.initiator_sender { return Err(MacpError::Forbidden); } - let _payload = decode_commitment_payload(&env.payload)?; + validate_commitment_payload_for_session(session, &env.payload)?; if !Self::commitment_ready(session, &state) { return Err(MacpError::InvalidPayload); } @@ -220,9 +220,9 @@ mod tests { 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(), + mode_version: "1.0.0".into(), + configuration_version: "config".into(), + policy_version: "policy".into(), context: vec![], roots: vec![], initiator_sender: "coordinator".into(), diff --git a/src/mode/task.rs b/src/mode/task.rs index 9c31e40..1032559 100644 --- a/src/mode/task.rs +++ b/src/mode/task.rs @@ -1,5 +1,5 @@ use crate::error::MacpError; -use crate::mode::util::{decode_commitment_payload, is_declared_participant}; +use crate::mode::util::{is_declared_participant, validate_commitment_payload_for_session}; use crate::mode::{Mode, ModeResponse}; use crate::pb::Envelope; use crate::session::Session; @@ -185,6 +185,9 @@ impl Mode for TaskMode { if !Self::can_assignee_respond(session, task, &env.sender) { return Err(MacpError::Forbidden); } + if state.rejections.iter().any(|r| r.assignee == env.sender) { + return Err(MacpError::InvalidPayload); + } state.rejections.push(TaskRejectRecord { task_id: payload.task_id, assignee: env.sender.clone(), @@ -259,7 +262,7 @@ impl Mode for TaskMode { if env.sender != session.initiator_sender { return Err(MacpError::Forbidden); } - let _payload = decode_commitment_payload(&env.payload)?; + validate_commitment_payload_for_session(session, &env.payload)?; if state.terminal_report.is_none() { return Err(MacpError::InvalidPayload); } @@ -292,9 +295,9 @@ mod tests { 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(), + mode_version: "1.0.0".into(), + configuration_version: "config".into(), + policy_version: "policy".into(), context: vec![], roots: vec![], initiator_sender: "planner".into(), @@ -924,6 +927,78 @@ mod tests { assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); } + // --- Duplicate rejection --- + + #[test] + fn duplicate_rejection_from_same_sender_rejected() { + let mode = TaskMode; + let mut session = base_session(); + session.participants = vec!["planner".into(), "w1".into(), "w2".into()]; + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("w1", "TaskReject", make_task_reject("t1", "w1")), + ) + .unwrap(); + apply(&mut session, result); + let err = mode + .on_message( + &session, + &env("w1", "TaskReject", make_task_reject("t1", "w1")), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn different_senders_can_both_reject() { + let mode = TaskMode; + let mut session = base_session(); + session.participants = vec!["planner".into(), "w1".into(), "w2".into()]; + let result = mode + .on_session_start(&session, &env("planner", "SessionStart", vec![])) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("planner", "TaskRequest", make_task_request("t1", "")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("w1", "TaskReject", make_task_reject("t1", "w1")), + ) + .unwrap(); + apply(&mut session, result); + let result = mode + .on_message( + &session, + &env("w2", "TaskReject", make_task_reject("t1", "w2")), + ) + .unwrap(); + match result { + ModeResponse::PersistState(data) => { + let state: TaskState = serde_json::from_slice(&data).unwrap(); + assert_eq!(state.rejections.len(), 2); + } + _ => panic!("Expected PersistState"), + } + } + // --- Unknown message type --- #[test] diff --git a/src/mode/util.rs b/src/mode/util.rs index 081b71a..ab97bf4 100644 --- a/src/mode/util.rs +++ b/src/mode/util.rs @@ -1,11 +1,50 @@ use crate::error::MacpError; use crate::pb::CommitmentPayload; +use crate::session::Session; use prost::Message; pub fn decode_commitment_payload(payload: &[u8]) -> Result { CommitmentPayload::decode(payload).map_err(|_| MacpError::InvalidPayload) } +pub fn validate_commitment_payload_for_session( + session: &Session, + payload: &[u8], +) -> Result { + let commitment = decode_commitment_payload(payload)?; + + if commitment.commitment_id.trim().is_empty() + || commitment.action.trim().is_empty() + || commitment.authority_scope.trim().is_empty() + || commitment.reason.trim().is_empty() + { + return Err(MacpError::InvalidPayload); + } + + if commitment.mode_version != session.mode_version + || commitment.configuration_version != session.configuration_version + { + return Err(MacpError::InvalidPayload); + } + + if !session.policy_version.is_empty() && commitment.policy_version != session.policy_version { + return Err(MacpError::InvalidPayload); + } + + Ok(commitment) +} + pub fn is_declared_participant(participants: &[String], sender: &str) -> bool { participants.iter().any(|participant| participant == sender) } + +pub fn participants_all_accept( + participants: &[String], + accepts: &std::collections::BTreeMap, + proposal_id: &str, +) -> bool { + !participants.is_empty() + && participants + .iter() + .all(|participant| accepts.get(participant).map(String::as_str) == Some(proposal_id)) +} diff --git a/src/registry.rs b/src/registry.rs index 1e8528e..a75286a 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,9 +1,98 @@ use crate::session::Session; use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; use tokio::sync::RwLock; +#[derive(serde::Serialize, serde::Deserialize)] +struct PersistedRoot { + uri: String, + name: String, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct PersistedSession { + session_id: String, + state: crate::session::SessionState, + ttl_expiry: i64, + started_at_unix_ms: i64, + resolution: Option>, + mode: String, + mode_state: Vec, + participants: Vec, + seen_message_ids: Vec, + intent: String, + mode_version: String, + configuration_version: String, + policy_version: String, + context: Vec, + roots: Vec, + initiator_sender: String, +} + +impl From<&Session> for PersistedSession { + fn from(session: &Session) -> Self { + Self { + session_id: session.session_id.clone(), + state: session.state.clone(), + ttl_expiry: session.ttl_expiry, + started_at_unix_ms: session.started_at_unix_ms, + resolution: session.resolution.clone(), + mode: session.mode.clone(), + mode_state: session.mode_state.clone(), + participants: session.participants.clone(), + seen_message_ids: session.seen_message_ids.iter().cloned().collect(), + intent: session.intent.clone(), + mode_version: session.mode_version.clone(), + configuration_version: session.configuration_version.clone(), + policy_version: session.policy_version.clone(), + context: session.context.clone(), + roots: session + .roots + .iter() + .map(|root| PersistedRoot { + uri: root.uri.clone(), + name: root.name.clone(), + }) + .collect(), + initiator_sender: session.initiator_sender.clone(), + } + } +} + +impl From for Session { + fn from(session: PersistedSession) -> Self { + Self { + session_id: session.session_id, + state: session.state, + ttl_expiry: session.ttl_expiry, + started_at_unix_ms: session.started_at_unix_ms, + resolution: session.resolution, + mode: session.mode, + mode_state: session.mode_state, + participants: session.participants, + seen_message_ids: session.seen_message_ids.into_iter().collect(), + intent: session.intent, + mode_version: session.mode_version, + configuration_version: session.configuration_version, + policy_version: session.policy_version, + context: session.context, + roots: session + .roots + .into_iter() + .map(|root| crate::pb::Root { + uri: root.uri, + name: root.name, + }) + .collect(), + initiator_sender: session.initiator_sender, + } + } +} + pub struct SessionRegistry { pub(crate) sessions: RwLock>, + persistence_path: Option, } impl Default for SessionRegistry { @@ -16,18 +105,138 @@ impl SessionRegistry { pub fn new() -> Self { Self { sessions: RwLock::new(HashMap::new()), + persistence_path: None, + } + } + + pub fn with_persistence>(dir: P) -> std::io::Result { + let dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&dir)?; + let path = dir.join("sessions.json"); + let sessions = Self::load_sessions(&path)?; + Ok(Self { + sessions: RwLock::new(sessions), + persistence_path: Some(path), + }) + } + + fn load_sessions(path: &Path) -> std::io::Result> { + if !path.exists() { + return Ok(HashMap::new()); } + let bytes = fs::read(path)?; + let persisted: HashMap = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(e) => { + eprintln!("warning: failed to deserialize sessions from {}: {e}; starting with empty state", path.display()); + HashMap::new() + } + }; + Ok(persisted + .into_iter() + .map(|(id, session)| (id, session.into())) + .collect()) + } + + fn persist_map(path: &Path, sessions: &HashMap) -> std::io::Result<()> { + let persisted: HashMap = sessions + .iter() + .map(|(id, session)| (id.clone(), PersistedSession::from(session))) + .collect(); + let bytes = serde_json::to_vec_pretty(&persisted)?; + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, bytes)?; + fs::rename(&tmp_path, path) + } + + pub(crate) async fn persist_locked( + &self, + sessions: &HashMap, + ) -> std::io::Result<()> { + if let Some(path) = &self.persistence_path { + Self::persist_map(path, sessions)?; + } + Ok(()) + } + + pub async fn persist_snapshot(&self) -> std::io::Result<()> { + let guard = self.sessions.read().await; + self.persist_locked(&guard).await } - /// Get a clone of a session by ID. Returns None if not found. pub async fn get_session(&self, session_id: &str) -> Option { let guard = self.sessions.read().await; guard.get(session_id).cloned() } - /// Insert a session directly. Used by tests and for pre-populating state. pub async fn insert_session_for_test(&self, session_id: String, session: Session) { let mut guard = self.sessions.write().await; guard.insert(session_id, session); + let _ = self.persist_locked(&guard).await; + } + + pub async fn count_open_sessions_for_initiator(&self, sender: &str) -> usize { + let guard = self.sessions.read().await; + guard + .values() + .filter(|session| { + session.initiator_sender == sender + && session.state == crate::session::SessionState::Open + }) + .count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::{Session, SessionState}; + use std::collections::HashSet; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn sample_session(id: &str) -> Session { + Session { + session_id: id.into(), + state: SessionState::Open, + ttl_expiry: 10, + started_at_unix_ms: 1, + resolution: None, + mode: "macp.mode.decision.v1".into(), + mode_state: vec![1, 2, 3], + participants: vec!["alice".into()], + seen_message_ids: HashSet::from(["m1".into()]), + intent: "intent".into(), + mode_version: "1.0.0".into(), + configuration_version: "cfg".into(), + policy_version: "pol".into(), + context: vec![9], + roots: vec![crate::pb::Root { + uri: "root://1".into(), + name: "r1".into(), + }], + initiator_sender: "alice".into(), + } + } + + #[tokio::test] + async fn persistent_registry_round_trip() { + let base = std::env::temp_dir().join(format!( + "macp-registry-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let registry = SessionRegistry::with_persistence(&base).unwrap(); + registry + .insert_session_for_test("s1".into(), sample_session("s1")) + .await; + + let reopened = SessionRegistry::with_persistence(&base).unwrap(); + let session = reopened.get_session("s1").await.unwrap(); + assert_eq!(session.mode, "macp.mode.decision.v1"); + assert_eq!(session.mode_version, "1.0.0"); + assert!(session.seen_message_ids.contains("m1")); } } diff --git a/src/runtime.rs b/src/runtime.rs index 3ef471a..8fa556a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use crate::error::MacpError; @@ -11,11 +11,15 @@ 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::pb::{Envelope, SessionStartPayload}; use crate::registry::SessionRegistry; -use crate::session::{extract_ttl_ms, parse_session_start_payload, Session, SessionState}; +use crate::session::{ + extract_ttl_ms, is_standard_mode, parse_session_start_payload, + validate_standard_session_start_payload, Session, SessionState, +}; + +const EXPERIMENTAL_DEFAULT_TTL_MS: i64 = 60_000; -/// Result of processing a message through the runtime. #[derive(Debug)] pub struct ProcessResult { pub session_state: SessionState, @@ -31,21 +35,13 @@ pub struct Runtime { impl Runtime { pub fn new(registry: Arc, log_store: Arc) -> Self { let mut modes: HashMap> = HashMap::new(); - - // 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 (legacy only for decision/multi_round) - modes.insert("decision".into(), Box::new(DecisionMode)); - modes.insert("multi_round".into(), Box::new(MultiRoundMode)); - Self { registry, log_store, @@ -53,7 +49,6 @@ impl Runtime { } } - /// Returns the standards-track mode names in canonical registry order. pub fn registered_mode_names(&self) -> Vec { standard_mode_names() .iter() @@ -62,14 +57,6 @@ impl Runtime { .collect() } - fn resolve_mode_name(mode_field: &str) -> &str { - if mode_field.is_empty() { - "macp.mode.decision.v1" - } else { - mode_field - } - } - fn make_incoming_entry(env: &Envelope) -> LogEntry { LogEntry { message_id: env.message_id.clone(), @@ -95,12 +82,10 @@ impl Runtime { fn apply_mode_response(session: &mut Session, response: ModeResponse) { match response { ModeResponse::NoOp => {} - ModeResponse::PersistState(s) => { - session.mode_state = s; - } - ModeResponse::Resolve(r) => { + ModeResponse::PersistState(state) => session.mode_state = state, + ModeResponse::Resolve(resolution) => { session.state = SessionState::Resolved; - session.resolution = Some(r); + session.resolution = Some(resolution); } ModeResponse::PersistAndResolve { state, resolution } => { session.mode_state = state; @@ -110,6 +95,24 @@ impl Runtime { } } + async fn persist_sessions(&self, sessions: &HashMap) { + if let Err(err) = self.registry.persist_locked(sessions).await { + eprintln!("warning: failed to persist session registry: {err}"); + } + } + + async fn maybe_expire_session(&self, session_id: &str, session: &mut Session) -> bool { + let now = Utc::now().timestamp_millis(); + if session.state == SessionState::Open && now > session.ttl_expiry { + self.log_store + .append(session_id, Self::make_internal_entry("TtlExpired", b"")) + .await; + session.state = SessionState::Expired; + return true; + } + false + } + pub async fn process(&self, env: &Envelope) -> Result { match env.message_type.as_str() { "SessionStart" => self.process_session_start(env).await, @@ -119,16 +122,27 @@ impl Runtime { } async fn process_session_start(&self, env: &Envelope) -> Result { - let mode_name = Self::resolve_mode_name(&env.mode); + if env.mode.trim().is_empty() { + return Err(MacpError::InvalidEnvelope); + } + let mode_name = env.mode.as_str(); let mode = self.modes.get(mode_name).ok_or(MacpError::UnknownMode)?; - // Parse protobuf SessionStartPayload - let start_payload = parse_session_start_payload(&env.payload)?; - let ttl_ms = extract_ttl_ms(&start_payload)?; + let start_payload = if env.payload.is_empty() && !is_standard_mode(mode_name) { + SessionStartPayload::default() + } else { + parse_session_start_payload(&env.payload)? + }; + validate_standard_session_start_payload(mode_name, &start_payload)?; + let ttl_ms = if is_standard_mode(mode_name) { + extract_ttl_ms(&start_payload)? + } else if start_payload.ttl_ms == 0 { + EXPERIMENTAL_DEFAULT_TTL_MS + } else { + extract_ttl_ms(&start_payload)? + }; let mut guard = self.registry.sessions.write().await; - - // Check for duplicate session — idempotent if same message_id if let Some(existing) = guard.get(&env.session_id) { if existing.seen_message_ids.contains(&env.message_id) { return Ok(ProcessResult { @@ -139,23 +153,18 @@ impl Runtime { return Err(MacpError::DuplicateSession); } - let now = Utc::now().timestamp_millis(); - let ttl_expiry = now + ttl_ms; - - // Extract participants from SessionStartPayload - let participants = start_payload.participants.clone(); - - // Create session with initial state and RFC version fields + let accepted_at = Utc::now().timestamp_millis(); + let ttl_expiry = accepted_at.saturating_add(ttl_ms); let session = Session { session_id: env.session_id.clone(), state: SessionState::Open, ttl_expiry, - started_at_unix_ms: now, + started_at_unix_ms: accepted_at, resolution: None, mode: mode_name.to_string(), mode_state: vec![], - participants, - seen_message_ids: HashSet::new(), + participants: start_payload.participants.clone(), + seen_message_ids: std::collections::HashSet::new(), intent: start_payload.intent.clone(), mode_version: start_payload.mode_version.clone(), configuration_version: start_payload.configuration_version.clone(), @@ -165,10 +174,8 @@ impl Runtime { initiator_sender: env.sender.clone(), }; - // Call mode's on_session_start BEFORE recording side effects let response = mode.on_session_start(&session, env)?; - // Only on success: create log and record message_id self.log_store.create_session_log(&env.session_id).await; self.log_store .append(&env.session_id, Self::make_incoming_entry(env)) @@ -178,7 +185,11 @@ impl Runtime { session.seen_message_ids.insert(env.message_id.clone()); Self::apply_mode_response(&mut session, response); - // For multi_round mode, extract participants from mode_state if not already set + // Multi-round mode stores participants in its own state rather than in + // the session-level field. This block back-fills session.participants so + // that authorization checks work uniformly. It is intentionally coupled + // to MultiRoundState; if new experimental modes adopt the same pattern + // this should be generalized. if session.participants.is_empty() && !session.mode_state.is_empty() { if let Ok(state) = serde_json::from_slice::( &session.mode_state, @@ -189,6 +200,7 @@ impl Runtime { let result_state = session.state.clone(); guard.insert(env.session_id.clone(), session); + self.persist_sessions(&guard).await; Ok(ProcessResult { session_state: result_state, @@ -198,12 +210,10 @@ impl Runtime { async fn process_message(&self, env: &Envelope) -> Result { let mut guard = self.registry.sessions.write().await; - let session = guard .get_mut(&env.session_id) .ok_or(MacpError::UnknownSession)?; - // Message deduplication if session.seen_message_ids.contains(&env.message_id) { return Ok(ProcessResult { session_state: session.state.clone(), @@ -211,16 +221,8 @@ impl Runtime { }); } - // TTL check - let now = Utc::now().timestamp_millis(); - if session.state == SessionState::Open && now > session.ttl_expiry { - self.log_store - .append( - &env.session_id, - Self::make_internal_entry("TtlExpired", b""), - ) - .await; - session.state = SessionState::Expired; + if self.maybe_expire_session(&env.session_id, session).await { + self.persist_sessions(&guard).await; return Err(MacpError::TtlExpired); } @@ -228,30 +230,31 @@ impl Runtime { return Err(MacpError::SessionNotOpen); } - let mode_name = session.mode.clone(); - let mode = self.modes.get(&mode_name).ok_or(MacpError::UnknownMode)?; - - // Mode-aware authorization (replaces hardcoded participant check) + let mode = self + .modes + .get(&session.mode) + .ok_or(MacpError::UnknownMode)?; mode.authorize_sender(session, env)?; - - // Dispatch to mode BEFORE recording side effects let response = mode.on_message(session, env)?; - // Only on success: record message_id and log session.seen_message_ids.insert(env.message_id.clone()); self.log_store .append(&env.session_id, Self::make_incoming_entry(env)) .await; - Self::apply_mode_response(session, response); + let result_state = session.state.clone(); + self.persist_sessions(&guard).await; Ok(ProcessResult { - session_state: session.state.clone(), + session_state: result_state, duplicate: false, }) } - /// Process a Signal message. Signals are non-binding and non-session-scoped. + /// Process a Signal envelope. Signals are defined in the MACP spec for + /// out-of-band notifications (progress, heartbeat, etc.) but their semantics + /// are not yet finalized. This stub accepts any Signal without side-effects + /// so that compliant clients can send them without error. async fn process_signal(&self, _env: &Envelope) -> Result { Ok(ProcessResult { session_state: SessionState::Open, @@ -259,59 +262,46 @@ impl Runtime { }) } - /// Get a session with TTL check. Transitions expired sessions to Expired state. pub async fn get_session_checked(&self, session_id: &str) -> Option { let mut guard = self.registry.sessions.write().await; - if let Some(session) = guard.get_mut(session_id) { - let now = Utc::now().timestamp_millis(); - if session.state == SessionState::Open && now > session.ttl_expiry { - self.log_store - .append(session_id, Self::make_internal_entry("TtlExpired", b"")) - .await; - session.state = SessionState::Expired; - } - Some(session.clone()) + let changed = if let Some(session) = guard.get_mut(session_id) { + self.maybe_expire_session(session_id, session).await } else { - None + return None; + }; + if changed { + self.persist_sessions(&guard).await; } + guard.get(session_id).cloned() } - /// Cancel a session by ID. Idempotent for already-resolved/expired sessions. pub async fn cancel_session( &self, session_id: &str, reason: &str, ) -> Result { let mut guard = self.registry.sessions.write().await; - let session = guard.get_mut(session_id).ok_or(MacpError::UnknownSession)?; - // TTL check: transition expired sessions - let now = Utc::now().timestamp_millis(); - if session.state == SessionState::Open && now > session.ttl_expiry { - self.log_store - .append(session_id, Self::make_internal_entry("TtlExpired", b"")) - .await; - session.state = SessionState::Expired; - } + let _ = self.maybe_expire_session(session_id, session).await; - // Idempotent: already resolved or expired if session.state == SessionState::Resolved || session.state == SessionState::Expired { + let result_state = session.state.clone(); + self.persist_sessions(&guard).await; return Ok(ProcessResult { - session_state: session.state.clone(), + session_state: result_state, duplicate: false, }); } - // Log cancellation self.log_store .append( session_id, Self::make_internal_entry("SessionCancel", reason.as_bytes()), ) .await; - session.state = SessionState::Expired; + self.persist_sessions(&guard).await; Ok(ProcessResult { session_state: SessionState::Expired, @@ -324,12 +314,7 @@ 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 crate::pb::{CommitmentPayload, SessionStartPayload}; use prost::Message; fn make_runtime() -> Runtime { @@ -338,18 +323,18 @@ mod tests { Runtime::new(registry, log_store) } - fn encode_session_start(ttl_ms: i64, participants: Vec) -> Vec { - let payload = SessionStartPayload { - intent: String::new(), + fn session_start(participants: Vec) -> Vec { + SessionStartPayload { + intent: "intent".into(), participants, - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), - ttl_ms, + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: "policy-1".into(), + ttl_ms: 1_000, context: vec![], roots: vec![], - }; - payload.encode_to_vec() + } + .encode_to_vec() } fn env( @@ -358,7 +343,7 @@ mod tests { message_id: &str, session_id: &str, sender: &str, - payload: &[u8], + payload: Vec, ) -> Envelope { Envelope { macp_version: "1.0".into(), @@ -368,1515 +353,287 @@ mod tests { session_id: session_id.into(), sender: sender.into(), timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: payload.to_vec(), + payload, } } #[tokio::test] - async fn decision_mode_full_flow() { - let rt = make_runtime(); - - // SessionStart - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - // Normal message - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - rt.process(&e).await.unwrap(); - - // Resolve - let e = env("decision", "Message", "m3", "s1", "alice", b"resolve"); - rt.process(&e).await.unwrap(); - - // After resolve - let e = env("decision", "Message", "m4", "s1", "alice", b"nope"); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "SessionNotOpen"); - } - - #[tokio::test] - async fn empty_mode_defaults_to_decision() { + async fn standard_session_start_is_strict() { let rt = make_runtime(); - - // Empty mode defaults to macp.mode.decision.v1 which requires participants - let payload = encode_session_start(0, vec!["alice".into()]); - let e = env("", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - let guard = rt.registry.sessions.read().await; - assert_eq!(guard["s1"].mode, "macp.mode.decision.v1"); + let bad = SessionStartPayload { + ttl_ms: 0, + ..Default::default() + } + .encode_to_vec(); + let err = rt + .process(&env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "s1", + "agent://orchestrator", + bad, + )) + .await + .unwrap_err(); + assert!(matches!( + err, + MacpError::InvalidPayload | MacpError::InvalidTtl + )); } #[tokio::test] - async fn unknown_mode_rejected() { + async fn empty_mode_is_rejected() { let rt = make_runtime(); - - let e = env("nonexistent", "SessionStart", "m1", "s1", "alice", b""); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "UnknownMode"); + let err = rt + .process(&env( + "", + "SessionStart", + "m1", + "s1", + "agent://orchestrator", + session_start(vec!["agent://fraud".into()]), + )) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "InvalidEnvelope"); } #[tokio::test] - async fn multi_round_flow() { + async fn rejected_messages_do_not_enter_dedup_state() { let rt = make_runtime(); - - let start_payload = encode_session_start( - 0, // default TTL - vec!["alice".into(), "bob".into()], - ); - let e = env( - "multi_round", + rt.process(&env( + "macp.mode.decision.v1", "SessionStart", - "m0", - "s1", - "creator", - &start_payload, - ); - rt.process(&e).await.unwrap(); - - // Alice contributes - let e = env( - "multi_round", - "Contribute", "m1", "s1", - "alice", - br#"{"value":"option_a"}"#, - ); - rt.process(&e).await.unwrap(); - - // Bob contributes different value — no convergence - let e = env( - "multi_round", - "Contribute", - "m2", - "s1", - "bob", - br#"{"value":"option_b"}"#, - ); - rt.process(&e).await.unwrap(); - - { - let guard = rt.registry.sessions.read().await; - assert_eq!(guard["s1"].state, SessionState::Open); - } - - // Bob revises to match alice — convergence - let e = env( - "multi_round", - "Contribute", - "m3", - "s1", - "bob", - br#"{"value":"option_a"}"#, - ); - rt.process(&e).await.unwrap(); + "agent://orchestrator", + session_start(vec!["agent://fraud".into()]), + )) + .await + .unwrap(); + + let bad = rt + .process(&env( + "macp.mode.decision.v1", + "Proposal", + "m2", + "s1", + "agent://fraud", + b"not-protobuf".to_vec(), + )) + .await + .unwrap_err(); + assert_eq!(bad.to_string(), "InvalidPayload"); - { - let guard = rt.registry.sessions.read().await; - assert_eq!(guard["s1"].state, SessionState::Resolved); - let resolution = guard["s1"].resolution.as_ref().unwrap(); - let res: serde_json::Value = serde_json::from_slice(resolution).unwrap(); - assert_eq!(res["converged_value"], "option_a"); + let good = ProposalPayload { + proposal_id: "p1".into(), + option: "step-up".into(), + rationale: "risk".into(), + supporting_data: vec![], } - } - - #[tokio::test] - async fn mode_response_apply_noop() { - let mut session = Session { - session_id: "s".into(), - state: SessionState::Open, - ttl_expiry: i64::MAX, - started_at_unix_ms: 0, - resolution: None, - mode: "decision".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }; - Runtime::apply_mode_response(&mut session, ModeResponse::NoOp); - assert_eq!(session.state, SessionState::Open); - assert!(session.resolution.is_none()); - } - - #[tokio::test] - async fn mode_response_apply_persist_and_resolve() { - let mut session = Session { - session_id: "s".into(), - state: SessionState::Open, - ttl_expiry: i64::MAX, - started_at_unix_ms: 0, - resolution: None, - mode: "multi_round".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }; - Runtime::apply_mode_response( - &mut session, - ModeResponse::PersistAndResolve { - state: b"new_state".to_vec(), - resolution: b"resolved_data".to_vec(), - }, - ); - assert_eq!(session.state, SessionState::Resolved); - assert_eq!(session.mode_state, b"new_state"); - assert_eq!(session.resolution, Some(b"resolved_data".to_vec())); - } - - #[tokio::test] - async fn log_before_mutate_ordering() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let log = rt.log_store.get_log("s1").await.unwrap(); - assert_eq!(log.len(), 1); - assert_eq!(log[0].message_type, "SessionStart"); - assert_eq!(log[0].entry_kind, EntryKind::Incoming); - } - - #[tokio::test] - async fn ttl_expiry_logs_internal_entry() { - let rt = make_runtime(); - - let payload = encode_session_start(1, vec![]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - // Wait for TTL to expire - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "TtlExpired"); - - let log = rt.log_store.get_log("s1").await.unwrap(); - assert_eq!(log.len(), 2); - assert_eq!(log[1].entry_kind, EntryKind::Internal); - assert_eq!(log[1].message_type, "TtlExpired"); - } - - // --- Phase 3: Deduplication tests --- - - #[tokio::test] - async fn duplicate_message_returns_duplicate_true() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let result = rt.process(&e).await.unwrap(); + .encode_to_vec(); + let result = rt + .process(&env( + "macp.mode.decision.v1", + "Proposal", + "m2", + "s1", + "agent://orchestrator", + good, + )) + .await + .unwrap(); assert!(!result.duplicate); - - // Same message_id again - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let result = rt.process(&e).await.unwrap(); - assert!(result.duplicate); - } - - #[tokio::test] - async fn duplicate_session_start_same_message_id_is_idempotent() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - // Same session_id and same message_id - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - let result = rt.process(&e).await.unwrap(); - assert!(result.duplicate); - } - - #[tokio::test] - async fn duplicate_session_start_different_message_id_errors() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - // Same session_id but different message_id - let e = env("decision", "SessionStart", "m2", "s1", "alice", b""); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "DuplicateSession"); - } - - #[tokio::test] - async fn duplicate_does_not_log() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - rt.process(&e).await.unwrap(); - - let log_before = rt.log_store.get_log("s1").await.unwrap().len(); - - // Duplicate - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let result = rt.process(&e).await.unwrap(); - assert!(result.duplicate); - - let log_after = rt.log_store.get_log("s1").await.unwrap().len(); - assert_eq!(log_before, log_after); // No new log entry } - // --- CancelSession tests --- - #[tokio::test] - async fn cancel_open_session() { + async fn get_session_transitions_expired_sessions() { let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let result = rt.cancel_session("s1", "test cancel").await.unwrap(); - assert_eq!(result.session_state, SessionState::Expired); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.state, SessionState::Expired); + let payload = SessionStartPayload { + intent: "intent".into(), + participants: vec!["agent://fraud".into()], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: "policy-1".into(), + ttl_ms: 1, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + rt.process(&env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "s1", + "agent://orchestrator", + payload, + )) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let session = rt.get_session_checked("s1").await.unwrap(); + assert_eq!(session.state, SessionState::Expired); } #[tokio::test] - async fn cancel_resolved_session_is_idempotent() { + async fn experimental_mode_keeps_legacy_default_ttl() { let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"resolve"); - rt.process(&e).await.unwrap(); - - let result = rt.cancel_session("s1", "too late").await.unwrap(); - assert_eq!(result.session_state, SessionState::Resolved); + let payload = SessionStartPayload { + participants: vec!["creator".into(), "other".into()], + ..Default::default() + } + .encode_to_vec(); + rt.process(&env( + "macp.mode.multi_round.v1", + "SessionStart", + "m1", + "s1", + "creator", + payload, + )) + .await + .unwrap(); + let session = rt.get_session_checked("s1").await.unwrap(); + assert!(session.ttl_expiry > session.started_at_unix_ms); } #[tokio::test] - async fn cancel_unknown_session_errors() { + async fn duplicate_session_start_message_id_returns_duplicate() { let rt = make_runtime(); - let err = rt - .cancel_session("nonexistent", "reason") + let payload = session_start(vec!["agent://fraud".into()]); + rt.process(&env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "s1", + "agent://orchestrator", + payload.clone(), + )) + .await + .unwrap(); + + let result = rt + .process(&env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "s1", + "agent://orchestrator", + payload, + )) .await - .unwrap_err(); - assert_eq!(err.to_string(), "UnknownSession"); - } - - #[tokio::test] - async fn message_after_cancel_rejected() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - rt.cancel_session("s1", "cancelled").await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "SessionNotOpen"); - } - - #[tokio::test] - async fn cancel_logs_entry() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - rt.cancel_session("s1", "test reason").await.unwrap(); - - let log = rt.log_store.get_log("s1").await.unwrap(); - assert_eq!(log.len(), 2); - assert_eq!(log[1].message_type, "SessionCancel"); - assert_eq!(log[1].entry_kind, EntryKind::Internal); - } - - // --- Participant validation tests --- - - #[tokio::test] - async fn forbidden_when_sender_not_in_participants() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - // "charlie" is not a participant - let e = env("decision", "Message", "m2", "s1", "charlie", b"hello"); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "Forbidden"); - } - - #[tokio::test] - async fn allowed_when_participants_empty() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - // Any sender allowed when participants is empty - let e = env("decision", "Message", "m2", "s1", "charlie", b"hello"); - rt.process(&e).await.unwrap(); - } - - #[tokio::test] - async fn allowed_when_sender_is_participant() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - rt.process(&e).await.unwrap(); + .unwrap(); + assert!(result.duplicate); } - // --- Mode naming tests --- - #[tokio::test] - async fn rfc_mode_name_works() { + async fn cancel_idempotent_on_already_expired() { let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into()]); - let e = env( + let payload = SessionStartPayload { + intent: "intent".into(), + participants: vec!["agent://fraud".into()], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: "policy-1".into(), + ttl_ms: 1, + context: vec![], + roots: vec![], + } + .encode_to_vec(); + rt.process(&env( "macp.mode.decision.v1", "SessionStart", "m1", "s1", - "alice", - &payload, - ); - rt.process(&e).await.unwrap(); - - let guard = rt.registry.sessions.read().await; - assert_eq!(guard["s1"].mode, "macp.mode.decision.v1"); - } - - #[tokio::test] - async fn short_alias_still_works() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let guard = rt.registry.sessions.read().await; - assert_eq!(guard["s1"].mode, "decision"); + "agent://orchestrator", + payload, + )) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let result = rt.cancel_session("s1", "cleanup").await.unwrap(); + assert_eq!(result.session_state, SessionState::Expired); } - // --- Signal tests --- - #[tokio::test] - async fn signal_with_empty_session_id_accepted() { + async fn commitment_versions_are_carried_into_resolution() { let rt = make_runtime(); + rt.process(&env( + "macp.mode.proposal.v1", + "SessionStart", + "m1", + "s1", + "agent://buyer", + session_start(vec!["agent://buyer".into(), "agent://seller".into()]), + )) + .await + .unwrap(); - let e = env("", "Signal", "sig1", "", "alice", b""); - let result = rt.process(&e).await.unwrap(); - assert!(!result.duplicate); - } - - #[tokio::test] - async fn signal_does_not_create_session() { - let rt = make_runtime(); - - let e = env("", "Signal", "sig1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await; - assert!(s.is_none()); - } - - // --- ProcessResult state field tests --- - - #[tokio::test] - async fn process_result_state_open_after_session_start() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - let result = rt.process(&e).await.unwrap(); - assert_eq!(result.session_state, SessionState::Open); - assert!(!result.duplicate); - } - - #[tokio::test] - async fn process_result_state_resolved_after_resolve() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"resolve"); - let result = rt.process(&e).await.unwrap(); - assert_eq!(result.session_state, SessionState::Resolved); - } - - #[tokio::test] - async fn process_result_state_open_for_normal_message() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let result = rt.process(&e).await.unwrap(); - assert_eq!(result.session_state, SessionState::Open); - } - - // --- started_at_unix_ms tests --- - - #[tokio::test] - async fn started_at_unix_ms_populated() { - let rt = make_runtime(); - let before = chrono::Utc::now().timestamp_millis(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let after = chrono::Utc::now().timestamp_millis(); - let s = rt.registry.get_session("s1").await.unwrap(); - assert!(s.started_at_unix_ms >= before); - assert!(s.started_at_unix_ms <= after); - } - - // --- Dedup across different sessions --- - - #[tokio::test] - async fn same_message_id_different_sessions_not_duplicate() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "SessionStart", "m1", "s2", "alice", b""); - let result = rt.process(&e).await.unwrap(); - assert!(!result.duplicate); - } - - // --- Cancel already-expired session is idempotent --- - - #[tokio::test] - async fn cancel_already_expired_session_is_idempotent() { - let rt = make_runtime(); - - let payload = encode_session_start(1, vec![]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - // Wait for TTL to expire, then trigger expiry - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let _ = rt.process(&e).await; // triggers expiry - - // Cancel expired session — should be idempotent - let result = rt.cancel_session("s1", "already expired").await.unwrap(); - assert_eq!(result.session_state, SessionState::Expired); - } - - // --- Multi-round with protobuf participants --- - - #[tokio::test] - async fn multi_round_participants_from_protobuf_payload() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env( - "multi_round", - "SessionStart", - "m0", - "s1", - "creator", - &payload, - ); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.participants, vec!["alice", "bob"]); - } - - #[tokio::test] - async fn multi_round_with_participant_validation() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env( - "multi_round", - "SessionStart", - "m0", - "s1", - "creator", - &payload, - ); - rt.process(&e).await.unwrap(); - - // Unauthorized participant - let e = env( - "multi_round", - "Contribute", - "m1", - "s1", - "charlie", - br#"{"value":"x"}"#, - ); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "Forbidden"); - - // Authorized participant - let e = env( - "multi_round", - "Contribute", - "m2", - "s1", - "alice", - br#"{"value":"x"}"#, - ); - rt.process(&e).await.unwrap(); - } - - // --- RFC mode names for multi_round --- - - #[tokio::test] - async fn rfc_mode_name_multi_round_works() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into()]); - let e = env( - "macp.mode.multi_round.v1", - "SessionStart", - "m0", - "s1", - "alice", - &payload, - ); - rt.process(&e).await.unwrap(); - - let guard = rt.registry.sessions.read().await; - assert_eq!(guard["s1"].mode, "macp.mode.multi_round.v1"); - } - - // --- Signal with payload --- - - #[tokio::test] - async fn signal_with_payload_accepted() { - let rt = make_runtime(); - - let e = env("", "Signal", "sig1", "", "alice", b"some signal data"); - let result = rt.process(&e).await.unwrap(); - assert_eq!(result.session_state, SessionState::Open); - } - - // --- ModeResponse::PersistState --- - - #[tokio::test] - async fn mode_response_apply_persist_state() { - let mut session = Session { - session_id: "s".into(), - state: SessionState::Open, - ttl_expiry: i64::MAX, - started_at_unix_ms: 0, - resolution: None, - mode: "decision".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }; - Runtime::apply_mode_response( - &mut session, - ModeResponse::PersistState(b"persisted".to_vec()), - ); - assert_eq!(session.state, SessionState::Open); - assert_eq!(session.mode_state, b"persisted"); - assert!(session.resolution.is_none()); - } - - #[tokio::test] - async fn mode_response_apply_resolve() { - let mut session = Session { - session_id: "s".into(), - state: SessionState::Open, - ttl_expiry: i64::MAX, - started_at_unix_ms: 0, - resolution: None, - mode: "decision".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }; - Runtime::apply_mode_response(&mut session, ModeResponse::Resolve(b"resolved".to_vec())); - assert_eq!(session.state, SessionState::Resolved); - assert_eq!(session.resolution, Some(b"resolved".to_vec())); - assert!(session.mode_state.is_empty()); - } - - // --- Multiple sessions isolation --- - - #[tokio::test] - async fn multiple_sessions_independent() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "SessionStart", "m2", "s2", "bob", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m3", "s1", "alice", b"resolve"); - rt.process(&e).await.unwrap(); - - let s2 = rt.registry.get_session("s2").await.unwrap(); - assert_eq!(s2.state, SessionState::Open); - - let s1 = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s1.state, SessionState::Resolved); - } - - // --- Protobuf TTL from SessionStartPayload --- - - #[tokio::test] - async fn session_start_with_protobuf_ttl() { - let rt = make_runtime(); - - let payload = encode_session_start(30_000, vec![]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - let now = chrono::Utc::now().timestamp_millis(); - assert!(s.ttl_expiry > now); - assert!(s.ttl_expiry <= now + 31_000); - } - - #[tokio::test] - async fn session_start_invalid_protobuf_ttl_rejected() { - let rt = make_runtime(); - - let payload = encode_session_start(-100, vec![]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "InvalidTtl"); - } - - // --- Cancel preserves log integrity --- - - #[tokio::test] - async fn cancel_reason_recorded_in_log() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - rt.cancel_session("s1", "user requested").await.unwrap(); - - let log = rt.log_store.get_log("s1").await.unwrap(); - assert_eq!(log.len(), 2); - assert_eq!(log[1].message_type, "SessionCancel"); - assert_eq!(log[1].raw_payload, b"user requested"); - assert_eq!(log[1].sender, "_runtime"); - } - - // --- Dedup does not invoke mode --- - - #[tokio::test] - async fn duplicate_resolve_does_not_double_resolve() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"resolve"); - let result = rt.process(&e).await.unwrap(); - assert_eq!(result.session_state, SessionState::Resolved); - assert!(!result.duplicate); - - let e = env("decision", "Message", "m2", "s1", "alice", b"resolve"); - let result = rt.process(&e).await.unwrap(); - assert!(result.duplicate); - assert_eq!(result.session_state, SessionState::Resolved); - } - - // --- Version fields stored on session --- - - #[tokio::test] - async fn session_stores_version_fields() { - let rt = make_runtime(); - - let payload = SessionStartPayload { - intent: "test coordination".into(), - participants: vec![], - mode_version: "1.0".into(), - configuration_version: "cfg-v2".into(), - policy_version: "pol-v1".into(), - ttl_ms: 0, - context: vec![], - roots: vec![], - }; - let e = env( - "decision", - "SessionStart", - "m1", - "s1", - "alice", - &payload.encode_to_vec(), - ); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.intent, "test coordination"); - assert_eq!(s.mode_version, "1.0"); - assert_eq!(s.configuration_version, "cfg-v2"); - assert_eq!(s.policy_version, "pol-v1"); - } - - // --- PR #1a: Admission pipeline bug tests --- - - #[tokio::test] - async fn rejected_message_does_not_burn_message_id() { - let rt = make_runtime(); - - // Create a decision session with a proposal so we can test invalid payloads - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - // Send a Proposal with invalid payload — should be rejected - let e = env("decision", "Proposal", "m2", "s1", "alice", b"not protobuf"); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - - // Retry the same message_id with valid payload — should succeed (not duplicate) - let valid_payload = ProposalPayload { - proposal_id: "p1".into(), - option: "option_a".into(), - rationale: "test".into(), - supporting_data: vec![], - } - .encode_to_vec(); - let e = env("decision", "Proposal", "m2", "s1", "alice", &valid_payload); - let result = rt.process(&e).await.unwrap(); - assert!(!result.duplicate); - } - - #[tokio::test] - async fn rejected_message_not_in_log() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let log_before = rt.log_store.get_log("s1").await.unwrap().len(); - - // Send a Proposal with invalid payload — should be rejected - let e = env("decision", "Proposal", "m2", "s1", "alice", b"not protobuf"); - let _ = rt.process(&e).await; - - let log_after = rt.log_store.get_log("s1").await.unwrap().len(); - assert_eq!(log_before, log_after); // No new log entry for rejected message - } - - #[tokio::test] - async fn accepted_messages_still_dedup_correctly() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let result = rt.process(&e).await.unwrap(); - assert!(!result.duplicate); - - // Same message_id again — should be duplicate - let e = env("decision", "Message", "m2", "s1", "alice", b"hello"); - let result = rt.process(&e).await.unwrap(); - assert!(result.duplicate); - } - - #[tokio::test] - async fn session_start_mode_rejection_no_side_effects() { - let rt = make_runtime(); - - // MultiRound requires participants — empty participants should fail - let e = env("multi_round", "SessionStart", "m1", "s1", "creator", b""); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - - // Session should not exist - assert!(rt.registry.get_session("s1").await.is_none()); - // Log should not exist - assert!(rt.log_store.get_log("s1").await.is_none()); - } - - // --- PR #1b: TTL on GetSession/CancelSession tests --- - - #[tokio::test] - async fn get_session_checked_transitions_expired() { - let rt = make_runtime(); - - let payload = encode_session_start(1, vec![]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - // Wait for TTL to expire - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - let session = rt.get_session_checked("s1").await.unwrap(); - assert_eq!(session.state, SessionState::Expired); - } - - #[tokio::test] - async fn cancel_expired_session_returns_expired_idempotent() { - let rt = make_runtime(); - - let payload = encode_session_start(1, vec![]); - let e = env("decision", "SessionStart", "m1", "s1", "alice", &payload); - rt.process(&e).await.unwrap(); - - // Wait for TTL to expire - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - // Cancel should detect TTL expiry and return Expired idempotently - let result = rt.cancel_session("s1", "cancel attempt").await.unwrap(); - assert_eq!(result.session_state, SessionState::Expired); - } - - // --- PR #5: Participant enforcement tests --- - - #[tokio::test] - async fn canonical_decision_mode_requires_participants() { - let rt = make_runtime(); - - // macp.mode.decision.v1 with no participants should fail - let e = env( - "macp.mode.decision.v1", - "SessionStart", - "m1", - "s1", - "alice", - b"", - ); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[tokio::test] - async fn canonical_decision_mode_with_participants_succeeds() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env( - "macp.mode.decision.v1", - "SessionStart", - "m1", - "s1", - "alice", - &payload, - ); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.mode, "macp.mode.decision.v1"); - assert_eq!(s.participants, vec!["alice", "bob"]); - } - - #[tokio::test] - async fn legacy_decision_alias_allows_empty_participants() { - let rt = make_runtime(); - - let e = env("decision", "SessionStart", "m1", "s1", "alice", b""); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.mode, "decision"); - } - - // --- Phase 2: Session data model tests --- - - #[tokio::test] - async fn session_stores_context_and_roots() { - let rt = make_runtime(); - - let payload = SessionStartPayload { - intent: String::new(), - participants: vec!["alice".into()], - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), - ttl_ms: 0, - context: b"some context".to_vec(), - roots: vec![crate::pb::Root { - uri: "file:///tmp".into(), - name: "test-root".into(), - }], - }; - let e = env( - "macp.mode.decision.v1", - "SessionStart", - "m1", - "s1", - "alice", - &payload.encode_to_vec(), - ); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.context, b"some context"); - assert_eq!(s.roots.len(), 1); - assert_eq!(s.roots[0].uri, "file:///tmp"); - assert_eq!(s.roots[0].name, "test-root"); - } - - #[tokio::test] - async fn session_stores_initiator_sender() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into()]); - let e = env( - "macp.mode.decision.v1", - "SessionStart", - "m1", - "s1", - "alice", - &payload, - ); - rt.process(&e).await.unwrap(); - - let s = rt.registry.get_session("s1").await.unwrap(); - assert_eq!(s.initiator_sender, "alice"); - } - - // --- Phase 3: Mode-aware authorization tests --- - - #[tokio::test] - async fn commitment_from_initiator_allowed_outside_participants() { - let rt = make_runtime(); - - // Create session with participants alice+bob, initiator is "coordinator" - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env( - "macp.mode.decision.v1", - "SessionStart", - "m0", - "s1", - "coordinator", - &payload, - ); - rt.process(&e).await.unwrap(); - - // Alice submits a proposal - let proposal = crate::decision_pb::ProposalPayload { + let proposal = crate::proposal_pb::ProposalPayload { proposal_id: "p1".into(), - option: "opt".into(), - rationale: "r".into(), - supporting_data: vec![], + title: "offer".into(), + summary: "summary".into(), + details: vec![], + tags: vec![], } .encode_to_vec(); - let e = env( - "macp.mode.decision.v1", + rt.process(&env( + "macp.mode.proposal.v1", "Proposal", - "m1", - "s1", - "alice", - &proposal, - ); - rt.process(&e).await.unwrap(); - - // Coordinator (not in participants) sends Commitment — should succeed (no votes required per RFC) - let e = env( - "macp.mode.decision.v1", - "Commitment", "m2", "s1", - "coordinator", - b"commit", - ); - let result = rt.process(&e).await.unwrap(); - assert_eq!(result.session_state, SessionState::Resolved); - } - - #[tokio::test] - async fn proposal_from_non_participant_still_forbidden() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env( - "macp.mode.decision.v1", - "SessionStart", - "m0", - "s1", - "coordinator", - &payload, - ); - rt.process(&e).await.unwrap(); - - // "charlie" not a participant and not sending Commitment - let proposal = crate::decision_pb::ProposalPayload { + "agent://seller", + proposal, + )) + .await + .unwrap(); + let accept = crate::proposal_pb::AcceptPayload { proposal_id: "p1".into(), - option: "opt".into(), - rationale: "r".into(), - supporting_data: vec![], + reason: String::new(), } .encode_to_vec(); - let e = env( - "macp.mode.decision.v1", - "Proposal", - "m1", - "s1", - "charlie", - &proposal, - ); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "Forbidden"); - } - - #[tokio::test] - async fn multi_round_authorization_unchanged() { - let rt = make_runtime(); - - let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); - let e = env( - "multi_round", - "SessionStart", - "m0", - "s1", - "creator", - &payload, - ); - rt.process(&e).await.unwrap(); - - // "charlie" still forbidden (default authorize_sender) - let e = env( - "multi_round", - "Contribute", - "m1", - "s1", - "charlie", - br#"{"value":"x"}"#, - ); - let err = rt.process(&e).await.unwrap_err(); - assert_eq!(err.to_string(), "Forbidden"); - - // alice succeeds - let e = env( - "multi_round", - "Contribute", - "m2", - "s1", - "alice", - br#"{"value":"x"}"#, - ); - 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( + rt.process(&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( + "s1", + "agent://seller", + accept.clone(), + )) + .await + .unwrap(); + rt.process(&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", + "Accept", "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) + "s1", + "agent://buyer", + accept, + )) + .await + .unwrap(); let commitment = CommitmentPayload { commitment_id: "c1".into(), - action: "quorum.approved".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.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); + policy_version: "policy-1".into(), + configuration_version: "cfg-1".into(), } - - // 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"); + .encode_to_vec(); + let result = rt + .process(&env( + "macp.mode.proposal.v1", + "Commitment", + "m5", + "s1", + "agent://buyer", + commitment, + )) + .await + .unwrap(); + assert_eq!(result.session_state, SessionState::Resolved); } } diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..b8a9c8e --- /dev/null +++ b/src/security.rs @@ -0,0 +1,888 @@ +use crate::error::MacpError; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use tonic::metadata::MetadataMap; + +#[derive(Clone, Debug)] +pub struct AuthIdentity { + pub sender: String, + pub allowed_modes: Option>, + pub can_start_sessions: bool, + pub max_open_sessions: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RawIdentity { + token: String, + sender: String, + #[serde(default)] + allowed_modes: Vec, + #[serde(default = "default_true")] + can_start_sessions: bool, + max_open_sessions: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(untagged)] +enum RawConfig { + List(Vec), + Wrapped { tokens: Vec }, +} + +fn default_true() -> bool { + true +} + +#[derive(Clone, Debug)] +pub struct RateLimitConfig { + pub limit: usize, + pub window: Duration, +} + +#[derive(Default)] +struct RateBucket { + start_events: Mutex>>, + message_events: Mutex>>, +} + +#[derive(Clone)] +pub struct SecurityLayer { + identities: Arc>, + rate_bucket: Arc, + auth_required: bool, + allow_dev_sender_header: bool, + pub max_payload_bytes: usize, + session_start_rate: RateLimitConfig, + message_rate: RateLimitConfig, +} + +impl SecurityLayer { + pub fn dev_mode() -> Self { + Self { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: true, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + } + } + + pub fn from_env() -> Result> { + let max_payload_bytes = std::env::var("MACP_MAX_PAYLOAD_BYTES") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1_048_576); + + let session_start_rate = RateLimitConfig { + limit: std::env::var("MACP_SESSION_START_LIMIT_PER_MINUTE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(60), + window: Duration::from_secs(60), + }; + let message_rate = RateLimitConfig { + limit: std::env::var("MACP_MESSAGE_LIMIT_PER_MINUTE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(600), + window: Duration::from_secs(60), + }; + + let allow_dev_sender_header = std::env::var("MACP_ALLOW_DEV_SENDER_HEADER") + .ok() + .as_deref() + == Some("1"); + + let raw = if let Ok(json) = std::env::var("MACP_AUTH_TOKENS_JSON") { + Some(json) + } else if let Ok(path) = std::env::var("MACP_AUTH_TOKENS_FILE") { + Some(fs::read_to_string(PathBuf::from(path))?) + } else { + None + }; + + let auth_required = raw.is_some() || !allow_dev_sender_header; + let identities = raw + .map(|json| Self::parse_identities(&json)) + .transpose()? + .unwrap_or_default(); + + Ok(Self { + identities: Arc::new(identities), + rate_bucket: Arc::new(RateBucket::default()), + auth_required, + allow_dev_sender_header, + max_payload_bytes, + session_start_rate, + message_rate, + }) + } + + fn parse_identities( + json: &str, + ) -> Result, Box> { + let parsed: RawConfig = serde_json::from_str(json)?; + let items = match parsed { + RawConfig::List(items) => items, + RawConfig::Wrapped { tokens } => tokens, + }; + let mut identities = HashMap::new(); + for item in items { + identities.insert( + item.token, + AuthIdentity { + sender: item.sender, + allowed_modes: if item.allowed_modes.is_empty() { + None + } else { + Some(item.allowed_modes.into_iter().collect()) + }, + can_start_sessions: item.can_start_sessions, + max_open_sessions: item.max_open_sessions, + }, + ); + } + Ok(identities) + } + + fn bearer_token(metadata: &MetadataMap) -> Option { + metadata + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_string) + .or_else(|| { + metadata + .get("x-macp-token") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }) + } + + pub fn authenticate_metadata(&self, metadata: &MetadataMap) -> Result { + if let Some(token) = Self::bearer_token(metadata) { + return self + .identities + .get(&token) + .cloned() + .ok_or(MacpError::Unauthenticated); + } + + if self.allow_dev_sender_header { + if let Some(sender) = metadata + .get("x-macp-agent-id") + .and_then(|value| value.to_str().ok()) + { + return Ok(AuthIdentity { + sender: sender.to_string(), + allowed_modes: None, + can_start_sessions: true, + max_open_sessions: None, + }); + } + } + + if self.auth_required { + Err(MacpError::Unauthenticated) + } else { + Ok(AuthIdentity { + sender: "agent://anonymous".into(), + allowed_modes: None, + can_start_sessions: true, + max_open_sessions: None, + }) + } + } + + pub fn authorize_mode( + &self, + identity: &AuthIdentity, + mode: &str, + is_session_start: bool, + ) -> Result<(), MacpError> { + if is_session_start && !identity.can_start_sessions { + return Err(MacpError::Forbidden); + } + if let Some(allowed_modes) = &identity.allowed_modes { + if !allowed_modes.contains(mode) { + return Err(MacpError::Forbidden); + } + } + Ok(()) + } + + async fn check_bucket( + bucket: &Mutex>>, + sender: &str, + config: &RateLimitConfig, + ) -> Result<(), MacpError> { + let now = Instant::now(); + let mut guard = bucket.lock().await; + let deque = guard.entry(sender.to_string()).or_default(); + while deque + .front() + .map(|instant| now.duration_since(*instant) > config.window) + .unwrap_or(false) + { + deque.pop_front(); + } + if deque.len() >= config.limit { + return Err(MacpError::RateLimited); + } + deque.push_back(now); + Ok(()) + } + + pub async fn enforce_rate_limit( + &self, + sender: &str, + is_session_start: bool, + ) -> Result<(), MacpError> { + if is_session_start { + Self::check_bucket( + &self.rate_bucket.start_events, + sender, + &self.session_start_rate, + ) + .await + } else { + Self::check_bucket(&self.rate_bucket.message_events, sender, &self.message_rate).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + use tonic::metadata::MetadataMap; + + /// Build a SecurityLayer with bearer token identities loaded from a JSON string. + /// This avoids touching environment variables (safe for parallel tests). + fn layer_with_tokens(json: &str) -> SecurityLayer { + let identities = SecurityLayer::parse_identities(json).expect("valid JSON"); + SecurityLayer { + identities: Arc::new(identities), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: true, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + } + } + + /// Build a SecurityLayer with no tokens that does not require auth. + fn insecure_layer() -> SecurityLayer { + SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + } + } + + // --------------------------------------------------------------- + // 1. dev_mode() creates a SecurityLayer that doesn't require auth + // --------------------------------------------------------------- + + #[test] + fn dev_mode_does_not_require_auth() { + let layer = SecurityLayer::dev_mode(); + let meta = MetadataMap::new(); + let id = layer + .authenticate_metadata(&meta) + .expect("should succeed without auth"); + assert_eq!(id.sender, "agent://anonymous"); + assert!(id.allowed_modes.is_none()); + assert!(id.can_start_sessions); + } + + #[test] + fn dev_mode_allows_dev_sender_header() { + let layer = SecurityLayer::dev_mode(); + let mut meta = MetadataMap::new(); + meta.insert("x-macp-agent-id", "agent://dev-bot".parse().unwrap()); + let id = layer.authenticate_metadata(&meta).expect("should succeed"); + assert_eq!(id.sender, "agent://dev-bot"); + } + + #[test] + fn dev_mode_has_unlimited_rate_limits() { + let layer = SecurityLayer::dev_mode(); + assert_eq!(layer.session_start_rate.limit, usize::MAX); + assert_eq!(layer.message_rate.limit, usize::MAX); + } + + // --------------------------------------------------------------- + // 2. from_env() with no env vars creates an insecure layer + // --------------------------------------------------------------- + + #[test] + fn from_env_defaults_without_env_vars() { + // from_env reads live env vars, so we verify the code path indirectly: + // When no token JSON/file is set AND allow_dev_sender_header is false, + // auth_required = (!allow_dev_sender_header) = true. + // But if no tokens AND no dev header => auth_required = true. + // + // We can verify that default max_payload, rate limits, etc. are sane + // by constructing through from_env in a controlled subprocess, but that + // is fragile. Instead we test the exact same logic through direct construction. + let layer = insecure_layer(); + assert!(!layer.auth_required); + assert_eq!(layer.max_payload_bytes, 1_048_576); + } + + // --------------------------------------------------------------- + // 3. Bearer token auth: loading tokens and authenticating + // --------------------------------------------------------------- + + #[test] + fn bearer_token_authentication_via_authorization_header() { + let json = r#"[{"token":"tok-abc","sender":"agent://alice","allowed_modes":[],"can_start_sessions":true}]"#; + let layer = layer_with_tokens(json); + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer tok-abc".parse().unwrap()); + + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate"); + assert_eq!(id.sender, "agent://alice"); + assert!(id.allowed_modes.is_none()); // empty vec -> None + assert!(id.can_start_sessions); + } + + #[test] + fn bearer_token_authentication_via_x_macp_token_header() { + let json = r#"[{"token":"tok-xyz","sender":"agent://bob"}]"#; + let layer = layer_with_tokens(json); + + let mut meta = MetadataMap::new(); + meta.insert("x-macp-token", "tok-xyz".parse().unwrap()); + + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate"); + assert_eq!(id.sender, "agent://bob"); + } + + #[test] + fn invalid_bearer_token_returns_unauthenticated() { + let json = r#"[{"token":"tok-real","sender":"agent://alice"}]"#; + let layer = layer_with_tokens(json); + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer tok-fake".parse().unwrap()); + + let err = layer.authenticate_metadata(&meta).unwrap_err(); + assert!(matches!(err, MacpError::Unauthenticated)); + } + + #[test] + fn no_token_when_auth_required_returns_unauthenticated() { + let json = r#"[{"token":"tok-only","sender":"agent://sole"}]"#; + let layer = layer_with_tokens(json); + + let meta = MetadataMap::new(); // no auth header at all + let err = layer.authenticate_metadata(&meta).unwrap_err(); + assert!(matches!(err, MacpError::Unauthenticated)); + } + + #[test] + fn parse_identities_wrapped_format() { + let json = r#"{"tokens":[{"token":"t1","sender":"agent://wrapped"}]}"#; + let layer = layer_with_tokens(json); + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer t1".parse().unwrap()); + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate"); + assert_eq!(id.sender, "agent://wrapped"); + } + + #[test] + fn parse_identities_with_allowed_modes() { + let json = r#"[{"token":"t-modes","sender":"agent://limited","allowed_modes":["macp.mode.decision.v1","macp.mode.task.v1"],"can_start_sessions":false,"max_open_sessions":5}]"#; + let layer = layer_with_tokens(json); + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer t-modes".parse().unwrap()); + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate"); + + assert_eq!(id.sender, "agent://limited"); + assert!(!id.can_start_sessions); + assert_eq!(id.max_open_sessions, Some(5)); + let modes = id + .allowed_modes + .as_ref() + .expect("should have allowed_modes"); + assert!(modes.contains("macp.mode.decision.v1")); + assert!(modes.contains("macp.mode.task.v1")); + assert!(!modes.contains("macp.mode.proposal.v1")); + } + + #[test] + fn authorization_header_takes_priority_over_x_macp_token() { + let json = r#"[ + {"token":"bearer-tok","sender":"agent://bearer-user"}, + {"token":"header-tok","sender":"agent://header-user"} + ]"#; + let layer = layer_with_tokens(json); + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer bearer-tok".parse().unwrap()); + meta.insert("x-macp-token", "header-tok".parse().unwrap()); + + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate"); + // Authorization header should take priority + assert_eq!(id.sender, "agent://bearer-user"); + } + + // --------------------------------------------------------------- + // 4. Dev header extraction: x-macp-agent-id + // --------------------------------------------------------------- + + #[test] + fn dev_sender_header_extracted_when_allowed() { + let layer = SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: true, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + }; + + let mut meta = MetadataMap::new(); + meta.insert("x-macp-agent-id", "agent://dev-agent".parse().unwrap()); + + let id = layer.authenticate_metadata(&meta).expect("should succeed"); + assert_eq!(id.sender, "agent://dev-agent"); + assert!(id.allowed_modes.is_none()); + assert!(id.can_start_sessions); + assert!(id.max_open_sessions.is_none()); + } + + #[test] + fn dev_sender_header_ignored_when_not_allowed() { + // auth_required=true, allow_dev_sender_header=false, no tokens + let layer = SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: true, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + }; + + let mut meta = MetadataMap::new(); + meta.insert("x-macp-agent-id", "agent://sneaky".parse().unwrap()); + + let err = layer.authenticate_metadata(&meta).unwrap_err(); + assert!(matches!(err, MacpError::Unauthenticated)); + } + + #[test] + fn bearer_token_takes_priority_over_dev_header() { + let json = r#"[{"token":"real-tok","sender":"agent://real"}]"#; + let identities = SecurityLayer::parse_identities(json).unwrap(); + + let layer = SecurityLayer { + identities: Arc::new(identities), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: true, + allow_dev_sender_header: true, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + }; + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer real-tok".parse().unwrap()); + meta.insert("x-macp-agent-id", "agent://dev-override".parse().unwrap()); + + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate via bearer"); + assert_eq!(id.sender, "agent://real"); + } + + // --------------------------------------------------------------- + // 5. authorize_mode() with allowed modes and without + // --------------------------------------------------------------- + + #[test] + fn authorize_mode_allows_any_mode_when_no_restriction() { + let layer = SecurityLayer::dev_mode(); + let id = AuthIdentity { + sender: "agent://any".into(), + allowed_modes: None, + can_start_sessions: true, + max_open_sessions: None, + }; + assert!(layer + .authorize_mode(&id, "macp.mode.decision.v1", false) + .is_ok()); + assert!(layer.authorize_mode(&id, "macp.mode.task.v1", true).is_ok()); + assert!(layer.authorize_mode(&id, "arbitrary.mode", false).is_ok()); + } + + #[test] + fn authorize_mode_rejects_unlisted_mode() { + let layer = SecurityLayer::dev_mode(); + let mut allowed = HashSet::new(); + allowed.insert("macp.mode.decision.v1".to_string()); + + let id = AuthIdentity { + sender: "agent://restricted".into(), + allowed_modes: Some(allowed), + can_start_sessions: true, + max_open_sessions: None, + }; + assert!(layer + .authorize_mode(&id, "macp.mode.decision.v1", false) + .is_ok()); + let err = layer + .authorize_mode(&id, "macp.mode.task.v1", false) + .unwrap_err(); + assert!(matches!(err, MacpError::Forbidden)); + } + + #[test] + fn authorize_mode_rejects_session_start_when_not_allowed() { + let layer = SecurityLayer::dev_mode(); + let id = AuthIdentity { + sender: "agent://no-start".into(), + allowed_modes: None, + can_start_sessions: false, + max_open_sessions: None, + }; + let err = layer + .authorize_mode(&id, "macp.mode.decision.v1", true) + .unwrap_err(); + assert!(matches!(err, MacpError::Forbidden)); + } + + #[test] + fn authorize_mode_allows_non_session_start_even_when_start_forbidden() { + let layer = SecurityLayer::dev_mode(); + let id = AuthIdentity { + sender: "agent://no-start".into(), + allowed_modes: None, + can_start_sessions: false, + max_open_sessions: None, + }; + // Regular messages (not session start) should succeed + assert!(layer + .authorize_mode(&id, "macp.mode.decision.v1", false) + .is_ok()); + } + + #[test] + fn authorize_mode_checks_both_can_start_and_allowed_modes() { + let layer = SecurityLayer::dev_mode(); + let mut allowed = HashSet::new(); + allowed.insert("macp.mode.decision.v1".to_string()); + + let id = AuthIdentity { + sender: "agent://double-check".into(), + allowed_modes: Some(allowed), + can_start_sessions: false, + max_open_sessions: None, + }; + + // Cannot start sessions (checked first) + let err = layer + .authorize_mode(&id, "macp.mode.decision.v1", true) + .unwrap_err(); + assert!(matches!(err, MacpError::Forbidden)); + + // Cannot use unlisted mode + let err = layer + .authorize_mode(&id, "macp.mode.task.v1", false) + .unwrap_err(); + assert!(matches!(err, MacpError::Forbidden)); + + // Can send non-start message on allowed mode + assert!(layer + .authorize_mode(&id, "macp.mode.decision.v1", false) + .is_ok()); + } + + // --------------------------------------------------------------- + // 6. enforce_rate_limit() with session_start and message categories + // --------------------------------------------------------------- + + #[tokio::test] + async fn rate_limit_session_start_enforced() { + let layer = SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: 3, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + }; + + let sender = "agent://rate-test"; + // First 3 should succeed + for _ in 0..3 { + assert!(layer.enforce_rate_limit(sender, true).await.is_ok()); + } + // 4th should be rate limited + let err = layer.enforce_rate_limit(sender, true).await.unwrap_err(); + assert!(matches!(err, MacpError::RateLimited)); + + // Regular messages should still be fine (separate bucket) + assert!(layer.enforce_rate_limit(sender, false).await.is_ok()); + } + + #[tokio::test] + async fn rate_limit_message_enforced() { + let layer = SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: 2, + window: Duration::from_secs(60), + }, + }; + + let sender = "agent://msg-test"; + assert!(layer.enforce_rate_limit(sender, false).await.is_ok()); + assert!(layer.enforce_rate_limit(sender, false).await.is_ok()); + let err = layer.enforce_rate_limit(sender, false).await.unwrap_err(); + assert!(matches!(err, MacpError::RateLimited)); + + // Session starts should still be fine (separate bucket) + assert!(layer.enforce_rate_limit(sender, true).await.is_ok()); + } + + #[tokio::test] + async fn rate_limit_per_sender_isolation() { + let layer = SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: 1, + window: Duration::from_secs(60), + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + }; + + // Sender A exhausts limit + assert!(layer.enforce_rate_limit("agent://a", true).await.is_ok()); + assert!(layer.enforce_rate_limit("agent://a", true).await.is_err()); + + // Sender B should still be able to start sessions + assert!(layer.enforce_rate_limit("agent://b", true).await.is_ok()); + } + + #[tokio::test] + async fn rate_limit_window_expiry() { + let layer = SecurityLayer { + identities: Arc::new(HashMap::new()), + rate_bucket: Arc::new(RateBucket::default()), + auth_required: false, + allow_dev_sender_header: false, + max_payload_bytes: 1_048_576, + session_start_rate: RateLimitConfig { + limit: 1, + window: Duration::from_millis(1), // very short window + }, + message_rate: RateLimitConfig { + limit: usize::MAX, + window: Duration::from_secs(60), + }, + }; + + let sender = "agent://expiry-test"; + assert!(layer.enforce_rate_limit(sender, true).await.is_ok()); + + // Wait for the window to expire + tokio::time::sleep(Duration::from_millis(5)).await; + + // Should succeed again after window expiry + assert!(layer.enforce_rate_limit(sender, true).await.is_ok()); + } + + // --------------------------------------------------------------- + // 7. Anonymous fallback behavior + // --------------------------------------------------------------- + + #[test] + fn anonymous_fallback_when_no_auth_required() { + let layer = insecure_layer(); + let meta = MetadataMap::new(); + let id = layer + .authenticate_metadata(&meta) + .expect("should return anonymous"); + assert_eq!(id.sender, "agent://anonymous"); + assert!(id.allowed_modes.is_none()); + assert!(id.can_start_sessions); + assert!(id.max_open_sessions.is_none()); + } + + #[test] + fn no_anonymous_fallback_when_auth_required() { + let json = r#"[{"token":"t","sender":"agent://real"}]"#; + let layer = layer_with_tokens(json); // auth_required = true + + let meta = MetadataMap::new(); + let err = layer.authenticate_metadata(&meta).unwrap_err(); + assert!(matches!(err, MacpError::Unauthenticated)); + } + + #[test] + fn dev_mode_anonymous_fallback_with_empty_metadata() { + // dev_mode: auth_required=false, allow_dev_sender_header=true + // With no headers at all, falls through to anonymous + let layer = SecurityLayer::dev_mode(); + let meta = MetadataMap::new(); + let id = layer.authenticate_metadata(&meta).expect("should succeed"); + assert_eq!(id.sender, "agent://anonymous"); + } + + // --------------------------------------------------------------- + // 8. Token file loading via MACP_AUTH_TOKENS_FILE + // --------------------------------------------------------------- + + #[test] + fn token_file_loading_via_parse_identities() { + // Test the parse_identities path that from_env uses after reading the file. + // We write a temp file and then read + parse it the same way from_env would. + let json = r#"[ + {"token":"file-tok-1","sender":"agent://file-alice","allowed_modes":["macp.mode.decision.v1"]}, + {"token":"file-tok-2","sender":"agent://file-bob","can_start_sessions":false} + ]"#; + let mut tmp = NamedTempFile::new().expect("create temp file"); + write!(tmp, "{}", json).expect("write temp file"); + + let contents = fs::read_to_string(tmp.path()).expect("read temp file"); + let identities = SecurityLayer::parse_identities(&contents).expect("parse identities"); + + assert_eq!(identities.len(), 2); + + let alice = identities.get("file-tok-1").expect("alice entry"); + assert_eq!(alice.sender, "agent://file-alice"); + let alice_modes = alice.allowed_modes.as_ref().expect("should have modes"); + assert!(alice_modes.contains("macp.mode.decision.v1")); + assert!(alice.can_start_sessions); // default_true + + let bob = identities.get("file-tok-2").expect("bob entry"); + assert_eq!(bob.sender, "agent://file-bob"); + assert!(!bob.can_start_sessions); + assert!(bob.allowed_modes.is_none()); // empty vec -> None + } + + #[test] + fn token_file_end_to_end_via_layer() { + // Build a layer as if loaded from a token file, then authenticate with it. + let json = r#"[{"token":"e2e-tok","sender":"agent://e2e-agent"}]"#; + let mut tmp = NamedTempFile::new().expect("create temp file"); + write!(tmp, "{}", json).expect("write temp file"); + + let contents = fs::read_to_string(tmp.path()).expect("read temp file"); + let layer = layer_with_tokens(&contents); + + let mut meta = MetadataMap::new(); + meta.insert("authorization", "Bearer e2e-tok".parse().unwrap()); + let id = layer + .authenticate_metadata(&meta) + .expect("should authenticate"); + assert_eq!(id.sender, "agent://e2e-agent"); + } + + #[test] + fn parse_identities_invalid_json_returns_error() { + let result = SecurityLayer::parse_identities("not valid json"); + assert!(result.is_err()); + } + + #[test] + fn parse_identities_empty_list() { + let identities = SecurityLayer::parse_identities("[]").expect("valid empty list"); + assert!(identities.is_empty()); + } + + #[test] + fn parse_identities_wrapped_empty() { + let identities = + SecurityLayer::parse_identities(r#"{"tokens":[]}"#).expect("valid wrapped empty"); + assert!(identities.is_empty()); + } +} diff --git a/src/server.rs b/src/server.rs index 56a95bd..f01bc9e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -12,6 +12,7 @@ use macp_runtime::pb::{ WatchRootsResponse, }; use macp_runtime::runtime::Runtime; +use macp_runtime::security::{AuthIdentity, SecurityLayer}; use macp_runtime::session::SessionState; use std::collections::HashMap; use std::sync::Arc; @@ -19,29 +20,29 @@ use tonic::{Request, Response, Status}; pub struct MacpServer { runtime: Arc, + security: SecurityLayer, } impl MacpServer { - pub fn new(runtime: Arc) -> Self { - Self { runtime } + pub fn new(runtime: Arc, security: SecurityLayer) -> Self { + Self { runtime, security } } - fn validate(env: &Envelope) -> Result<(), MacpError> { + fn validate_envelope_shape(&self, env: &Envelope) -> Result<(), MacpError> { if env.macp_version != "1.0" { return Err(MacpError::InvalidMacpVersion); } - // Signals may have empty session_id - if env.message_type != "Signal" && env.session_id.is_empty() { + if env.message_type.is_empty() || env.message_id.is_empty() { return Err(MacpError::InvalidEnvelope); } - if env.message_id.is_empty() { + if env.message_type != "Signal" && env.session_id.is_empty() { return Err(MacpError::InvalidEnvelope); } - if env.sender.is_empty() { + if env.message_type != "Signal" && env.mode.trim().is_empty() { return Err(MacpError::InvalidEnvelope); } - if env.message_type.is_empty() { - return Err(MacpError::InvalidEnvelope); + if env.payload.len() > self.security.max_payload_bytes { + return Err(MacpError::PayloadTooLarge); } Ok(()) } @@ -71,6 +72,80 @@ impl MacpServer { }), } } + + fn apply_authenticated_sender( + identity: &AuthIdentity, + mut env: Envelope, + ) -> Result { + if !env.sender.is_empty() && env.sender != identity.sender { + return Err(MacpError::Unauthenticated); + } + env.sender = identity.sender.clone(); + Ok(env) + } + + async fn authenticate_send_request( + &self, + request: &Request, + env: Envelope, + ) -> Result { + let identity = self.security.authenticate_metadata(request.metadata())?; + let env = Self::apply_authenticated_sender(&identity, env)?; + let is_session_start = env.message_type == "SessionStart"; + self.security + .authorize_mode(&identity, &env.mode, is_session_start)?; + self.security + .enforce_rate_limit(&identity.sender, is_session_start) + .await?; + if is_session_start { + if let Some(max_open) = identity.max_open_sessions { + if self + .runtime + .registry + .count_open_sessions_for_initiator(&identity.sender) + .await + >= max_open + { + return Err(MacpError::RateLimited); + } + } + } + Ok(env) + } + + async fn authenticate_session_access( + &self, + request: &Request, + session_id: &str, + ) -> Result { + let identity = self + .security + .authenticate_metadata(request.metadata()) + .map_err(Self::status_from_error)?; + let session = self + .runtime + .get_session_checked(session_id) + .await + .ok_or_else(|| Status::not_found(format!("Session '{}' not found", session_id)))?; + let allowed = session.initiator_sender == identity.sender + || session.participants.iter().any(|p| p == &identity.sender); + if !allowed { + return Err(Status::permission_denied( + "FORBIDDEN: session access denied", + )); + } + Ok(identity) + } + + fn status_from_error(err: MacpError) -> Status { + match err { + MacpError::Unauthenticated => Status::unauthenticated(err.to_string()), + MacpError::Forbidden => Status::permission_denied(err.to_string()), + MacpError::PayloadTooLarge => Status::resource_exhausted(err.to_string()), + MacpError::RateLimited => Status::resource_exhausted(err.to_string()), + _ => Status::failed_precondition(err.to_string()), + } + } } #[tonic::async_trait] @@ -80,23 +155,18 @@ impl MacpRuntimeService for MacpServer { request: Request, ) -> Result, Status> { let req = request.into_inner(); - - // Negotiate protocol version - let supported = &req.supported_protocol_versions; - if !supported.iter().any(|v| v == "1.0") { + if !req.supported_protocol_versions.iter().any(|v| v == "1.0") { return Err(Status::failed_precondition( "UNSUPPORTED_PROTOCOL_VERSION: no mutually supported protocol version", )); } - let mode_names = self.runtime.registered_mode_names(); - Ok(Response::new(InitializeResponse { selected_protocol_version: "1.0".into(), runtime_info: Some(RuntimeInfo { name: "macp-runtime".into(), title: "MACP Reference Runtime".into(), - version: "0.3.0".into(), + version: "0.4.0".into(), description: "Reference implementation of the Multi-Agent Coordination Protocol" .into(), website_url: String::new(), @@ -118,25 +188,30 @@ impl MacpRuntimeService for MacpServer { }), experimental: None, }), - supported_modes: mode_names, - instructions: String::new(), + supported_modes: self.runtime.registered_mode_names(), + instructions: "Authenticate requests with Authorization: Bearer . For local development only, x-macp-agent-id may be enabled by configuration.".into(), })) } async fn send(&self, request: Request) -> Result, Status> { - let send_req = request.into_inner(); - let env = send_req + let env = request + .get_ref() .envelope + .clone() .ok_or_else(|| Status::invalid_argument("SendRequest must contain an envelope"))?; let result = async { - Self::validate(&env)?; - self.runtime.process(&env).await + self.validate_envelope_shape(&env)?; + let env = self.authenticate_send_request(&request, env).await?; + self.runtime + .process(&env) + .await + .map(|process_result| (env, process_result)) } .await; let ack = match result { - Ok(process_result) => Ack { + Ok((env, process_result)) => Ack { ok: true, duplicate: process_result.duplicate, message_id: env.message_id.clone(), @@ -145,7 +220,10 @@ impl MacpRuntimeService for MacpServer { session_state: Self::session_state_to_pb(&process_result.session_state), error: None, }, - Err(e) => Self::make_error_ack(&e, &env), + Err(err) => { + let env = request.get_ref().envelope.clone().unwrap_or_default(); + Self::make_error_ack(&err, &env) + } }; Ok(Response::new(SendResponse { ack: Some(ack) })) @@ -155,38 +233,39 @@ impl MacpRuntimeService for MacpServer { &self, request: Request, ) -> Result, Status> { - let req = request.into_inner(); - - match self.runtime.get_session_checked(&req.session_id).await { - Some(session) => { - let state = Self::session_state_to_pb(&session.state); - - Ok(Response::new(GetSessionResponse { - metadata: Some(SessionMetadata { - session_id: session.session_id.clone(), - mode: session.mode.clone(), - state, - started_at_unix_ms: session.started_at_unix_ms, - expires_at_unix_ms: session.ttl_expiry, - mode_version: session.mode_version.clone(), - configuration_version: session.configuration_version.clone(), - policy_version: session.policy_version.clone(), - }), - })) - } - None => Err(Status::not_found(format!( - "Session '{}' not found", - req.session_id - ))), - } + let session_id = request.get_ref().session_id.clone(); + let _identity = self + .authenticate_session_access(&request, &session_id) + .await?; + let session = self + .runtime + .get_session_checked(&session_id) + .await + .ok_or_else(|| Status::not_found(format!("Session '{}' not found", session_id)))?; + + Ok(Response::new(GetSessionResponse { + metadata: Some(SessionMetadata { + session_id: session.session_id.clone(), + mode: session.mode.clone(), + state: Self::session_state_to_pb(&session.state), + started_at_unix_ms: session.started_at_unix_ms, + expires_at_unix_ms: session.ttl_expiry, + mode_version: session.mode_version.clone(), + configuration_version: session.configuration_version.clone(), + policy_version: session.policy_version.clone(), + }), + })) } async fn cancel_session( &self, request: Request, ) -> Result, Status> { + let session_id = request.get_ref().session_id.clone(); + let _identity = self + .authenticate_session_access(&request, &session_id) + .await?; let req = request.into_inner(); - match self .runtime .cancel_session(&req.session_id, &req.reason) @@ -203,8 +282,8 @@ impl MacpRuntimeService for MacpServer { error: None, }), })), - Err(e) => { - let ack = Ack { + Err(err) => Ok(Response::new(CancelSessionResponse { + ack: Some(Ack { ok: false, duplicate: false, message_id: String::new(), @@ -212,15 +291,14 @@ impl MacpRuntimeService for MacpServer { accepted_at_unix_ms: chrono::Utc::now().timestamp_millis(), session_state: PbSessionState::Unspecified.into(), error: Some(PbMacpError { - code: e.error_code().into(), - message: e.to_string(), + code: err.error_code().into(), + message: err.to_string(), session_id: req.session_id, message_id: String::new(), details: vec![], }), - }; - Ok(Response::new(CancelSessionResponse { ack: Some(ack) })) - } + }), + })), } } @@ -229,8 +307,6 @@ impl MacpRuntimeService for MacpServer { request: Request, ) -> Result, Status> { let req = request.into_inner(); - - // Empty agent_id or "macp-runtime" returns self manifest; anything else is not found if !req.agent_id.is_empty() && req.agent_id != "macp-runtime" { return Err(Status::not_found(format!( "Agent '{}' not found", @@ -238,14 +314,12 @@ impl MacpRuntimeService for MacpServer { ))); } - let mode_names = self.runtime.registered_mode_names(); - Ok(Response::new(GetManifestResponse { manifest: Some(macp_runtime::pb::AgentManifest { agent_id: "macp-runtime".into(), title: "MACP Reference Runtime".into(), description: "Reference implementation of MACP".into(), - supported_modes: mode_names, + supported_modes: self.runtime.registered_mode_names(), input_content_types: vec!["application/macp-envelope+proto".into()], output_content_types: vec!["application/macp-envelope+proto".into()], metadata: HashMap::new(), @@ -276,72 +350,11 @@ impl MacpRuntimeService for MacpServer { async fn stream_session( &self, - request: Request>, + _request: Request>, ) -> Result, Status> { - use tokio_stream::StreamExt; - - let runtime = self.runtime.clone(); - let mut inbound = request.into_inner(); - - let output = async_stream::try_stream! { - while let Some(req) = inbound.next().await { - let req = req?; - let env = req.envelope.ok_or_else(|| { - Status::invalid_argument("StreamSessionRequest must contain an envelope") - })?; - - let result = async { - MacpServer::validate(&env)?; - runtime.process(&env).await - } - .await; - - match result { - Ok(result) => { - let ack_env = Envelope { - macp_version: "1.0".into(), - mode: env.mode.clone(), - message_type: "Ack".into(), - message_id: format!("ack-{}", env.message_id), - session_id: env.session_id.clone(), - sender: "_runtime".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: serde_json::to_vec(&serde_json::json!({ - "ok": true, - "duplicate": result.duplicate, - "session_state": format!("{:?}", result.session_state), - })) - .unwrap_or_default(), - }; - yield StreamSessionResponse { - envelope: Some(ack_env), - }; - } - Err(e) => { - // Build an error envelope as a response - let error_ack = MacpServer::make_error_ack(&e, &env); - let error_env = Envelope { - macp_version: "1.0".into(), - mode: env.mode.clone(), - message_type: "Error".into(), - message_id: format!("err-{}", env.message_id), - session_id: env.session_id.clone(), - sender: "_runtime".into(), - timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), - payload: serde_json::to_vec(&serde_json::json!({ - "code": error_ack.error.as_ref().map(|e| &e.code), - "message": error_ack.error.as_ref().map(|e| &e.message), - })).unwrap_or_default(), - }; - yield StreamSessionResponse { - envelope: Some(error_env), - }; - } - } - } - }; - - Ok(Response::new(Box::pin(output))) + Err(Status::unimplemented( + "StreamSession is intentionally disabled in the unary freeze profile", + )) } type WatchModeRegistryStream = std::pin::Pin< @@ -373,1628 +386,222 @@ 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; fn make_server() -> (MacpServer, Arc) { let registry = Arc::new(SessionRegistry::new()); let log_store = Arc::new(LogStore::new()); let runtime = Arc::new(Runtime::new(registry, log_store)); - let server = MacpServer::new(runtime.clone()); + let server = MacpServer::new(runtime.clone(), SecurityLayer::dev_mode()); (server, runtime) } - fn send_req(env: Envelope) -> Request { - Request::new(SendRequest { + fn send_req(sender: &str, env: Envelope) -> Request { + let mut req = Request::new(SendRequest { envelope: Some(env), - }) + }); + req.metadata_mut() + .insert("x-macp-agent-id", sender.parse().unwrap()); + req } - async fn do_send(server: &MacpServer, env: Envelope) -> Ack { - let resp = server.send(send_req(env)).await.unwrap(); + async fn do_send(server: &MacpServer, sender: &str, env: Envelope) -> Ack { + let resp = server.send(send_req(sender, env)).await.unwrap(); resp.into_inner().ack.unwrap() } - // --- TTL/Session state tests --- - - #[tokio::test] - async fn expired_session_transitions_to_expired() { - let (_, runtime) = make_server(); - let server = MacpServer::new(runtime.clone()); - - let expired_ttl = Utc::now().timestamp_millis() - 1000; - runtime - .registry - .insert_session_for_test( - "s_expired".into(), - Session { - session_id: "s_expired".into(), - state: SessionState::Open, - ttl_expiry: expired_ttl, - started_at_unix_ms: expired_ttl - 60_000, - resolution: None, - mode: "decision".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }, - ) - .await; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m1".into(), - session_id: "s_expired".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "SESSION_NOT_OPEN"); - - let s = runtime.registry.get_session("s_expired").await.unwrap(); - assert_eq!(s.state, SessionState::Expired); - } - - #[tokio::test] - async fn non_expired_session_stays_open() { - let (_, runtime) = make_server(); - let server = MacpServer::new(runtime.clone()); - - let future_ttl = Utc::now().timestamp_millis() + 60_000; - runtime - .registry - .insert_session_for_test( - "s_alive".into(), - Session { - session_id: "s_alive".into(), - state: SessionState::Open, - ttl_expiry: future_ttl, - started_at_unix_ms: future_ttl - 120_000, - resolution: None, - mode: "decision".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }, - ) - .await; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m1".into(), - session_id: "s_alive".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - - let ack = do_send(&server, env).await; - assert!(ack.ok); - - let s = runtime.registry.get_session("s_alive").await.unwrap(); - assert_eq!(s.state, SessionState::Open); - } - - #[tokio::test] - async fn resolved_session_not_overwritten_to_expired() { - let (_, runtime) = make_server(); - let server = MacpServer::new(runtime.clone()); - - let expired_ttl = Utc::now().timestamp_millis() - 1000; - runtime - .registry - .insert_session_for_test( - "s_resolved".into(), - Session { - session_id: "s_resolved".into(), - state: SessionState::Resolved, - ttl_expiry: expired_ttl, - started_at_unix_ms: expired_ttl - 60_000, - resolution: Some(b"resolve".to_vec()), - mode: "decision".into(), - mode_state: vec![], - participants: vec![], - 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: String::new(), - }, - ) - .await; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m1".into(), - session_id: "s_resolved".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "SESSION_NOT_OPEN"); - - let s = runtime.registry.get_session("s_resolved").await.unwrap(); - assert_eq!(s.state, SessionState::Resolved); - } - - // --- Version validation --- - - #[tokio::test] - async fn version_1_0_accepted() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - } - - #[tokio::test] - async fn version_v1_rejected() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "v1".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!( - ack.error.as_ref().unwrap().code, - "UNSUPPORTED_PROTOCOL_VERSION" - ); - } - - #[tokio::test] - async fn empty_version_rejected() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: String::new(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!( - ack.error.as_ref().unwrap().code, - "UNSUPPORTED_PROTOCOL_VERSION" - ); - } - - // --- Envelope validation --- - - #[tokio::test] - async fn empty_session_id_rejected() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: String::new(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); - } - - #[tokio::test] - async fn empty_message_id_rejected() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: String::new(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); - } - - // --- Signal with empty session_id allowed --- - - #[tokio::test] - async fn signal_empty_session_id_allowed() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: String::new(), - message_type: "Signal".into(), - message_id: "sig1".into(), - session_id: String::new(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - } - - // --- Ack fields populated correctly --- - - #[tokio::test] - async fn ack_fields_on_success() { - let (server, _) = make_server(); - let before = Utc::now().timestamp_millis(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "msg123".into(), - session_id: "sess456".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - + fn start_payload() -> Vec { + SessionStartPayload { + intent: "intent".into(), + participants: vec!["agent://fraud".into()], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + policy_version: "policy-1".into(), + ttl_ms: 1000, + context: vec![], + roots: vec![], + } + .encode_to_vec() + } + + #[tokio::test] + async fn sender_is_derived_from_authenticated_metadata() { + let (server, runtime) = make_server(); + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "SessionStart".into(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload(), + }, + ) + .await; assert!(ack.ok); - assert!(!ack.duplicate); - assert_eq!(ack.message_id, "msg123"); - assert_eq!(ack.session_id, "sess456"); - assert!(ack.accepted_at_unix_ms >= before); - assert_eq!(ack.session_state, PbSessionState::Open as i32); - assert!(ack.error.is_none()); + let session = runtime.get_session_checked("s1").await.unwrap(); + assert_eq!(session.initiator_sender, "agent://orchestrator"); } #[tokio::test] - async fn ack_fields_on_error() { + async fn spoofed_sender_is_rejected() { let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "msg789".into(), - session_id: "nonexistent".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "SessionStart".into(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: "agent://spoof".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload(), + }, + ) + .await; assert!(!ack.ok); - assert!(!ack.duplicate); - assert_eq!(ack.message_id, "msg789"); - assert_eq!(ack.session_id, "nonexistent"); - let err = ack.error.unwrap(); - assert_eq!(err.code, "SESSION_NOT_FOUND"); - assert_eq!(err.message, "UnknownSession"); - assert_eq!(err.session_id, "nonexistent"); - assert_eq!(err.message_id, "msg789"); + assert_eq!(ack.error.as_ref().unwrap().code, "UNAUTHENTICATED"); } - // --- Deduplication at server layer --- - #[tokio::test] - async fn ack_duplicate_field_set() { + async fn get_session_requires_session_membership() { let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "SessionStart".into(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload(), + }, + ) + .await; assert!(ack.ok); - assert!(!ack.duplicate); - // Send a message - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), + let mut req = Request::new(GetSessionRequest { session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(!ack.duplicate); - - // Duplicate - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - assert!(ack.duplicate); - } - - // --- GetSession --- - - #[tokio::test] - async fn get_session_success() { - let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_get".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - do_send(&server, env).await; - - let resp = server - .get_session(Request::new(GetSessionRequest { - session_id: "s_get".into(), - })) - .await - .unwrap(); - let meta = resp.into_inner().metadata.unwrap(); - assert_eq!(meta.session_id, "s_get"); - assert_eq!(meta.mode, "decision"); - assert_eq!(meta.state, PbSessionState::Open as i32); - assert!(meta.started_at_unix_ms > 0); - assert!(meta.expires_at_unix_ms > meta.started_at_unix_ms); + }); + req.metadata_mut() + .insert("x-macp-agent-id", "agent://outsider".parse().unwrap()); + let err = server.get_session(req).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); } - #[tokio::test] - async fn get_session_not_found() { - let (server, _) = make_server(); - - let result = server - .get_session(Request::new(GetSessionRequest { - session_id: "nonexistent".into(), - })) - .await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound); - } + // Note: stream_session cannot be unit-tested directly because + // tonic::Streaming requires an HTTP/2 body. The endpoint returns + // Status::unimplemented, verified via integration testing. #[tokio::test] - async fn get_session_shows_resolved_state() { + async fn list_modes_returns_standard_modes() { let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_res".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - do_send(&server, env).await; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), - session_id: "s_res".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"resolve".to_vec(), - }; - do_send(&server, env).await; - let resp = server - .get_session(Request::new(GetSessionRequest { - session_id: "s_res".into(), - })) + .list_modes(Request::new(ListModesRequest {})) .await .unwrap(); - let meta = resp.into_inner().metadata.unwrap(); - assert_eq!(meta.state, PbSessionState::Resolved as i32); - } - - #[tokio::test] - async fn get_session_with_version_fields() { - let (server, _) = make_server(); - - let start_payload = SessionStartPayload { - intent: "coordinate".into(), - participants: vec![], - mode_version: "1.0".into(), - configuration_version: "cfg-1".into(), - policy_version: "pol-1".into(), - ttl_ms: 0, - context: vec![], - roots: vec![], - }; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_ver".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), - }; - do_send(&server, env).await; + let names: Vec = resp + .into_inner() + .modes + .iter() + .map(|m| m.mode.clone()) + .collect(); + assert_eq!(names.len(), 5); + assert!(names.contains(&"macp.mode.decision.v1".to_string())); + assert!(names.contains(&"macp.mode.proposal.v1".to_string())); + assert!(names.contains(&"macp.mode.task.v1".to_string())); + assert!(names.contains(&"macp.mode.handoff.v1".to_string())); + assert!(names.contains(&"macp.mode.quorum.v1".to_string())); + } + + #[tokio::test] + async fn get_session_returns_metadata() { + let (server, _) = make_server(); + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "SessionStart".into(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload(), + }, + ) + .await; + assert!(ack.ok); - let resp = server - .get_session(Request::new(GetSessionRequest { - session_id: "s_ver".into(), - })) - .await - .unwrap(); + let mut req = Request::new(GetSessionRequest { + session_id: "s1".into(), + }); + req.metadata_mut() + .insert("x-macp-agent-id", "agent://orchestrator".parse().unwrap()); + let resp = server.get_session(req).await.unwrap(); let meta = resp.into_inner().metadata.unwrap(); - assert_eq!(meta.mode_version, "1.0"); + assert_eq!(meta.session_id, "s1"); + assert_eq!(meta.mode, "macp.mode.decision.v1"); + assert_eq!(meta.mode_version, "1.0.0"); assert_eq!(meta.configuration_version, "cfg-1"); - assert_eq!(meta.policy_version, "pol-1"); } - // --- CancelSession at server layer --- - #[tokio::test] - async fn cancel_session_success() { + async fn cancel_session_transitions_to_expired() { let (server, _) = make_server(); + let ack = do_send( + &server, + "agent://orchestrator", + Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "SessionStart".into(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload(), + }, + ) + .await; + assert!(ack.ok); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_cancel".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - do_send(&server, env).await; - - let resp = server - .cancel_session(Request::new(CancelSessionRequest { - session_id: "s_cancel".into(), - reason: "testing".into(), - })) - .await - .unwrap(); + let mut req = Request::new(CancelSessionRequest { + session_id: "s1".into(), + reason: "no longer needed".into(), + }); + req.metadata_mut() + .insert("x-macp-agent-id", "agent://orchestrator".parse().unwrap()); + let resp = server.cancel_session(req).await.unwrap(); let ack = resp.into_inner().ack.unwrap(); assert!(ack.ok); - assert_eq!(ack.session_id, "s_cancel"); assert_eq!(ack.session_state, PbSessionState::Expired as i32); } #[tokio::test] - async fn cancel_session_not_found() { - let (server, _) = make_server(); - - let resp = server - .cancel_session(Request::new(CancelSessionRequest { - session_id: "nonexistent".into(), - reason: "testing".into(), - })) - .await - .unwrap(); - let ack = resp.into_inner().ack.unwrap(); - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "SESSION_NOT_FOUND"); - } - - #[tokio::test] - async fn cancel_then_message_rejected() { - let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_cm".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - do_send(&server, env).await; - - server - .cancel_session(Request::new(CancelSessionRequest { - session_id: "s_cm".into(), - reason: "done".into(), - })) - .await - .unwrap(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), - session_id: "s_cm".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "SESSION_NOT_OPEN"); - } - - // --- Participant validation at server layer --- - - #[tokio::test] - async fn forbidden_error_at_server_layer() { - let (server, _) = make_server(); - - let start_payload = SessionStartPayload { - intent: String::new(), - participants: vec!["alice".into()], - mode_version: String::new(), - configuration_version: String::new(), - policy_version: String::new(), - ttl_ms: 0, - context: vec![], - roots: vec![], - }; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_auth".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: start_payload.encode_to_vec(), - }; - do_send(&server, env).await; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), - session_id: "s_auth".into(), - sender: "bob".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "FORBIDDEN"); - } - - // --- Full decision flow through server --- - - #[tokio::test] - async fn full_decision_flow_through_server() { + async fn cancel_session_unknown_session_returns_error() { let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s_flow".into(), - sender: "alice".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - assert_eq!(ack.session_state, PbSessionState::Open as i32); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m2".into(), - session_id: "s_flow".into(), - sender: "alice".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"hello".to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - assert_eq!(ack.session_state, PbSessionState::Open as i32); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m3".into(), - session_id: "s_flow".into(), - sender: "alice".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: b"resolve".to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - assert_eq!(ack.session_state, PbSessionState::Resolved as i32); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), - message_id: "m4".into(), - session_id: "s_flow".into(), - sender: "alice".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 multi-round flow through server --- - - #[tokio::test] - async fn full_multi_round_flow_through_server() { - let (server, _) = make_server(); - - let start_payload = SessionStartPayload { - intent: String::new(), - ttl_ms: 60_000, - participants: vec!["alice".into(), "bob".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: "multi_round".into(), - message_type: "SessionStart".into(), - message_id: "m0".into(), - session_id: "s_mr".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); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m1".into(), - session_id: "s_mr".into(), - sender: "alice".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: br#"{"value":"option_a"}"#.to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - assert_eq!(ack.session_state, PbSessionState::Open as i32); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "multi_round".into(), - message_type: "Contribute".into(), - message_id: "m2".into(), - session_id: "s_mr".into(), - sender: "bob".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: br#"{"value":"option_a"}"#.to_vec(), - }; - let ack = do_send(&server, env).await; - assert!(ack.ok); - assert_eq!(ack.session_state, PbSessionState::Resolved as i32); - - let resp = server - .get_session(Request::new(GetSessionRequest { - session_id: "s_mr".into(), - })) - .await - .unwrap(); - let meta = resp.into_inner().metadata.unwrap(); - assert_eq!(meta.state, PbSessionState::Resolved as i32); - } - - // --- Unknown mode error code --- - - #[tokio::test] - async fn unknown_mode_error_code() { - let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "nonexistent_mode".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "MODE_NOT_SUPPORTED"); - } - - // --- Duplicate session error code --- - - #[tokio::test] - async fn duplicate_session_error_code() { - let (server, _) = make_server(); - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - do_send(&server, env).await; - - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m2".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); - } - - // --- Initialize RPC --- - - #[tokio::test] - async fn initialize_success() { - let (server, _) = make_server(); - - let resp = server - .initialize(Request::new(InitializeRequest { - supported_protocol_versions: vec!["1.0".into()], - client_info: None, - capabilities: None, - })) - .await - .unwrap(); - let init = resp.into_inner(); - assert_eq!(init.selected_protocol_version, "1.0"); - assert!(init.runtime_info.is_some()); - let info = init.runtime_info.unwrap(); - assert_eq!(info.name, "macp-runtime"); - assert_eq!(info.version, "0.3.0"); - assert!(init.capabilities.is_some()); - assert!(!init.supported_modes.is_empty()); - } - - #[tokio::test] - async fn initialize_no_common_version() { - let (server, _) = make_server(); - - let result = server - .initialize(Request::new(InitializeRequest { - supported_protocol_versions: vec!["2.0".into()], - client_info: None, - capabilities: None, - })) - .await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn initialize_selects_1_0_from_multiple() { - let (server, _) = make_server(); - - let resp = server - .initialize(Request::new(InitializeRequest { - supported_protocol_versions: vec!["1.0".into(), "2.0".into()], - client_info: None, - capabilities: None, - })) - .await - .unwrap(); - assert_eq!(resp.into_inner().selected_protocol_version, "1.0"); - } - - // --- ListModes --- - - #[tokio::test] - async fn list_modes_returns_descriptors() { - let (server, _) = make_server(); - - let resp = server - .list_modes(Request::new(ListModesRequest {})) - .await - .unwrap(); - let modes = resp.into_inner().modes; - 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 --- - - #[tokio::test] - async fn get_manifest_returns_runtime_manifest() { - let (server, _) = make_server(); - - let resp = server - .get_manifest(Request::new(GetManifestRequest { - agent_id: String::new(), - })) - .await - .unwrap(); - let manifest = resp.into_inner().manifest.unwrap(); - assert_eq!(manifest.agent_id, "macp-runtime"); - assert!(!manifest.supported_modes.is_empty()); - } - - // --- ListRoots --- - - #[tokio::test] - async fn list_roots_returns_empty() { - let (server, _) = make_server(); - - let resp = server - .list_roots(Request::new(ListRootsRequest {})) - .await - .unwrap(); - assert!(resp.into_inner().roots.is_empty()); - } - - // --- Phase 1: Discovery surface fixes --- - - #[tokio::test] - async fn get_manifest_empty_agent_id_returns_self() { - let (server, _) = make_server(); - - let resp = server - .get_manifest(Request::new(GetManifestRequest { - agent_id: String::new(), - })) - .await - .unwrap(); - let manifest = resp.into_inner().manifest.unwrap(); - assert_eq!(manifest.agent_id, "macp-runtime"); - } - - #[tokio::test] - async fn get_manifest_self_agent_id_returns_self() { - let (server, _) = make_server(); - - let resp = server - .get_manifest(Request::new(GetManifestRequest { - agent_id: "macp-runtime".into(), - })) - .await - .unwrap(); - let manifest = resp.into_inner().manifest.unwrap(); - assert_eq!(manifest.agent_id, "macp-runtime"); - } - - #[tokio::test] - async fn get_manifest_unknown_agent_id_returns_not_found() { - let (server, _) = make_server(); - - let result = server - .get_manifest(Request::new(GetManifestRequest { - agent_id: "unknown-agent".into(), - })) - .await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound); - } - - #[tokio::test] - async fn get_manifest_content_types_are_macp_media_types() { - let (server, _) = make_server(); - - let resp = server - .get_manifest(Request::new(GetManifestRequest { - agent_id: String::new(), - })) - .await - .unwrap(); - let manifest = resp.into_inner().manifest.unwrap(); - assert_eq!( - manifest.input_content_types, - vec!["application/macp-envelope+proto"] - ); - assert_eq!( - manifest.output_content_types, - vec!["application/macp-envelope+proto"] - ); - } - - #[tokio::test] - async fn list_modes_decision_version_is_semver() { - let (server, _) = make_server(); - - let resp = server - .list_modes(Request::new(ListModesRequest {})) - .await - .unwrap(); - let modes = resp.into_inner().modes; - assert_eq!(modes[0].mode_version, "1.0.0"); - } - - #[tokio::test] - async fn list_modes_decision_has_schema_uris() { - let (server, _) = make_server(); - - let resp = server - .list_modes(Request::new(ListModesRequest {})) - .await - .unwrap(); - let modes = resp.into_inner().modes; - assert_eq!( - modes[0].schema_uris.get("protobuf").unwrap(), - "buf.build/multiagentcoordinationprotocol/macp" - ); - } - - #[tokio::test] - async fn list_modes_does_not_include_experimental() { - let (server, _) = make_server(); - - let resp = server - .list_modes(Request::new(ListModesRequest {})) - .await - .unwrap(); - let modes = resp.into_inner().modes; - assert!(!modes.iter().any(|m| m.mode.contains("multi_round"))); - } - - #[tokio::test] - async fn multi_round_still_works_by_direct_name() { - let (server, _) = make_server(); - - let start_payload = SessionStartPayload { - intent: String::new(), - ttl_ms: 60_000, - participants: vec!["alice".into(), "bob".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.multi_round.v1".into(), - message_type: "SessionStart".into(), - message_id: "m0".into(), - session_id: "s_mr_direct".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); - } - - #[tokio::test] - async fn initialize_stream_capability_is_false() { - let (server, _) = make_server(); - - let resp = server - .initialize(Request::new(InitializeRequest { - supported_protocol_versions: vec!["1.0".into()], - client_info: None, - capabilities: None, - })) - .await - .unwrap(); - let caps = resp.into_inner().capabilities.unwrap(); - assert!(!caps.sessions.unwrap().stream); - } - - // --- Phase 2: Envelope validation --- - - #[tokio::test] - async fn empty_sender_rejected() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "SessionStart".into(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: String::new(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - assert!(!ack.ok); - assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); - } - - #[tokio::test] - async fn empty_message_type_rejected() { - let (server, _) = make_server(); - let env = Envelope { - macp_version: "1.0".into(), - mode: "decision".into(), - message_type: String::new(), - message_id: "m1".into(), - session_id: "s1".into(), - sender: "test".into(), - timestamp_unix_ms: Utc::now().timestamp_millis(), - payload: vec![], - }; - let ack = do_send(&server, env).await; - 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"); + // authenticate_session_access will fail with NotFound for unknown session + let mut req = Request::new(CancelSessionRequest { + session_id: "nonexistent".into(), + reason: "test".into(), + }); + req.metadata_mut() + .insert("x-macp-agent-id", "agent://orchestrator".parse().unwrap()); + let err = server.cancel_session(req).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::NotFound); } } diff --git a/src/session.rs b/src/session.rs index 95450fd..461f03c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -3,10 +3,9 @@ use crate::pb::SessionStartPayload; use prost::Message; use std::collections::HashSet; -pub const DEFAULT_TTL_MS: i64 = 60_000; pub const MAX_TTL_MS: i64 = 24 * 60 * 60 * 1000; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum SessionState { Open, Resolved, @@ -24,36 +23,72 @@ pub struct Session { pub mode_state: Vec, pub participants: Vec, pub seen_message_ids: HashSet, - // RFC version fields from SessionStartPayload pub intent: String, pub mode_version: String, pub configuration_version: String, pub policy_version: String, - // RFC session data fields pub context: Vec, pub roots: Vec, pub initiator_sender: String, } +pub fn is_standard_mode(mode: &str) -> bool { + matches!( + mode, + "macp.mode.decision.v1" + | "macp.mode.proposal.v1" + | "macp.mode.task.v1" + | "macp.mode.handoff.v1" + | "macp.mode.quorum.v1" + ) +} + /// Parse a protobuf-encoded SessionStartPayload from raw bytes. pub fn parse_session_start_payload(payload: &[u8]) -> Result { if payload.is_empty() { - return Ok(SessionStartPayload::default()); + return Err(MacpError::InvalidPayload); } SessionStartPayload::decode(payload).map_err(|_| MacpError::InvalidPayload) } /// Extract and validate TTL from a parsed SessionStartPayload. pub fn extract_ttl_ms(payload: &SessionStartPayload) -> Result { - if payload.ttl_ms == 0 { - return Ok(DEFAULT_TTL_MS); - } if !(1..=MAX_TTL_MS).contains(&payload.ttl_ms) { return Err(MacpError::InvalidTtl); } Ok(payload.ttl_ms) } +/// Enforce the canonical SessionStart binding contract for standards-track modes. +pub fn validate_standard_session_start_payload( + mode: &str, + payload: &SessionStartPayload, +) -> Result<(), MacpError> { + if !is_standard_mode(mode) { + return Ok(()); + } + + extract_ttl_ms(payload)?; + + if payload.mode_version.trim().is_empty() || payload.configuration_version.trim().is_empty() { + return Err(MacpError::InvalidPayload); + } + + if payload.participants.is_empty() { + return Err(MacpError::InvalidPayload); + } + + let mut seen = HashSet::new(); + for participant in &payload.participants { + let participant = participant.trim(); + if participant.is_empty() || !seen.insert(participant.to_string()) { + return Err(MacpError::InvalidPayload); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -63,8 +98,8 @@ mod tests { let payload = SessionStartPayload { intent: String::new(), participants, - mode_version: String::new(), - configuration_version: String::new(), + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), policy_version: String::new(), ttl_ms, context: vec![], @@ -74,10 +109,9 @@ mod tests { } #[test] - fn parse_empty_payload_returns_default() { - let result = parse_session_start_payload(b"").unwrap(); - assert_eq!(result.ttl_ms, 0); - assert!(result.participants.is_empty()); + fn parse_empty_payload_is_invalid() { + let err = parse_session_start_payload(b"").unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); } #[test] @@ -89,19 +123,13 @@ mod tests { } #[test] - fn parse_invalid_bytes_returns_error() { - let err = parse_session_start_payload(b"not protobuf").unwrap_err(); - assert_eq!(err.to_string(), "InvalidPayload"); - } - - #[test] - fn extract_ttl_default_when_zero() { + fn extract_ttl_requires_explicit_positive_value() { let payload = SessionStartPayload::default(); - assert_eq!(extract_ttl_ms(&payload).unwrap(), DEFAULT_TTL_MS); - } + assert_eq!( + extract_ttl_ms(&payload).unwrap_err().to_string(), + "InvalidTtl" + ); - #[test] - fn extract_ttl_valid() { let payload = SessionStartPayload { ttl_ms: 5000, ..Default::default() @@ -110,94 +138,56 @@ mod tests { } #[test] - fn extract_ttl_boundary_min() { - let payload = SessionStartPayload { - ttl_ms: 1, - ..Default::default() - }; - assert_eq!(extract_ttl_ms(&payload).unwrap(), 1); - } - - #[test] - fn extract_ttl_boundary_max() { + fn standard_mode_requires_explicit_versions_and_participants() { let payload = SessionStartPayload { - ttl_ms: MAX_TTL_MS, - ..Default::default() - }; - assert_eq!(extract_ttl_ms(&payload).unwrap(), MAX_TTL_MS); - } - - #[test] - fn extract_ttl_negative_returns_invalid() { - let payload = SessionStartPayload { - ttl_ms: -5000, - ..Default::default() - }; - let err = extract_ttl_ms(&payload).unwrap_err(); - assert_eq!(err.to_string(), "InvalidTtl"); - } - - #[test] - fn extract_ttl_exceeds_max_returns_invalid() { - let payload = SessionStartPayload { - ttl_ms: MAX_TTL_MS + 1, - ..Default::default() - }; - let err = extract_ttl_ms(&payload).unwrap_err(); - assert_eq!(err.to_string(), "InvalidTtl"); - } - - #[test] - fn parse_payload_with_context_bytes() { - let payload = SessionStartPayload { - intent: "test intent".into(), - ttl_ms: 10_000, participants: vec!["alice".into()], - mode_version: "1.0".into(), - configuration_version: String::new(), - policy_version: String::new(), - context: b"some context data".to_vec(), - roots: vec![], + mode_version: String::new(), + configuration_version: "cfg-1".into(), + ttl_ms: 1000, + ..Default::default() }; - let bytes = payload.encode_to_vec(); - let result = parse_session_start_payload(&bytes).unwrap(); - assert_eq!(result.ttl_ms, 10_000); - assert_eq!(result.participants, vec!["alice"]); - assert_eq!(result.intent, "test intent"); - assert_eq!(result.mode_version, "1.0"); - assert_eq!(result.context, b"some context data"); - } + assert_eq!( + validate_standard_session_start_payload("macp.mode.decision.v1", &payload) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); - #[test] - fn parse_payload_with_only_participants() { let payload = SessionStartPayload { - ttl_ms: 0, - participants: vec!["a".into(), "b".into(), "c".into()], + participants: vec![], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + ttl_ms: 1000, ..Default::default() }; - let bytes = payload.encode_to_vec(); - let result = parse_session_start_payload(&bytes).unwrap(); - assert_eq!(result.ttl_ms, 0); - assert_eq!(result.participants.len(), 3); + assert_eq!( + validate_standard_session_start_payload("macp.mode.decision.v1", &payload) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); } #[test] - fn extract_ttl_at_minus_one_returns_invalid() { + fn standard_mode_rejects_duplicate_participants() { let payload = SessionStartPayload { - ttl_ms: -1, + participants: vec!["alice".into(), "alice".into()], + mode_version: "1.0.0".into(), + configuration_version: "cfg-1".into(), + ttl_ms: 1000, ..Default::default() }; - let err = extract_ttl_ms(&payload).unwrap_err(); - assert_eq!(err.to_string(), "InvalidTtl"); + assert_eq!( + validate_standard_session_start_payload("macp.mode.proposal.v1", &payload) + .unwrap_err() + .to_string(), + "InvalidPayload" + ); } #[test] - fn session_state_equality() { - assert_eq!(SessionState::Open, SessionState::Open); - assert_eq!(SessionState::Resolved, SessionState::Resolved); - assert_eq!(SessionState::Expired, SessionState::Expired); - assert_ne!(SessionState::Open, SessionState::Resolved); - assert_ne!(SessionState::Open, SessionState::Expired); - assert_ne!(SessionState::Resolved, SessionState::Expired); + fn experimental_modes_keep_legacy_flexibility() { + let payload = SessionStartPayload::default(); + validate_standard_session_start_payload("macp.mode.multi_round.v1", &payload).unwrap(); } }