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 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}