From 982fde36221767d61de4fc4c10aa54aa7eb6df17 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 02:38:08 -0700 Subject: [PATCH 01/10] buyer: carry the capability request on the offer Closes the gap between "the filter exists" and "a job is not awarded to a seat that cannot do it". #866 shipped the capability predicate and wired it into both selection paths, then built the request empty at both award sites, so every claim passed and the feature shipped inert. The offer now carries the request as three params, and both award sites read it off the SIGNED OFFER rather than from award params: ["param","harness_family", family] ["param","harness_model", model] ["param","capability", token, ...] An absent request passes every claim, and an offer that requests nothing is byte-identical to one posted before this existed, so filtering is opt-in on the wire rather than only inside the predicate. Model needs a family (#788). A bare model does not say which harness would run it, and the harness request is what binds dispatch, so the predicate refuses it rather than ignoring it. Two gates run before an offer is signed, because posting commits: it arms the auto-award and starts the deadline, so a request nothing can satisfy turns a caller's typo into a committed offer and a guaranteed park. - The vocabulary gate knows the closed lists: a family or token no seat can ever advertise. - The satisfiability gate owns no rules. It synthesizes the claim that would satisfy the request exactly and asks the real award predicate whether that claim passes. A gate restating "a model needs a family" would be a second copy of a rule owned elsewhere, and the day #788 makes a bare model valid it would have to be found and changed by hand. Deriving the verdict means it changes itself. Neither gate is the enforcement boundary. A foreign client can publish either shape straight to the relay, so the award-time refusal and its park row remain the wire-level truth, and both layers are tested. A request that matches no live claim now parks naming what to fix. The reason stays silent unless capability was genuinely the obstacle: if any live claim satisfied the request, or none was live, a capability-shaped reason would blame the request for a price or mint failure and hide the real one. The all-inert tripwire from #866 is replaced by one that pins the new state: every axis read off the offer at both sites, no axis inert, and exactly two award sites. It also pins the test mirror against the production wiring, which is not hypothetical - that mirror shipped with model unwired for one revision and two tests silently asserted that a model request awards the claim it should have refused. The post_job schema descriptions become hard-filter promises in the same commit, since they are caller-facing claims about a money path. Display-only harness_variant and hardware stay unrequestable, now asserted against the filter struct rather than a list of param names. --- crates/maxplayer-core/src/buyer/lifecycle.rs | 653 +++++++++++++++++-- crates/maxplayer-core/src/buyer/mod.rs | 90 ++- crates/maxplayer-core/src/capability.rs | 71 ++ crates/maxplayer-core/src/gateway.rs | 250 +++++++ crates/maxplayer-core/src/heartbeat.rs | 42 ++ crates/maxplayer-core/src/job_lifecycle.rs | 221 ++++++- crates/maxplayer-core/src/payment_wallet.rs | 3 + crates/maxplayer-core/src/seller.rs | 3 + crates/maxplayer-core/src/seller_node/run.rs | 17 +- crates/maxplayer/src/mcp.rs | 111 +++- docs/protocol-v1.md | 38 ++ 11 files changed, 1382 insertions(+), 117 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 0c5f300b5..6a341285c 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -200,6 +200,110 @@ pub fn claim_meets_capability_request( Ok(()) } +/// Whether a capability request is UNSATISFIABLE BY CONSTRUCTION — no claim that could ever be +/// published can pass it — and if so, why (#897). +/// +/// ★ DERIVED FROM THE PREDICATE, NOT A SECOND COPY OF ITS RULES. It synthesizes the claim that would +/// satisfy the request exactly — a seat advertising precisely what was asked for — and runs the REAL +/// [`claim_meets_capability_request`] against it. A request the perfect claim cannot pass is one no +/// claim can pass. +/// +/// That construction is the whole point. A gate that restated "a model needs a family" in its own +/// words would be a second copy of a rule owned elsewhere, and the two would rot apart silently: +/// #788 may later make a bare model valid, and on that day this gate must stop refusing WITHOUT +/// anyone remembering it exists. Because the verdict comes from the predicate, it will. +/// +/// SCOPE, and it is a real limit rather than a caveat: this is a fail-fast for OUR posting surface, +/// where refusing costs the caller nothing and posting commits an offer. It is NOT the enforcement +/// boundary. A foreign client can publish a model-only offer straight to the relay, and for that +/// offer the award-time refusal and its park row are the wire-level truth. Both layers are tested and +/// neither makes the other unnecessary. +pub fn unsatisfiable_capability_request( + requested_harness_family: Option<&str>, + requested_model: Option<&str>, + required_capabilities: &[String], +) -> Option { + // The seat that advertises exactly what was asked for. A model is only representable PAIRED with + // a family, so a request naming a model and no family cannot be advertised by any seat — which is + // the unsatisfiability this probe detects rather than asserts. + let advertised = crate::heartbeat::SeatCapability { + harness_families: requested_harness_family + .map(|family| vec![family.to_owned()]) + .unwrap_or_default(), + models: match (requested_harness_family, requested_model) { + (Some(family), Some(model)) => vec![crate::heartbeat::HarnessModel { + family: family.to_owned(), + model: model.to_owned(), + }], + _ => Vec::new(), + }, + capabilities: required_capabilities.to_vec(), + ..crate::heartbeat::SeatCapability::default() + }; + // Money fields are placeholders and are never read: `claim_meets_capability_request` consults + // only the three request axes. Zeroes rather than plausible amounts, so nothing here can be + // mistaken for a price this function decides anything about. + let filters = AwardFilters { + offer_amount_sats: 0, + max_sats: 0, + buyer_mint: "", + allow_real_mints: false, + requested_agent: None, + requested_harness_family, + requested_model, + required_capabilities, + }; + claim_meets_capability_request(&advertised, &filters).err() +} + +/// Why the job's capability request left nothing awardable, as an operator-facing clause — or `None` +/// when the capability request is not what stood in the way (#897). +/// +/// DIAGNOSTICS ONLY. Nothing here decides an award; it explains one that already did not happen, so a +/// job that parks says what to fix instead of only that it parked. +/// +/// `None` in three distinct cases, and each would otherwise produce a misleading line: +/// - No request ⇒ nothing to explain; the obstacle was price, mint, agent or an empty relay. +/// - No live claims ⇒ nothing was refused. "No seat advertises X" would be false where the truth is +/// that nobody claimed at all, and it would send an operator to fix a request that is fine. +/// - Some live claim SATISFIED the request ⇒ capability was not the obstacle. Naming it here would +/// blame the request for a price or mint failure and hide the real one. This is the case worth +/// being strict about: it is the one where a capability-shaped reason is most plausible and most +/// wrong. +/// +/// Deliberately NOT a wire reason code. #821 adds `capability_missing` to the protocol and #859 makes +/// an undispatchable job carry that label; this is the local operator string that can land first +/// without pre-empting either, and it is what those two replace on the wire. +pub fn capability_park_reason(view: &JobView, filters: &AwardFilters) -> Option { + if filters.requested_harness_family.is_none() + && filters.requested_model.is_none() + && filters.required_capabilities.is_empty() + { + return None; + } + let mut refusals: Vec = Vec::new(); + for claim in view.claims.iter().filter(|claim| claim.live) { + match claim_meets_capability_request(&claim.capability, filters) { + // A live claim that passes means the request was satisfiable and something else refused + // this job. Bail rather than report: a partial list reads as the whole reason. + Ok(()) => return None, + Err(refusal) => { + let rendered = refusal.to_string(); + if !refusals.contains(&rendered) { + refusals.push(rendered); + } + } + } + } + if refusals.is_empty() { + return None; + } + Some(format!( + "no live claim satisfied the job's capability request ({})", + refusals.join("; ") + )) +} + /// Whether a claim may be awarded a job that asked for a specific harness. /// /// No request ⇒ every claim passes. A request ⇒ the claim must ADVERTISE that harness. A claim @@ -1160,6 +1264,28 @@ pub fn park_reason_unreadable(unanswered_reads: u32) -> String { ) } +/// Park reason for an auto-award whose offer deadline passed with nothing awardable. +/// +/// `capability` is the clause from [`capability_park_reason`], or `None` when the capability request +/// was not the obstacle. With `None` the wording is exactly what it was before capability requests +/// existed, so a job that asked for nothing parks with an unchanged row. +/// +/// The wording lives beside the decision rather than at the call site, matching +/// [`park_reason_unreadable`] — the reason a row carries and the evidence that produced it cannot +/// drift, and it was previously the one park reason spelled as a duplicated literal at its call site. +/// +/// The deadline is still stated when a capability clause is present, because both facts are true and +/// they imply different actions: the deadline says this job is over, the clause says what would have +/// to change for the next one to succeed. +pub fn park_reason_deadline_passed(capability: Option<&str>) -> String { + match capability { + Some(clause) => { + format!("offer deadline passed before an awardable claim appeared — {clause}") + } + None => "offer deadline passed before an awardable claim appeared".to_owned(), + } +} + /// Classify a reserved job for [`BuyerStore::reconcile`] from its payment progress + relay /// liveness. The payment journal is authoritative over relay liveness: a `Closed` payment is /// `Paid` regardless of whether the claim still looks live, and an ambiguous payment is KEPT @@ -1269,7 +1395,13 @@ mod tests { branch: None, job_class: None, contribution: None, + // The NEUTRAL request — a job that asks for nothing. The default on purpose: every test + // built on this fixture asserts today's award behaviour, and that behaviour must be + // byte-unchanged by #897. Tests that exercise a request set these fields explicitly. requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } @@ -3471,24 +3603,396 @@ mod tests { ); } - // The capability request is INERT, and this is the assertion that makes landing the offer-side - // plumbing impossible to do quietly. + /// An offer carrying a capability request, and the filters built from it EXACTLY as both + /// production award sites build them (`buyer/mod.rs`). + /// + /// The existing predicate tests set these fields on hand-built filters, which proves the + /// predicate decides correctly. It cannot prove the OFFER reaches the predicate — the gap #897 + /// exists to close. This builds the request the way production does, so the two tests together + /// cover the predicate and the wiring rather than the predicate twice. + fn offer_requesting( + job_id: &str, + amount: u64, + family: Option<&str>, + capabilities: &[&str], + ) -> OfferView { + OfferView { + requested_harness_family: family.map(str::to_owned), + required_capabilities: capabilities.iter().map(|token| (*token).to_owned()).collect(), + ..offer_view(job_id, amount) + } + } + + /// Mirror of the `AwardFilters` both award sites construct. Kept in one place here so a test + /// cannot accidentally exercise a request shape production never builds — and + /// `both_award_paths_read_the_capability_request_off_the_offer` is what holds this mirror honest, + /// by pinning the form the real sites use. + fn filters_from_offer<'a>(offer: &'a OfferView, max_sats: u64) -> AwardFilters<'a> { + AwardFilters { + offer_amount_sats: offer.amount_sats, + max_sats, + buyer_mint: DEFAULT_MINT_URL, + allow_real_mints: false, + requested_agent: offer.requested_agent.as_deref(), + requested_harness_family: offer.requested_harness_family.as_deref(), + requested_model: offer.requested_model.as_deref(), + required_capabilities: &offer.required_capabilities, + } + } + + // THE ACCEPTANCE TEST FOR #897, both axes through BOTH selection entry points. + // + // A request honoured on one path and dropped on the other is precisely the bypass #866 was filed + // to close, so a test that checked only `select_awardable_claim` would leave the manual path — the + // one an operator drives by hand, naming a claim — free to award what the auto path refuses. + // + // The payable claim is deliberately otherwise-perfect: live, priced at the offer amount, quoting + // the buyer's mint. Only capability separates award from refusal, so a green here cannot be a + // price or mint failure wearing a capability costume. + #[test] + fn an_offer_sourced_request_refuses_a_non_matching_claim_on_both_paths() { + let job = "a".repeat(64); + let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let claim_id = "c".repeat(64); + let mut payable = claim(&job, true, 10, &mints); + payable.capability = seat(&["codex"], &[], &["rust"]); + + // Control FIRST: the same claim, the same paths, no request. A refusal below means nothing + // unless this passes — otherwise the test proves only that the claim was unawardable. + let indifferent = offer_requesting(&job, 10, None, &[]); + let view = JobView { offer: Some(indifferent.clone()), ..view_with(&job, 10, vec![payable]) }; + assert_eq!( + select_awardable_claim(&view, &filters_from_offer(&indifferent, 10)), + Some(claim_id.clone()), + "control: with no request on the offer the claim must be awarded as before" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters_from_offer(&indifferent, 10)), + Ok(()), + "control: the manual path must also award it with no request" + ); + + // FAMILY the claim does not advertise. + let wants_family = offer_requesting(&job, 10, Some("claude-code"), &[]); + let view = JobView { offer: Some(wants_family.clone()), ..view.clone() }; + assert_eq!( + select_awardable_claim(&view, &filters_from_offer(&wants_family, 10)), + None, + "auto path: a claim failing the offer's family request must not be selected" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters_from_offer(&wants_family, 10)), + Err(NamedAwardRefused::Capability { + claim_id: claim_id.clone(), + refusal: CapabilityRefusal::HarnessFamily { requested: "claude-code".into() }, + }), + "manual path: naming the claim must NOT bypass the offer's family request" + ); + + // CAPABILITY the claim does not advertise. Asserted separately because a wiring that carried + // only the family would leave this axis silently inert while the family assertions pass. + let wants_python = offer_requesting(&job, 10, None, &["python"]); + let view = JobView { offer: Some(wants_python.clone()), ..view.clone() }; + assert_eq!( + select_awardable_claim(&view, &filters_from_offer(&wants_python, 10)), + None, + "auto path: a claim missing a required capability must not be selected" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters_from_offer(&wants_python, 10)), + Err(NamedAwardRefused::Capability { + claim_id: claim_id.clone(), + refusal: CapabilityRefusal::Capabilities { missing: vec!["python".to_owned()] }, + }), + "manual path: naming the claim must NOT bypass the offer's capability request" + ); + + // MODEL the claim does not advertise, paired with a family it DOES. The pair is the unit, so + // this is the case that separates a real model filter from one that matches on family alone. + let wants_model = OfferView { + requested_model: Some("opus".to_owned()), + ..offer_requesting(&job, 10, Some("codex"), &[]) + }; + let view = JobView { offer: Some(wants_model.clone()), ..view.clone() }; + assert_eq!( + select_awardable_claim(&view, &filters_from_offer(&wants_model, 10)), + None, + "auto path: a claim advertising the family but not the model must not be selected" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters_from_offer(&wants_model, 10)), + Err(NamedAwardRefused::Capability { + claim_id: claim_id.clone(), + refusal: CapabilityRefusal::Model { + family: "codex".into(), + requested: "opus".into(), + }, + }), + "manual path: naming the claim must NOT bypass the offer's model request" + ); + + // And a request the claim DOES satisfy still awards — otherwise the refusals above are + // equally explained by a filter that refuses everything. + let satisfied = offer_requesting(&job, 10, Some("codex"), &["rust"]); + let view = JobView { offer: Some(satisfied.clone()), ..view.clone() }; + assert_eq!( + select_awardable_claim(&view, &filters_from_offer(&satisfied, 10)), + Some(claim_id.clone()), + "a matching request must still award — the filter narrows, it does not block" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters_from_offer(&satisfied, 10)), + Ok(()), + "the manual path must award a claim that satisfies the request" + ); + } + + // THE COUPLING TEST: the post-time gate refuses EXACTLY the requests the award predicate can + // never pass — no more, no less. + // + // This is the assertion that keeps the two from rotting apart. The gate exists only to surface a + // predicate consequence earlier, so any request where the two disagree is a bug in one of them: + // a gate that refuses MORE blocks posts that would have been awarded, and a gate that refuses + // LESS is the silent park the gate was added to prevent. + // + // Both sides are computed here rather than asserted against a hand-written expectation. A table + // of "these shapes must be refused" would be a third copy of the rule and would need editing on + // the day #788 makes a bare model valid; this needs none — on that day the predicate changes and + // both sides of the comparison move together. + #[test] + fn the_post_time_gate_refuses_exactly_what_the_predicate_can_never_pass() { + let rust = vec!["rust".to_owned()]; + let shapes: Vec<(Option<&str>, Option<&str>, &[String])> = vec![ + // Satisfiable: absent, single-axis, and fully-specified requests. + (None, None, &[]), + (Some("codex"), None, &[]), + (Some("codex"), Some("gpt-5.6-sol[low]"), &[]), + (None, None, &rust), + (Some("codex"), Some("gpt-5.6-sol[low]"), &rust), + // Unsatisfiable by construction: a model with no family to pair it to (#788). + (None, Some("opus"), &[]), + (None, Some("opus"), &rust), + ]; + + for (family, model, capabilities) in shapes { + let gate = unsatisfiable_capability_request(family, model, capabilities); + + // What the predicate says about the claim that advertises EXACTLY this request. If even + // that claim is refused, no publishable claim can pass. + let perfect = seat( + &family.map(|f| vec![f]).unwrap_or_default(), + &match (family, model) { + (Some(f), Some(m)) => vec![(f, m)], + _ => Vec::new(), + }, + &capabilities.iter().map(String::as_str).collect::>(), + ); + let mut filters = filters(10, 10); + filters.requested_harness_family = family; + filters.requested_model = model; + filters.required_capabilities = capabilities; + let predicate = claim_meets_capability_request(&perfect, &filters).err(); + + assert_eq!( + gate, predicate, + "gate and predicate disagree on {family:?}/{model:?}/{capabilities:?}. The gate must \ + refuse exactly what the predicate can never pass: refusing more blocks posts that \ + would have been awarded, refusing less restores the silent park the gate exists to \ + prevent." + ); + } + + // Positive control on BOTH sides: the table above is only meaningful if it contains a shape + // that is refused and a shape that is not. Without this, a gate stuck at `None` and a + // predicate stuck at `None` would agree on every row and pass. + assert!( + unsatisfiable_capability_request(None, Some("opus"), &[]).is_some(), + "control: a model with no harness family must be refused by the gate" + ); + assert!( + unsatisfiable_capability_request(Some("codex"), Some("opus"), &[]).is_none(), + "control: a fully-specified request must NOT be refused by the gate" + ); + } + + // A request that matches no live claim PARKS with a reason naming what to fix, rather than + // awarding anyway or failing silently (#897 acceptance). + #[test] + fn a_request_matching_no_claim_parks_with_an_actionable_reason() { + let job = "a".repeat(64); + let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mut payable = claim(&job, true, 10, &mints); + payable.capability = seat(&["codex"], &[], &[]); + + let wants = offer_requesting(&job, 10, Some("claude-code"), &[]); + let view = JobView { offer: Some(wants.clone()), ..view_with(&job, 10, vec![payable]) }; + let reason = capability_park_reason(&view, &filters_from_offer(&wants, 10)) + .expect("an unsatisfiable request must produce a reason"); + assert!( + reason.contains("claude-code"), + "the reason must name the REQUEST that refused, so an operator knows what to change: \ + {reason}" + ); + + // The park row an operator actually reads states BOTH facts: the job is over, and this is + // what would have to change. Neither alone is actionable. + let row = park_reason_deadline_passed(Some(&reason)); + assert!(row.contains("offer deadline passed"), "{row}"); + assert!(row.contains("claude-code"), "{row}"); + + // And with no capability obstacle the row is EXACTLY what it was before #897 — a job that + // asked for nothing must not acquire new wording. + assert_eq!( + park_reason_deadline_passed(None), + "offer deadline passed before an awardable claim appeared" + ); + } + + // THE WIRE-LEVEL LAYER, and the reason the post-time gate is not the whole answer. // - // `post_job`'s schema tells callers that model is "not yet a hard filter" - // (`crates/maxplayer/src/mcp.rs`), and `post_job_award_filter_descriptions_match_enforcement` - // pins that STRING. But that test is a one-way ratchet pointing the safe way: it goes red when - // someone edits the DESCRIPTION and stays green when someone changes the BEHAVIOUR. The - // dangerous order — wire the offer, leave the description — would ship a false caller-facing - // claim on the money path under a passing test. + // Our posting surface refuses a model-only request before signing. A FOREIGN client is under no + // such obligation: it can publish a model-only offer straight to the relay, and our buyer will + // read it. For that offer the award-time refusal and this park row are the only truth there is. // - // So pin the behaviour at its source. Both award paths pass the request inertly today; when - // either starts reading it off the offer this goes red, and whoever does it has to come here and - // find the description they also owe. + // So this is deliberately NOT reachable through `post_job` — it constructs the offer the way the + // relay would hand it to us. A test that went through our own posting path could not express this + // case at all, because the gate would refuse it first, and the layer would look tested when the + // only tested thing was the gate. #[test] - fn both_award_paths_pass_the_capability_request_inertly() { + fn a_foreign_model_only_offer_is_refused_at_award_and_parks_saying_why() { + let job = "a".repeat(64); + let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let claim_id = "c".repeat(64); + // A seat that genuinely advertises the model, under a family. Even this claim must be refused: + // the defect is in the REQUEST, and a claim-blaming refusal would send an operator to fix a + // seat that is doing everything right. + let mut advertising = claim(&job, true, 10, &mints); + advertising.capability = seat(&["codex"], &[("codex", "opus")], &[]); + + let model_only = offer_requesting(&job, 10, None, &[]); + let model_only = OfferView { requested_model: Some("opus".to_owned()), ..model_only }; + let view = JobView { + offer: Some(model_only.clone()), + ..view_with(&job, 10, vec![advertising]) + }; + let filters = filters_from_offer(&model_only, 10); + + assert_eq!( + select_awardable_claim(&view, &filters), + None, + "auto path: a model with no family must refuse, not be ignored (#788)" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters), + Err(NamedAwardRefused::Capability { + claim_id: claim_id.clone(), + refusal: CapabilityRefusal::ModelWithoutHarnessFamily { requested: "opus".into() }, + }), + "manual path: naming the claim must not bypass the malformed request either" + ); + + let reason = capability_park_reason(&view, &filters) + .expect("a model-only request must produce a park reason"); + assert!( + reason.contains("without a harness family"), + "the row must name the REQUEST defect, not blame the claim — an operator reading it has \ + to know to add a family rather than to go find another seat: {reason}" + ); + } + + // The park reason must stay SILENT unless the capability request is genuinely the obstacle. + // This is the half that makes the reason worth trusting: a clause that appears whenever a request + // is present would blame the request for every price and mint failure, and it would be most + // convincing exactly when it was wrong. + #[test] + fn the_park_reason_declines_to_blame_capability_when_it_was_not_the_obstacle() { + let job = "a".repeat(64); + let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let mut matching = claim(&job, true, 10, &mints); + matching.capability = seat(&["codex"], &[], &[]); + + // No request ⇒ nothing to explain. + let indifferent = offer_requesting(&job, 10, None, &[]); + let view = JobView { + offer: Some(indifferent.clone()), + ..view_with(&job, 10, vec![matching.clone()]) + }; + assert_eq!(capability_park_reason(&view, &filters_from_offer(&indifferent, 10)), None); + + // A request that SOME live claim satisfies ⇒ capability was not the obstacle. The claim here + // matches the request, so whatever stopped the award was price, mint or budget. + let satisfied = offer_requesting(&job, 10, Some("codex"), &[]); + let view = JobView { offer: Some(satisfied.clone()), ..view.clone() }; + assert_eq!( + capability_park_reason(&view, &filters_from_offer(&satisfied, 10)), + None, + "a satisfied request must never appear in a park reason" + ); + + // No LIVE claims ⇒ nothing was refused. "No seat advertises X" would be false where the truth + // is that nobody claimed, and it would send an operator to fix a request that is fine. + let wants = offer_requesting(&job, 10, Some("claude-code"), &[]); + let dead = claim(&job, false, 10, &mints); + let view = JobView { offer: Some(wants.clone()), ..view_with(&job, 10, vec![dead]) }; + assert_eq!( + capability_park_reason(&view, &filters_from_offer(&wants, 10)), + None, + "an empty relay is not a capability failure" + ); + } + + // #897 opens a NEW filter surface — the offer's request params — and the display-only fields must + // not reach it. `hardware_is_unreachable_from_the_filterable_surface` guards the seat's + // ADVERTISEMENT; nothing guarded the REQUEST side, because until now there was no request. + // + // Asserted against the filter struct itself rather than against a list of param names: a test + // naming params would keep passing if a display-only axis were added straight to `AwardFilters`, + // which is the one place a filter can actually read. + #[test] + fn display_only_fields_never_reach_the_award_filter() { + let filter_surface = include_str!("lifecycle.rs"); + let declaration = filter_surface + .split_once("pub struct AwardFilters<'a> {") + .expect("AwardFilters declaration") + .1 + .split_once("\n}") + .expect("end of AwardFilters declaration") + .0; + + for display_only in [ + crate::heartbeat::HARNESS_VARIANT_TAG, + crate::heartbeat::HARDWARE_TAG, + ] { + assert!( + !declaration.contains(display_only), + "{display_only} is display-only and must NEVER be filterable \ + (docs/protocol-v1.md 4.5.1) — it is operator-declared free text that nothing can \ + contradict, so filtering on it would award money on an unfalsifiable claim. It \ + appears in the AwardFilters declaration." + ); + } + + // Positive control: the assertion above can only mean something if this substring search + // finds a field that IS there. Without it, a mis-derived `declaration` slice would pass + // every assertion above while proving nothing. + assert!( + declaration.contains("required_capabilities"), + "control: the filterable capability field must be found in the slice being searched" + ); + } + + // ALL THREE axes of the capability request are read OFF THE SIGNED OFFER, at BOTH award sites + // (#897, replacing the all-inert tripwire #866 left behind). + // + // The property is not "the fields are populated" but "they come from the OFFER". A site reading + // any of them from award params, config, or a local would let the request a buyer is held to + // differ from the one it signed and published — and that is the whole reason the filter reads the + // relay rather than its own inputs. + #[test] + fn both_award_paths_read_the_capability_request_off_the_offer() { let award_paths = include_str!("mod.rs"); - // Occurrences on LIVE lines only. A commented-out `requested_harness_family: None` holds a - // whole-file count at 2 while the live code is wired — a comment manufacturing the very + // Occurrences on LIVE lines only. A commented-out field name holds a whole-file count at the + // expected value while the live code says something else — a comment manufacturing the very // occurrence that answers the probe. Line comments are the only comment form in this file. let live = |needle: &str| { award_paths @@ -3498,55 +4002,86 @@ mod tests { .sum::() }; - let inert = live("requested_harness_family: None"); - assert_eq!( - inert, 2, - "expected exactly 2 inert capability requests in buyer/mod.rs (manual award and \ - drive_auto_award), found {inert}.\n\ - IF THIS WENT RED BECAUSE YOU WIRED THE OFFER: that is the intended change, and it owes \ - two edits IN THE SAME COMMIT — the `model` property description at \ - crates/maxplayer/src/mcp.rs:219 (\"not yet a hard filter\") and the post_job tool \ - description at :201 (\"model is a recorded auto-award preference\"). Both are \ - caller-facing promises about a money path and both become false the moment a model \ - request reaches this predicate. Editing THIS assertion instead is the cheap repair and \ - the wrong one: it is the only thing standing between that change and a silently false \ - schema." - ); - assert_eq!( - live("required_capabilities: &[]"), - inert, - "every inert harness-family request must carry an inert capability list with it — a \ - half-wired request filters on one axis while the schema disclaims both" - ); - // The MODEL axis, which is what both descriptions named above are actually about. A wiring - // that touches model ALONE leaves the two counts above at 2, so only this assertion stands - // between that change and a green tripwire — and the consequence is worse than a missed - // filter: `a_model_request_without_a_harness_family_is_refused_not_ignored` pins that a - // model request carrying no family refuses EVERY claim, so model-only wiring stops awards - // entirely rather than narrowing them. - assert_eq!( - live("requested_model: None"), - inert, - "the model axis must be inert alongside the other two — a model request with no harness \ - family is REFUSED, not ignored (see \ - a_model_request_without_a_harness_family_is_refused_not_ignored), so wiring model alone \ - stops every award instead of filtering one" - ); - // Each axis is pinned by two counts: the inert form above, and the total here. The totals - // are what catch a wiring that ADDS a live line instead of editing one — a `Some(..)` - // sitting beside a surviving `None` satisfies the counts above and reads as inert. - for field in [ - "requested_harness_family:", - "required_capabilities:", - "requested_model:", + // The two production award sites: the manual `award_claim` RPC and `drive_auto_award`. + const AWARD_SITES: usize = 2; + + // Every axis, spelled as the offer-sourced form. Enumerated as a table rather than as three + // hand-written assertions so that adding a fourth request field to `AwardFilters` and wiring + // it is the only way to satisfy this — a new axis left inert has to be added here to pass, + // which is the moment its author reads what that costs. + // `filters_from_offer` in THIS file is a hand-written mirror of the production wiring, and + // every offer-sourced test below is built on it. A mirror that drifts makes those tests prove + // less than they appear to while still passing — which is not hypothetical: this mirror + // shipped with `requested_model: None` for one revision, and two model tests silently + // asserted that a model request awards the claim it should have refused. They went red only + // because the model cases were written; nothing structural would have caught it. + let mirror = include_str!("lifecycle.rs"); + + for (axis, wired_form) in [ + ("harness family", "requested_harness_family: offer.requested_harness_family.as_deref()"), + ("model", "requested_model: offer.requested_model.as_deref()"), + ("capabilities", "required_capabilities: &offer.required_capabilities"), ] { - let total = live(field); + let wired = live(wired_form); assert_eq!( - total, inert, - "every live `{field}` in buyer/mod.rs must be the inert form; found {total} live \ - mentions against {inert} inert ones, so at least one carries a real request" + wired, AWARD_SITES, + "expected both award sites in buyer/mod.rs to read the {axis} request off the \ + SIGNED OFFER, found {wired}.\n\ + A request honoured on one path and dropped on the other is exactly the bypass #866 \ + was filed to close, and naming a claim chooses WHICH claim is judged, never WHETHER \ + it is." + ); + assert!( + mirror.contains(wired_form), + "the test mirror `filters_from_offer` does not spell the {axis} axis the way both \ + production award sites do (`{wired_form}`). Every offer-sourced test is built on \ + that mirror, so a drifted mirror means those tests exercise a request shape \ + production never builds — and they keep passing while doing it." ); } + + // No axis may be inert. Kept because it fails on the OPPOSITE mistake from the table above: + // the table catches an axis that was never wired, this catches one that was wired and then + // quietly turned off. + // + // Scoped to the axis names — NOT a bare `: None` search. `buyer/mod.rs` is full of unrelated + // `Option` fields set to None, so a bare search reports a number that has nothing to do with + // this property, and a needle that answers a question you did not ask is worse than none. + for axis in [ + "requested_harness_family: None", + "requested_model: None", + "required_capabilities: &[]", + ] { + let inert = live(axis); + assert_eq!( + inert, 0, + "found {inert} inert `{axis}` in buyer/mod.rs. An axis passed inertly is a filter \ + the caller was told exists and that refuses nothing — the exact state #897 was \ + filed to end. If an axis genuinely must be disabled, the caller-facing description \ + in crates/maxplayer/src/mcp.rs owes the same edit IN THE SAME COMMIT, because it \ + promises callers a hard filter on a money path." + ); + } + + // The counts above say the two sites we know about are wired. This says there are only two. + // Without it, a THIRD award site could construct its own filters — inertly, or from award + // params — and every assertion above would still pass, because they count occurrences of the + // correct form rather than bounding the total. + // + // Counted on `AwardFilters {` rather than on the field names, deliberately. The field names + // are NOT a usable denominator here: `requested_harness_family:` legitimately appears a third + // time in `post_job`, where it fills a `PostJobRequest` on the POSTING path. A total over the + // field name conflates the two concerns and goes red for a change that is none of this test's + // business — and a duplicate field inside one literal is already a compile error, so the + // field-name total was never buying anything the specific forms above do not. + let award_sites = live("AwardFilters {"); + assert_eq!( + award_sites, AWARD_SITES, + "expected exactly {AWARD_SITES} AwardFilters constructions in buyer/mod.rs (the manual \ + award RPC and drive_auto_award), found {award_sites}. A new award site must read the \ + capability request off the offer exactly as those two do — a request honoured on some \ + paths and dropped on others is the bypass #866 was filed to close." + ); } // The wire-in, asserted separately from the predicate: `select_awardable_claim` must CONSULT it. diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index e0e9b19fa..30e1aa8e7 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -427,13 +427,27 @@ struct PostJobParams { /// never auto-awards a claim it cannot pay or priced above this. #[serde(default)] max_sats: Option, - /// 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_family` — a model + /// with no family refuses every claim rather than being ignored (#788). #[serde(default)] harness: Option, #[serde(default)] model: Option, + /// 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 binds dispatch where a preset binds a + /// configuration. Both may be given and both are then enforced. + #[serde(default)] + harness_family: Option, + /// 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>, } /// Resolve the offer kind from the contribution pins: all four present ⇒ contribution; none ⇒ @@ -490,6 +504,12 @@ async fn post_job(context: &Arc, 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) => { @@ -801,13 +821,16 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { 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: &[], + // #897 capability request, read from the SIGNED OFFER on the relay — never from award + // params, so the request cannot be changed after the fact. Absent ⇒ passes every + // claim, so a buyer that asks for nothing sees the behaviour it saw before. + // + // These two lines must stay identical to the auto path in `drive_auto_award`. A + // request honoured on one path and dropped on the other is the bypass #866 was filed + // to close, and naming a claim chooses WHICH claim is judged, never WHETHER it is. + requested_harness_family: offer.requested_harness_family.as_deref(), + requested_model: offer.requested_model.as_deref(), + required_capabilities: &offer.required_capabilities, }; // Manual award names the claim but applies the SAME hard filters as auto-award — @@ -1290,6 +1313,26 @@ async fn drive_auto_award( return Ok(()); }; unconfirmed_reads = 0; + 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(), + // #897 capability request, read from the SIGNED OFFER — the SAME two fields the manual + // path reads above, in the same order. Both selection paths call + // `claim_meets_capability_request`: `select_awardable_claim` here, + // `named_claim_awardable` on the manual path, and a test holds the refusal property from + // BOTH entry points so this cannot quietly become false the way its predecessor did. + requested_harness_family: offer.requested_harness_family.as_deref(), + requested_model: offer.requested_model.as_deref(), + required_capabilities: &offer.required_capabilities, + }; + + // 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 @@ -1297,33 +1340,14 @@ async fn drive_auto_award( 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; } diff --git a/crates/maxplayer-core/src/capability.rs b/crates/maxplayer-core/src/capability.rs index 95c783c48..d0cac01e8 100644 --- a/crates/maxplayer-core/src/capability.rs +++ b/crates/maxplayer-core/src/capability.rs @@ -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::*; diff --git a/crates/maxplayer-core/src/gateway.rs b/crates/maxplayer-core/src/gateway.rs index bd749dfde..51905ef26 100644 --- a/crates/maxplayer-core/src/gateway.rs +++ b/crates/maxplayer-core/src/gateway.rs @@ -79,6 +79,16 @@ pub struct OfferDraft { /// The harness this job asks for, as `["param", "agent", …]`. `None` (or `"any"`) ⇒ no /// preference: any seller may claim and run it on whichever harness it prefers. pub requested_agent: Option, + /// The harness FAMILY this job asks for (#897), as + /// `["param", "harness_family", …]`. `None` ⇒ no preference. + pub requested_harness_family: Option, + /// The model this job asks for (#897), as `["param", "harness_model", …]`. `None` ⇒ no + /// preference. Only meaningful paired with a family; a model alone refuses every claim (#788). + pub requested_model: Option, + /// Capability tokens this job REQUIRES (#897), as `["param", "capability", …]`. Empty ⇒ no + /// requirement, and no tag is emitted, so an offer that requires nothing stays byte-identical to + /// one posted before capability requests existed. + pub required_capabilities: Vec, } impl OfferDraft { @@ -96,6 +106,9 @@ impl OfferDraft { deadline_unix, seller_pubkey: Some(seller_pubkey.into()), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } @@ -112,6 +125,9 @@ impl OfferDraft { deadline_unix, seller_pubkey: None, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } @@ -122,6 +138,49 @@ impl OfferDraft { self } + /// Request a harness family, a model, and/or a set of capability tokens for this job (#897). + /// + /// All three axes take ONE builder because they are ONE request: the model is only meaningful + /// paired with a family (#788), so an API that let a caller set them independently would make the + /// invalid pairing the easy thing to write. They travel together and are judged together. + /// + /// Blank and all-whitespace values state nothing and are dropped, so "no requirement" has one + /// representation on the wire — the same "stated or absent" contract the seat-side readers apply + /// (`docs/protocol-v1.md` §4.5.2). Tokens are de-duplicated for the same reason: two spellings of + /// one requirement would put a set on the wire that no seat's advertisement is shaped like. + /// + /// Vocabulary is NOT checked here, and the family/model PAIRING is not enforced here either. This + /// builds what it is told to build; the vocabulary gate is + /// [`crate::capability::validate_capability_request`], run by the posting path before an event is + /// signed, and the pairing rule is the award predicate's — it refuses a model with no family + /// rather than ignoring it, which is the fail-closed backstop that also covers offers this code + /// never built. + pub fn requiring_capability( + mut self, + requested_harness_family: Option<&str>, + requested_model: Option<&str>, + required_capabilities: &[String], + ) -> Self { + self.requested_harness_family = requested_harness_family + .map(str::trim) + .filter(|family| !family.is_empty()) + .map(str::to_owned); + self.requested_model = requested_model + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_owned); + let mut tokens: Vec = Vec::new(); + for token in required_capabilities { + let stated = token.trim(); + if stated.is_empty() || tokens.iter().any(|kept| kept == stated) { + continue; + } + tokens.push(stated.to_owned()); + } + self.required_capabilities = tokens; + self + } + pub fn to_event_draft(&self) -> EventDraft { // The offer does not name a mint — the seller authors the accepted mint(s) in its claim // `creq`, so there is no `["mint", …]` tag here. @@ -138,6 +197,29 @@ impl OfferDraft { requested_agent, ])); } + // #897 capability request. Both arms are conditional, so an offer that requests nothing emits + // no tag and is byte-identical to one posted before this existed — filtering is opt-in per + // offer, and that identity is what makes it opt-in on the wire rather than only in the + // predicate. + if let Some(requested_harness_family) = &self.requested_harness_family { + tags.push(TagSpec::new([ + "param", + crate::heartbeat::HARNESS_FAMILY_PARAM, + requested_harness_family, + ])); + } + if let Some(requested_model) = &self.requested_model { + tags.push(TagSpec::new([ + "param", + crate::heartbeat::HARNESS_MODEL_PARAM, + requested_model, + ])); + } + if !self.required_capabilities.is_empty() { + let mut values = vec!["param".to_owned(), crate::heartbeat::CAPABILITY_PARAM.to_owned()]; + values.extend(self.required_capabilities.iter().cloned()); + tags.push(TagSpec(values)); + } if let Some(seller_pubkey) = &self.seller_pubkey { tags.push(TagSpec::new(["p", seller_pubkey])); } @@ -159,6 +241,13 @@ pub struct ParsedOffer { /// The harness this job requested, canonicalised. `None` ⇒ no preference (the parameter was /// absent, blank, or the explicit `any`). pub requested_agent: Option, + /// The harness FAMILY this job requested (#897). `None` ⇒ no preference (absent or blank). + pub requested_harness_family: Option, + /// The model this job requested (#897). `None` ⇒ no preference. Refused rather than ignored when + /// it arrives without a family (#788). + pub requested_model: Option, + /// Capability tokens this job requires (#897). Empty ⇒ no requirement. + pub required_capabilities: Vec, } impl ParsedOffer { @@ -350,6 +439,20 @@ pub fn parse_offer(event: &EventDraft) -> Result { &event.tags, crate::seller_agents::AGENT_PARAM, )), + // #897. Trimmed to the same "stated or absent" contract the seat-side readers apply, so a + // padded `" codex "` cannot become a request no seat's advertisement can equal. The + // vocabulary is NOT enforced here: an out-of-vocabulary value is unmatchable by construction + // and refusing the whole offer at parse would make one bad param hide an otherwise readable + // offer from every reader, including the ones that only want its price. + requested_harness_family: stated(param_value( + &event.tags, + crate::heartbeat::HARNESS_FAMILY_PARAM, + )), + requested_model: stated(param_value( + &event.tags, + crate::heartbeat::HARNESS_MODEL_PARAM, + )), + required_capabilities: param_values(&event.tags, crate::heartbeat::CAPABILITY_PARAM), }) } @@ -364,6 +467,45 @@ fn param_value<'a>(tags: &'a [TagSpec], name: &str) -> Option<&'a str> { .map(String::as_str) } +/// One wire value normalized to the "stated or absent" contract (`docs/protocol-v1.md` §4.5.2): +/// trimmed, and absent when nothing survives. +/// +/// The emitters here already trim, so this only matters for tags written by someone else — which is +/// every tag a reader ever sees. An all-whitespace value read raw would become a request no operator +/// typed and no seat can match, and for the filterable fields it decides awards. +fn stated(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|stated| !stated.is_empty()) + .map(str::to_owned) +} + +/// Read a multi-value `["param", , , , …]` parameter off an event's tags. +/// +/// Values from index 2 onward, [`stated`]-normalized and de-duplicated. Absent ⇒ empty, which never +/// constrains anything: an empty requirement passes every claim. +/// +/// Takes the FIRST matching tag only, matching [`param_value`]. A second `["param", , …]` tag +/// is therefore ignored rather than merged — deliberately, because merging would let a writer grow a +/// buyer's requirement set across tags in a shape no emitter here produces, and the offer is signed: +/// the conservative read of an ambiguous request is the one the buyer can be shown. +fn param_values(tags: &[TagSpec], name: &str) -> Vec { + let Some(tag) = tags.iter().find(|tag| { + tag.0.first().map(String::as_str) == Some("param") + && tag.0.get(1).map(String::as_str) == Some(name) + }) else { + return Vec::new(); + }; + let mut values: Vec = Vec::new(); + for value in tag.0.iter().skip(2) { + let Some(stated) = stated(Some(value)) else { continue }; + if !values.contains(&stated) { + values.push(stated); + } + } + values +} + /// Parses the buyer-visible git delivery fields carried by a result event. pub fn parse_git_result_delivery(event: &EventDraft) -> Result { if event.kind != JOB_RESULT_KIND { @@ -1041,6 +1183,111 @@ mod tests { } } + // #897 — the capability request survives the wire: draft → tags → parse, both axes. + // + // A request is only worth anything if the value the AWARD FILTER reads equals the value the + // buyer posted, so this asserts the parsed values and not just the tag shapes: a correct tag + // read back wrong is the same outcome as no tag at all, and the tag assertion alone cannot + // see it. + #[test] + fn offer_carries_the_capability_request_across_the_wire() { + let asking = OfferDraft::untargeted("t", "text/plain", 5, 1_800_000_001) + .requiring_capability( + Some("codex"), + Some("gpt-5.6-sol[low]"), + &["rust".to_owned(), "node".to_owned()], + ); + let draft = asking.to_event_draft(); + + let family = draft + .tags + .iter() + .find(|tag| tag.first() == Some("param") && tag.0.get(1).map(String::as_str) == Some("harness_family")) + .expect("offer carries the harness family param"); + assert_eq!(family.0, vec!["param", "harness_family", "codex"]); + + // The model value is whatever the harness reported, verbatim — bracket and dot included. A + // request is matched against the advertisement by exact equality, so any normalisation here + // would silently stop matching the seats that advertise these ids. + let model = draft + .tags + .iter() + .find(|tag| tag.first() == Some("param") && tag.0.get(1).map(String::as_str) == Some("harness_model")) + .expect("offer carries the harness model param"); + assert_eq!(model.0, vec!["param", "harness_model", "gpt-5.6-sol[low]"]); + + // ONE multi-value tag, not one tag per token: the readers take the first matching tag, so a + // second would be silently dropped and the buyer filtered on a subset of its own request. + let capability: Vec<_> = draft + .tags + .iter() + .filter(|tag| tag.first() == Some("param") && tag.0.get(1).map(String::as_str) == Some("capability")) + .collect(); + assert_eq!(capability.len(), 1, "the capability request is ONE multi-value tag"); + assert_eq!(capability[0].0, vec!["param", "capability", "rust", "node"]); + + let parsed = parse_offer(&draft).expect("parse"); + assert_eq!(parsed.requested_harness_family.as_deref(), Some("codex")); + assert_eq!(parsed.requested_model.as_deref(), Some("gpt-5.6-sol[low]")); + assert_eq!(parsed.required_capabilities, vec!["rust", "node"]); + } + + // #897 — a job that asks for nothing posts the offer it always did, BYTE-IDENTICAL. + // + // Filtering is opt-in per offer, and this is what makes it opt-in on the WIRE rather than only + // inside the predicate. The empty and whitespace forms are covered together because they must + // reach the same place: "no requirement" has exactly one representation, so a padded value + // cannot become a request no seat can ever match. + #[test] + fn an_absent_capability_request_posts_a_byte_identical_offer() { + let plain = OfferDraft::untargeted("t", "text/plain", 5, 1_800_000_001); + for (family, model, capabilities) in [ + (None, None, Vec::new()), + (Some(""), Some(""), Vec::new()), + (Some(" "), Some("\t"), vec!["".to_owned(), " ".to_owned()]), + ] { + let asking = plain.clone().requiring_capability(family, model, &capabilities); + assert_eq!( + asking.to_event_draft(), + plain.to_event_draft(), + "{family:?}/{model:?}/{capabilities:?} must post the same offer as no request at all" + ); + let parsed = parse_offer(&asking.to_event_draft()).expect("parse"); + assert_eq!(parsed.requested_harness_family, None); + assert_eq!(parsed.requested_model, None); + assert!(parsed.required_capabilities.is_empty()); + } + } + + // #897 — readers normalize what SOMEONE ELSE wrote. Our own emitters already trim, so this is + // the only case that matters: a padded value read raw becomes a request no operator typed and + // no seat can match, and for the filterable fields it decides awards. + #[test] + fn the_capability_request_reader_normalizes_a_hand_written_offer() { + let mut draft = OfferDraft::untargeted("t", "text/plain", 5, 1_800_000_001).to_event_draft(); + draft.tags.push(TagSpec::new(["param", "harness_family", " codex "])); + draft.tags.push(TagSpec(vec![ + "param".to_owned(), + "capability".to_owned(), + " rust ".to_owned(), + " ".to_owned(), + "rust".to_owned(), + "node".to_owned(), + ])); + + let parsed = parse_offer(&draft).expect("parse"); + assert_eq!( + parsed.requested_harness_family.as_deref(), + Some("codex"), + "a padded family must equal the family a seat advertises" + ); + assert_eq!( + parsed.required_capabilities, + vec!["rust", "node"], + "blank values state nothing and a repeated token is one requirement" + ); + } + // TOOTH — a claim advertises the harnesses its seller can run, in order; a seller that states // none emits a byte-identical pre-registry claim rather than an empty tag. #[test] @@ -1142,6 +1389,9 @@ mod tests { deadline_unix: 1_800_000_001, seller_pubkey: Some(SELLER.into()), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } ); } diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index 8de232c44..f920d333c 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -65,6 +65,20 @@ pub const ACCEPTED_MINTS_TAG: &str = "accepted_mints"; /// [`crate::agent_presets::HARNESS_FAMILIES`]; a preset with no family contributes nothing. pub const HARNESS_FAMILY_TAG: &str = "harness_family"; +/// Offer param naming the harness family a job REQUESTS (#897): +/// `["param", "harness_family", ""]`, single-value, matched against +/// [`HARNESS_FAMILY_TAG`]. +/// +/// Same word as the advertisement deliberately: the buyer's request and the seat's claim are +/// compared by exact equality, so spelling them from one constant is what keeps the two sides in one +/// vocabulary. A request param that drifted from the advertisement tag would filter on a word no +/// seat can say, and nothing would flag it. +/// +/// Distinct from [`crate::seller_agents::AGENT_PARAM`], which names a PRESET: a family spans the +/// presets sharing a harness, so a family request binds dispatch where a preset name binds a +/// configuration. Both may be present and both are then enforced. +pub const HARNESS_FAMILY_PARAM: &str = "harness_family"; + /// Wire tag pairing ONE serving harness to the model it LAST REPORTED (#784): /// `["harness_model", "", ""]`, REPEATED once per serving harness. /// @@ -136,6 +150,21 @@ pub const HARNESS_FAMILY_TAG: &str = "harness_family"; /// that would close the gap properly. pub const HARNESS_MODEL_TAG: &str = "harness_model"; +/// Offer param naming the model a job REQUESTS (#897): +/// `["param", "harness_model", ""]`, single-value, matched against [`HARNESS_MODEL_TAG`]. +/// +/// ⚠ ONLY MEANINGFUL PAIRED WITH [`HARNESS_FAMILY_PARAM`], and a model arriving without one refuses +/// every claim rather than being ignored — the PAIR is the unit, because a bare model on a +/// multi-harness seat does not say which harness would run it (#788). That is a v1 restriction with a +/// mechanism behind it: the harness filter binds delivery because the seller enforces it +/// exact-or-nothing at dispatch, and a model phrased as a refinement of a harness request inherits +/// that chain where a model-only request would inherit none of it. +/// +/// ⚠ And it filters on a LAST-OBSERVED self-report, never a promise: see [`HARNESS_MODEL_TAG`]. A +/// model request narrows who is CONSIDERED; it does not pin what executes. #785 carries the +/// selection work that would make it a commitment. +pub const HARNESS_MODEL_PARAM: &str = "harness_model"; + /// Wire tag carrying the seat's harness VARIANT (#784) — fork/config colour, free text, single value. /// /// DISPLAY ONLY. It is deliberately absent from #784's filterable set {family, model, capabilities}, @@ -204,6 +233,19 @@ pub const HARNESS_VARIANT_TAG: &str = "harness_variant"; /// rather than at the filter. Bounding the probe on a cadence is #891. pub const CAPABILITIES_TAG: &str = "capabilities"; +/// Offer param naming the capability tokens a job REQUIRES (#897): +/// `["param", "capability", "", …]`, multi-value, matched against [`CAPABILITIES_TAG`]. +/// +/// Singular against the plural advertisement, exactly as [`crate::seller_agents::AGENT_PARAM`] is +/// singular against [`crate::seller_agents::AGENT_TAG`] — the request names one requirement at a +/// time, the advertisement names a set. +/// +/// ONE multi-value tag, never repeated single-value tags. The readers here take the FIRST matching +/// tag, so a second `["param", "capability", …]` would be silently dropped and a buyer would be +/// filtered on a subset of what it asked for — the failure direction that awards work nobody +/// verified was requestable. +pub const CAPABILITY_PARAM: &str = "capability"; + /// Wire tag carrying operator colour about the machine (#784) — e.g. "mac studio, 64GB". Free text, /// single value. /// diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 293f7a1e6..2eb39efe9 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -75,6 +75,20 @@ pub struct PostJobRequest { /// preference, and the offer is byte-identical to one posted before harness selection existed. /// A requested harness narrows the market: only sellers advertising it may be awarded. pub requested_agent: Option, + /// Ask for a harness FAMILY (#897). `None` ⇒ no preference. Enforced as a hard award filter on + /// both award paths, so only a seller advertising that family may be awarded. + pub requested_harness_family: Option, + /// Ask for a MODEL (#897). `None` ⇒ no preference. Enforced as a hard award filter on both award + /// paths, matched against the family/model PAIR a seat advertises. + /// + /// ⚠ Requires `requested_harness_family`: a model with no family refuses every claim (#788), so + /// posting one alone stops awards rather than narrowing them. + pub requested_model: Option, + /// Capability tokens the job REQUIRES (#897). Empty ⇒ no requirement, and the offer is + /// byte-identical to one posted before capability requests existed. Every token must be in + /// [`crate::capability::CAPABILITIES`]; the posting path refuses the request otherwise, before + /// any event is signed. + pub required_capabilities: Vec, } /// Job class of a posted offer. Making this an enum (rather than an all-or-nothing cluster of @@ -216,6 +230,22 @@ pub struct OfferView { /// parameters, so the request cannot be changed after the fact. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_agent: Option, + /// The harness FAMILY this job requested (`["param", "harness_family", …]`), #897. `None` ⇒ any. + /// Read from the signed offer for the same reason `requested_agent` is: the request a buyer is + /// held to must be the one it published, not one supplied at award time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_harness_family: Option, + /// The model this job requested (`["param", "harness_model", …]`), #897. `None` ⇒ any. + /// + /// ⚠ Only meaningful PAIRED with `requested_harness_family`: a model with no family is REFUSED + /// rather than ignored (#788), so it stops awards instead of narrowing them. And it matches a + /// seat's LAST-OBSERVED self-report, so it narrows who is considered without pinning what runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_model: Option, + /// Capability tokens this job requires (`["param", "capability", …]`), #897. Empty ⇒ none, and + /// every claim passes this filter unchanged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required_capabilities: Vec, } /// Serializable view of a well-formed contribution offer's pins. @@ -656,7 +686,45 @@ fn build_offer_draft( })?, ) } - .requesting_agent(request.requested_agent.as_deref()); + .requesting_agent(request.requested_agent.as_deref()) + .requiring_capability( + request.requested_harness_family.as_deref(), + request.requested_model.as_deref(), + &request.required_capabilities, + ); + + // #897 — TWO GATES, on the NORMALIZED request rather than the raw one. Validating what was handed + // in would refuse a padded `" rust "` for a defect the builder just fixed, and would pass a value + // the builder had dropped. What reaches the wire is what must be judged. + // + // WHY GATE AT POST AT ALL: posting commits. `post_job` arms the auto-award and puts a signed offer + // on the relay with its deadline running, so a request nothing can satisfy converts a caller's + // mistake into a committed offer plus a guaranteed park. Post time is the cheapest moment the + // mistake can surface, and refusing here costs the caller nothing. + // + // The division of labour matters and is not a duplication: + // · The VOCABULARY gate knows the closed lists — a family or token no seat can ever advertise. + // That is a fact about the vocabularies, not about matching. + // · The SATISFIABILITY gate owns nothing: it asks the AWARD PREDICATE whether the perfect claim + // would pass. The predicate keeps sole ownership of matching semantics; this only surfaces a + // consequence of them earlier. + // + // Neither is the enforcement boundary — a foreign client can publish either shape straight to the + // relay, where the award-time refusal is what holds. These make our own surface fail fast. + crate::capability::validate_capability_request( + offer.requested_harness_family.as_deref(), + &offer.required_capabilities, + ) + .map_err(|defect| JobLifecycleError::Input(format!("post_job refused: {defect}")))?; + if let Some(refusal) = crate::buyer::lifecycle::unsatisfiable_capability_request( + offer.requested_harness_family.as_deref(), + offer.requested_model.as_deref(), + &offer.required_capabilities, + ) { + return Err(JobLifecycleError::Input(format!( + "post_job refused: no seat could ever satisfy this request — {refusal}" + ))); + } let mut draft = offer.to_event_draft(); if let (Some(repo), Some(branch)) = (&request.repo, &request.branch) { @@ -2577,6 +2645,14 @@ pub(crate) async fn fetch_job_view_async( .map(str::to_owned), contribution: contribution_offer_view(&draft.tags), requested_agent: parsed.as_ref().and_then(|p| p.requested_agent.clone()), + requested_harness_family: parsed + .as_ref() + .and_then(|p| p.requested_harness_family.clone()), + requested_model: parsed.as_ref().and_then(|p| p.requested_model.clone()), + required_capabilities: parsed + .as_ref() + .map(|p| p.required_capabilities.clone()) + .unwrap_or_default(), } }); @@ -4401,6 +4477,9 @@ mod tests { branch: None, job: JobKind::FromScratch, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }, ) .expect_err("seller required"); @@ -4738,6 +4817,9 @@ mod tests { branch: None, job: JobKind::FromScratch, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }, ) .expect_err("must refuse nested block_on"); @@ -4829,6 +4911,9 @@ mod tests { accepts: vec!["fork".into()], }), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } @@ -5022,6 +5107,9 @@ mod tests { branch: None, job: JobKind::Contribution(contribution_spec(owner, url, branch, oid, accepts)), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } @@ -5074,6 +5162,131 @@ mod tests { assert_eq!(bind.base_oid, base_oid); } + /// A from-scratch post request carrying a capability request, for the gate tests below. + fn post_request_requesting( + family: Option<&str>, + model: Option<&str>, + capabilities: &[&str], + ) -> PostJobRequest { + PostJobRequest { + task: "t".into(), + output: "text/plain".into(), + amount_sats: 3, + seller_pubkey: Some("bb".repeat(32)), + untargeted: false, + deadline_unix: Some(10), + repo: None, + branch: None, + job: JobKind::FromScratch, + requested_agent: None, + requested_harness_family: family.map(str::to_owned), + requested_model: model.map(str::to_owned), + required_capabilities: capabilities.iter().map(|t| (*t).to_owned()).collect(), + } + } + + // #897 — the post path REFUSES a request no seat could ever satisfy, before any event is built. + // + // Posting commits: it arms the auto-award and puts a signed offer on the relay with its deadline + // running. So an unsatisfiable request is not merely useless, it converts a caller's typo into a + // committed offer and a guaranteed park. Post time is the cheapest moment it can surface. + // + // Asserted on the ERROR, and separately on the draft NOT being built: a gate that returned the + // error after emitting the event would satisfy an error-only assertion. + #[test] + fn post_job_refuses_a_request_no_seat_could_satisfy() { + // Out-of-vocabulary family: no seat can advertise it, because families reach the wire only + // through `harness_family_for_preset`. + let error = build_offer_draft(&post_request_requesting(Some("gpt-cli"), None, &[]), 10, None) + .expect_err("an unknown harness family must be refused"); + assert!( + error.to_string().contains("gpt-cli") && error.to_string().contains("not a known family"), + "the error must name the value AND the vocabulary, so a caller can fix it without \ + reading our source: {error}" + ); + assert!( + error.to_string().contains("codex"), + "the error must list the known families — naming the defect without the alternatives \ + makes the caller guess: {error}" + ); + + // Out-of-vocabulary capability token. + let error = build_offer_draft(&post_request_requesting(None, None, &["kubernetes"]), 10, None) + .expect_err("an unknown capability token must be refused"); + assert!( + error.to_string().contains("kubernetes") && error.to_string().contains("node"), + "the error must name the bad token and the known ones: {error}" + ); + + // A model with no family — refused by the SATISFIABILITY gate, which asks the award predicate + // rather than restating #788's rule. + let error = build_offer_draft(&post_request_requesting(None, Some("opus"), &[]), 10, None) + .expect_err("a model with no harness family must be refused"); + assert!( + error.to_string().contains("no seat could ever satisfy"), + "a model-only request is unsatisfiable by construction and the error should say so \ + rather than reading as a vocabulary complaint: {error}" + ); + assert!( + error.to_string().contains("opus"), + "the error must name the model the caller asked for: {error}" + ); + + // CONTROLS: every satisfiable shape must still post. Without these the assertions above are + // equally explained by a gate that refuses everything with a capability request on it. + for (family, model, capabilities) in [ + (None, None, Vec::new()), + (Some("codex"), None, Vec::new()), + (Some("codex"), Some("gpt-5.6-sol[low]"), Vec::new()), + (None, None, vec!["rust"]), + (Some("claude-code"), None, vec!["rust", "node"]), + ] { + let request = post_request_requesting(family, model, &capabilities); + assert!( + build_offer_draft(&request, 10, None).is_ok(), + "control: {family:?}/{model:?}/{capabilities:?} is satisfiable and must post" + ); + } + } + + // The request reaches the EMITTED TAGS, and an absent request emits nothing (#897). + // + // The gateway tests cover the draft→tags→parse round trip. This covers the seam ABOVE it: that + // `post_job`'s own request object is what feeds that round trip, rather than the fields being + // carried on the type and dropped on the way to the event. + #[test] + fn post_job_emits_the_capability_request_it_was_given() { + let draft = build_offer_draft( + &post_request_requesting(Some("codex"), Some("gpt-5.6-sol[low]"), &["rust"]), + 10, + None, + ) + .expect("draft"); + + let param = |name: &str| { + draft + .tags + .iter() + .find(|tag| { + tag.first() == Some("param") && tag.0.get(1).map(String::as_str) == Some(name) + }) + .map(|tag| tag.0[2..].to_vec()) + }; + assert_eq!(param("harness_family"), Some(vec!["codex".to_owned()])); + assert_eq!(param("harness_model"), Some(vec!["gpt-5.6-sol[low]".to_owned()])); + assert_eq!(param("capability"), Some(vec!["rust".to_owned()])); + + // And a post with no request is byte-identical to one built before any of this existed — + // the property that makes filtering opt-in on the wire, not just in the predicate. + let plain = build_offer_draft(&post_request_requesting(None, None, &[]), 10, None) + .expect("draft"); + assert_eq!( + plain, + OfferDraft::new("t", "text/plain", 3, 10, "bb".repeat(32)).to_event_draft(), + "a post with no capability request must emit the pre-#897 offer exactly" + ); + } + #[test] fn post_job_from_scratch_emits_byte_identical_tags() { // No contribution params ⇒ Ok(None) ⇒ built tags are byte-identical to the bare offer. @@ -5088,6 +5301,9 @@ mod tests { branch: None, job: JobKind::FromScratch, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }; let contribution: Option = match &request.job { JobKind::FromScratch => None, @@ -5292,6 +5508,9 @@ mod tests { branch: None, job: JobKind::FromScratch, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }, ) .await diff --git a/crates/maxplayer-core/src/payment_wallet.rs b/crates/maxplayer-core/src/payment_wallet.rs index 7644d55a8..a433b6c62 100644 --- a/crates/maxplayer-core/src/payment_wallet.rs +++ b/crates/maxplayer-core/src/payment_wallet.rs @@ -4301,6 +4301,9 @@ mod tests { deadline_unix: 1, seller_pubkey: Some(seller.into()), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } diff --git a/crates/maxplayer-core/src/seller.rs b/crates/maxplayer-core/src/seller.rs index 6284330d2..6c893320f 100644 --- a/crates/maxplayer-core/src/seller.rs +++ b/crates/maxplayer-core/src/seller.rs @@ -740,6 +740,9 @@ mod tests { deadline_unix: 2_000_000_000, seller_pubkey: seller.map(str::to_owned), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } diff --git a/crates/maxplayer-core/src/seller_node/run.rs b/crates/maxplayer-core/src/seller_node/run.rs index 1aa86e6f0..599ea4316 100644 --- a/crates/maxplayer-core/src/seller_node/run.rs +++ b/crates/maxplayer-core/src/seller_node/run.rs @@ -3231,6 +3231,13 @@ impl SellerNodeRunner { deadline_unix: row.deadline_unix as u64, seller_pubkey: row.targeted.then(|| seller_pubkey.clone()), requested_agent: row.requested_agent.clone(), + // The seller's own claim decision is capability-blind: the buyer's request filters + // which claims may be AWARDED, and it is judged buyer-side against what this seat + // advertises. Reconstructing it here would be a second reading of the request that + // could disagree with the one that decides. + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }; match classify_offer( &offer, @@ -6289,8 +6296,13 @@ impl SellerNodeRunner { unit: offer.unit.clone(), deadline_unix: offer.deadline_unix.max(0) as u64, seller_pubkey: offer.targeted.then(|| seller_pubkey.clone()), - // The pay path is harness-blind: which harness ran the job never changes the terms. + // The pay path is harness-blind: which harness ran the job never changes the terms. It is + // capability-blind for the same reason — the request decided WHO could be awarded, and + // that decision is upstream of and independent from what the agreed terms are. requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }; let accepted_mints: std::collections::HashSet = request.mints.iter().cloned().collect(); @@ -7097,6 +7109,9 @@ mod tests { deadline_unix, seller_pubkey: targeted_to.map(str::to_owned), requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), } } diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index b349ce7a5..fbc18c1ef 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -198,7 +198,7 @@ fn tools() -> Value { json!([ { "name": "post_job", - "description": "Publish a real maxplayer job offer (OFFER kind) to the configured maxplayer relay, then let the buyer daemon drive the award: once a payable seller claim appears the daemon auto-awards it under the hood, so the normal flow is just post_job then collect (two calls). max_sats caps what the daemon will commit to (defaults to amount_sats); it never auto-awards a claim it cannot pay. harness is a hard award filter (only a seller advertising it can be awarded); model is a recorded auto-award preference. Targeted seller p-tag is the documented default (pass seller_pubkey); set untargeted=true for an open offer. Optional repo+branch attach git delivery tags. CONTRIBUTION (freelance-PR) mode: supply target_repo_owner + target_repo_url + base_branch + base_oid to post a job-class=contribution offer against a repo you own (seller forks it and delivers a PR); these four are ALL-OR-NOTHING (a partial set is refused). Omit all four ⇒ from-scratch job. Never echoes secrets.", + "description": "Publish a real maxplayer job offer (OFFER kind) to the configured maxplayer relay, then let the buyer daemon drive the award: once a payable seller claim appears the daemon auto-awards it under the hood, so the normal flow is just post_job then collect (two calls). max_sats caps what the daemon will commit to (defaults to amount_sats); it never auto-awards a claim it cannot pay. harness, harness_family, model and capabilities are ALL hard award filters (only a seller advertising them can be awarded), enforced identically on the manual and automatic award paths; model requires harness_family. Omit them all and every claim passes exactly as before. Targeted seller p-tag is the documented default (pass seller_pubkey); set untargeted=true for an open offer. Optional repo+branch attach git delivery tags. CONTRIBUTION (freelance-PR) mode: supply target_repo_owner + target_repo_url + base_branch + base_oid to post a job-class=contribution offer against a repo you own (seller forks it and delivers a PR); these four are ALL-OR-NOTHING (a partial set is refused). Omit all four ⇒ from-scratch job. Never echoes secrets.", "inputSchema": { "type": "object", "properties": { @@ -216,7 +216,16 @@ fn tools() -> Value { }, "model": { "type": "string", - "description": "Preferred seller model. Recorded as an auto-award preference; not yet a hard filter." + "description": "Request a specific seller model. Posted on the offer as [\"param\",\"harness_model\",] and enforced as a HARD award filter: only a seller advertising that model FOR the requested harness family can be awarded. REQUIRES harness_family — a model without one is refused at post time, because a bare model does not say which harness would run it. Matched by exact equality against the model id the seat advertises (e.g. gpt-5.6-sol[low]), which is whatever its harness reported, so this is a LAST-OBSERVED self-report: it narrows who is considered, it does not pin what executes. Claude seats advertise no model yet, so a model request currently matches only non-Claude families." + }, + "harness_family": { + "type": "string", + "description": "Request a harness FAMILY (claude-code|codex|cursor|goose). Posted on the offer as [\"param\",\"harness_family\",] and enforced as a HARD award filter: only a seller advertising that family can be awarded. Distinct from harness, which names a preset — a family spans the presets sharing a harness, so a family request binds dispatch where a preset binds a configuration. Both may be given and both are then enforced. An unknown family is refused at post time." + }, + "capabilities": { + "type": "array", + "items": { "type": "string" }, + "description": "Capability tokens this job REQUIRES (node|python|rust). Posted on the offer as [\"param\",\"capability\",,…] and enforced as a HARD award filter: only a seller advertising every token can be awarded. An unknown token is refused at post time. NOTE what a token proves: the tool's probe binary resolved at SEAT START, nothing more — not that a build using it succeeds, and not that it is still installed now. Omit for no requirement." }, "seller_pubkey": { "type": "string", @@ -518,10 +527,14 @@ mod tests { use std::io::Cursor; use std::sync::atomic::{AtomicU64, Ordering}; - // The post_job schema promises that harness constrains award selection while model does not. - // Keep those caller-facing claims tied to the real award predicate: removing the harness - // predicate or weakening either description makes this test fail. If AwardFilters grows a - // model filter, update both the model description and this test together. + // The post_job schema promises that EVERY request axis constrains award selection (#897 wired + // the last of them). Keep those caller-facing claims tied to the real award predicate: removing + // a predicate or weakening a description makes this test fail. + // + // These are promises about a MONEY path — a caller told "hard award filter" may reasonably post a + // job it would not have posted otherwise — so the descriptions are asserted per axis rather than + // in aggregate. An axis whose description silently reverts to a weaker claim is the failure this + // guards, and aggregate wording would not see it. #[cfg(feature = "wallet")] #[test] fn post_job_award_filter_descriptions_match_enforcement() { @@ -538,19 +551,41 @@ mod tests { .find(|tool| tool["name"] == "post_job") .expect("post_job tool"); let properties = &post_job["inputSchema"]["properties"]; - let harness_description = properties["harness"]["description"] - .as_str() - .expect("harness description") - .to_ascii_lowercase(); - let model_description = properties["model"]["description"] - .as_str() - .expect("model description") - .to_ascii_lowercase(); + let description = |axis: &str| { + properties[axis]["description"] + .as_str() + .unwrap_or_else(|| panic!("{axis} description")) + .to_ascii_lowercase() + }; - assert!(harness_description.contains("hard award filter")); - assert!(harness_description.contains("only a seller advertising")); - assert!(model_description.contains("recorded as an auto-award preference")); - assert!(model_description.contains("not yet a hard filter")); + // Every axis that reaches `AwardFilters` must SAY it is a hard filter. `harness_family`, + // `model` and `capabilities` all became hard filters in #897; `harness` already was. + for axis in ["harness", "harness_family", "model", "capabilities"] { + let text = description(axis); + assert!( + text.contains("hard award filter"), + "{axis} reaches the award predicate, so its description must tell callers it is a \ + hard award filter — this is a promise about a money path: {text}" + ); + assert!( + text.contains("only a seller advertising"), + "{axis} description must say only an advertising seller can be awarded: {text}" + ); + } + + // The model axis carries two extra caller-facing facts that are load-bearing and easy to + // drop: it needs a family (#788), and it matches a self-report rather than pinning execution. + let model_description = description("model"); + assert!( + model_description.contains("requires harness_family"), + "a model without a family is REFUSED, so the schema must say it requires one — a caller \ + that omits it gets no awards at all: {model_description}" + ); + assert!( + model_description.contains("does not pin what executes"), + "a matched model is a last-observed self-report, never a guarantee about the next job; \ + the schema must not let a caller read it as a commitment: {model_description}" + ); let job_id = "a".repeat(64); let seller_pubkey = "aa1e5f8c9d3b6a2f4e7c1d0b8a5f3e2c1d0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f"; @@ -582,22 +617,52 @@ mod tests { pending: false, read_confirmed: true, }; - let filters = AwardFilters { + let neutral = AwardFilters { offer_amount_sats: 10, max_sats: 10, buyer_mint: DEFAULT_MINT_URL, allow_real_mints: false, - requested_agent: Some("claude"), + requested_agent: None, requested_harness_family: None, requested_model: None, required_capabilities: &[], }; + // CONTROL FIRST: this claim IS awardable with no request. Every refusal below is meaningless + // without it — an unawardable claim refuses for every request and proves no filter works. assert_eq!( - select_awardable_claim(&view, &filters), - None, - "a payable codex-only claim must not win a job requesting claude" + select_awardable_claim(&view, &neutral), + Some("c".repeat(64)), + "control: the claim must be awardable when nothing is requested" ); + + // Each axis, enforced. The claim advertises the `codex` PRESET and a default (empty) + // capability, so it fails a claude preset request, any family request, any model request, and + // any capability requirement. + let rust = vec!["rust".to_owned()]; + for (axis, filters) in [ + ("harness", AwardFilters { requested_agent: Some("claude"), ..neutral }), + ( + "harness_family", + AwardFilters { requested_harness_family: Some("claude-code"), ..neutral }, + ), + ( + "model", + AwardFilters { + requested_harness_family: Some("codex"), + requested_model: Some("opus"), + ..neutral + }, + ), + ("capabilities", AwardFilters { required_capabilities: &rust, ..neutral }), + ] { + assert_eq!( + select_awardable_claim(&view, &filters), + None, + "the schema calls {axis} a hard award filter, so a payable claim that does not \ + advertise it must not be awarded" + ); + } } static NEXT: AtomicU64 = AtomicU64::new(0); diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index c46c8396c..7fb58073c 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -340,6 +340,9 @@ reject a lifecycle event that lacks it. | `["v","1"]` | 1 | yes | Protocol major | | `["p", seller_pubkey]` | 0..1 | no | Targets one seat | | `["param","agent", agent_id]` | 0..1 | no | Requests one harness | +| `["param","harness_family", family]` | 0..1 | no | Requires one harness family | +| `["param","harness_model", model]` | 0..1 | no | Requires one model; needs `harness_family` | +| `["param","capability", token, ...]` | 0..1 | no | Requires every listed capability token | | `["delivery","git"]` | 0..1 | no | Delivery binding mode | | `["repo", locator]` | 0..1 | no | Bound delivery remote | | `["branch", name]` | 0..1 | no | Bound delivery branch | @@ -347,6 +350,41 @@ reject a lifecycle event that lacks it. The `delivery`, `repo`, and `branch` tags bind delivery as one group. If the offer uses any of them, it MUST carry all three. A reader MUST reject a partial group. +#### 6.1.1 The capability request + +The three `harness_family` / `harness_model` / `capability` params are the offer's CAPABILITY REQUEST. +They name what a seat must advertise to be awarded this job, and they are matched against the +filterable claim tags of §6.2 — the same words on both sides, compared by exact equality. + +Every param is optional and an ABSENT request passes every claim. An offer that requests nothing is +byte-identical to one posted before this existed, so filtering is opt-in per offer rather than a +change in how offers are read. + +A buyer MUST decide the award on the request carried by the SIGNED OFFER, never on a request supplied +at award time. Both award paths — automatic selection and a manually named claim — MUST apply it +identically. Naming a claim selects WHICH claim is judged, never WHETHER it is judged. + +`harness_model` is meaningful only ALONGSIDE `harness_family`. A model alone does not say which +harness would run it, so a reader MUST refuse such an offer rather than ignore the model: the harness +request is what binds dispatch, and a model phrased as a refinement of it inherits that binding where +a bare model inherits none. A refusal is the fail-closed outcome; silently dropping the model would +award a job on terms the buyer did not ask for. + +`capability` is ONE multi-value tag, not one tag per token. A reader takes the first matching tag, so +a second would be silently dropped and the buyer filtered on a subset of its own request. + +Values follow the same "stated or absent" rule as §4.5.2: a reader MUST trim, and a value that states +nothing is absent. A request naming a family outside §4.5 or a token outside §4.5.2 can never match +and SHOULD be refused before the offer is published. + +Matching decides who is CONSIDERED; it never guarantees what executes. `harness_model` is a +last-observed self-report (§4.5.4) and a capability token proves binary presence at probe time +(§4.5.3). The award is the payment decision, so nothing downstream revises it. + +The display-only fields of §4.5.1 — `harness_variant` and `hardware` — MUST NOT be requestable. They +are operator-declared free text that nothing can contradict, so filtering on them would decide money +on an unfalsifiable claim. + ### 6.2 Claim, kind `3402` | Tag | Card. | Req. | Meaning | From 43b97c99e16c0b5c58c5d202d6e857eba687816e Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 02:48:32 -0700 Subject: [PATCH 02/10] buyer: strengthen the #897 guards and their controls The display-only guard parsed the declaration as raw text, so a doc comment merely mentioning hardware would have failed it - and that is the comment a careful author would add. It now reads field names, and carries a positive control against a synthetic declaration that IS bad, so a parser returning nothing cannot pass by inspecting nothing. The coupling test gains out-of-vocabulary token rows. The token rule is the one rule both post-time gates can see, and a row is worth more than an argument in a comment that they agree. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/lifecycle.rs | 77 +++++++++++++------- crates/maxplayer-core/src/buyer/mod.rs | 8 +- 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 6a341285c..ef90b95fb 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -3762,6 +3762,8 @@ mod tests { #[test] fn the_post_time_gate_refuses_exactly_what_the_predicate_can_never_pass() { let rust = vec!["rust".to_owned()]; + let bogus = vec!["kubernetes".to_owned()]; + let mixed = vec!["rust".to_owned(), "kubernetes".to_owned()]; let shapes: Vec<(Option<&str>, Option<&str>, &[String])> = vec![ // Satisfiable: absent, single-axis, and fully-specified requests. (None, None, &[]), @@ -3772,6 +3774,13 @@ mod tests { // Unsatisfiable by construction: a model with no family to pair it to (#788). (None, Some("opus"), &[]), (None, Some("opus"), &rust), + // Out-of-vocabulary capability token. Included because the token rule is the ONE rule + // both post-time gates can see: the vocabulary gate checks it directly, and the + // predicate checks it too, so the satisfiability gate surfaces it as well. Both read the + // same `CAPABILITIES` constant, so adding a token cannot make them disagree — but the + // rule being stated twice is worth a row here rather than an argument in a comment. + (None, None, &bogus), + (Some("codex"), None, &mixed), ]; for (family, model, capabilities) in shapes { @@ -3950,35 +3959,53 @@ mod tests { // which is the one place a filter can actually read. #[test] fn display_only_fields_never_reach_the_award_filter() { - let filter_surface = include_str!("lifecycle.rs"); - let declaration = filter_surface - .split_once("pub struct AwardFilters<'a> {") - .expect("AwardFilters declaration") - .1 - .split_once("\n}") - .expect("end of AwardFilters declaration") - .0; - - for display_only in [ - crate::heartbeat::HARNESS_VARIANT_TAG, - crate::heartbeat::HARDWARE_TAG, - ] { - assert!( - !declaration.contains(display_only), - "{display_only} is display-only and must NEVER be filterable \ - (docs/protocol-v1.md 4.5.1) — it is operator-declared free text that nothing can \ - contradict, so filtering on it would award money on an unfalsifiable claim. It \ - appears in the AwardFilters declaration." - ); + // FIELD NAMES, not raw text. Searching the declaration's text would fire on a doc comment + // that merely MENTIONS hardware — and the natural comment to write beside these fields is + // exactly "hardware is never filterable", so the obvious implementation is a false-alarm + // generator aimed at the one sentence a careful author would add. + fn filter_field_names(source: &str) -> Vec<&str> { + source + .split_once("pub struct AwardFilters<'a> {") + .expect("AwardFilters declaration") + .1 + .split_once("\n}") + .expect("end of AwardFilters declaration") + .0 + .lines() + .filter_map(|line| line.trim().strip_prefix("pub ")) + .filter_map(|field| field.split(':').next()) + .collect() } - // Positive control: the assertion above can only mean something if this substring search - // finds a field that IS there. Without it, a mis-derived `declaration` slice would pass - // every assertion above while proving nothing. + let display_only = [crate::heartbeat::HARNESS_VARIANT_TAG, crate::heartbeat::HARDWARE_TAG]; + + // POSITIVE CONTROL ON THE DETECTOR ITSELF, against a synthetic declaration that IS bad. + // Without it, a `filter_field_names` that returned an empty list — a renamed struct, a + // changed brace style, a `pub(crate)` field — would pass the real assertion below while + // inspecting nothing, and the pass would look identical to a correct one. + let planted = filter_field_names( + "pub struct AwardFilters<'a> {\n pub max_sats: u64,\n pub hardware: Option<&'a str>,\n}", + ); assert!( - declaration.contains("required_capabilities"), - "control: the filterable capability field must be found in the slice being searched" + planted.contains(&"hardware"), + "control: the detector must FIND a display-only field when one is present, else its \ + verdict on the real declaration means nothing. Found: {planted:?}" ); + + let fields = filter_field_names(include_str!("lifecycle.rs")); + assert!( + fields.contains(&"required_capabilities"), + "control: the real declaration must parse into recognizable fields. Found: {fields:?}" + ); + for banned in display_only { + assert!( + !fields.contains(&banned), + "{banned} is display-only and must NEVER be filterable \ + (docs/protocol-v1.md 4.5.1) — it is operator-declared free text that nothing can \ + contradict, so filtering on it would decide money on an unfalsifiable claim. It is \ + a field of AwardFilters. Fields: {fields:?}" + ); + } } // ALL THREE axes of the capability request are read OFF THE SIGNED OFFER, at BOTH award sites diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 30e1aa8e7..ae77a1b41 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -825,9 +825,11 @@ async fn award(context: &BuyerContext, id: Value, params: Value) -> Response { // params, so the request cannot be changed after the fact. Absent ⇒ passes every // claim, so a buyer that asks for nothing sees the behaviour it saw before. // - // These two lines must stay identical to the auto path in `drive_auto_award`. A - // request honoured on one path and dropped on the other is the bypass #866 was filed - // to close, and naming a claim chooses WHICH claim is judged, never WHETHER it is. + // Every axis below must stay identical to the auto path in `drive_auto_award` — a + // property `both_award_paths_read_the_capability_request_off_the_offer` holds, rather + // than a convention to remember. A request honoured on one path and dropped on the + // other is the bypass #866 was filed to close, and naming a claim chooses WHICH claim + // is judged, never WHETHER it is. requested_harness_family: offer.requested_harness_family.as_deref(), requested_model: offer.requested_model.as_deref(), required_capabilities: &offer.required_capabilities, From 654b0e501ba52acfdf8c39097adc712bfda4d196 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 02:52:43 -0700 Subject: [PATCH 03/10] buyer: one constructor for award filters, shared by both paths The red-prove found the mirror check worthless: its needle was a string literal in the same file it searched, so it matched itself rather than the helper, and it passed with the mirror drifted. A guard is not a guard until it has gone red once, and this one could not. The fix removes the thing being guarded. Both award sites now call award_filters_for_offer, so 'the two paths filter identically' is structural rather than detected, and the test helper calls that same constructor instead of restating it. There is nothing left to drift. The tripwire drops the per-axis string scraping for two properties that are stronger and shorter: both sites go through the constructor, and no site hand-rolls a literal. A new request axis now reaches both paths with no edit to the test. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/lifecycle.rs | 155 +++++++++---------- crates/maxplayer-core/src/buyer/mod.rs | 49 ++---- 2 files changed, 86 insertions(+), 118 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index ef90b95fb..3858e60ab 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -19,8 +19,8 @@ use cashu::{Amount, CurrencyUnit}; use crate::crossmint::plan_payment; use crate::job_lifecycle::{ - AwardClaimOutcome, AwardPresence, JobLifecycleError, JobView, PreparedAward, PresenceRead, - SendOutcome, + AwardClaimOutcome, AwardPresence, JobLifecycleError, JobView, OfferView, PreparedAward, + PresenceRead, SendOutcome, }; use super::reservations::{Converted, JobDisposition, ReservationState, ReserveRefused}; @@ -59,6 +59,41 @@ pub struct AwardFilters<'a> { pub required_capabilities: &'a [String], } +/// Build the award filters for a job from its SIGNED OFFER — the ONE constructor both award paths +/// use (#897). +/// +/// ★ THIS EXISTS SO THE TWO PATHS CANNOT DIVERGE, rather than so a test can detect that they have. +/// The manual RPC and `drive_auto_award` must apply identical filters — a request honoured on one and +/// dropped on the other is the bypass #866 was filed to close — and two hand-written literals make +/// that a property somebody has to keep noticing. One constructor makes it structural: a new request +/// axis is added here once and both paths have it. +/// +/// It also removes a subtler hazard that had already bitten: while the two literals existed, the +/// tests needed a THIRD copy to build filters the way production does, and that copy silently drifted +/// from production for a revision — tests kept passing while asserting behaviour production did not +/// have. There is now nothing to drift from, which is why this is a constructor rather than a +/// stronger test. +/// +/// Everything filterable comes from the OFFER; only the money context is passed in, because the +/// buyer's mint and its real-mint policy are properties of the buyer rather than of the job. +pub fn award_filters_for_offer<'a>( + offer: &'a OfferView, + max_sats: u64, + buyer_mint: &'a str, + allow_real_mints: bool, +) -> AwardFilters<'a> { + AwardFilters { + offer_amount_sats: offer.amount_sats, + max_sats, + buyer_mint, + allow_real_mints, + requested_agent: offer.requested_agent.as_deref(), + requested_harness_family: offer.requested_harness_family.as_deref(), + requested_model: offer.requested_model.as_deref(), + required_capabilities: &offer.required_capabilities, + } +} + /// Select the claim to auto-award: the first LIVE claim whose seller-authored `creq` passes every /// hard filter. Pure — relay truth in, claim id out. Never invents a claim, and never returns one /// the buyer cannot pay (price mismatch, over budget, or no mutually-payable mint). @@ -3623,21 +3658,17 @@ mod tests { } } - /// Mirror of the `AwardFilters` both award sites construct. Kept in one place here so a test - /// cannot accidentally exercise a request shape production never builds — and - /// `both_award_paths_read_the_capability_request_off_the_offer` is what holds this mirror honest, - /// by pinning the form the real sites use. + /// The filters production would build for this offer — THE PRODUCTION CONSTRUCTOR ITSELF, not a + /// copy of it. + /// + /// This was briefly a hand-written mirror, and the mirror drifted: it carried + /// `requested_model: None` for a revision while production read the offer, so two model tests + /// passed while asserting the opposite of the intended behaviour. A test that restates production + /// wiring is a second copy subject to exactly the same drift rule as a gate that restates a + /// predicate, and the fix in both cases is to call the real thing rather than to test the copy + /// harder. fn filters_from_offer<'a>(offer: &'a OfferView, max_sats: u64) -> AwardFilters<'a> { - AwardFilters { - offer_amount_sats: offer.amount_sats, - max_sats, - buyer_mint: DEFAULT_MINT_URL, - allow_real_mints: false, - requested_agent: offer.requested_agent.as_deref(), - requested_harness_family: offer.requested_harness_family.as_deref(), - requested_model: offer.requested_model.as_deref(), - required_capabilities: &offer.required_capabilities, - } + award_filters_for_offer(offer, max_sats, DEFAULT_MINT_URL, false) } // THE ACCEPTANCE TEST FOR #897, both axes through BOTH selection entry points. @@ -4036,78 +4067,30 @@ mod tests { // hand-written assertions so that adding a fourth request field to `AwardFilters` and wiring // it is the only way to satisfy this — a new axis left inert has to be added here to pass, // which is the moment its author reads what that costs. - // `filters_from_offer` in THIS file is a hand-written mirror of the production wiring, and - // every offer-sourced test below is built on it. A mirror that drifts makes those tests prove - // less than they appear to while still passing — which is not hypothetical: this mirror - // shipped with `requested_model: None` for one revision, and two model tests silently - // asserted that a model request awards the claim it should have refused. They went red only - // because the model cases were written; nothing structural would have caught it. - let mirror = include_str!("lifecycle.rs"); - - for (axis, wired_form) in [ - ("harness family", "requested_harness_family: offer.requested_harness_family.as_deref()"), - ("model", "requested_model: offer.requested_model.as_deref()"), - ("capabilities", "required_capabilities: &offer.required_capabilities"), - ] { - let wired = live(wired_form); - assert_eq!( - wired, AWARD_SITES, - "expected both award sites in buyer/mod.rs to read the {axis} request off the \ - SIGNED OFFER, found {wired}.\n\ - A request honoured on one path and dropped on the other is exactly the bypass #866 \ - was filed to close, and naming a claim chooses WHICH claim is judged, never WHETHER \ - it is." - ); - assert!( - mirror.contains(wired_form), - "the test mirror `filters_from_offer` does not spell the {axis} axis the way both \ - production award sites do (`{wired_form}`). Every offer-sourced test is built on \ - that mirror, so a drifted mirror means those tests exercise a request shape \ - production never builds — and they keep passing while doing it." - ); - } - - // No axis may be inert. Kept because it fails on the OPPOSITE mistake from the table above: - // the table catches an axis that was never wired, this catches one that was wired and then - // quietly turned off. - // - // Scoped to the axis names — NOT a bare `: None` search. `buyer/mod.rs` is full of unrelated - // `Option` fields set to None, so a bare search reports a number that has nothing to do with - // this property, and a needle that answers a question you did not ask is worse than none. - for axis in [ - "requested_harness_family: None", - "requested_model: None", - "required_capabilities: &[]", - ] { - let inert = live(axis); - assert_eq!( - inert, 0, - "found {inert} inert `{axis}` in buyer/mod.rs. An axis passed inertly is a filter \ - the caller was told exists and that refuses nothing — the exact state #897 was \ - filed to end. If an axis genuinely must be disabled, the caller-facing description \ - in crates/maxplayer/src/mcp.rs owes the same edit IN THE SAME COMMIT, because it \ - promises callers a hard filter on a money path." - ); - } + // Both award sites must go through `award_filters_for_offer`, the ONE constructor. That + // function reads every axis off the offer, so this single property replaces the per-axis + // string-scraping this test used to do — and it replaces it with something stronger, because + // a new request axis added to the constructor reaches both paths with no edit here. + let shared = live("lifecycle::award_filters_for_offer("); + assert_eq!( + shared, AWARD_SITES, + "expected both award sites in buyer/mod.rs to build filters through \ + `lifecycle::award_filters_for_offer`, found {shared}. That constructor is what makes \ + 'both paths filter identically' structural rather than a convention: a site building \ + its own AwardFilters can silently drop a request axis, which is exactly the bypass #866 \ + was filed to close." + ); - // The counts above say the two sites we know about are wired. This says there are only two. - // Without it, a THIRD award site could construct its own filters — inertly, or from award - // params — and every assertion above would still pass, because they count occurrences of the - // correct form rather than bounding the total. - // - // Counted on `AwardFilters {` rather than on the field names, deliberately. The field names - // are NOT a usable denominator here: `requested_harness_family:` legitimately appears a third - // time in `post_job`, where it fills a `PostJobRequest` on the POSTING path. A total over the - // field name conflates the two concerns and goes red for a change that is none of this test's - // business — and a duplicate field inside one literal is already a compile error, so the - // field-name total was never buying anything the specific forms above do not. - let award_sites = live("AwardFilters {"); + // AND NO SITE MAY HAND-ROLL ITS OWN. Without this the assertion above passes while a third + // site quietly constructs a literal beside the two that behave — the counts would confirm the + // good paths and say nothing about the bad one. + let hand_rolled = live("AwardFilters {"); assert_eq!( - award_sites, AWARD_SITES, - "expected exactly {AWARD_SITES} AwardFilters constructions in buyer/mod.rs (the manual \ - award RPC and drive_auto_award), found {award_sites}. A new award site must read the \ - capability request off the offer exactly as those two do — a request honoured on some \ - paths and dropped on others is the bypass #866 was filed to close." + hand_rolled, 0, + "found {hand_rolled} hand-written `AwardFilters` literal(s) in buyer/mod.rs. Every award \ + site must use `award_filters_for_offer` so a new request axis cannot reach one path and \ + miss the other. If a site genuinely needs different filters, that is a money-path \ + decision and belongs in the constructor with a reason, not in a local literal." ); } diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index ae77a1b41..33d4559fc 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -815,25 +815,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(), - // #897 capability request, read from the SIGNED OFFER on the relay — never from award - // params, so the request cannot be changed after the fact. Absent ⇒ passes every - // claim, so a buyer that asks for nothing sees the behaviour it saw before. - // - // Every axis below must stay identical to the auto path in `drive_auto_award` — a - // property `both_award_paths_read_the_capability_request_off_the_offer` holds, rather - // than a convention to remember. A request honoured on one path and dropped on the - // other is the bypass #866 was filed to close, and naming a claim chooses WHICH claim - // is judged, never WHETHER it is. - requested_harness_family: offer.requested_harness_family.as_deref(), - requested_model: offer.requested_model.as_deref(), - required_capabilities: &offer.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 @@ -1315,21 +1306,15 @@ async fn drive_auto_award( return Ok(()); }; unconfirmed_reads = 0; - let filters = AwardFilters { - offer_amount_sats: offer.amount_sats, + // 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, - buyer_mint: context.home.config.default_mint(), - allow_real_mints: context.home.config.allow_real_mints, - requested_agent: offer.requested_agent.as_deref(), - // #897 capability request, read from the SIGNED OFFER — the SAME two fields the manual - // path reads above, in the same order. Both selection paths call - // `claim_meets_capability_request`: `select_awardable_claim` here, - // `named_claim_awardable` on the manual path, and a test holds the refusal property from - // BOTH entry points so this cannot quietly become false the way its predecessor did. - requested_harness_family: offer.requested_harness_family.as_deref(), - requested_model: offer.requested_model.as_deref(), - required_capabilities: &offer.required_capabilities, - }; + 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 From 9394648751169dcb9387fc28c093c15e5c039165 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 03:38:32 -0700 Subject: [PATCH 04/10] buyer: give the live-mints initializers the #897 request fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PostJobRequest initializers gated on `live-mints` lacked the three fields #897 adds, so CI's money-path job could not compile. That feature is on in that job and nowhere else in CI, and no local row had built it, so every row that did run was green on a set that excluded these sites. Every PostJobRequest, OfferView and ParsedOffer initializer in the crate is now accounted for across all cfg branches, rather than only the two the compiler happened to name under one feature set. Also drops the AwardFilters import that no longer has a use in buyer/mod.rs. Verified by compiling every feature row in ci.yml with --no-run: 7 of 7, zero errors. The live-mints row is compile-only by design — its tests reach a live third-party mint. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/mod.rs | 2 +- crates/maxplayer-core/src/job_lifecycle.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 33d4559fc..3515b6137 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -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}; diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 2eb39efe9..580dcc559 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -4668,6 +4668,9 @@ mod tests { branch: None, job: JobKind::FromScratch, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }, ) .await @@ -4758,6 +4761,9 @@ mod tests { branch: None, job: JobKind::FromScratch, requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: Vec::new(), }, ) .await From 05c73ff103bb4dbad2a76611a6eba52d5d551828 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 08:57:13 -0700 Subject: [PATCH 05/10] buyer: bind the capability request to the harness that runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model or family request named a harness nothing selects. Only `requested_agent` reaches execution — `offer_row` persists it alone, `classify_offer` gates on it, `execute_job` hands it to `dispatch`, and `dispatch` runs the seat's FIRST preset when it is absent. So a multi-harness seat could satisfy a codex request and run Claude. A model now requires a preset, and a stated family must agree with the preset's own family or be derived from it. The rule lives in `claim_meets_capability_request`, the one predicate both award paths already consult, so the post-time gate inherits it through the synthesized claim rather than restating it. The capability park reason also could not reach production. Past the deadline `derive_claim_liveness` has demoted every processing claim to expired, so a diagnosis reading `live` found nothing and stayed silent in exactly the case it exists to explain. It now reverses that demotion — `expired` is written only by that function — and re-derives with the same function at the deadline instant, so it owns no liveness rule of its own. Three comments claimed a family request binds dispatch. It does not, and the claim would have ended the search for any reader. Two were introduced with the request params; the third predates them. Feature-union row at this tree: 1229 passed, 0 failed, 2 ignored, rc=0. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/lifecycle.rs | 442 ++++++++++++++++--- crates/maxplayer-core/src/heartbeat.rs | 19 +- crates/maxplayer-core/src/job_lifecycle.rs | 111 ++++- 3 files changed, 480 insertions(+), 92 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 3858e60ab..d23627b05 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -46,13 +46,18 @@ pub struct AwardFilters<'a> { /// signed offer is the authority for what the job requested). `None` ⇒ no preference and every /// claim passes this filter unchanged. pub requested_agent: Option<&'a str>, - /// The harness FAMILY the offer asked for (#784). `None` ⇒ no preference. Distinct from - /// `requested_agent`, which names a preset: a family spans the presets that share a harness, so - /// a family request binds dispatch where a preset name binds a configuration. + /// The harness FAMILY the offer asked for. `None` ⇒ no preference. + /// + /// Selects WHICH SEATS MAY CLAIM, and nothing more: a seat that does not advertise the family is + /// refused, but a multi-harness seat that does advertise it still dispatches on its own preset. + /// Only `requested_agent` reaches execution. A stated family must therefore AGREE with the + /// preset when both are present — see + /// [`CapabilityRefusal::HarnessFamilyContradictsPreset`]. pub requested_harness_family: Option<&'a str>, - /// The model the offer asked for (#784). Only meaningful PAIRED with a family, and refused - /// rather than ignored when it arrives without one — see - /// [`CapabilityRefusal::ModelWithoutHarnessFamily`]. + /// The model the offer asked for. Only meaningful PAIRED with a preset, and refused rather than + /// ignored when it arrives without one — see + /// [`CapabilityRefusal::ModelWithoutHarnessPreset`]. When the family is absent it is DERIVED + /// from the preset, so `agent` + `model` is a complete request. pub requested_model: Option<&'a str>, /// Capability tokens the offer requires (#784). Empty ⇒ no requirement. Every token is validated /// against [`crate::capability::CAPABILITIES`] before any claim is judged. @@ -124,9 +129,24 @@ pub enum CapabilityRefusal { /// the claim advertises no model at all and when it advertises that model for a DIFFERENT /// family — a model is only meaningful paired to the harness that would run it. Model { family: String, requested: String }, - /// The job named a model but no harness family. A buyer-side request defect, refused here as the + /// The job named a model but no harness PRESET. A buyer-side request defect, refused here as the /// fail-closed backstop so it can never be silently ignored on the money path. - ModelWithoutHarnessFamily { requested: String }, + /// + /// The preset is the anchor because it is the only requested axis the seller persists and + /// dispatches on: `offer_row` stores `requested_agent` alone, `classify_offer` gates on it, and + /// `execute_job` hands it to `SellerAgents::dispatch`. Family and model reach neither, so a model + /// hung off a family would name a harness nothing selects — and `dispatch(None)` runs the seat's + /// FIRST configured preset, which is how a multi-harness seat could accept a codex request and + /// deterministically run Claude. + ModelWithoutHarnessPreset { requested: String }, + /// The job named both a preset and a harness family, and they disagree. Dispatch honours the + /// preset, so the family names a harness this job would not run on — the offer would be asking + /// for one thing and executing another. + HarnessFamilyContradictsPreset { preset: String, preset_family: String, requested: String }, + /// The job constrained the harness (a family or a model) while naming a preset with no family in + /// [`crate::agent_presets::HARNESS_FAMILIES`] — a custom preset. Nothing can establish that what + /// dispatch will run matches what the job asked for, so it refuses rather than assuming. + PresetHasNoKnownFamily { preset: String }, /// The job required capability tokens the claim does not advertise. Names the MISSING tokens /// rather than the whole request, so the refusal says what to fix. Capabilities { missing: Vec }, @@ -147,8 +167,15 @@ impl std::fmt::Display for CapabilityRefusal { Self::Model { family, requested } => { write!(f, "claim does not advertise model {requested} for family {family}") } - Self::ModelWithoutHarnessFamily { requested } => { - write!(f, "model {requested} requested without a harness family") + Self::ModelWithoutHarnessPreset { requested } => { + write!(f, "model {requested} requested without a harness preset (`agent`)") + } + Self::HarnessFamilyContradictsPreset { preset, preset_family, requested } => write!( + f, + "harness family {requested} contradicts preset {preset}, which dispatches {preset_family}" + ), + Self::PresetHasNoKnownFamily { preset } => { + write!(f, "preset {preset} has no known harness family to check the request against") } Self::Capabilities { missing } => { write!(f, "claim is missing required capabilities: {}", missing.join(", ")) @@ -197,16 +224,64 @@ pub fn claim_meets_capability_request( { return Err(CapabilityRefusal::UnknownCapabilityToken { token: unknown.clone() }); } + // The request must be one DISPATCH can honour, before any of it is matched against a claim. + // + // Only `requested_agent` reaches execution: `offer_row` persists it alone, `classify_offer` + // gates on it, and `execute_job` passes it to `SellerAgents::dispatch`, which runs the seat's + // FIRST configured preset when it is absent. A family or model that disagrees with the preset + // therefore describes a harness this job will not run on, and a multi-harness seat would satisfy + // the filter and then execute something else. Refusing here is what keeps the offer's request and + // the seat's dispatch the same statement. + let preset = crate::seller_agents::normalize_request(filters.requested_agent); + if let Some(requested_model) = filters.requested_model { + if preset.is_none() { + return Err(CapabilityRefusal::ModelWithoutHarnessPreset { + requested: requested_model.to_owned(), + }); + } + } + let preset_family = + preset.as_deref().and_then(crate::agent_presets::harness_family_for_preset); + if let Some(preset_name) = preset.as_deref() { + // A bare preset constrains nothing about the family — `claim_serves_requested_agent` already + // enforces it, and a seat naming that preset is by definition able to run it. + if filters.requested_harness_family.is_some() || filters.requested_model.is_some() { + let Some(preset_family) = preset_family else { + return Err(CapabilityRefusal::PresetHasNoKnownFamily { + preset: preset_name.to_owned(), + }); + }; + if let Some(requested) = filters.requested_harness_family { + if requested != preset_family { + return Err(CapabilityRefusal::HarnessFamilyContradictsPreset { + preset: preset_name.to_owned(), + preset_family: preset_family.to_owned(), + requested: requested.to_owned(), + }); + } + } + } + } + // The family this request actually binds to: stated outright, or DERIVED from the preset when a + // model needs one to pair against. Deriving rather than demanding both keeps `agent` + `model` + // — the shape a buyer reaches for first — a valid request instead of a refusal. + let effective_family: Option<&str> = match filters.requested_harness_family { + Some(stated) => Some(stated), + None if filters.requested_model.is_some() => preset_family, + None => None, + }; // Family first: when a claim fails both, the family refusal is the actionable one — a model // refusal would send the operator chasing a model on a harness the seat never offered. - if let Some(requested) = filters.requested_harness_family { + if let Some(requested) = effective_family { if !advertised.harness_families.iter().any(|family| family == requested) { return Err(CapabilityRefusal::HarnessFamily { requested: requested.to_owned() }); } } if let Some(requested_model) = filters.requested_model { - let Some(family) = filters.requested_harness_family else { - return Err(CapabilityRefusal::ModelWithoutHarnessFamily { + // Some by construction: a model requires a preset, and a preset that constrains the harness + // must map to a family or it was refused above. + let Some(family) = effective_family else { + return Err(CapabilityRefusal::ModelWithoutHarnessPreset { requested: requested_model.to_owned(), }); }; @@ -254,36 +329,56 @@ pub fn claim_meets_capability_request( /// offer the award-time refusal and its park row are the wire-level truth. Both layers are tested and /// neither makes the other unnecessary. pub fn unsatisfiable_capability_request( + requested_agent: Option<&str>, requested_harness_family: Option<&str>, requested_model: Option<&str>, required_capabilities: &[String], ) -> Option { - // The seat that advertises exactly what was asked for. A model is only representable PAIRED with - // a family, so a request naming a model and no family cannot be advertised by any seat — which is - // the unsatisfiability this probe detects rather than asserts. + // The seat that advertises exactly what was asked for, along EVERY axis that could be asked + // about. It advertises the stated family AND the preset's own family, and pairs the model to + // both, so it satisfies whichever one the predicate decides is effective. + // + // Advertising both rather than working out which one applies is deliberate: computing the + // effective family here would be a second copy of the derive rule, and the two would rot apart + // exactly as a restated predicate would. A maximally-capable seat needs to know only which axes + // were REQUESTED, never how they resolve — so what survives is a request no seat could satisfy, + // which is the question this asks. + let mut harness_families: Vec = Vec::new(); + for family in [ + requested_harness_family, + requested_agent.and_then(crate::agent_presets::harness_family_for_preset), + ] + .into_iter() + .flatten() + { + if !harness_families.iter().any(|held| held == family) { + harness_families.push(family.to_owned()); + } + } let advertised = crate::heartbeat::SeatCapability { - harness_families: requested_harness_family - .map(|family| vec![family.to_owned()]) - .unwrap_or_default(), - models: match (requested_harness_family, requested_model) { - (Some(family), Some(model)) => vec![crate::heartbeat::HarnessModel { - family: family.to_owned(), - model: model.to_owned(), - }], - _ => Vec::new(), + models: match requested_model { + Some(model) => harness_families + .iter() + .map(|family| crate::heartbeat::HarnessModel { + family: family.clone(), + model: model.to_owned(), + }) + .collect(), + None => Vec::new(), }, + harness_families, capabilities: required_capabilities.to_vec(), ..crate::heartbeat::SeatCapability::default() }; // Money fields are placeholders and are never read: `claim_meets_capability_request` consults - // only the three request axes. Zeroes rather than plausible amounts, so nothing here can be - // mistaken for a price this function decides anything about. + // only the request axes. Zeroes rather than plausible amounts, so nothing here can be mistaken + // for a price this function decides anything about. let filters = AwardFilters { offer_amount_sats: 0, max_sats: 0, buyer_mint: "", allow_real_mints: false, - requested_agent: None, + requested_agent, requested_harness_family, requested_model, required_capabilities, @@ -316,8 +411,14 @@ pub fn capability_park_reason(view: &JobView, filters: &AwardFilters) -> Option< { return None; } + // Judged on the claims that were candidates AT THE DEADLINE, not on `view.claims` as they stand + // now. A job reaches this diagnosis only after its deadline passed, and by then liveness has + // been re-derived against the current clock and every ordinary claim is expired — so reading + // `view.claims` here would find nothing live and stay silent in precisely the case this exists + // to explain. + let candidates = crate::job_lifecycle::claims_at_deadline(view); let mut refusals: Vec = Vec::new(); - for claim in view.claims.iter().filter(|claim| claim.live) { + for claim in candidates.iter().filter(|claim| claim.live) { match claim_meets_capability_request(&claim.capability, filters) { // A live claim that passes means the request was satisfiable and something else refused // this job. Bail rather than report: a partial list reads as the whole reason. @@ -1563,6 +1664,10 @@ mod tests { harness_variant: None, hardware: None, }; + // The seat serves the `codex` PRESET as well as the family. A model axis now has to name a + // preset, because dispatch reads nothing else — so without this the model case would be + // refused for an unserved preset and would stop exercising the model rule at all. + named.agents = vec!["codex".to_owned()]; let view = view_with(&job, 10, vec![named]); let id = "c".repeat(64); @@ -1584,6 +1689,7 @@ mod tests { // Axis 2 — right family, model the seat does not advertise. The PAIR is the unit. let mut wants_model = filters(10, 100); + wants_model.requested_agent = Some("codex"); wants_model.requested_harness_family = Some("codex"); wants_model.requested_model = Some("opus"); if select_awardable_claim(&view, &wants_model).is_some() { @@ -1610,6 +1716,7 @@ mod tests { // above. That is a different bug wearing the same green, and only this catches it. let conforming_caps = vec!["python".to_owned()]; let mut conforming = filters(10, 100); + conforming.requested_agent = Some("codex"); conforming.requested_harness_family = Some("codex"); conforming.requested_model = Some("sonnet"); conforming.required_capabilities = &conforming_caps; @@ -3542,6 +3649,9 @@ mod tests { #[test] fn a_model_matches_only_when_paired_with_its_own_family() { let mut wants = filters(10, 10); + // A model request names the PRESET too — dispatch reads nothing else. `claude` maps to the + // requested family, so the two agree and this stays a test about model pairing. + wants.requested_agent = Some("claude"); wants.requested_harness_family = Some("claude-code"); wants.requested_model = Some("opus"); assert_eq!( @@ -3572,19 +3682,74 @@ mod tests { ); } - // A model without a family is a defect in the REQUEST. Refused rather than ignored: ignoring it + // A model without a PRESET is a defect in the REQUEST. Refused rather than ignored: ignoring it // would award on a weaker filter than the buyer asked for, silently, on the money path. + // + // The preset is the anchor because it is the only axis dispatch reads. A model hung off a family + // alone would pass this filter and then run on whatever preset the seat happens to list first. #[test] - fn a_model_request_without_a_harness_family_is_refused_not_ignored() { + fn a_model_request_without_a_harness_preset_is_refused_not_ignored() { let mut wants = filters(10, 10); wants.requested_model = Some("opus"); + wants.requested_harness_family = Some("claude-code"); assert_eq!( claim_meets_capability_request( &seat(&["claude-code"], &[("claude-code", "opus")], &[]), &wants, ), - Err(CapabilityRefusal::ModelWithoutHarnessFamily { requested: "opus".into() }), - "a seat that DOES advertise the model must still be refused — the request is the defect" + Err(CapabilityRefusal::ModelWithoutHarnessPreset { requested: "opus".into() }), + "a seat that DOES advertise the model, for the requested family, must STILL be refused: \ + without a preset nothing binds which harness dispatch would actually start" + ); + } + + // The counterexample from review: a preset and a family that name different harnesses. Dispatch + // honours the preset, so awarding this would run Claude on a job that asked for codex — and a + // multi-harness seat advertises BOTH families, so every other filter passes. + #[test] + fn a_family_that_contradicts_the_preset_is_refused_on_a_seat_that_advertises_both() { + let mut wants = filters(10, 10); + wants.requested_agent = Some("claude"); + wants.requested_harness_family = Some("codex"); + let multi_harness = + seat(&["claude-code", "codex"], &[("claude-code", "opus"), ("codex", "gpt-5.6")], &[]); + assert_eq!( + claim_meets_capability_request(&multi_harness, &wants), + Err(CapabilityRefusal::HarnessFamilyContradictsPreset { + preset: "claude".into(), + preset_family: "claude-code".into(), + requested: "codex".into(), + }), + "the seat can genuinely serve both, so nothing about the CLAIM refuses this — the \ + request itself is unrunnable and only a request-level check can see it" + ); + } + + // The shape a buyer reaches for first: name the preset, name the model, say nothing about the + // family. The family is DERIVED from the preset rather than demanded, so this is a valid request. + #[test] + fn a_preset_and_a_model_derive_the_family_instead_of_refusing() { + let mut wants = filters(10, 10); + wants.requested_agent = Some("claude"); + wants.requested_model = Some("opus"); + assert_eq!( + claim_meets_capability_request( + &seat(&["claude-code"], &[("claude-code", "opus")], &[]), + &wants, + ), + Ok(()), + "agent=claude implies family=claude-code, so the model has a family to pair against" + ); + assert_eq!( + claim_meets_capability_request( + &seat(&["claude-code"], &[("claude-code", "sonnet")], &[]), + &wants, + ), + Err(CapabilityRefusal::Model { + family: "claude-code".into(), + requested: "opus".into() + }), + "and the derived family is a real filter, not a formality" ); } @@ -3630,6 +3795,7 @@ mod tests { #[test] fn a_claim_failing_both_reports_the_family_refusal() { let mut wants = filters(10, 10); + wants.requested_agent = Some("claude"); wants.requested_harness_family = Some("claude-code"); wants.requested_model = Some("opus"); assert_eq!( @@ -3687,6 +3853,10 @@ mod tests { let claim_id = "c".repeat(64); let mut payable = claim(&job, true, 10, &mints); payable.capability = seat(&["codex"], &[], &["rust"]); + // The seat also serves the `codex` PRESET, so the model case below can name one. A model + // request without a preset is refused as a request defect and would never reach the model + // comparison this test exists to make. + payable.agents = vec!["codex".to_owned()]; // Control FIRST: the same claim, the same paths, no request. A refusal below means nothing // unless this passes — otherwise the test proves only that the claim was unawardable. @@ -3741,6 +3911,7 @@ mod tests { // MODEL the claim does not advertise, paired with a family it DOES. The pair is the unit, so // this is the case that separates a real model filter from one that matches on family alone. let wants_model = OfferView { + requested_agent: Some("codex".to_owned()), requested_model: Some("opus".to_owned()), ..offer_requesting(&job, 10, Some("codex"), &[]) }; @@ -3795,39 +3966,68 @@ mod tests { let rust = vec!["rust".to_owned()]; let bogus = vec!["kubernetes".to_owned()]; let mixed = vec!["rust".to_owned(), "kubernetes".to_owned()]; - let shapes: Vec<(Option<&str>, Option<&str>, &[String])> = vec![ + let shapes: Vec<(Option<&str>, Option<&str>, Option<&str>, &[String])> = vec![ // Satisfiable: absent, single-axis, and fully-specified requests. - (None, None, &[]), - (Some("codex"), None, &[]), - (Some("codex"), Some("gpt-5.6-sol[low]"), &[]), - (None, None, &rust), - (Some("codex"), Some("gpt-5.6-sol[low]"), &rust), - // Unsatisfiable by construction: a model with no family to pair it to (#788). - (None, Some("opus"), &[]), - (None, Some("opus"), &rust), + (None, None, None, &[]), + (None, Some("codex"), None, &[]), + (Some("codex"), None, None, &[]), + (Some("codex"), Some("codex"), Some("gpt-5.6-sol[low]"), &[]), + // The family DERIVED from the preset rather than stated. + (Some("codex"), None, Some("gpt-5.6-sol[low]"), &[]), + (None, None, None, &rust), + (Some("codex"), Some("codex"), Some("gpt-5.6-sol[low]"), &rust), + // Unsatisfiable: a model with no preset, so nothing binds the harness that would run it. + // A family does not rescue it — dispatch never reads one. + (None, None, Some("opus"), &[]), + (None, Some("claude-code"), Some("opus"), &[]), + (None, Some("claude-code"), Some("opus"), &rust), + // Unsatisfiable: a family naming a harness the preset would not dispatch. + (Some("claude"), Some("codex"), None, &[]), + (Some("claude"), Some("codex"), Some("opus"), &[]), + // Unsatisfiable: a custom preset has no family, so nothing can establish that what + // dispatch runs is what was asked for. + (Some("my-custom-harness"), Some("codex"), None, &[]), + (Some("my-custom-harness"), None, Some("opus"), &[]), + // But a custom preset ALONE constrains no harness and stays satisfiable — the preset + // filter already binds dispatch exactly. + (Some("my-custom-harness"), None, None, &[]), // Out-of-vocabulary capability token. Included because the token rule is the ONE rule // both post-time gates can see: the vocabulary gate checks it directly, and the // predicate checks it too, so the satisfiability gate surfaces it as well. Both read the // same `CAPABILITIES` constant, so adding a token cannot make them disagree — but the // rule being stated twice is worth a row here rather than an argument in a comment. - (None, None, &bogus), - (Some("codex"), None, &mixed), + (None, None, None, &bogus), + (None, Some("codex"), None, &mixed), ]; - for (family, model, capabilities) in shapes { - let gate = unsatisfiable_capability_request(family, model, capabilities); + for (agent, family, model, capabilities) in shapes { + let gate = unsatisfiable_capability_request(agent, family, model, capabilities); // What the predicate says about the claim that advertises EXACTLY this request. If even // that claim is refused, no publishable claim can pass. - let perfect = seat( - &family.map(|f| vec![f]).unwrap_or_default(), - &match (family, model) { - (Some(f), Some(m)) => vec![(f, m)], - _ => Vec::new(), - }, - &capabilities.iter().map(String::as_str).collect::>(), - ); + // + // Built here rather than reused from the gate, so the two are a CROSS-CHECK instead of + // one artifact agreeing with itself. It advertises the stated family and the preset's + // family both, because either can be the one the predicate binds to and this oracle + // must not have to know which — knowing would make it a copy of the rule under test. + let mut families: Vec<&str> = Vec::new(); + for candidate in + [family, agent.and_then(crate::agent_presets::harness_family_for_preset)] + .into_iter() + .flatten() + { + if !families.contains(&candidate) { + families.push(candidate); + } + } + let pairs: Vec<(&str, &str)> = match model { + Some(model) => families.iter().map(|family| (*family, model)).collect(), + None => Vec::new(), + }; + let perfect = + seat(&families, &pairs, &capabilities.iter().map(String::as_str).collect::>()); let mut filters = filters(10, 10); + filters.requested_agent = agent; filters.requested_harness_family = family; filters.requested_model = model; filters.required_capabilities = capabilities; @@ -3835,23 +4035,41 @@ mod tests { assert_eq!( gate, predicate, - "gate and predicate disagree on {family:?}/{model:?}/{capabilities:?}. The gate must \ - refuse exactly what the predicate can never pass: refusing more blocks posts that \ - would have been awarded, refusing less restores the silent park the gate exists to \ - prevent." + "gate and predicate disagree on {agent:?}/{family:?}/{model:?}/{capabilities:?}. The \ + gate must refuse exactly what the predicate can never pass: refusing more blocks \ + posts that would have been awarded, refusing less restores the silent park the gate \ + exists to prevent." ); } - // Positive control on BOTH sides: the table above is only meaningful if it contains a shape - // that is refused and a shape that is not. Without this, a gate stuck at `None` and a - // predicate stuck at `None` would agree on every row and pass. + // Positive controls on BOTH sides, one per rule the table exercises. Without these, a gate + // stuck at `None` and a predicate stuck at `None` would agree on every row and pass — and + // the controls pin the actual verdicts, so an oracle that is wrong the same way the gate is + // wrong still fails here. + assert!( + unsatisfiable_capability_request(None, None, Some("opus"), &[]).is_some(), + "control: a model with no harness preset must be refused by the gate" + ); + assert!( + unsatisfiable_capability_request(None, Some("claude-code"), Some("opus"), &[]).is_some(), + "control: a family does NOT substitute for the preset — dispatch never reads one" + ); + assert!( + unsatisfiable_capability_request(Some("claude"), Some("codex"), None, &[]).is_some(), + "control: a family contradicting the preset must be refused by the gate" + ); + assert!( + unsatisfiable_capability_request(Some("codex"), Some("codex"), Some("opus"), &[]) + .is_none(), + "control: a fully-specified, self-consistent request must NOT be refused by the gate" + ); assert!( - unsatisfiable_capability_request(None, Some("opus"), &[]).is_some(), - "control: a model with no harness family must be refused by the gate" + unsatisfiable_capability_request(Some("codex"), None, Some("opus"), &[]).is_none(), + "control: the family may be DERIVED from the preset rather than stated" ); assert!( - unsatisfiable_capability_request(Some("codex"), Some("opus"), &[]).is_none(), - "control: a fully-specified request must NOT be refused by the gate" + unsatisfiable_capability_request(None, Some("codex"), None, &[]).is_none(), + "control: a family-only request stays valid as a SEAT filter" ); } @@ -3920,13 +4138,13 @@ mod tests { assert_eq!( select_awardable_claim(&view, &filters), None, - "auto path: a model with no family must refuse, not be ignored (#788)" + "auto path: a model with no preset must refuse, not be ignored" ); assert_eq!( named_claim_awardable(&view, &claim_id, &filters), Err(NamedAwardRefused::Capability { claim_id: claim_id.clone(), - refusal: CapabilityRefusal::ModelWithoutHarnessFamily { requested: "opus".into() }, + refusal: CapabilityRefusal::ModelWithoutHarnessPreset { requested: "opus".into() }, }), "manual path: naming the claim must not bypass the malformed request either" ); @@ -3934,7 +4152,7 @@ mod tests { let reason = capability_park_reason(&view, &filters) .expect("a model-only request must produce a park reason"); assert!( - reason.contains("without a harness family"), + reason.contains("without a harness preset"), "the row must name the REQUEST defect, not blame the claim — an operator reading it has \ to know to add a family rather than to go find another seat: {reason}" ); @@ -3972,8 +4190,12 @@ mod tests { // No LIVE claims ⇒ nothing was refused. "No seat advertises X" would be false where the truth // is that nobody claimed, and it would send an operator to fix a request that is fine. let wants = offer_requesting(&job, 10, Some("claude-code"), &[]); - let dead = claim(&job, false, 10, &mints); - let view = JobView { offer: Some(wants.clone()), ..view_with(&job, 10, vec![dead]) }; + // NO CLAIMS AT ALL, which is what "an empty relay" means. A claim carrying `live: false` + // would NOT do: the diagnosis re-derives liveness from status, so a claim still saying + // "processing" is a candidate however the flag was set, and an `expired` one was a + // candidate at the deadline by definition — that status is written only by the demotion. + // Hand-setting the flag would assert against a state production cannot hold. + let view = JobView { offer: Some(wants.clone()), ..view_with(&job, 10, Vec::new()) }; assert_eq!( capability_park_reason(&view, &filters_from_offer(&wants, 10)), None, @@ -3981,6 +4203,86 @@ mod tests { ); } + // THE PRODUCTION SHAPE OF THE DEADLINE PARK — the state `drive_auto_award` actually holds when + // it writes the row, rather than one assembled to suit the diagnosis. + // + // The earlier park tests hand-built `live: true` and called the helpers directly. That is a state + // the real branch CANNOT receive: a job parks for a passed deadline only after liveness has been + // re-derived against the current clock, and `derive_claim_liveness` demotes every `processing` + // claim to expired the moment `now > deadline`. Reading `live` at that point finds nothing, so + // the diagnosis returned `None` and the generic deadline row stood — a green test over a code + // path production could never reach. + // + // So this drives PRODUCTION'S OWN liveness step at `now = deadline + 1` BEFORE forming the row. + #[test] + fn the_capability_clause_survives_the_real_deadline_demotion() { + let job = "a".repeat(64); + let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let deadline = 1_000_u64; + + let park_row_for = |capability: crate::heartbeat::SeatCapability, requested| { + let mut claimed = claim(&job, true, 10, &mints); + claimed.capability = capability; + let offer = OfferView { + deadline_unix: deadline, + ..offer_requesting(&job, 10, requested, &[]) + }; + let mut claims = vec![claimed]; + // PRODUCTION'S OWN STEP, at the clock the park branch runs on. + crate::job_lifecycle::derive_claim_liveness( + &mut claims, + &[], + Some(deadline), + deadline + 1, + ); + assert!( + claims.iter().all(|claim| !claim.live), + "precondition: past the deadline production holds NO live claim — if this ever \ + stops being true the regression has stopped reproducing the bug it guards" + ); + let view = JobView { + offer: Some(offer.clone()), + claims, + ..view_with(&job, 10, Vec::new()) + }; + park_reason_deadline_passed( + capability_park_reason(&view, &filters_from_offer(&offer, 10)).as_deref(), + ) + }; + + let refused = park_row_for(seat(&["codex"], &[], &[]), Some("claude-code")); + assert!( + refused.contains("claude-code"), + "the row an operator reads must name the request that refused every claim, not only \ + that time ran out: {refused}" + ); + + // SILENT when capability was not the obstacle — the case where a capability-shaped reason is + // most plausible and most wrong. + let satisfied = park_row_for(seat(&["claude-code"], &[], &[]), Some("claude-code")); + assert_eq!( + satisfied, + park_reason_deadline_passed(None), + "a claim that SATISFIED the request means price, mint or budget stopped the award; \ + blaming capability would send the operator to fix a request that is fine" + ); + + // SILENT with no claims at all — nothing was refused, so there is nothing to explain. + let wants = OfferView { + deadline_unix: deadline, + ..offer_requesting(&job, 10, Some("claude-code"), &[]) + }; + let empty = JobView { + offer: Some(wants.clone()), + ..view_with(&job, 10, Vec::new()) + }; + assert_eq!( + capability_park_reason(&empty, &filters_from_offer(&wants, 10)), + None, + "an empty relay is not a capability failure" + ); + } + // #897 opens a NEW filter surface — the offer's request params — and the display-only fields must // not reach it. `hardware_is_unreachable_from_the_filterable_surface` guards the seat's // ADVERTISEMENT; nothing guarded the REQUEST side, because until now there was no request. diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index f920d333c..77a688232 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -74,9 +74,16 @@ pub const HARNESS_FAMILY_TAG: &str = "harness_family"; /// vocabulary. A request param that drifted from the advertisement tag would filter on a word no /// seat can say, and nothing would flag it. /// -/// Distinct from [`crate::seller_agents::AGENT_PARAM`], which names a PRESET: a family spans the -/// presets sharing a harness, so a family request binds dispatch where a preset name binds a -/// configuration. Both may be present and both are then enforced. +/// Distinct from [`crate::seller_agents::AGENT_PARAM`], which names a PRESET, and the difference is +/// what each one BINDS. A family request selects WHICH SEATS MAY CLAIM: it is matched against +/// [`HARNESS_FAMILY_TAG`] at award time and refuses a seat that does not advertise it. It does NOT +/// choose which harness a winning seat then dispatches — only the preset reaches execution +/// (`offer_row` persists `requested_agent` alone, `classify_offer` gates on it, and `execute_job` +/// hands it to `SellerAgents::dispatch`, which runs the seat's FIRST configured preset when it is +/// absent). So a multi-harness seat can satisfy a family request and run a different harness. +/// +/// A request that must bind execution therefore names the preset, and a model request REQUIRES one +/// — see [`crate::buyer::lifecycle::CapabilityRefusal::ModelWithoutHarnessPreset`]. pub const HARNESS_FAMILY_PARAM: &str = "harness_family"; /// Wire tag pairing ONE serving harness to the model it LAST REPORTED (#784): @@ -567,7 +574,11 @@ fn stated_values(values: Vec) -> Vec { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct HarnessModel { /// The harness family this model belongs to — a value from - /// [`crate::agent_presets::HARNESS_FAMILIES`]. This is what a buyer names to make dispatch bind. + /// [`crate::agent_presets::HARNESS_FAMILIES`]. + /// + /// ⚠ Naming this in a request does NOT make dispatch bind — that claim was here before the + /// request side existed and it is false. A family narrows which seats may claim; the PRESET is + /// the only requested axis execution reads. pub family: String, /// The harness-resolved session model id, verbatim. pub model: String, diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 580dcc559..c40280fb0 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -717,6 +717,7 @@ fn build_offer_draft( ) .map_err(|defect| JobLifecycleError::Input(format!("post_job refused: {defect}")))?; if let Some(refusal) = crate::buyer::lifecycle::unsatisfiable_capability_request( + offer.requested_agent.as_deref(), offer.requested_harness_family.as_deref(), offer.requested_model.as_deref(), &offer.required_capabilities, @@ -2067,7 +2068,7 @@ fn delivery_pay_deadline(results: &[ResultView], seller_pubkey: &str) -> Option< /// /// `offer_deadline_unix == None` (offer not yet on the relay) means expiry cannot be derived, /// so status-based liveness is preserved unchanged. -fn derive_claim_liveness( +pub(crate) fn derive_claim_liveness( claims: &mut [ClaimView], results: &[ResultView], offer_deadline_unix: Option, @@ -2093,6 +2094,42 @@ fn derive_claim_liveness( live_claim_id } +/// The claims that were award CANDIDATES at the instant the offer's deadline struck (#897). +/// +/// Diagnosing a park means reasoning about the claims that were in the running, and a job parks for +/// a passed deadline only once [`derive_claim_liveness`] has already demoted every `processing` +/// claim to expired. At that point `live` is false on all of them, so a diagnosis that reads `live` +/// sees an empty field in exactly the case where a seat did claim — and reports nothing, which reads +/// as "capability was not the problem" rather than as "this question was never asked". +/// +/// Re-derives with the SAME function at `now = deadline` instead of restating which statuses count +/// as candidates. At that instant the demotion branch (`now > deadline`) is inert and +/// `delivery_pay_deadline` is never reached, so this reproduces the liveness any pre-deadline +/// evaluation would have produced. It owns no rule of its own and so cannot drift from the one it +/// is asking about. +pub(crate) fn claims_at_deadline(view: &JobView) -> Vec { + let mut claims = view.claims.clone(); + // The demotion is DESTRUCTIVE — it overwrites `status` — so re-deriving at the deadline is not + // enough on its own: the information the diagnosis needs was already spent. Reverse the marker + // first. + // + // [`CLAIM_STATUS_EXPIRED`] is never a relay value. `derive_claim_liveness` is the only thing that + // writes it, and only for a claim that was `processing` when the deadline passed, so restoring it + // reads that function's own marker rather than guessing at a status. The round trip is asserted + // by `the_capability_clause_survives_the_real_deadline_demotion`, which demotes at `deadline + 1` + // and requires the claim to be a candidate again here — so if the demotion ever writes a + // different status, that test fails rather than this quietly seeing nothing. + for claim in claims.iter_mut() { + if claim.status == CLAIM_STATUS_EXPIRED { + claim.status = "processing".to_owned(); + } + } + if let Some(deadline) = view.offer.as_ref().map(|offer| offer.deadline_unix) { + derive_claim_liveness(&mut claims, &view.results, Some(deadline), deadline); + } + claims +} + /// A buyer AWARD found on the relay, parsed into exactly the fields an `awards` row needs and /// nothing else. Constructed ONLY when every field was read off the event itself, so a caller /// holding one can repair the local ledger without inferring or defaulting anything. @@ -5170,6 +5207,7 @@ mod tests { /// A from-scratch post request carrying a capability request, for the gate tests below. fn post_request_requesting( + agent: Option<&str>, family: Option<&str>, model: Option<&str>, capabilities: &[&str], @@ -5184,7 +5222,7 @@ mod tests { repo: None, branch: None, job: JobKind::FromScratch, - requested_agent: None, + requested_agent: agent.map(str::to_owned), requested_harness_family: family.map(str::to_owned), requested_model: model.map(str::to_owned), required_capabilities: capabilities.iter().map(|t| (*t).to_owned()).collect(), @@ -5203,7 +5241,7 @@ mod tests { fn post_job_refuses_a_request_no_seat_could_satisfy() { // Out-of-vocabulary family: no seat can advertise it, because families reach the wire only // through `harness_family_for_preset`. - let error = build_offer_draft(&post_request_requesting(Some("gpt-cli"), None, &[]), 10, None) + let error = build_offer_draft(&post_request_requesting(None, Some("gpt-cli"), None, &[]), 10, None) .expect_err("an unknown harness family must be refused"); assert!( error.to_string().contains("gpt-cli") && error.to_string().contains("not a known family"), @@ -5217,17 +5255,30 @@ mod tests { ); // Out-of-vocabulary capability token. - let error = build_offer_draft(&post_request_requesting(None, None, &["kubernetes"]), 10, None) + let error = + build_offer_draft(&post_request_requesting(None, None, None, &["kubernetes"]), 10, None) .expect_err("an unknown capability token must be refused"); assert!( error.to_string().contains("kubernetes") && error.to_string().contains("node"), "the error must name the bad token and the known ones: {error}" ); - // A model with no family — refused by the SATISFIABILITY gate, which asks the award predicate - // rather than restating #788's rule. - let error = build_offer_draft(&post_request_requesting(None, Some("opus"), &[]), 10, None) - .expect_err("a model with no harness family must be refused"); + // A model with no PRESET — refused by the SATISFIABILITY gate, which asks the award predicate + // rather than restating the rule. A family does not rescue it: dispatch never reads one, so + // the second row here is the one that would silently execute on the wrong harness. + for (agent, family) in [(None, None), (None, Some("claude-code"))] { + let error = + build_offer_draft(&post_request_requesting(agent, family, Some("opus"), &[]), 10, None) + .expect_err("a model with no harness preset must be refused"); + assert!( + error.to_string().contains("no seat could ever satisfy"), + "family={family:?}: unsatisfiable by construction, and the error should say so \ + rather than reading as a vocabulary complaint: {error}" + ); + } + let error = + build_offer_draft(&post_request_requesting(None, None, Some("opus"), &[]), 10, None) + .expect_err("a model with no harness preset must be refused"); assert!( error.to_string().contains("no seat could ever satisfy"), "a model-only request is unsatisfiable by construction and the error should say so \ @@ -5238,19 +5289,38 @@ mod tests { "the error must name the model the caller asked for: {error}" ); + // A preset and a family naming DIFFERENT harnesses. Dispatch honours the preset, so this + // offer would ask for codex and run Claude — and because a multi-harness seat advertises + // both families, no claim-level check can see it. Refused before the offer is signed. + let error = build_offer_draft( + &post_request_requesting(Some("claude"), Some("codex"), None, &[]), + 10, + None, + ) + .expect_err("a family contradicting the preset must be refused"); + assert!( + error.to_string().contains("claude") && error.to_string().contains("codex"), + "the error must name BOTH sides of the contradiction so the caller knows which to \ + change: {error}" + ); + // CONTROLS: every satisfiable shape must still post. Without these the assertions above are // equally explained by a gate that refuses everything with a capability request on it. - for (family, model, capabilities) in [ - (None, None, Vec::new()), - (Some("codex"), None, Vec::new()), - (Some("codex"), Some("gpt-5.6-sol[low]"), Vec::new()), - (None, None, vec!["rust"]), - (Some("claude-code"), None, vec!["rust", "node"]), + for (agent, family, model, capabilities) in [ + (None, None, None, Vec::new()), + (None, Some("codex"), None, Vec::new()), + (Some("codex"), None, None, Vec::new()), + (Some("codex"), Some("codex"), Some("gpt-5.6-sol[low]"), Vec::new()), + // The family DERIVED from the preset rather than stated — the shape a caller reaches + // for first, and the one a stricter rule would have broken. + (Some("codex"), None, Some("gpt-5.6-sol[low]"), Vec::new()), + (None, None, None, vec!["rust"]), + (None, Some("claude-code"), None, vec!["rust", "node"]), ] { - let request = post_request_requesting(family, model, &capabilities); + let request = post_request_requesting(agent, family, model, &capabilities); assert!( build_offer_draft(&request, 10, None).is_ok(), - "control: {family:?}/{model:?}/{capabilities:?} is satisfiable and must post" + "control: {agent:?}/{family:?}/{model:?}/{capabilities:?} is satisfiable and must post" ); } } @@ -5263,7 +5333,12 @@ mod tests { #[test] fn post_job_emits_the_capability_request_it_was_given() { let draft = build_offer_draft( - &post_request_requesting(Some("codex"), Some("gpt-5.6-sol[low]"), &["rust"]), + &post_request_requesting( + Some("codex"), + Some("codex"), + Some("gpt-5.6-sol[low]"), + &["rust"], + ), 10, None, ) @@ -5284,7 +5359,7 @@ mod tests { // And a post with no request is byte-identical to one built before any of this existed — // the property that makes filtering opt-in on the wire, not just in the predicate. - let plain = build_offer_draft(&post_request_requesting(None, None, &[]), 10, None) + let plain = build_offer_draft(&post_request_requesting(None, None, None, &[]), 10, None) .expect("draft"); assert_eq!( plain, From afd7375d33626a8963db2cb0e2b9396025dc3c87 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 09:05:57 -0700 Subject: [PATCH 06/10] mcp: say that a model request needs the preset, not the family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post_job schema told callers a model requires harness_family. It requires `harness` — only the preset reaches execution, so a model hung off a family names a harness the job would not actually run on. The family is derived from the preset when unstated, so harness+model is a complete request. The harness_family description claimed a family request binds dispatch. It selects which seats may CLAIM; a multi-harness seat can satisfy it and dispatch something else. Drops the claim that Claude seats advertise no model. That was true when written and is not a fact this schema should assert — replaced with the rule it was an instance of: a seat advertises a model only for harnesses whose ACP session reports one. Co-Authored-By: Claude Opus 5 --- crates/maxplayer/src/mcp.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index fbc18c1ef..b1195106f 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -198,7 +198,7 @@ fn tools() -> Value { json!([ { "name": "post_job", - "description": "Publish a real maxplayer job offer (OFFER kind) to the configured maxplayer relay, then let the buyer daemon drive the award: once a payable seller claim appears the daemon auto-awards it under the hood, so the normal flow is just post_job then collect (two calls). max_sats caps what the daemon will commit to (defaults to amount_sats); it never auto-awards a claim it cannot pay. harness, harness_family, model and capabilities are ALL hard award filters (only a seller advertising them can be awarded), enforced identically on the manual and automatic award paths; model requires harness_family. Omit them all and every claim passes exactly as before. Targeted seller p-tag is the documented default (pass seller_pubkey); set untargeted=true for an open offer. Optional repo+branch attach git delivery tags. CONTRIBUTION (freelance-PR) mode: supply target_repo_owner + target_repo_url + base_branch + base_oid to post a job-class=contribution offer against a repo you own (seller forks it and delivers a PR); these four are ALL-OR-NOTHING (a partial set is refused). Omit all four ⇒ from-scratch job. Never echoes secrets.", + "description": "Publish a real maxplayer job offer (OFFER kind) to the configured maxplayer relay, then let the buyer daemon drive the award: once a payable seller claim appears the daemon auto-awards it under the hood, so the normal flow is just post_job then collect (two calls). max_sats caps what the daemon will commit to (defaults to amount_sats); it never auto-awards a claim it cannot pay. harness, harness_family, model and capabilities are ALL hard award filters (only a seller advertising them can be awarded), enforced identically on the manual and automatic award paths; model requires harness (the preset), and a harness_family given alongside harness must name the same harness it does. Omit them all and every claim passes exactly as before. Targeted seller p-tag is the documented default (pass seller_pubkey); set untargeted=true for an open offer. Optional repo+branch attach git delivery tags. CONTRIBUTION (freelance-PR) mode: supply target_repo_owner + target_repo_url + base_branch + base_oid to post a job-class=contribution offer against a repo you own (seller forks it and delivers a PR); these four are ALL-OR-NOTHING (a partial set is refused). Omit all four ⇒ from-scratch job. Never echoes secrets.", "inputSchema": { "type": "object", "properties": { @@ -216,11 +216,11 @@ fn tools() -> Value { }, "model": { "type": "string", - "description": "Request a specific seller model. Posted on the offer as [\"param\",\"harness_model\",] and enforced as a HARD award filter: only a seller advertising that model FOR the requested harness family can be awarded. REQUIRES harness_family — a model without one is refused at post time, because a bare model does not say which harness would run it. Matched by exact equality against the model id the seat advertises (e.g. gpt-5.6-sol[low]), which is whatever its harness reported, so this is a LAST-OBSERVED self-report: it narrows who is considered, it does not pin what executes. Claude seats advertise no model yet, so a model request currently matches only non-Claude families." + "description": "Request a specific seller model. Posted on the offer as [\"param\",\"harness_model\",] and enforced as a HARD award filter: only a seller advertising that model FOR the harness the job will dispatch on can be awarded. REQUIRES harness (the preset) — a model without one is refused at post time, because only the preset reaches execution, so a model hung off anything else names a harness the job would not actually run. The family is DERIVED from the preset when you do not state one, so harness+model is a complete request. Matched by exact equality against the model id the seat advertises (e.g. gpt-5.6-sol[low]), which is whatever its harness reported, so this is a LAST-OBSERVED self-report: it narrows who is considered, it does not pin what executes. A seat advertises a model only for harnesses whose ACP session reports one, so a model request matches nothing on a seat whose harness reports none." }, "harness_family": { "type": "string", - "description": "Request a harness FAMILY (claude-code|codex|cursor|goose). Posted on the offer as [\"param\",\"harness_family\",] and enforced as a HARD award filter: only a seller advertising that family can be awarded. Distinct from harness, which names a preset — a family spans the presets sharing a harness, so a family request binds dispatch where a preset binds a configuration. Both may be given and both are then enforced. An unknown family is refused at post time." + "description": "Request a harness FAMILY (claude-code|codex|cursor|goose). Posted on the offer as [\"param\",\"harness_family\",] and enforced as a HARD award filter: only a seller advertising that family can be awarded. Distinct from harness, which names a preset, and the difference is what each one BINDS: a family selects WHICH SEATS MAY CLAIM, while only the preset decides which harness a winning seat then runs — so a multi-harness seat can satisfy a family request and dispatch a different harness. Give harness when the request must bind execution. If you give both, the family must name the same harness the preset does, or the offer is refused at post time. An unknown family is refused at post time." }, "capabilities": { "type": "array", @@ -574,12 +574,12 @@ mod tests { } // The model axis carries two extra caller-facing facts that are load-bearing and easy to - // drop: it needs a family (#788), and it matches a self-report rather than pinning execution. + // drop: it needs the PRESET, and it matches a self-report rather than pinning execution. let model_description = description("model"); assert!( - model_description.contains("requires harness_family"), - "a model without a family is REFUSED, so the schema must say it requires one — a caller \ - that omits it gets no awards at all: {model_description}" + model_description.contains("requires harness"), + "a model without a harness preset is REFUSED, so the schema must say it requires one — \ + a caller that omits it gets no awards at all: {model_description}" ); assert!( model_description.contains("does not pin what executes"), @@ -648,7 +648,10 @@ mod tests { ), ( "model", + // The preset is named too: a model request without one is a REQUEST defect and + // would refuse before the claim's model was ever compared. AwardFilters { + requested_agent: Some("codex"), requested_harness_family: Some("codex"), requested_model: Some("opus"), ..neutral From a14678a2cf8bff3bc7def3ecf0c9db482608c506 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 11:33:46 -0700 Subject: [PATCH 07/10] docs: say what binds dispatch, everywhere it is claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit corrected "three comments" that claimed a family or model request binds dispatch. Three was the count of sites I remembered writing. The real count is 19 hunks across 6 files, and the ones I missed were the normative ones. The claim appears in at least four lexical forms — "binds dispatch", "enforced by dispatch", "ENFORCED, at the seat, when the job is dispatched", and "binds delivery because the seller enforces it exact-or-nothing at dispatch". A sweep built from the wording I had used myself could not match the others, and it indexed only Rust doc comments, so it could not see the spec at all. docs/protocol-v1.md carried it twice and is the higher authority: 4.5.3 called the family "the only one of the three backed by a mechanism", and 6.1.1 justified the model rule with the same inheritance argument. That 4.5.3 sentence predates #897 (7aa40e4); the rest are mine from 93a304b. What is true, re-read rather than recalled: AgentRegistry::dispatch matches on the preset NAME and returns the seat's first preset when none is named. In the whole seller run path requested_harness_family appears only as None in three initializers — nothing reads it. So no filterable field is enforced at dispatch, and 4.5.3's trichotomy is now one echo and two silences. 6.1.1 states the rule the predicate implements: a model needs the agent preset, a stated family must agree with the preset's own family, an unstated one is derived from it, and a family named alone stays valid as a seat filter that binds who may CLAIM and not what dispatches. gateway.rs held four stale comments but enforces no pairing in code — checked, not assumed. It owns no copy of the rule, so it needed no change when the model's anchor moved from the family to the preset. That is the same property the satisfiability gate has, and it is why both were free. Feature-union row at this tree: 1229 passed, 0 failed, 2 ignored, rc=0. Five warnings, all pre-existing, unchanged. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/lifecycle.rs | 2 +- crates/maxplayer-core/src/buyer/mod.rs | 11 ++++-- crates/maxplayer-core/src/gateway.rs | 26 ++++++++----- crates/maxplayer-core/src/heartbeat.rs | 41 ++++++++++++-------- crates/maxplayer-core/src/job_lifecycle.rs | 18 ++++++--- docs/protocol-v1.md | 36 +++++++++++------ 6 files changed, 86 insertions(+), 48 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index d23627b05..0cb1c04c3 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -318,7 +318,7 @@ pub fn claim_meets_capability_request( /// [`claim_meets_capability_request`] against it. A request the perfect claim cannot pass is one no /// claim can pass. /// -/// That construction is the whole point. A gate that restated "a model needs a family" in its own +/// That construction is the whole point. A gate that restated "a model needs a preset" in its own /// words would be a second copy of a rule owned elsewhere, and the two would rot apart silently: /// #788 may later make a bare model valid, and on that day this gate must stop refusing WITHOUT /// anyone remembering it exists. Because the verdict comes from the predicate, it will. diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 3515b6137..f05f2e7ca 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -431,16 +431,19 @@ struct PostJobParams { /// 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_family` — a model - /// with no family refuses every claim rather than being ignored (#788). + /// 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, #[serde(default)] model: Option, /// 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 binds dispatch where a preset binds a - /// configuration. Both may be given and both are then enforced. + /// 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, /// Capability tokens the job requires (#897) — a subset of diff --git a/crates/maxplayer-core/src/gateway.rs b/crates/maxplayer-core/src/gateway.rs index 51905ef26..7512debf9 100644 --- a/crates/maxplayer-core/src/gateway.rs +++ b/crates/maxplayer-core/src/gateway.rs @@ -83,7 +83,8 @@ pub struct OfferDraft { /// `["param", "harness_family", …]`. `None` ⇒ no preference. pub requested_harness_family: Option, /// The model this job asks for (#897), as `["param", "harness_model", …]`. `None` ⇒ no - /// preference. Only meaningful paired with a family; a model alone refuses every claim (#788). + /// preference. Only meaningful paired with `requested_agent` — the preset is the only axis + /// dispatch reads — and a model without one refuses every claim. pub requested_model: Option, /// Capability tokens this job REQUIRES (#897), as `["param", "capability", …]`. Empty ⇒ no /// requirement, and no tag is emitted, so an offer that requires nothing stays byte-identical to @@ -140,21 +141,28 @@ impl OfferDraft { /// Request a harness family, a model, and/or a set of capability tokens for this job (#897). /// - /// All three axes take ONE builder because they are ONE request: the model is only meaningful - /// paired with a family (#788), so an API that let a caller set them independently would make the - /// invalid pairing the easy thing to write. They travel together and are judged together. + /// All three axes take ONE builder because they are ONE request: they travel together and are + /// judged together. + /// + /// ⚠ The PRESET is not one of them — it is set by [`Self::requesting_agent`]. A model needs the + /// preset, so a caller requesting a model through this builder alone builds an offer no claim can + /// satisfy. That is the fail-closed direction and the posting path refuses it before signing, but + /// it is the one pairing this signature cannot make obvious. /// /// Blank and all-whitespace values state nothing and are dropped, so "no requirement" has one /// representation on the wire — the same "stated or absent" contract the seat-side readers apply /// (`docs/protocol-v1.md` §4.5.2). Tokens are de-duplicated for the same reason: two spellings of /// one requirement would put a set on the wire that no seat's advertisement is shaped like. /// - /// Vocabulary is NOT checked here, and the family/model PAIRING is not enforced here either. This + /// Vocabulary is NOT checked here, and the PAIRING rules are not enforced here either. This /// builds what it is told to build; the vocabulary gate is /// [`crate::capability::validate_capability_request`], run by the posting path before an event is - /// signed, and the pairing rule is the award predicate's — it refuses a model with no family - /// rather than ignoring it, which is the fail-closed backstop that also covers offers this code - /// never built. + /// signed, and the pairing rules are the award predicate's — it refuses a model with no preset, + /// and a family contradicting the preset, rather than ignoring either. That is the fail-closed + /// backstop, and it also covers offers this code never built. + /// + /// Owning no copy of those rules is why this builder needed no change when the model's anchor + /// moved from the family to the preset (#897 review). pub fn requiring_capability( mut self, requested_harness_family: Option<&str>, @@ -244,7 +252,7 @@ pub struct ParsedOffer { /// The harness FAMILY this job requested (#897). `None` ⇒ no preference (absent or blank). pub requested_harness_family: Option, /// The model this job requested (#897). `None` ⇒ no preference. Refused rather than ignored when - /// it arrives without a family (#788). + /// it arrives without `requested_agent`. pub requested_model: Option, /// Capability tokens this job requires (#897). Empty ⇒ no requirement. pub required_capabilities: Vec, diff --git a/crates/maxplayer-core/src/heartbeat.rs b/crates/maxplayer-core/src/heartbeat.rs index 77a688232..e12eb013b 100644 --- a/crates/maxplayer-core/src/heartbeat.rs +++ b/crates/maxplayer-core/src/heartbeat.rs @@ -131,10 +131,15 @@ pub const HARNESS_FAMILY_PARAM: &str = "harness_family"; /// ## Why paired, and why not a flat list /// /// A model belongs to a harness; it is not an independent axis. On a multi-harness seat a bare model -/// does not say which harness would run, and the harness is what dispatch enforces -/// ([`crate::seller_agents::AgentRegistry::dispatch`] is exact-or-nothing). So a buyer filtering on a -/// model must be able to name the harness that carries it — then the model request is a REFINEMENT -/// of a harness request and inherits the guarantee that already exists, with no new seller logic. +/// does not say which harness would run, so the ADVERTISEMENT pairs each model to the family that +/// carries it — a flat list could not say which is which. +/// +/// ⚠ Pairing the advertisement is not the same as binding execution, and the two must not be run +/// together. What dispatch selects on is the PRESET +/// ([`crate::seller_agents::AgentRegistry::dispatch`] is exact-or-nothing on the preset NAME, and +/// falls back to the seat's first preset when none is named). A family is not read there at all. So +/// a model request inherits no guarantee from a family request; it is the `agent` preset that a +/// model must be paired with on the REQUEST side, and only that pairing reaches execution. /// /// The flat `["models", id, …]` this replaces cannot express that pairing. Pairing by POSITION /// against the family list was rejected for a sharper reason: a harness that reports no model @@ -160,12 +165,13 @@ pub const HARNESS_MODEL_TAG: &str = "harness_model"; /// Offer param naming the model a job REQUESTS (#897): /// `["param", "harness_model", ""]`, single-value, matched against [`HARNESS_MODEL_TAG`]. /// -/// ⚠ ONLY MEANINGFUL PAIRED WITH [`HARNESS_FAMILY_PARAM`], and a model arriving without one refuses -/// every claim rather than being ignored — the PAIR is the unit, because a bare model on a -/// multi-harness seat does not say which harness would run it (#788). That is a v1 restriction with a -/// mechanism behind it: the harness filter binds delivery because the seller enforces it -/// exact-or-nothing at dispatch, and a model phrased as a refinement of a harness request inherits -/// that chain where a model-only request would inherit none of it. +/// ⚠ ONLY MEANINGFUL PAIRED WITH THE `agent` PRESET, and a model arriving without one refuses every +/// claim rather than being ignored — the PAIR is the unit, because a bare model on a multi-harness +/// seat does not say which harness would run it. The PRESET is the anchor rather than +/// [`HARNESS_FAMILY_PARAM`] because it is the only part of a request that reaches execution: the +/// seat dispatches on the preset alone and runs its first configured preset when none is named, so +/// a model hung off a family would pass the filter and then run on whichever preset the seat happens +/// to list first. The family is DERIVED from the preset when it is not stated. /// /// ⚠ And it filters on a LAST-OBSERVED self-report, never a promise: see [`HARNESS_MODEL_TAG`]. A /// model request narrows who is CONSIDERED; it does not pin what executes. #785 carries the @@ -212,16 +218,17 @@ pub const HARNESS_VARIANT_TAG: &str = "harness_variant"; /// /// ## Known asymmetry — probing buys provenance, not detectability /// -/// This remains the only filterable field with NEITHER dispatch enforcement NOR any echo a buyer -/// could compare against. `harness_family` is enforced by dispatch (exact-or-nothing) — a real -/// mechanism. `harness_model` is merely ECHOED: the result carries `["model", name]`, so a buyer can -/// notice a divergence from what it awarded on, but both values are the SELLER'S OWN WORD and -/// `docs/protocol-v1.md` §6.4 states that nothing verifies that block. It is an inconsistency -/// signal, not a falsifier. No event carries a capability back at all. +/// No filterable field has dispatch enforcement, and this one also has no echo a buyer could +/// compare against. `harness_family` is NOT enforced by dispatch: dispatch selects on the offer's +/// `agent` preset alone and runs the seat's first preset when none is named, so a family decides who +/// may be CONSIDERED and never what executes. `harness_model` is merely ECHOED: the result carries +/// `["model", name]`, so a buyer can notice a divergence from what it awarded on, but both values +/// are the SELLER'S OWN WORD and `docs/protocol-v1.md` §6.4 states that nothing verifies that block. +/// It is an inconsistency signal, not a falsifier. No event carries a capability back at all. /// /// The residual is accepted because the probe makes the claim true at the SOURCE, and because /// presence is honestly necessary-not-sufficient for any capability signal — but the three are one -/// enforcement, one echo and one silence, and must not be read as three grades of the same proof. +/// echo and two silences, and must not be read as grades of the same proof. /// /// ## Freshness — bounded by UPTIME, not by the beat /// diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index c40280fb0..8c3f6fbd9 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -76,13 +76,17 @@ pub struct PostJobRequest { /// A requested harness narrows the market: only sellers advertising it may be awarded. pub requested_agent: Option, /// Ask for a harness FAMILY (#897). `None` ⇒ no preference. Enforced as a hard award filter on - /// both award paths, so only a seller advertising that family may be awarded. + /// both award paths, so only a seller advertising that family may be awarded. It selects who may + /// CLAIM and does not bind which harness a multi-harness seat dispatches — only `requested_agent` + /// does that — so when both are present the family must AGREE with the preset's own family. pub requested_harness_family: Option, /// Ask for a MODEL (#897). `None` ⇒ no preference. Enforced as a hard award filter on both award /// paths, matched against the family/model PAIR a seat advertises. /// - /// ⚠ Requires `requested_harness_family`: a model with no family refuses every claim (#788), so - /// posting one alone stops awards rather than narrowing them. + /// ⚠ Requires `requested_agent`: the preset is the only axis dispatch reads, so it is the only + /// one that binds the model to the harness that will actually run. The family is DERIVED from + /// the preset when it is not stated. Posting a model alone stops awards rather than narrowing + /// them. pub requested_model: Option, /// Capability tokens the job REQUIRES (#897). Empty ⇒ no requirement, and the offer is /// byte-identical to one posted before capability requests existed. Every token must be in @@ -237,9 +241,11 @@ pub struct OfferView { pub requested_harness_family: Option, /// The model this job requested (`["param", "harness_model", …]`), #897. `None` ⇒ any. /// - /// ⚠ Only meaningful PAIRED with `requested_harness_family`: a model with no family is REFUSED - /// rather than ignored (#788), so it stops awards instead of narrowing them. And it matches a - /// seat's LAST-OBSERVED self-report, so it narrows who is considered without pinning what runs. + /// ⚠ Only meaningful PAIRED with `requested_agent`: a model with no preset is REFUSED rather + /// than ignored, so it stops awards instead of narrowing them. The preset is the anchor because + /// it is the only axis dispatch reads; the family is DERIVED from it when unstated. And the + /// model matches a seat's LAST-OBSERVED self-report, so it narrows who is considered without + /// pinning what runs. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_model: Option, /// Capability tokens this job requires (`["param", "capability", …]`), #897. Empty ⇒ none, and diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 7fb58073c..699db35fa 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -217,8 +217,10 @@ probe inside that container: a host-side check proves a capability the job will The three filterable fields are NOT equally checkable, and none of the three is verified by the protocol: -- `harness_family` is ENFORCED, at the seat, when the job is dispatched: dispatch binds to the named - family exactly or not at all. This is the only one of the three backed by a mechanism. +- `harness_family` is NEITHER ENFORCED NOR ECHOED. Nothing at the seat reads it: dispatch selects a + harness by the offer's `agent` preset alone, and runs the seat's first configured preset when no + preset is named. A family filter therefore decides who may be CONSIDERED and never what executes, + so a seat serving several families can satisfy the filter and then dispatch a different one. - `harness_model` is ECHOED. The result event carries `["model", name]` — the model the seller says it used. A buyer can compare that against the model it was awarded on, so a divergence is at least VISIBLE in the buyer's own records. It is not a falsifier: both values are the seller's word, and @@ -227,8 +229,9 @@ protocol: - `capabilities` is neither enforced nor echoed. No event carries a capability back, so nothing a buyer receives can disagree with the advertisement at all. -A reader MUST NOT read these as three grades of proof. One is an enforcement, one is an -inconsistency signal, and one is silence. +A reader MUST NOT read these as grades of proof. NONE of the three is an enforcement: one is an +inconsistency signal and two are silence. The only part of an offer that binds what executes is the +`agent` preset, which is why §6.1.1 requires a model request to name one. #### 4.5.4 Freshness @@ -340,8 +343,8 @@ reject a lifecycle event that lacks it. | `["v","1"]` | 1 | yes | Protocol major | | `["p", seller_pubkey]` | 0..1 | no | Targets one seat | | `["param","agent", agent_id]` | 0..1 | no | Requests one harness | -| `["param","harness_family", family]` | 0..1 | no | Requires one harness family | -| `["param","harness_model", model]` | 0..1 | no | Requires one model; needs `harness_family` | +| `["param","harness_family", family]` | 0..1 | no | Requires one harness family; must agree with `agent` | +| `["param","harness_model", model]` | 0..1 | no | Requires one model; needs `agent` | | `["param","capability", token, ...]` | 0..1 | no | Requires every listed capability token | | `["delivery","git"]` | 0..1 | no | Delivery binding mode | | `["repo", locator]` | 0..1 | no | Bound delivery remote | @@ -364,11 +367,22 @@ A buyer MUST decide the award on the request carried by the SIGNED OFFER, never at award time. Both award paths — automatic selection and a manually named claim — MUST apply it identically. Naming a claim selects WHICH claim is judged, never WHETHER it is judged. -`harness_model` is meaningful only ALONGSIDE `harness_family`. A model alone does not say which -harness would run it, so a reader MUST refuse such an offer rather than ignore the model: the harness -request is what binds dispatch, and a model phrased as a refinement of it inherits that binding where -a bare model inherits none. A refusal is the fail-closed outcome; silently dropping the model would -award a job on terms the buyer did not ask for. +`harness_model` is meaningful only ALONGSIDE the `agent` preset, and a reader MUST refuse an offer +naming a model without one rather than ignore the model. The preset is the anchor because it is the +only part of the request that reaches execution: dispatch selects on it alone (§4.5.3). A model hung +off a family instead would pass the filter and then run on whatever preset the seat happens to list +first — the divergence this request exists to prevent. A refusal is the fail-closed outcome; +silently dropping the model would award a job on terms the buyer did not ask for. + +`harness_family` and `agent` MUST NOT contradict each other. When both are present the family MUST +equal the preset's own family, and a reader MUST refuse an offer where they disagree: dispatch +honours the preset, so awarding one would run a harness the offer did not ask for. When a model is +requested with a preset but no family, the family is DERIVED from the preset rather than demanded — +naming the preset and the model is a complete request. + +A family named ALONE — no preset, no model — stays valid and none of the above narrows it. It binds +which seats may CLAIM the job. It does NOT bind which harness a multi-harness seat dispatches, and a +buyer needing that second guarantee MUST name the `agent` preset. `capability` is ONE multi-value tag, not one tag per token. A reader takes the first matching tag, so a second would be silently dropped and the buyer filtered on a subset of its own request. From 80abc309a9cd8b3a4969c42468e6402f3a6655f7 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 12:38:38 -0700 Subject: [PATCH 08/10] buyer: say that the coupling test's loop cannot cover the predicate The comparison loop in `the_post_time_gate_refuses_exactly_what_the_predicate_can_never_pass` reads like the assertion carrying that test. It is not, and a reviewer crediting it with coverage it lacks is the likely next mistake. Both sides of the comparison end in `claim_meets_capability_request`, so the oracle is computed from the subject. When the predicate breaks, both sides move together, the comparison still holds, and the loop reports a pass through the exact fault it appears to watch for. Measured, not feared: deleting the family-contradicts-preset check, and separately the model-requires-preset check, each left the loop green and reddened only the controls. So the controls are the whole of this test's coverage against a predicate fault, and a new rule in the predicate needs a new control here. The comment says both, next to the controls, where someone editing them will read it. The general form is worth more than the instance: whenever an oracle is derived from the thing under test, agreement between two values computed from one broken source is not evidence about that source. Same shape as the self-matching needle deleted earlier in this branch, one layer out. Comment-only: 0 non-comment lines added. Five guard tests re-run at this tree, 5 passed, 0 failed, rc=0. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/lifecycle.rs | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index 0cb1c04c3..e6bce30d6 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -4042,10 +4042,24 @@ mod tests { ); } - // Positive controls on BOTH sides, one per rule the table exercises. Without these, a gate - // stuck at `None` and a predicate stuck at `None` would agree on every row and pass — and - // the controls pin the actual verdicts, so an oracle that is wrong the same way the gate is - // wrong still fails here. + // ⛔ THESE CONTROLS ARE THE ONLY REAL COVERAGE THIS TEST HAS AGAINST A FAULT IN THE + // PREDICATE. THE LOOP ABOVE IS NOT. Do not read that loop as protecting the predicate, and + // do not weaken these on the grounds that the loop already compares the two sides. + // + // The oracle is COMPUTED FROM THE SUBJECT: both sides of that comparison end in + // `claim_meets_capability_request`. So when the PREDICATE is what breaks, both sides move + // together, the comparison still holds, and the loop reports a pass THROUGH the exact fault + // it looks like it is watching for. That is measured, not feared — deleting the + // family-contradicts-preset check, and separately the model-requires-preset check, each left + // the loop GREEN, and only these controls went red. + // + // The general form, because it is not special to this test: whenever an oracle is derived + // from the thing under test, the controls carry the whole of the coverage. Agreement between + // two things computed from one broken source is not evidence about the source. + // + // ⇒ ADDING A RULE TO THE PREDICATE MEANS ADDING A CONTROL HERE. The loop will not notice. + // One control per rule the table exercises; they pin actual verdicts rather than agreement, + // so an oracle wrong in the same way as the gate still fails here. assert!( unsatisfiable_capability_request(None, None, Some("opus"), &[]).is_some(), "control: a model with no harness preset must be refused by the gate" From 610ef42cd90e3c47f59de143d4c9e7996f249ad3 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 13:02:08 -0700 Subject: [PATCH 09/10] buyer: refuse a harness family outside the vocabulary at award time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the award predicate validated an unknown capability TOKEN and not an unknown requested harness FAMILY. Verified against the tree rather than taken on description: HARNESS_FAMILIES appears in buyer/lifecycle.rs exactly once, in a doc comment, and never in a check. Neither reader filters vocabulary either — harness_families_from_tags and the offer's param reader both only trim and drop blanks. So a foreign offer requesting `harness_family="not-a-family"`, matched by a claim advertising that same string, satisfied the predicate. With payable terms and no requested preset, both award paths selected it. The red-prove shows exactly that: with the check removed, the predicate returns Ok(()) and the auto path returns the claim id. The post-time vocabulary gate already refused this, but it only sees offers built by our own client — and the comment on `unsatisfiable_capability_request` tells the reader that the award-time refusal is the wire-level backstop for foreign offers. That was a comment documenting a safety property the code did not have, on the money path, which is the kind that ends the search for whoever reads it next. The check goes where the token check already lives: the request is judged before the claim, so a nonsense request cannot be laundered into a match by a claim that agrees with it. Only the STATED family needs it; a derived one comes from `harness_family_for_preset`, whose range is HARNESS_FAMILIES by construction. The family axis now matches the token axis at both layers, and both read the same HARNESS_FAMILIES constant, so they cannot drift apart. `UnknownHarnessFamily` is kept distinct from `HarnessFamily` for the reason `UnknownCapabilityToken` is distinct from `Capabilities`: "no seat advertises this" means wait or add a seat, "that is not a real family" means no seat can ever satisfy it. The adversarial exact-match case is the load-bearing one. A test pairing an unknown request with an ORDINARY claim goes red on a plain mismatch even with the vocabulary check deleted, so it would pass without the guard and prove nothing. Request and claim carry the same unknown value. Four rows and one control added to the coupling test, per the note beside those controls: a new rule in the predicate needs a new control, because the comparison loop cannot see a predicate fault. Also corrects a review-flagged comment: the model-only park test said the operator must "add a family"; the implemented rule requires the `agent` preset. Feature-union row at this tree: 1260 passed, 0 failed, 2 ignored, rc=0. Red-prove: check removed ⇒ 3 tests red including the award path; restored ⇒ green. Co-Authored-By: Claude Opus 5 --- crates/maxplayer-core/src/buyer/lifecycle.rs | 136 ++++++++++++++++++- docs/protocol-v1.md | 13 +- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/crates/maxplayer-core/src/buyer/lifecycle.rs b/crates/maxplayer-core/src/buyer/lifecycle.rs index e6bce30d6..11749f253 100644 --- a/crates/maxplayer-core/src/buyer/lifecycle.rs +++ b/crates/maxplayer-core/src/buyer/lifecycle.rs @@ -156,6 +156,21 @@ pub enum CapabilityRefusal { /// real token" means the request is wrong and no seat can ever satisfy it. Collapsing them would /// tell an operator to wait for a seat that cannot exist. UnknownCapabilityToken { token: String }, + /// The job requested a harness family outside [`crate::agent_presets::HARNESS_FAMILIES`]. + /// + /// Distinct from `HarnessFamily` for the same reason `UnknownCapabilityToken` is distinct from + /// `Capabilities`: the two imply OPPOSITE operator actions. "No seat advertises this family" + /// means wait or add a seat; "that is not a real family" means the request is wrong and no seat + /// can ever satisfy it. + /// + /// ⚠ THIS IS A WIRE-LEVEL GUARD, NOT A CONVENIENCE. The post-time vocabulary gate only sees + /// offers built by OUR client. A foreign client can publish any string it likes, and neither + /// reader filters vocabulary — `harness_families_from_tags` and the offer's param reader both + /// only trim and drop blanks. So without this check a foreign offer requesting a bogus family, + /// matched by a claim advertising THAT SAME bogus family, satisfied the predicate and both award + /// paths could select it. Judged on the REQUEST, before the claim is consulted, so a nonsense + /// request can never be laundered into a match by a claim that agrees with it. + UnknownHarnessFamily { requested: String }, } impl std::fmt::Display for CapabilityRefusal { @@ -183,6 +198,9 @@ impl std::fmt::Display for CapabilityRefusal { Self::UnknownCapabilityToken { token } => { write!(f, "{token} is not a known capability token") } + Self::UnknownHarnessFamily { requested } => { + write!(f, "{requested} is not a known harness family") + } } } } @@ -224,6 +242,20 @@ pub fn claim_meets_capability_request( { return Err(CapabilityRefusal::UnknownCapabilityToken { token: unknown.clone() }); } + // The STATED family must be a real one. Judged against the vocabulary, never against the claim: + // a claim advertising the same bogus string would otherwise satisfy the filter, and the offer + // and the seat would agree with each other about a harness that does not exist. + // + // Only the stated family needs this. A DERIVED one comes from `harness_family_for_preset`, whose + // range is `HARNESS_FAMILIES` by construction — asserted in `agent_presets` — so validating it + // here would be checking a value this crate produced rather than one the wire supplied. + if let Some(requested) = filters.requested_harness_family { + if !crate::agent_presets::HARNESS_FAMILIES.contains(&requested) { + return Err(CapabilityRefusal::UnknownHarnessFamily { + requested: requested.to_owned(), + }); + } + } // The request must be one DISPATCH can honour, before any of it is matched against a claim. // // Only `requested_agent` reaches execution: `offer_row` persists it alone, `classify_offer` @@ -3789,6 +3821,95 @@ mod tests { ); } + // The same rule for the FAMILY axis, which had the token axis's guard and not its own. + // + // Neither reader filters vocabulary — `harness_families_from_tags` and the offer's param reader + // both only trim and drop blanks — so a foreign offer can carry any string, and a claim can + // advertise the same one. + #[test] + fn an_unknown_harness_family_is_refused_even_when_the_claim_advertises_it() { + let mut wants = filters(10, 10); + wants.requested_harness_family = Some("not-a-family"); + + // ⛔ THE ADVERSARIAL CASE IS THE ONLY ONE THAT PROVES ANYTHING, AND IT IS EASY TO GET WRONG. + // The claim advertises THE SAME unknown value. Test this against an ORDINARY claim instead + // and it goes red on a plain family mismatch — which it would do with the vocabulary check + // deleted entirely. Such a test passes without the guard and certifies nothing. + assert_eq!( + claim_meets_capability_request(&seat(&["not-a-family"], &[], &[]), &wants), + Err(CapabilityRefusal::UnknownHarnessFamily { requested: "not-a-family".into() }), + "a claim that AGREES with a nonsense request must not launder it into a match" + ); + + // Judged on the REQUEST, before the claim: a seat advertising every real family is refused + // identically, so the refusal can never be read as a property of the seat. + assert_eq!( + claim_meets_capability_request( + &seat(&["claude-code", "codex", "cursor", "goose"], &[], &[]), + &wants, + ), + Err(CapabilityRefusal::UnknownHarnessFamily { requested: "not-a-family".into() }), + ); + + // And it narrows nothing real: every family in the vocabulary still passes on a seat that + // serves it. Without this the guard could be a blanket refusal and the cases above would not + // notice. + for family in crate::agent_presets::HARNESS_FAMILIES { + wants.requested_harness_family = Some(family); + assert_eq!( + claim_meets_capability_request(&seat(&[family], &[], &[]), &wants), + Ok(()), + "{family} is a real family and must still pass" + ); + } + } + + // The wire-level case this exists for: an offer we did not build, matched by a claim that agrees + // with it. The post-time vocabulary gate never sees this offer, so if the predicate does not + // refuse it, nothing does — and the comment on `unsatisfiable_capability_request` promises that + // the award-time refusal IS the backstop for foreign offers. + #[test] + fn a_foreign_offer_naming_a_bogus_family_is_refused_on_both_paths_and_parks_blaming_the_request() + { + let job = "a".repeat(64); + let mints = vec![DEFAULT_MINT_URL.to_owned()]; + let claim_id = "c".repeat(64); + + // A claim that is payable, live, and advertises exactly the bogus family requested. Every + // other filter passes; only the vocabulary check stands between this and an award. + let mut colluding = claim(&job, true, 10, &mints); + colluding.capability = seat(&["not-a-family"], &[], &[]); + + let bogus = offer_requesting(&job, 10, Some("not-a-family"), &[]); + let view = + JobView { offer: Some(bogus.clone()), ..view_with(&job, 10, vec![colluding]) }; + let filters = filters_from_offer(&bogus, 10); + + assert_eq!( + select_awardable_claim(&view, &filters), + None, + "auto path: a family outside the vocabulary must never award, however well it matches" + ); + assert_eq!( + named_claim_awardable(&view, &claim_id, &filters), + Err(NamedAwardRefused::Capability { + claim_id: claim_id.clone(), + refusal: CapabilityRefusal::UnknownHarnessFamily { + requested: "not-a-family".into() + }, + }), + "manual path: naming the claim must not bypass the vocabulary check either" + ); + + let reason = capability_park_reason(&view, &filters) + .expect("a bogus family must produce a park reason"); + assert!( + reason.contains("not a known harness family"), + "the row must blame the REQUEST. Blaming the claim would send an operator to find \ + another seat for a family that does not exist: {reason}" + ); + } + // A family refusal beats a model refusal when a claim fails both, because the family one is the // actionable half — a model refusal would send an operator chasing a model on a harness the seat // never offered. @@ -3998,6 +4119,13 @@ mod tests { // rule being stated twice is worth a row here rather than an argument in a comment. (None, None, None, &bogus), (None, Some("codex"), None, &mixed), + // Out-of-vocabulary FAMILY, the axis that had no vocabulary rule until review found it. + // Alone, and paired with each other axis, because the check runs on the REQUEST and must + // not be reachable only on some paths through the predicate. + (None, Some("not-a-family"), None, &[]), + (None, Some("not-a-family"), Some("opus"), &[]), + (Some("codex"), Some("not-a-family"), None, &[]), + (None, Some("not-a-family"), None, &rust), ]; for (agent, family, model, capabilities) in shapes { @@ -4085,6 +4213,12 @@ mod tests { unsatisfiable_capability_request(None, Some("codex"), None, &[]).is_none(), "control: a family-only request stays valid as a SEAT filter" ); + assert!( + unsatisfiable_capability_request(None, Some("not-a-family"), None, &[]).is_some(), + "control: a family outside the vocabulary must be refused by the gate — the synthesized \ + claim advertises exactly that family, so this passes only if the REQUEST is judged \ + before the claim" + ); } // A request that matches no live claim PARKS with a reason naming what to fix, rather than @@ -4168,7 +4302,7 @@ mod tests { assert!( reason.contains("without a harness preset"), "the row must name the REQUEST defect, not blame the claim — an operator reading it has \ - to know to add a family rather than to go find another seat: {reason}" + to know to add the `agent` preset rather than to go find another seat: {reason}" ); } diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 699db35fa..d552972c1 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -388,8 +388,17 @@ buyer needing that second guarantee MUST name the `agent` preset. a second would be silently dropped and the buyer filtered on a subset of its own request. Values follow the same "stated or absent" rule as §4.5.2: a reader MUST trim, and a value that states -nothing is absent. A request naming a family outside §4.5 or a token outside §4.5.2 can never match -and SHOULD be refused before the offer is published. +nothing is absent. A request naming a family outside §4.5 or a token outside §4.5.2 can never be +satisfied. A publisher SHOULD refuse it before the offer is published, and a buyer MUST refuse it at +AWARD time, judging the REQUEST before any claim is consulted. + +Both are required, and the award-time half is the load-bearing one. Refusing before publication only +covers offers that publisher built; anyone may sign an offer carrying any string. Neither the offer +reader nor the claim reader filters vocabulary — both only trim and drop blanks — so a claim +advertising the SAME out-of-vocabulary family as the request will match it on every other axis. A +buyer that checked only claim-against-request would find agreement and award. The request is +therefore judged against the vocabulary first, so that two parties agreeing about a harness that does +not exist cannot produce an award. Matching decides who is CONSIDERED; it never guarantees what executes. `harness_model` is a last-observed self-report (§4.5.4) and a capability token proves binary presence at probe time From 89b5394aa3d0dd6da395585b6135c6c844eaf549 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 25 Aug 2026 13:54:27 -0700 Subject: [PATCH 10/10] docs: the harness family filters claims, it does not bind dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELEASE_NOTES.md carried the exact premise this PR removed from the code. Two claims, both already false for the RC: `harness_family` is "exact-or-nothing at dispatch", and the runner sheet's family row is worth "enforced at dispatch". Dispatch reads the offer's `agent` preset and never reads `harness_family`, so neither was ever true of the shipped binary. Both lines also cited `docs/protocol-v1.md` §4.5.3/§4.5.4 as their source, and this PR corrects §4.5.3 to read that `harness_family` is NEITHER ENFORCED NOR ECHOED. They cited a spec asserting the opposite of what they claimed, which settles which artifact is wrong without arbitration. Found by enumerating the property (`enforc|exact-or-nothing|at dispatch`) rather than checking the two line numbers under review, with controls: 4 `harness_family` hits in the file and 0 on a negative needle. `heartbeat.rs:139` also says "exact-or-nothing" and is deliberately left alone. It says `AgentRegistry::dispatch` is exact-or-nothing on the preset NAME, which is true. The notes' error was applying a true property to the wrong field. The sheet's own mark is a separate defect on a separate surface. A tree-wide search found the same false claim in `web/app/src/ui/docks.ts` — the live mark, its doc comment and its hover tooltip — and in `web/app/test/capability.test.ts`, where a passing assertion pins the wrong mark green. That is a TypeScript change with its own test and is filed as its own head rather than folded into this money-path PR. Until it lands the sheet still shows the old mark, so these notes state the field's property and stop quoting a mark that is in flux. Doc-only. No code, no test, no behaviour change. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f02d8b8f6..d1ceb4387 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 @@ -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.