macp_modes/mode/mod.rs
1pub mod decision;
2pub mod handoff;
3pub mod multi_round;
4pub mod passthrough;
5pub mod proposal;
6pub mod quorum;
7pub mod task;
8pub mod util;
9
10use macp_core::error::MacpError;
11use macp_core::session::Session;
12use macp_pb::pb::{Envelope, ModeDescriptor};
13use std::collections::HashMap;
14
15/// The canonical standards-track modes implemented by this runtime.
16pub const STANDARD_MODE_NAMES: &[&str] = &[
17 "macp.mode.decision.v1",
18 "macp.mode.proposal.v1",
19 "macp.mode.task.v1",
20 "macp.mode.handoff.v1",
21 "macp.mode.quorum.v1",
22];
23
24/// Built-in extension modes shipped with this runtime but not yet standards-track.
25pub const EXTENSION_MODE_NAMES: &[&str] = &["ext.multi_round.v1"];
26
27// `ModeResponse` (data) lives in `macp-core` so `Session::apply_mode_response`
28// can consume it without a core->modes cycle. The `Mode` trait (behavior) stays
29// here. Re-exported so `crate::mode::ModeResponse` keeps resolving.
30pub use macp_core::mode::{MessageContext, ModeResponse};
31
32/// Trait that coordination modes implement.
33/// Modes receive immutable session references and return a ModeResponse.
34/// The runtime kernel is responsible for applying the response.
35pub trait Mode: Send + Sync {
36 fn on_session_start(
37 &self,
38 session: &Session,
39 env: &Envelope,
40 ) -> Result<ModeResponse, MacpError>;
41
42 fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError>;
43
44 /// Kernel entry point: `on_message` plus the runtime's
45 /// [`macp_core::mode::MessageContext`] (acceptance clock). Defaulted to plain
46 /// `on_message` so most modes ignore it; modes that need a trustworthy
47 /// time source (Handoff) override this instead of reading the forgeable
48 /// `Envelope.timestamp_unix_ms`. The runtime and replay always call this,
49 /// with the same clock value that the log entry records.
50 fn on_message_at(
51 &self,
52 session: &Session,
53 env: &Envelope,
54 ctx: &macp_core::mode::MessageContext,
55 ) -> Result<ModeResponse, MacpError> {
56 let _ = ctx;
57 self.on_message(session, env)
58 }
59
60 /// The client boundary: validate an envelope that a *client* submitted on
61 /// the live path, before it is dispatched.
62 ///
63 /// Called on live client-submitted envelopes **only** — never on replay,
64 /// and never on a runtime-synthesized envelope. Library kernels MUST call
65 /// it on inbound traffic. Default is `Ok(())`, so a mode with no
66 /// client-boundary rules needs no override.
67 ///
68 /// # Why this is a hook and not a check inside `on_message`
69 ///
70 /// Some envelope shapes are legitimate *as recorded history* but illegal
71 /// *as client submissions*. The motivating case is the handoff mode's
72 /// runtime-synthesized implicit `HandoffAccept` (RFC-MACP-0010 §5.1(3),
73 /// whose prohibition is scoped to submission "via `Send`"): once such an
74 /// entry is in the append-only log, replay must dispatch it through the
75 /// ordinary mode path, so the mode cannot refuse the shape outright. Only
76 /// the live boundary can tell the two apart, because only the live
77 /// boundary knows the envelope came from a client. Keeping the rejection
78 /// here — rather than marking the log entry with a discriminator — is what
79 /// makes the recorded payload itself a trustworthy provenance signal, and
80 /// it keeps a *stale reader* loud: an old binary replaying a newer log
81 /// rejects the entry in its own mode and fails replay visibly, instead of
82 /// silently skipping an entry kind it does not recognise.
83 ///
84 /// # Hazard: this hook is fail-open by construction
85 ///
86 /// Nothing forces a caller to invoke it — a kernel that drives the phases
87 /// by hand and never calls it simply has no client boundary, and still
88 /// compiles. This runtime is the worked example of why that matters:
89 /// `crate::step::validate_message` does call the hook, but the runtime
90 /// does **not** go through `step::validate_message` — `process_message`
91 /// calls [`Mode::authorize_sender`] and [`Mode::on_message_at`] directly so
92 /// it can interpose its durable append between validation and commit. So
93 /// wiring the hook into `step` alone would have left the runtime
94 /// unprotected; the runtime calls it explicitly at its own two live entry
95 /// points (`process_message` and `process_session_start`), which together
96 /// cover every client envelope (`Send` and `StreamSession` both funnel
97 /// into `Runtime::process`), while replay and crash recovery only re-read
98 /// entries that already passed the hook when they were first accepted.
99 /// There is no compile-time forcing function here — only this note.
100 fn validate_client_envelope(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
101 let _ = (session, env);
102 Ok(())
103 }
104
105 /// The synthesis seam: given this session and this clock reading, the
106 /// envelope (if any) that MUST enter accepted history *before* the message
107 /// currently being processed.
108 ///
109 /// Default `None` — nothing is ever due, which is the answer for every
110 /// mode but Handoff. The motivating case is RFC-MACP-0010 §5.1(2): once an
111 /// outstanding handoff offer's `implicit_accept_timeout_ms` has elapsed,
112 /// the runtime "MUST append a synthetic `HandoffAccept` envelope to the
113 /// session's accepted history — before evaluating any subsequent message
114 /// against the offer's acceptance state, and in particular before any
115 /// `Commitment` evaluation".
116 ///
117 /// # The kernel contract
118 ///
119 /// A kernel that calls this MUST, holding the session's lock and *before*
120 /// it validates or dispatches the triggering message:
121 ///
122 /// 1. dispatch the returned envelope through [`Mode::on_message_at`] with
123 /// `accepted_at_ms` equal to the envelope's own `timestamp_unix_ms`,
124 /// 2. append it durably as an ordinary accepted (`Incoming`) history entry,
125 /// stamping `received_at_ms` with the envelope's own
126 /// `timestamp_unix_ms` — **not** wall-clock. Replay derives its dispatch
127 /// clock from `received_at_ms` (`src/replay.rs`), so stamping anything
128 /// else desynchronizes the live and replayed clocks for this entry and
129 /// breaks byte-identical rebuild of `mode_state`. Harmless for handoff
130 /// specifically, whose accept arm is time-blind, but the contract is
131 /// general and the next mode to use this hook may not be.
132 /// 3. commit the resulting session state and insert the envelope's
133 /// `message_id` into the dedup set,
134 /// 4. publish it to the session's subscribers,
135 ///
136 /// and only then process the triggering message. Appending without
137 /// dispatching, or dispatching without appending, forks live state from
138 /// what replay will rebuild from the log.
139 ///
140 /// # Never called on replay
141 ///
142 /// The recorded entry *is* the product: replay dispatches it through the
143 /// ordinary message path like any other accepted entry, so the timer stays
144 /// outside the replay boundary while its recorded product is inside — the
145 /// same construction as the runtime-emitted lifecycle envelopes of
146 /// RFC-MACP-0001 §7.5. An implementation must therefore never read a clock
147 /// of its own: every field of the returned envelope, `timestamp_unix_ms`
148 /// included, has to be a pure function of the session state and `now_ms`,
149 /// because it is baked into permanent history and an observation-dependent
150 /// value could never be reproduced.
151 ///
152 /// # Idempotence
153 ///
154 /// Implementations MUST return `None` once the returned envelope has been
155 /// applied to the session: a second emission would append a duplicate
156 /// entry whose deterministic `message_id` already holds a dedup slot.
157 ///
158 /// Like [`Mode::validate_client_envelope`], this hook is fail-open by
159 /// construction — a kernel that never calls it simply never synthesizes,
160 /// and still compiles. There is no compile-time forcing function, only
161 /// this note.
162 fn due_synthetic_envelope(&self, session: &Session, now_ms: i64) -> Option<Envelope> {
163 let _ = (session, now_ms);
164 None
165 }
166
167 /// Authorize the sender for this message. Modes can override to customize
168 /// authorization (e.g., allowing orchestrator bypass for Commitment messages).
169 fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
170 if !session.participants.is_empty() && !session.participants.contains(&env.sender) {
171 return Err(MacpError::Forbidden);
172 }
173 Ok(())
174 }
175}
176
177pub fn standard_mode_names() -> &'static [&'static str] {
178 STANDARD_MODE_NAMES
179}
180
181pub fn extension_mode_names() -> &'static [&'static str] {
182 EXTENSION_MODE_NAMES
183}
184
185fn schema_map(path: &str) -> HashMap<String, String> {
186 HashMap::from([("protobuf".to_string(), path.to_string())])
187}
188
189pub fn standard_mode_descriptors() -> Vec<ModeDescriptor> {
190 vec![
191 ModeDescriptor {
192 mode: "macp.mode.decision.v1".into(),
193 mode_version: "1.0.0".into(),
194 title: "Decision Mode".into(),
195 description: "Structured decision making with proposals, evaluations, objections, votes, and a terminal Commitment.".into(),
196 determinism_class: "semantic-deterministic".into(),
197 participant_model: "declared".into(),
198 message_types: vec![
199 "SessionStart".into(),
200 "Proposal".into(),
201 "Evaluation".into(),
202 "Objection".into(),
203 "Vote".into(),
204 "Commitment".into(),
205 ],
206 terminal_message_types: vec!["Commitment".into()],
207 schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"),
208 },
209 ModeDescriptor {
210 mode: "macp.mode.proposal.v1".into(),
211 mode_version: "1.0.0".into(),
212 title: "Proposal Mode".into(),
213 description: "Negotiation with proposals, counterproposals, accepts, rejects, withdrawals, and a terminal Commitment.".into(),
214 determinism_class: "semantic-deterministic".into(),
215 participant_model: "peer".into(),
216 message_types: vec![
217 "SessionStart".into(),
218 "Proposal".into(),
219 "CounterProposal".into(),
220 "Accept".into(),
221 "Reject".into(),
222 "Withdraw".into(),
223 "Commitment".into(),
224 ],
225 terminal_message_types: vec!["Commitment".into()],
226 schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"),
227 },
228 ModeDescriptor {
229 mode: "macp.mode.task.v1".into(),
230 mode_version: "1.0.0".into(),
231 title: "Task Mode".into(),
232 description: "One bounded delegated task with assignee responses, progress, completion/failure reports, and a terminal Commitment.".into(),
233 determinism_class: "structural-only".into(),
234 participant_model: "orchestrated".into(),
235 message_types: vec![
236 "SessionStart".into(),
237 "TaskRequest".into(),
238 "TaskAccept".into(),
239 "TaskReject".into(),
240 "TaskUpdate".into(),
241 "TaskComplete".into(),
242 "TaskFail".into(),
243 "Commitment".into(),
244 ],
245 terminal_message_types: vec!["Commitment".into()],
246 schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"),
247 },
248 ModeDescriptor {
249 mode: "macp.mode.handoff.v1".into(),
250 mode_version: "1.0.0".into(),
251 title: "Handoff Mode".into(),
252 description: "Scoped responsibility transfer with handoff offers, context, target responses, and a terminal Commitment.".into(),
253 determinism_class: "context-frozen".into(),
254 participant_model: "delegated".into(),
255 message_types: vec![
256 "SessionStart".into(),
257 "HandoffOffer".into(),
258 "HandoffContext".into(),
259 "HandoffAccept".into(),
260 "HandoffDecline".into(),
261 "Commitment".into(),
262 ],
263 terminal_message_types: vec!["Commitment".into()],
264 schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"),
265 },
266 ModeDescriptor {
267 mode: "macp.mode.quorum.v1".into(),
268 mode_version: "1.0.0".into(),
269 title: "Quorum Mode".into(),
270 description: "Threshold approval with one approval request, participant ballots, and a terminal Commitment.".into(),
271 determinism_class: "semantic-deterministic".into(),
272 participant_model: "quorum".into(),
273 message_types: vec![
274 "SessionStart".into(),
275 "ApprovalRequest".into(),
276 "Approve".into(),
277 "Reject".into(),
278 "Abstain".into(),
279 "Commitment".into(),
280 ],
281 terminal_message_types: vec!["Commitment".into()],
282 schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"),
283 },
284 ]
285}
286
287pub fn extension_mode_descriptors() -> Vec<ModeDescriptor> {
288 vec![ModeDescriptor {
289 mode: "ext.multi_round.v1".into(),
290 mode_version: "1.0.0".into(),
291 title: "Multi-Round Mode".into(),
292 description: "Iterative convergence through multiple contribution rounds until all participants agree, with a terminal Commitment.".into(),
293 determinism_class: "semantic-deterministic".into(),
294 participant_model: "peer".into(),
295 message_types: vec![
296 "SessionStart".into(),
297 "Contribute".into(),
298 "Commitment".into(),
299 ],
300 terminal_message_types: vec!["Commitment".into()],
301 schema_uris: schema_map("buf.build/multiagentcoordinationprotocol/macp"),
302 }]
303}
304
305pub fn all_mode_descriptors() -> Vec<ModeDescriptor> {
306 let mut all = standard_mode_descriptors();
307 all.extend(extension_mode_descriptors());
308 all
309}