Skip to main content

lifeloop/router/
negotiation.rs

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