lifeloop/router/negotiation.rs
1//! Capability and placement negotiation (issue #13).
2//!
3//! Capability negotiation (issue #13). It is a *separate step*
4//! layered on top of [`route`]: the caller first produces a
5//! [`RoutingPlan`] via [`crate::router::route`], then evaluates a
6//! [`CapabilityRequest`] against that plan via [`negotiate`] (or the
7//! [`DefaultNegotiationStrategy`] helper, which carries the request
8//! and payloads and yields the same [`NegotiatedPlan`]).
9//!
10//! Keeping negotiation a separate step rather than baking it into
11//! `route()` lets issue #14 (receipt synthesis) consume the resulting
12//! [`NegotiatedPlan`] without forcing every caller of `route` to
13//! supply a capability request, and lets future issues swap in
14//! richer negotiation policies without touching the validation /
15//! plan-synthesis stages.
16//!
17//! # Boundary
18//!
19//! This module owns:
20//! * the [`CapabilityRequest`] / [`CapabilityRequirement`] vocabulary
21//! a caller uses to declare what they need from the adapter for
22//! one lifecycle event;
23//! * the comparison of those requirements against the resolved
24//! [`AdapterManifest`]'s [`SupportState`] claims;
25//! * per-payload placement evaluation (`acceptable_placements` ->
26//! manifest [`ManifestPlacementSupport`]) including payload-size
27//! rejection and structured fallback;
28//! * synthesis of a typed [`NegotiatedPlan`] that wraps the
29//! [`RoutingPlan`] with the negotiation decision, demoted
30//! capability records, per-payload [`PayloadPlacementDecision`]s,
31//! and structured warnings.
32//!
33//! This module does **not** own:
34//! * mid-session degradation tracking — `capability.degraded` event
35//! emission is owned by a follow-up router issue (#14+);
36//! * receipt synthesis — issue #14 will read [`NegotiatedPlan`] and
37//! produce a [`crate::LifecycleReceipt`];
38//! * payload body parsing — payload bodies remain opaque. This module
39//! only measures inline body bytes for placement limits and reads
40//! [`PayloadEnvelope::acceptable_placements`].
41//!
42//! # Manual support and `requires_operator`
43//!
44//! The spec body's negotiation table treats `requires_operator` as
45//! distinct from `degraded`: it surfaces when an adapter's only
46//! pathway for a *required* capability is `manual` (operator-driven).
47//! The rule we apply:
48//!
49//! * a required capability whose manifest support is `manual` and
50//! whose request asked for any non-manual support level produces
51//! `requires_operator` (operator action is the only way forward);
52//! * a required capability whose manifest support is `unavailable`
53//! produces `unsupported` (no path forward at all);
54//! * `unsupported` strictly dominates `requires_operator` (a missing
55//! capability is worse than one that needs an operator), and
56//! `requires_operator` dominates `degraded` (operator action is
57//! not just a softer outcome — it changes the dispatch path).
58//!
59//! # Routing vs manifest placement vocabularies
60//!
61//! [`PlacementClass`] (the routing vocabulary on
62//! `acceptable_placements`) and [`ManifestPlacementClass`] (the
63//! manifest claim vocabulary) are intentionally separate per the
64//! spec. This module owns the small, internal mapping between them
65//! used to look a routing class up in the manifest's claims.
66
67use crate::{
68 AcceptablePlacement, AdapterManifest, CapabilityDegradation, FailureClass, LifecycleEventKind,
69 ManifestPlacementClass, ManifestPlacementSupport, NegotiationOutcome, PayloadEnvelope,
70 PlacementClass, RequirementLevel, SupportState, Warning,
71};
72
73use super::plan::RoutingPlan;
74
75// ===========================================================================
76// CapabilityRequest
77// ===========================================================================
78
79/// Vocabulary of lifecycle capabilities a client can name in a
80/// [`CapabilityRequest`].
81///
82/// Each variant maps to a specific support claim on the
83/// [`AdapterManifest`]. `LifecycleEvent` is parameterized by the
84/// concrete [`LifecycleEventKind`] so a request can ask for "this
85/// event must be `native`" without enumerating every event up front.
86#[derive(Debug, Clone, PartialEq, Eq, Hash)]
87pub enum CapabilityKind {
88 /// Per-event support: the adapter must claim the named lifecycle
89 /// event in its `lifecycle_events` map.
90 LifecycleEvent(LifecycleEventKind),
91 /// Native or synthesized context-pressure observation surface.
92 ContextPressure,
93 /// Adapter emits its own native receipts.
94 NativeReceipts,
95 /// Lifeloop synthesizes receipts on the adapter's behalf.
96 LifeloopSynthesizedReceipts,
97 /// Durable cross-invocation receipt ledger.
98 ReceiptLedger,
99 /// Harness session-id correlation.
100 HarnessSessionId,
101 /// Harness run-id correlation.
102 HarnessRunId,
103 /// Harness task-id correlation.
104 HarnessTaskId,
105 /// Adapter's session-rename surface (optional manifest field).
106 SessionRename,
107 /// Native harness reset path for renewal.
108 RenewalResetNative,
109 /// Launcher/wrapper-mediated reset path for renewal.
110 RenewalResetWrapperMediated,
111 /// Operator/manual reset path for renewal.
112 RenewalResetManual,
113 /// Adapter can observe that a continuation boundary happened.
114 RenewalContinuationObservation,
115 /// Adapter can deliver client-provided continuation facts across
116 /// the reset/continuation boundary.
117 RenewalContinuationPayloadDelivery,
118 /// Adapter's operator approval surface (optional manifest field).
119 ApprovalSurface,
120}
121
122impl CapabilityKind {
123 /// Stable wire-style name for diagnostics and warning records.
124 /// This is *not* serialized as part of any public wire envelope —
125 /// it is the human-readable code surfaced on
126 /// [`CapabilityDegradation::capability`] and
127 /// [`Warning::capability`].
128 pub fn name(&self) -> String {
129 match self {
130 Self::LifecycleEvent(ev) => match serde_json::to_value(ev) {
131 Ok(serde_json::Value::String(s)) => format!("lifecycle_event:{s}"),
132 _ => "lifecycle_event".to_string(),
133 },
134 Self::ContextPressure => "context_pressure".into(),
135 Self::NativeReceipts => "receipts.native".into(),
136 Self::LifeloopSynthesizedReceipts => "receipts.lifeloop_synthesized".into(),
137 Self::ReceiptLedger => "receipts.receipt_ledger".into(),
138 Self::HarnessSessionId => "session_identity.harness_session_id".into(),
139 Self::HarnessRunId => "session_identity.harness_run_id".into(),
140 Self::HarnessTaskId => "session_identity.harness_task_id".into(),
141 Self::SessionRename => "session_rename".into(),
142 Self::RenewalResetNative => "renewal.reset.native".into(),
143 Self::RenewalResetWrapperMediated => "renewal.reset.wrapper_mediated".into(),
144 Self::RenewalResetManual => "renewal.reset.manual".into(),
145 Self::RenewalContinuationObservation => "renewal.continuation.observation".into(),
146 Self::RenewalContinuationPayloadDelivery => {
147 "renewal.continuation.payload_delivery".into()
148 }
149 Self::ApprovalSurface => "approval_surface".into(),
150 }
151 }
152}
153
154/// One requirement in a [`CapabilityRequest`].
155///
156/// `desired` is the support level the client is requesting. The
157/// negotiation comparison uses [`SupportState`] equality plus the
158/// implicit ordering "any non-`Unavailable` state is at least as
159/// supportive as `Unavailable`" — adapters whose claim equals or
160/// strengthens the desired state satisfy the requirement; adapters
161/// whose claim is `Unavailable` (or the optional capability is
162/// missing entirely) do not.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct CapabilityRequirement {
165 pub kind: CapabilityKind,
166 pub level: RequirementLevel,
167 /// Minimum acceptable support. `Native` is the strongest.
168 /// `Synthesized`, `Partial`, `Manual`, and `Unavailable` are
169 /// progressively weaker. A client typically asks for
170 /// `Synthesized` or `Native`; asking for `Manual` is unusual
171 /// but valid.
172 pub desired: SupportState,
173}
174
175impl CapabilityRequirement {
176 pub fn required(kind: CapabilityKind, desired: SupportState) -> Self {
177 Self {
178 kind,
179 level: RequirementLevel::Required,
180 desired,
181 }
182 }
183
184 pub fn preferred(kind: CapabilityKind, desired: SupportState) -> Self {
185 Self {
186 kind,
187 level: RequirementLevel::Preferred,
188 desired,
189 }
190 }
191
192 pub fn optional(kind: CapabilityKind, desired: SupportState) -> Self {
193 Self {
194 kind,
195 level: RequirementLevel::Optional,
196 desired,
197 }
198 }
199}
200
201/// A client's capability request for one lifecycle dispatch.
202///
203/// Optional capabilities may be omitted entirely; their absence is
204/// not an error and produces no warning. Mid-session degradation
205/// (capabilities that change after dispatch starts) is a follow-up
206/// issue — this struct captures the *pre-dispatch* request only.
207#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct CapabilityRequest {
209 pub requirements: Vec<CapabilityRequirement>,
210}
211
212impl CapabilityRequest {
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 pub fn with(mut self, req: CapabilityRequirement) -> Self {
218 self.requirements.push(req);
219 self
220 }
221}
222
223// ===========================================================================
224// Placement decision
225// ===========================================================================
226
227/// Why a particular [`AcceptablePlacement`] was rejected during
228/// per-payload placement evaluation.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum PlacementRejection {
231 /// The adapter's manifest does not declare this placement at
232 /// all (or declares it as `Unavailable`).
233 Unsupported {
234 placement: PlacementClass,
235 manifest_support: SupportState,
236 },
237 /// The adapter declares the placement but the payload's
238 /// `byte_size` exceeds the manifest's `max_bytes` for that
239 /// placement.
240 PayloadTooLarge {
241 placement: PlacementClass,
242 byte_size: u64,
243 max_bytes: u64,
244 },
245}
246
247impl PlacementRejection {
248 pub fn placement(&self) -> PlacementClass {
249 match self {
250 Self::Unsupported { placement, .. } => *placement,
251 Self::PayloadTooLarge { placement, .. } => *placement,
252 }
253 }
254}
255
256/// Per-payload placement decision produced by negotiation.
257///
258/// One of three shapes (encoded as a typed decision rather than a
259/// wire enum so the receipt stage can map it onto
260/// `payload_receipts[].status`):
261/// * `Chosen` — a [`PlacementClass`] satisfied the payload;
262/// * `Failed` — no acceptable placement was satisfiable *and at
263/// least one of them was [`RequirementLevel::Required`]*, so the
264/// dispatch fails closed with the structured rejection list;
265/// * `Skipped` — no acceptable placement was satisfiable but every
266/// acceptable placement was `Preferred`/`Optional`, so per the
267/// spec's placement-resolution rule Lifeloop continues with a
268/// skipped payload receipt rather than failing the dispatch.
269///
270/// Every variant carries the payload provenance observed during
271/// negotiation so receipt synthesis does not need to infer it from
272/// callback responses.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum PayloadPlacementDecision {
275 /// A placement was chosen. `payload_kind`, `byte_size`, and
276 /// `content_digest` are copied from the negotiated payload
277 /// envelope. `chosen` is the routing [`PlacementClass`] that won;
278 /// `first_preference` is true when the chosen placement was the
279 /// first entry in `acceptable_placements`. `rejected` lists any
280 /// earlier placements that failed before the chosen one was
281 /// reached.
282 Chosen {
283 payload_id: String,
284 payload_kind: String,
285 byte_size: u64,
286 content_digest: Option<String>,
287 chosen: PlacementClass,
288 first_preference: bool,
289 rejected: Vec<PlacementRejection>,
290 },
291 /// No acceptable placement was satisfiable. `payload_kind`,
292 /// `byte_size`, and `content_digest` are copied from the
293 /// negotiated payload envelope. `failure_class` is either
294 /// [`FailureClass::PlacementUnavailable`] (no acceptable placement
295 /// supported at all) or [`FailureClass::PayloadTooLarge`] (every
296 /// otherwise-supported placement rejected the byte size).
297 Failed {
298 payload_id: String,
299 payload_kind: String,
300 byte_size: u64,
301 content_digest: Option<String>,
302 failure_class: FailureClass,
303 rejected: Vec<PlacementRejection>,
304 },
305 /// No acceptable placement was satisfiable, but none of them was
306 /// `Required`, so the dispatch continues with a skipped payload
307 /// receipt. `payload_kind`, `byte_size`, and `content_digest` are
308 /// copied from the negotiated payload envelope; `rejected` lists
309 /// every placement that was tried and rejected.
310 Skipped {
311 payload_id: String,
312 payload_kind: String,
313 byte_size: u64,
314 content_digest: Option<String>,
315 rejected: Vec<PlacementRejection>,
316 },
317}
318
319impl PayloadPlacementDecision {
320 pub fn payload_id(&self) -> &str {
321 match self {
322 Self::Chosen { payload_id, .. } => payload_id,
323 Self::Failed { payload_id, .. } => payload_id,
324 Self::Skipped { payload_id, .. } => payload_id,
325 }
326 }
327
328 pub fn is_failed(&self) -> bool {
329 matches!(self, Self::Failed { .. })
330 }
331}
332
333// ===========================================================================
334// NegotiatedPlan
335// ===========================================================================
336
337/// Negotiation result wrapping a [`RoutingPlan`].
338///
339/// Issue #14 will consume this directly:
340/// * `outcome` flows into `LifecycleReceipt.status`
341/// (`satisfied` -> `delivered`, `degraded` -> `degraded`,
342/// `unsupported` -> `failed` with `failure_class=capability_unsupported`,
343/// `requires_operator` -> `failed` with
344/// `failure_class=operator_required`);
345/// * `capability_degradations` flows into
346/// `LifecycleReceipt.capability_degradations` verbatim;
347/// * `placement_decisions` flows into `payload_receipts` (one
348/// `PayloadReceipt` per decision);
349/// * `warnings` flows into `LifecycleReceipt.warnings`;
350/// * `failure_class` (when `Some`) flows into
351/// `LifecycleReceipt.failure_class` and seeds the default
352/// `retry_class` via [`FailureClass::default_retry`].
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct NegotiatedPlan {
355 pub plan: RoutingPlan,
356 pub outcome: NegotiationOutcome,
357 pub capability_degradations: Vec<CapabilityDegradation>,
358 pub placement_decisions: Vec<PayloadPlacementDecision>,
359 pub warnings: Vec<Warning>,
360 /// Set when `outcome` is fail-closed (`Unsupported` /
361 /// `RequiresOperator`) or when at least one payload placement
362 /// failed. `None` when the dispatch path may continue.
363 pub failure_class: Option<FailureClass>,
364}
365
366impl NegotiatedPlan {
367 /// True when `outcome` blocks dispatch (`Unsupported` or
368 /// `RequiresOperator`). The router skeleton fails closed: a
369 /// caller MUST check this flag before invoking the
370 /// [`crate::router::CallbackInvoker`] seam.
371 pub fn blocks_dispatch(&self) -> bool {
372 matches!(
373 self.outcome,
374 NegotiationOutcome::Unsupported | NegotiationOutcome::RequiresOperator
375 )
376 }
377}
378
379// ===========================================================================
380// Strategy implementation
381// ===========================================================================
382
383/// Stateful negotiation helper for issue #13.
384///
385/// Holds a [`CapabilityRequest`] plus the payload envelopes to
386/// evaluate, and produces the full [`NegotiatedPlan`] via
387/// [`Self::negotiate_full`] (a thin wrapper over the free
388/// [`negotiate`] function). Callers that only need the outcome read
389/// [`NegotiatedPlan::outcome`].
390#[derive(Debug, Clone, Default)]
391pub struct DefaultNegotiationStrategy {
392 pub request: CapabilityRequest,
393 pub payloads: Vec<PayloadEnvelope>,
394}
395
396impl DefaultNegotiationStrategy {
397 pub fn new(request: CapabilityRequest, payloads: Vec<PayloadEnvelope>) -> Self {
398 Self { request, payloads }
399 }
400
401 pub fn negotiate_full(&self, plan: &RoutingPlan) -> NegotiatedPlan {
402 negotiate(plan, &self.request, &self.payloads)
403 }
404}
405
406/// Negotiate a [`CapabilityRequest`] (and any payload envelopes)
407/// against an existing [`RoutingPlan`].
408///
409/// Returns a [`NegotiatedPlan`] wrapping the input plan with the
410/// negotiation decision. The input plan is cloned into the result —
411/// this preserves issue #7's guarantee that a `RoutingPlan` is
412/// `'static`-friendly.
413pub fn negotiate(
414 plan: &RoutingPlan,
415 request: &CapabilityRequest,
416 payloads: &[PayloadEnvelope],
417) -> NegotiatedPlan {
418 let manifest = &plan.adapter;
419
420 // Capability outcome aggregate.
421 let mut outcome = NegotiationOutcome::Satisfied;
422 let mut capability_degradations: Vec<CapabilityDegradation> = Vec::new();
423 let mut warnings: Vec<Warning> = Vec::new();
424
425 for req in &request.requirements {
426 let manifest_support = manifest_support_for(manifest, &req.kind);
427 let cap_outcome = classify_capability(req, manifest_support);
428 match cap_outcome {
429 CapabilityVerdict::Satisfied => {}
430 CapabilityVerdict::Degraded { previous, current } => {
431 capability_degradations.push(CapabilityDegradation {
432 capability: req.kind.name(),
433 previous_support: previous,
434 current_support: current,
435 evidence: None,
436 retry_class: None,
437 });
438 warnings.push(Warning {
439 code: "capability_degraded".into(),
440 message: format!(
441 "preferred capability `{}` degraded from `{previous:?}` to `{current:?}`",
442 req.kind.name()
443 ),
444 capability: Some(req.kind.name()),
445 });
446 outcome = strongest(outcome, NegotiationOutcome::Degraded);
447 }
448 CapabilityVerdict::RequiresOperator => {
449 warnings.push(Warning {
450 code: "operator_required".into(),
451 message: format!(
452 "required capability `{}` is only available through manual operator action",
453 req.kind.name()
454 ),
455 capability: Some(req.kind.name()),
456 });
457 outcome = strongest(outcome, NegotiationOutcome::RequiresOperator);
458 }
459 CapabilityVerdict::Unsupported => {
460 warnings.push(Warning {
461 code: "capability_unsupported".into(),
462 message: format!(
463 "required capability `{}` not supported by adapter `{}`",
464 req.kind.name(),
465 manifest.adapter_id
466 ),
467 capability: Some(req.kind.name()),
468 });
469 outcome = strongest(outcome, NegotiationOutcome::Unsupported);
470 }
471 }
472 }
473
474 // Per-payload placement decisions. Skipped on capability-level
475 // fail-closed outcomes — there is no point evaluating placement
476 // for a dispatch that cannot proceed. An empty payload list
477 // (no payloads on the request) produces no decisions and no
478 // additional warnings.
479 let mut placement_decisions: Vec<PayloadPlacementDecision> = Vec::new();
480 let dispatch_blocked = matches!(
481 outcome,
482 NegotiationOutcome::Unsupported | NegotiationOutcome::RequiresOperator
483 );
484 let mut placement_failure_class: Option<FailureClass> = None;
485
486 if !dispatch_blocked {
487 for env in payloads {
488 let decision = decide_placement(env, manifest);
489 match &decision {
490 PayloadPlacementDecision::Failed {
491 failure_class,
492 payload_id,
493 ..
494 } => {
495 // A required placement was unavailable: fail closed.
496 outcome = strongest(outcome, NegotiationOutcome::Unsupported);
497 placement_failure_class = Some(*failure_class);
498 warnings.push(Warning {
499 code: "placement_unavailable".into(),
500 message: format!(
501 "payload `{}` had no acceptable placement on adapter `{}`",
502 payload_id, manifest.adapter_id
503 ),
504 capability: None,
505 });
506 }
507 PayloadPlacementDecision::Skipped { payload_id, .. } => {
508 // Only preferred/optional placements were
509 // unavailable: the spec lets dispatch continue
510 // with a skipped receipt, degraded rather than
511 // fail-closed.
512 outcome = strongest(outcome, NegotiationOutcome::Degraded);
513 warnings.push(Warning {
514 code: "placement_skipped".into(),
515 message: format!(
516 "payload `{}` had no acceptable placement on adapter `{}`; \
517 all acceptable placements were preferred/optional, skipping",
518 payload_id, manifest.adapter_id
519 ),
520 capability: None,
521 });
522 }
523 PayloadPlacementDecision::Chosen { .. } => {}
524 }
525 placement_decisions.push(decision);
526 }
527 }
528
529 let failure_class = match outcome {
530 NegotiationOutcome::Satisfied | NegotiationOutcome::Degraded => None,
531 NegotiationOutcome::Unsupported => {
532 Some(placement_failure_class.unwrap_or(FailureClass::CapabilityUnsupported))
533 }
534 NegotiationOutcome::RequiresOperator => Some(FailureClass::OperatorRequired),
535 };
536
537 NegotiatedPlan {
538 plan: plan.clone(),
539 outcome,
540 capability_degradations,
541 placement_decisions,
542 warnings,
543 failure_class,
544 }
545}
546
547// ---------------------------------------------------------------------------
548// Capability classification
549// ---------------------------------------------------------------------------
550
551#[derive(Debug, Clone, PartialEq, Eq)]
552enum CapabilityVerdict {
553 Satisfied,
554 Degraded {
555 previous: SupportState,
556 current: SupportState,
557 },
558 RequiresOperator,
559 Unsupported,
560}
561
562fn classify_capability(
563 req: &CapabilityRequirement,
564 manifest_support: SupportState,
565) -> CapabilityVerdict {
566 let satisfies = support_satisfies(manifest_support, req.desired);
567
568 match req.level {
569 RequirementLevel::Required => {
570 if satisfies {
571 CapabilityVerdict::Satisfied
572 } else if manifest_support == SupportState::Manual
573 && req.desired != SupportState::Manual
574 {
575 // Required, only manual path available -> operator action needed.
576 CapabilityVerdict::RequiresOperator
577 } else {
578 CapabilityVerdict::Unsupported
579 }
580 }
581 RequirementLevel::Preferred => {
582 if satisfies {
583 CapabilityVerdict::Satisfied
584 } else {
585 CapabilityVerdict::Degraded {
586 previous: req.desired,
587 current: manifest_support,
588 }
589 }
590 }
591 RequirementLevel::Optional => {
592 // Optional capabilities never produce a degradation
593 // record on their own; they are always satisfied
594 // from the negotiation aggregate's point of view. A
595 // future mid-session-degradation issue may revisit
596 // this when an `optional` capability that *was*
597 // supported drops out.
598 CapabilityVerdict::Satisfied
599 }
600 }
601}
602
603/// Strength ordering on [`SupportState`] for negotiation purposes.
604/// `Native` is strongest. `Manual` is treated as weaker than
605/// `Synthesized`/`Partial` because it requires operator action; it
606/// is *only* satisfying when the requested support was itself
607/// `Manual`. `Unavailable` is the bottom.
608fn support_rank(s: SupportState) -> u8 {
609 match s {
610 SupportState::Native => 4,
611 SupportState::Synthesized => 3,
612 SupportState::Partial => 2,
613 SupportState::Manual => 1,
614 SupportState::Unavailable => 0,
615 }
616}
617
618fn support_satisfies(have: SupportState, want: SupportState) -> bool {
619 if have == SupportState::Unavailable {
620 return false;
621 }
622 if want == SupportState::Manual {
623 // Manual is a specific opt-in: it is satisfied by Manual
624 // or stronger non-Unavailable claims.
625 return have != SupportState::Unavailable;
626 }
627 if have == SupportState::Manual {
628 // Manual cannot silently satisfy a non-Manual request —
629 // the operator-required pathway routes through the
630 // RequiresOperator verdict instead.
631 return false;
632 }
633 support_rank(have) >= support_rank(want)
634}
635
636/// Outcome combiner. The most-blocking outcome wins:
637/// `Unsupported` > `RequiresOperator` > `Degraded` > `Satisfied`.
638fn strongest(a: NegotiationOutcome, b: NegotiationOutcome) -> NegotiationOutcome {
639 fn rank(o: NegotiationOutcome) -> u8 {
640 match o {
641 NegotiationOutcome::Satisfied => 0,
642 NegotiationOutcome::Degraded => 1,
643 NegotiationOutcome::RequiresOperator => 2,
644 NegotiationOutcome::Unsupported => 3,
645 }
646 }
647 if rank(a) >= rank(b) { a } else { b }
648}
649
650fn manifest_support_for(manifest: &AdapterManifest, kind: &CapabilityKind) -> SupportState {
651 match kind {
652 CapabilityKind::LifecycleEvent(ev) => manifest
653 .lifecycle_events
654 .get(ev)
655 .map(|claim| claim.support)
656 .unwrap_or(SupportState::Unavailable),
657 CapabilityKind::ContextPressure => manifest.context_pressure.support,
658 CapabilityKind::NativeReceipts => {
659 if manifest.receipts.native {
660 SupportState::Native
661 } else {
662 SupportState::Unavailable
663 }
664 }
665 CapabilityKind::LifeloopSynthesizedReceipts => {
666 if manifest.receipts.lifeloop_synthesized {
667 SupportState::Synthesized
668 } else {
669 SupportState::Unavailable
670 }
671 }
672 CapabilityKind::ReceiptLedger => manifest.receipts.receipt_ledger,
673 CapabilityKind::HarnessSessionId => manifest
674 .session_identity
675 .as_ref()
676 .map(|si| si.harness_session_id)
677 .unwrap_or(SupportState::Unavailable),
678 CapabilityKind::HarnessRunId => manifest
679 .session_identity
680 .as_ref()
681 .map(|si| si.harness_run_id)
682 .unwrap_or(SupportState::Unavailable),
683 CapabilityKind::HarnessTaskId => manifest
684 .session_identity
685 .as_ref()
686 .map(|si| si.harness_task_id)
687 .unwrap_or(SupportState::Unavailable),
688 CapabilityKind::SessionRename => manifest
689 .session_rename
690 .as_ref()
691 .map(|s| s.support)
692 .unwrap_or(SupportState::Unavailable),
693 CapabilityKind::RenewalResetNative => manifest
694 .renewal
695 .as_ref()
696 .map(|r| r.reset.native)
697 .unwrap_or(SupportState::Unavailable),
698 CapabilityKind::RenewalResetWrapperMediated => manifest
699 .renewal
700 .as_ref()
701 .map(|r| r.reset.wrapper_mediated)
702 .unwrap_or(SupportState::Unavailable),
703 CapabilityKind::RenewalResetManual => manifest
704 .renewal
705 .as_ref()
706 .map(|r| r.reset.manual)
707 .unwrap_or(SupportState::Unavailable),
708 CapabilityKind::RenewalContinuationObservation => manifest
709 .renewal
710 .as_ref()
711 .map(|r| r.continuation.observation)
712 .unwrap_or(SupportState::Unavailable),
713 CapabilityKind::RenewalContinuationPayloadDelivery => manifest
714 .renewal
715 .as_ref()
716 .map(|r| r.continuation.payload_delivery)
717 .unwrap_or(SupportState::Unavailable),
718 CapabilityKind::ApprovalSurface => manifest
719 .approval_surface
720 .as_ref()
721 .map(|s| s.support)
722 .unwrap_or(SupportState::Unavailable),
723 }
724}
725
726// ---------------------------------------------------------------------------
727// Placement evaluation
728// ---------------------------------------------------------------------------
729
730/// Map a routing [`PlacementClass`] to the
731/// [`ManifestPlacementClass`] under which the manifest declares its
732/// support. The two vocabularies are intentionally separate per
733/// the spec ("Manifest placement classes"); this mapping is local
734/// to negotiation and is not exposed as a public conversion.
735///
736/// * `DeveloperEquivalentFrame` -> `PreFrameLeading` (strong
737/// leading-edge instruction layer).
738/// * `PrePromptFrame` -> `PreFrameTrailing` (prepended just before
739/// the next prompt: trailing edge of the open frame, before
740/// model execution).
741/// * `SideChannelContext` -> `ManualOperator` (out-of-band channel
742/// exposed by an operator-driven surface).
743/// * `ReceiptOnly` -> `PreSession` is wrong; receipt-only payloads
744/// are not delivered to any frame at all. We treat
745/// `ReceiptOnly` as universally available — it records metadata
746/// and never injects content — and skip the manifest claim
747/// lookup for it.
748fn manifest_placement_for(p: PlacementClass) -> Option<ManifestPlacementClass> {
749 match p {
750 PlacementClass::DeveloperEquivalentFrame => Some(ManifestPlacementClass::PreFrameLeading),
751 PlacementClass::PrePromptFrame => Some(ManifestPlacementClass::PreFrameTrailing),
752 PlacementClass::SideChannelContext => Some(ManifestPlacementClass::ManualOperator),
753 PlacementClass::ReceiptOnly => None,
754 }
755}
756
757/// Evaluate one [`AcceptablePlacement`] against the manifest.
758fn evaluate_one(
759 ap: &AcceptablePlacement,
760 byte_size: u64,
761 manifest: &AdapterManifest,
762) -> Result<(), PlacementRejection> {
763 // ReceiptOnly is universally available: it records metadata and
764 // does not inject payload content into any frame, so no manifest
765 // claim is required.
766 let Some(mfc) = manifest_placement_for(ap.placement) else {
767 return Ok(());
768 };
769 let support: ManifestPlacementSupport =
770 manifest
771 .placement
772 .get(&mfc)
773 .cloned()
774 .unwrap_or(ManifestPlacementSupport {
775 support: SupportState::Unavailable,
776 max_bytes: None,
777 });
778 if support.support == SupportState::Unavailable {
779 return Err(PlacementRejection::Unsupported {
780 placement: ap.placement,
781 manifest_support: support.support,
782 });
783 }
784 if let Some(max) = support.max_bytes
785 && byte_size > max
786 {
787 return Err(PlacementRejection::PayloadTooLarge {
788 placement: ap.placement,
789 byte_size,
790 max_bytes: max,
791 });
792 }
793 Ok(())
794}
795
796fn decide_placement(env: &PayloadEnvelope, manifest: &AdapterManifest) -> PayloadPlacementDecision {
797 // Placement bodies are opaque; inline bodies are measured as
798 // transported bytes so an under-reported `byte_size` cannot bypass
799 // manifest limits.
800 let byte_size = env.effective_byte_size();
801 let mut rejected: Vec<PlacementRejection> = Vec::new();
802 for (idx, ap) in env.acceptable_placements.iter().enumerate() {
803 match evaluate_one(ap, byte_size, manifest) {
804 Ok(()) => {
805 return PayloadPlacementDecision::Chosen {
806 payload_id: env.payload_id.clone(),
807 payload_kind: env.payload_kind.clone(),
808 byte_size,
809 content_digest: env.content_digest.clone(),
810 chosen: ap.placement,
811 first_preference: idx == 0,
812 rejected,
813 };
814 }
815 Err(rej) => rejected.push(rej),
816 }
817 }
818
819 // No acceptable placement satisfied. Per the spec's
820 // placement-resolution rule the dispatch only fails closed when
821 // at least one acceptable placement was `required`; when every
822 // acceptable placement was `preferred`/`optional`, Lifeloop
823 // continues with a skipped payload receipt.
824 //
825 // An *empty* `acceptable_placements` is malformed (the wire
826 // validator rejects it), but `negotiate` is a public entry point
827 // that does not validate — so guard explicitly and fail closed
828 // rather than silently skipping a payload that declared no way to
829 // be placed. The skip path requires at least one declared
830 // placement that was preferred/optional.
831 let any_required = env
832 .acceptable_placements
833 .iter()
834 .any(|ap| ap.requirement == RequirementLevel::Required);
835 if !any_required && !env.acceptable_placements.is_empty() {
836 return PayloadPlacementDecision::Skipped {
837 payload_id: env.payload_id.clone(),
838 payload_kind: env.payload_kind.clone(),
839 byte_size,
840 content_digest: env.content_digest.clone(),
841 rejected,
842 };
843 }
844
845 // A required placement was unavailable. Distinguish "nothing was
846 // supported" from "everything was rejected for size".
847 let all_size_failures = !rejected.is_empty()
848 && rejected
849 .iter()
850 .all(|r| matches!(r, PlacementRejection::PayloadTooLarge { .. }));
851 let failure_class = if all_size_failures {
852 FailureClass::PayloadTooLarge
853 } else {
854 FailureClass::PlacementUnavailable
855 };
856
857 PayloadPlacementDecision::Failed {
858 payload_id: env.payload_id.clone(),
859 payload_kind: env.payload_kind.clone(),
860 byte_size,
861 content_digest: env.content_digest.clone(),
862 failure_class,
863 rejected,
864 }
865}