Skip to main content

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}