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