Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ buyer message can contradict the advertisement. Issue #891 tracks a bounded re-p

### What a buyer gets

`harness_family` is exact-or-nothing at dispatch.
`harness_family` decides which seats may claim a job. It does not decide which harness runs one.
Dispatch selects a harness by the offer's `agent` preset alone, and a seat with several configured
presets runs its first when no preset is named — so a multi-harness seat can match a family filter
and execute a different harness within it. A buyer that needs the execution guarantee must name the
`agent` preset. Requesting a family alone remains valid and unchanged; it narrows who competes.

`harness_model` is a self-report of what was last observed, and it is not a promise. Nothing
selects or pins a model. The seat states what its harness reported when it was last read, and an
Expand All @@ -53,8 +57,9 @@ invisible because of this release.
### What the runner sheet shows

The Profile section lists the five fields, and each row carries a mark naming what its value is
worth: `enforced at dispatch` for the harness family, `last observed` for the model, `as of seat
start` for capabilities, and `operator-declared` for the variant and the hardware. The marks come
worth: `last observed` for the model, `as of seat start` for capabilities, and `operator-declared`
for the variant and the hardware. The harness family is a claim filter and not a dispatch
guarantee, and its row is being corrected to say so. The marks come
from `docs/protocol-v1.md` §4.5.3 and §4.5.4. They are on the rows because the five are not equal,
and a sheet that displayed them alike would invite exactly the reading the spec forbids.

Expand Down
1,159 changes: 1,077 additions & 82 deletions crates/maxplayer-core/src/buyer/lifecycle.rs

Large diffs are not rendered by default.

94 changes: 54 additions & 40 deletions crates/maxplayer-core/src/buyer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ use crate::job_lifecycle::{
};
use crate::payment::{PaymentMachine, PaymentRecord, PaymentState};
use lifecycle::{
AwardError, AwardFilters, AwardOutcome, MissingOfferAction, PaymentProgress, RearmAction,
AwardError, AwardOutcome, MissingOfferAction, PaymentProgress, RearmAction,
SettleError,
};
use lock::{HomeLock, LockError};
Expand Down Expand Up @@ -427,13 +427,30 @@ struct PostJobParams {
/// never auto-awards a claim it cannot pay or priced above this.
#[serde(default)]
max_sats: Option<u64>,
/// Auto-award preferences recorded with the intent. `harness` is ALSO posted on the offer as
/// its requested agent, so it is a hard award filter: only a seller advertising that harness
/// can be awarded. `model` has no wire field yet and stays a recorded preference.
/// Auto-award preferences recorded with the intent. BOTH are also posted on the offer and are
/// therefore hard award filters: only a seller advertising them can be awarded.
///
/// `harness` names a PRESET and is matched against the claim's `agents`. `model` (#897) is matched
/// against the family/model PAIR a seat advertises, and so REQUIRES `harness` — the preset is the
/// only axis dispatch reads, so it is the only one that can bind the model to the harness that
/// will actually run. The family is DERIVED from the preset when it is not stated. A model with
/// no preset refuses every claim rather than being ignored.
#[serde(default)]
harness: Option<String>,
#[serde(default)]
model: Option<String>,
/// Harness FAMILY the job requires (#897). Posted on the offer and enforced as a hard award
/// filter on BOTH award paths. Distinct from `harness`, which names a preset: a family spans the
/// presets sharing a harness, so a family request selects which seats may CLAIM the job and does
/// NOT bind which harness a multi-harness seat dispatches — only `harness` does that. When both
/// are given they must AGREE: a family naming a harness the preset would not run is refused.
#[serde(default)]
harness_family: Option<String>,
/// Capability tokens the job requires (#897) — a subset of
/// [`maxplayer_core::capability::CAPABILITIES`]. Posted on the offer and enforced as a hard
/// award filter on BOTH award paths. Omitted or empty ⇒ no requirement.
#[serde(default)]
capabilities: Option<Vec<String>>,
}

/// Resolve the offer kind from the contribution pins: all four present ⇒ contribution; none ⇒
Expand Down Expand Up @@ -490,6 +507,12 @@ async fn post_job(context: &Arc<BuyerContext>, id: Value, params: Value) -> Resp
branch: params.branch,
job,
requested_agent: harness.clone(),
requested_harness_family: params.harness_family,
// #897: `model` now reaches the WIRE as well as the intent. It stays recorded on the intent
// because that is a separate fact — what the buyer asked for locally — from what the signed
// offer says, and the award filter reads only the offer.
requested_model: model.clone(),
required_capabilities: params.capabilities.unwrap_or_default(),
};
match job_lifecycle::post_job_async(&context.home, request).await {
Ok(outcome) => {
Expand Down Expand Up @@ -795,20 +818,16 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response {
};
let offer_amount = offer.amount_sats;
let max_sats = params.max_sats.unwrap_or(offer_amount);
let filters = AwardFilters {
offer_amount_sats: offer_amount,
// ONE constructor, shared with `drive_auto_award` — the capability request (#897) and
// every other filter come from the SIGNED OFFER, never from award params, so the request
// cannot be changed after the fact. Sharing the constructor is what makes "both paths
// filter identically" structural instead of a convention someone has to keep noticing.
let filters = lifecycle::award_filters_for_offer(
offer,
max_sats,
buyer_mint: context.home.config.default_mint(),
allow_real_mints: context.home.config.allow_real_mints,
requested_agent: offer.requested_agent.as_deref(),
// #784 capability request — INERT until the offer carries these. The predicate is
// live and wired; an absent request passes every claim, so award behaviour here is
// byte-unchanged. The offer-side fields live in `job_lifecycle.rs`'s OfferView and
// its tag parse, and land in a follow-up (see this PR's body).
requested_harness_family: None,
requested_model: None,
required_capabilities: &[],
};
context.home.config.default_mint(),
context.home.config.allow_real_mints,
);

// Manual award names the claim but applies the SAME hard filters as auto-award —
// max_sats, price, mint AND the #784 capability request. Naming a claim chooses which
Expand Down Expand Up @@ -1290,40 +1309,35 @@ async fn drive_auto_award(
return Ok(());
};
unconfirmed_reads = 0;
// THE SAME constructor the manual award path uses, so the two cannot apply different filters.
// Both selection entry points then consult `claim_meets_capability_request`:
// `select_awardable_claim` here, `named_claim_awardable` on the manual path.
let filters = lifecycle::award_filters_for_offer(
offer,
max_sats,
context.home.config.default_mint(),
context.home.config.allow_real_mints,
);

// Built AFTER `filters` so the deadline park can name the capability request that refused
// everything, instead of only reporting that time ran out. The order of these two blocks is
// the only thing that makes an actionable reason available here; the decision itself is
// unchanged, and a job with no request parks with the wording it always did.
if now_unix() as u64 > offer.deadline_unix {
// A pinned attempt past its deadline is NOT "no awardable claim appeared" — a claim
// was selected and signed for. Reflect the ATTEMPT's truth on the intent instead of
// a false park reason; the periodic sweep continues anything still unresolved.
if settle_intent_from_attempt(context, &keys, job_id).await {
return Ok(());
}
crate::opline!(
"{}",
auto_award_park_line(job_id, "offer deadline passed before an awardable claim appeared")
);
let _ = context.store.mark_award_parked(
job_id,
"offer deadline passed before an awardable claim appeared",
now_unix(),
let reason = lifecycle::park_reason_deadline_passed(
lifecycle::capability_park_reason(&view, &filters).as_deref(),
);
crate::opline!("{}", auto_award_park_line(job_id, &reason));
let _ = context.store.mark_award_parked(job_id, &reason, now_unix());
return Ok(());
}

let filters = AwardFilters {
offer_amount_sats: offer.amount_sats,
max_sats,
buyer_mint: context.home.config.default_mint(),
allow_real_mints: context.home.config.allow_real_mints,
requested_agent: offer.requested_agent.as_deref(),
// #784 capability request — INERT until the offer carries these, exactly as on the
// manual path above. Both selection paths call `claim_meets_capability_request`:
// `select_awardable_claim` here, `named_claim_awardable` on the manual path. A test
// holds that property from BOTH entry points, so this sentence cannot quietly become
// false the way its predecessor did.
requested_harness_family: None,
requested_model: None,
required_capabilities: &[],
};
if let Some(claim_id) = lifecycle::select_awardable_claim(&view, &filters) {
return finalize_auto_award(context, job_id, offer.amount_sats, claim_id).await;
}
Expand Down
71 changes: 71 additions & 0 deletions crates/maxplayer-core/src/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,77 @@ pub fn probe_seat_capabilities(
})
}

/// Why a job's capability REQUEST is itself malformed (#897).
///
/// Distinct from [`crate::buyer::lifecycle::CapabilityRefusal`], which judges a well-formed request
/// against a seat's ADVERTISEMENT. The two reach different people and imply opposite actions: a
/// refusal tells an operator to wait or add a seat, which is a useful answer; a defect tells the
/// CALLER its own arguments name something no seat can ever advertise, which no amount of waiting
/// fixes. Collapsing them would tell a buyer to wait for a seat that cannot exist.
///
/// They are also caught at different times, and that is the point of having this type at all: this
/// one is caught BEFORE an offer is signed and published, where refusing costs nothing. The refusal
/// is caught at award, by which time the offer is on the relay and the deadline is already running.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RequestDefect {
/// The request named a harness family outside [`crate::agent_presets::HARNESS_FAMILIES`]. No
/// seat can advertise it, because families reach the wire only via
/// [`crate::agent_presets::harness_family_for_preset`].
UnknownHarnessFamily { requested: String },
/// The request named a capability token outside [`CAPABILITIES`]. Unmatchable by construction —
/// the only emitter of these tokens is [`probe_capabilities`], which yields entries of that list.
UnknownCapabilityToken { token: String },
}

impl std::fmt::Display for RequestDefect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownHarnessFamily { requested } => write!(
f,
"harness_family {requested:?} is not a known family (known: {})",
crate::agent_presets::HARNESS_FAMILIES.join(", ")
),
Self::UnknownCapabilityToken { token } => write!(
f,
"capability {token:?} is not a known capability token (known: {})",
CAPABILITIES.join(", ")
),
}
}
}

impl std::error::Error for RequestDefect {}

/// Validate a job's capability request against the closed vocabularies, BEFORE an offer carrying it
/// is signed (#897).
///
/// Posting an offer commits the buyer: the daemon drives the award from it and the deadline starts
/// running. An out-of-vocabulary request is unmatchable by construction, so an offer carrying one
/// can only ever park at its deadline having refused every claim — a spend of time and attention for
/// a defect that was visible before the event was built. Refusing here converts that into an
/// immediate, actionable error to the caller.
///
/// The award-side predicate keeps its own equivalent check and this does NOT replace it: offers
/// arrive from clients this code never ran, so the fail-closed backstop at judge time is what makes
/// the property hold on the wire rather than only in our own posting path.
pub fn validate_capability_request(
requested_harness_family: Option<&str>,
required_capabilities: &[String],
) -> Result<(), RequestDefect> {
if let Some(family) = requested_harness_family {
if !crate::agent_presets::HARNESS_FAMILIES.contains(&family) {
return Err(RequestDefect::UnknownHarnessFamily { requested: family.to_owned() });
}
}
if let Some(token) = required_capabilities
.iter()
.find(|token| !CAPABILITIES.contains(&token.as_str()))
{
return Err(RequestDefect::UnknownCapabilityToken { token: token.clone() });
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading