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