Skip to content

buyer: carry the capability request on the offer - #900

Merged
orveth merged 10 commits into
mainfrom
buyer-897-capability-request
Aug 25, 2026
Merged

buyer: carry the capability request on the offer#900
orveth merged 10 commits into
mainfrom
buyer-897-capability-request

Conversation

@orveth

@orveth orveth commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #897.

Capability filtering could not refuse anything, because no offer ever carried a capability request. #866 shipped the predicate and wired it into both selection paths; both award sites then built the request empty, so every claim passed and the feature shipped inert. This carries the request on the offer and populates AwardFilters from it at both sites.

Revision 2 — this PR was reviewed and did not survive it. Four separate things were wrong: it did not compile in CI's money-path job, the model/family request did not bind the harness that actually runs, the park reason it added could not be reached in production, and the award predicate never checked the requested family against the known vocabulary at all. All four are fixed here, and each is written up below rather than quietly amended, because in every case the miss is more useful than the fix.

What lands

The offer carries three params, and both award sites read them off the SIGNED OFFER — never from award params, so the request a buyer is held to is the one it published:

["param","harness_family", family]
["param","harness_model",  model]
["param","capability",     token, ...]

Wired end to end: OfferDraft → tags → parse_offerParsedOfferOfferViewAwardFilters at the manual award_claim RPC and at drive_auto_award.

An absent request passes every claim, and an offer that requests nothing is byte-identical to one posted before this existed. Filtering is opt-in on the wire, not just inside the predicate. Asserted, including the whitespace forms.

Blocker 1 — the request now binds the harness that actually runs

The review found that a model or family request named a harness nothing selects. That was correct, and it was the substantive defect in revision 1.

Verified against the tree rather than taken on description:

  • offer_row persists only requested_agent — not the family, not the model, not the capabilities. They cannot reach execution, which is a restart away from the claim.
  • classify_offer gates only on agents.serves(offer.requested_agent).
  • execute_job calls dispatch(requested_agent).
  • AgentRegistry::dispatch matches on the preset name and returns the seat's first configured preset when none is named — its own doc says so.

So a multi-harness seat could accept an offer requesting family=codex, model=… and deterministically run Claude. Every other filter passed, because the seat genuinely serves both.

The rule now enforced: a model requires the agent preset. When a family is also stated it must equal the preset's own family; when it is not stated it is derived from the preset. A family named alone stays valid and is unchanged — it binds which seats may claim, and it does not bind which harness a multi-harness seat dispatches. A buyer that needs the second guarantee must name the preset.

This moves the anchor from the family to the preset and stays fail-closed at every step. Deriving the family rather than demanding it keeps agent + model — the shape a buyer reaches for first — a valid request instead of a refusal.

Where it lives matters. The validation and the derivation are in claim_meets_capability_request, the one predicate both award paths already consult. The constructor stays a pure copy of the signed offer, and the post-time gate inherits the new rule for free, because it synthesizes a claim and asks the real predicate. Nothing restates the rule anywhere.

The synthesized claim advertises both the stated family and the preset's own family, and pairs the model to both. That is deliberate: computing which family is effective would put a second copy of the derive rule in the gate, and the two would rot apart. A maximally-capable seat only has to know which axes were requested, never how they resolve.

This is a behaviour change for existing callers

model was previously accepted with a family and refused without one. It now requires the preset. A caller passing family + model and no agent gets a post-time error where revision 1 accepted it.

A matched model remains a last-observed self-report, never a promise about the next job (§4.5.4). It narrows who is considered; it does not pin what executes. #785 carries the selection work that would make it a commitment. And a seat advertises a model only for harnesses whose ACP session reports one, so before requesting a model, check that seats in the target family actually advertise one — a model request matches nothing on a family that advertises none.

Blocker 2 — the park reason could not be reached in production

Also correct, and the design that fixes it took two attempts.

derive_claim_liveness flips every processing claim to expired once now > deadline, then marks live only the first processing-or-delivered claim. Past the deadline that set is empty, so capability_park_reason's .filter(|c| c.live) loop never ran, refusals stayed empty, and it returned None — in exactly the case it exists to explain. The revision-1 test hand-built live: true, a state the real branch cannot receive.

The first fix did not work, and the suite caught it. Re-deriving liveness at the deadline instant looks sufficient and is not: the demotion is destructive. It overwrites status, so by the time the park runs the information is already spent, and re-deriving sees the same expired claims. Re-running a derivation cannot recover what the first run destroyed.

So the fix reverses the marker first — expiredprocessing — and then re-derives with production's own function at the deadline instant. The justification is load-bearing and is why this is not a hack: CLAIM_STATUS_EXPIRED is never a relay value. derive_claim_liveness is its only writer, and it writes it only for a claim that was processing when the deadline passed. Reversing it reads that function's own marker rather than guessing at a status. The round trip is asserted by the regression, which demotes at deadline + 1 and requires the claim to be a candidate again — so if the demotion ever writes a different status, that test fails rather than the diagnosis quietly seeing nothing.

The review's criticism was narrower than the defect, and that is worth recording. It named the hand-built live: true. The deeper problem was that the helper builds every claim processing and varies only live, so a "no live claims" case was a processing claim with live: false — a state production cannot hold at all. A test asserting an unreachable state passes for a reason unrelated to the property it names.

Blocker 3 — an unknown harness family was never refused at award time

Also correct, and it is the one with money directly behind it.

Verified in the source before anything was built, rather than taken on the description:

  • HARNESS_FAMILIES appeared in buyer/lifecycle.rs exactly once — in a doc comment — and in no executable check.
  • The predicate validated capability tokens against CAPABILITIES, but judged the requested family only against what the claim advertises.
  • Neither reader filters vocabulary: 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 award is the payment decision, so this sat on the money path.

The sharp part is why nobody found it by reading. unsatisfiable_capability_request's own doc tells the reader that the award-time refusal is the wire-level backstop for foreign offers, while the post-time vocabulary gate only ever sees offers our own client built. 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, in the request-validation block: the request is judged before the claim, so a nonsense request cannot be laundered into a match by a claim that happens to agree with it. Only the stated family needs this. 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 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, while "that is not a real family" means no seat can ever satisfy it.

The adversarial exact-match test is the load-bearing one, and an obvious test here proves nothing. A test pairing an unknown request with an ordinary claim goes red on a plain family mismatch even with the vocabulary check deleted — so it would pass without the guard and certify nothing. The test therefore has the request and the claim carry the same unknown value, which is the only shape the guard alone can refuse. A second case walks all of HARNESS_FAMILIES and requires each to still be accepted, so the guard is proven not to be a blanket refusal.

Two gates before an offer is signed, and why

Posting commits: post_job arms the auto-award and puts a signed offer on the relay with its deadline running. A request nothing can satisfy therefore converts a caller's typo into a committed offer plus a guaranteed park. Post time is the cheapest moment that mistake can surface.

  • The vocabulary gate knows the closed lists — a family or capability token no seat can ever advertise. That is a fact about the vocabularies.
  • The satisfiability gate owns no rules. It synthesizes the claim that would satisfy the request exactly and asks the real claim_meets_capability_request whether that claim passes. A request the perfect claim cannot pass is one no claim can pass.

The second shape is deliberate. A gate restating "a model needs a preset" in its own words would be a second copy of a rule owned elsewhere, and on the day #788 makes a bare model valid someone would have to remember this gate exists and go change it. Because the verdict comes from the predicate, it changes itself. the_post_time_gate_refuses_exactly_what_the_predicate_can_never_pass asserts the two agree across seventeen request shapes, with six positive controls so a pair of stuck Nones cannot agree its way to green.

Neither gate is the enforcement boundary. A foreign client can publish either shape 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 a_foreign_model_only_offer_is_refused_at_award_and_parks_saying_why deliberately does not go through post_job — the gate would refuse the input first, and the layer would look tested when the only tested thing was the gate.

The parked outcome

A request that matches no live claim parks naming what to fix, rather than awarding anyway or failing silently.

The reason stays silent unless capability was genuinely the obstacle: None when there is no request, when no claim was live, and — the one worth being strict about — when some live claim satisfied the request. In that last case the award was stopped by price, mint or budget, and a capability-shaped reason would blame the request for it. That is the case where a capability explanation is most plausible and most wrong.

With no capability obstacle the park row is byte-identical to what it was before this change.

This is a local operator string, deliberately not a wire reason code. #821 adds capability_missing to the protocol and #859 makes an undispatchable job carry that label; this is what those two replace on the wire, and it lands first so the diagnosis exists before the code for it does. Stated order, per the issue's item 4.

One constructor, so the two paths cannot diverge

award_filters_for_offer is the single place award filters are built, called by the manual RPC, by drive_auto_award, and by the tests. "Both paths filter identically" is now structural rather than something a test detects after the fact, and a new request axis added to the constructor reaches both paths with no other edit.

That shape was arrived at the hard way, and the route is worth recording.

The two award sites originally each built their own AwardFilters literal, which meant the tests needed a third copy to build filters the way production does. That copy 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. They went red only because the model cases happened to get written.

The first fix was a test asserting the copy matched production. The red-prove showed that check could never fail: its needle was a string literal sitting in the same file it searched, so it matched itself rather than the helper, and it passed with the mirror deliberately drifted. It read as the strongest assertion in the test and was worth nothing.

A hand-written copy of production wiring and a needle published into its own corpus are the same defect — an artifact that agrees with itself instead of with the thing it describes — and in both cases the remedy is to delete the copy, not to test it harder. Hence a constructor rather than a stronger test.

The tripwire that remains pins two properties instead of scraping field spellings: both sites go through the constructor, and no site hand-rolls a literal. The second matters on its own — without it a third site could quietly build its own filters beside two that behave, and the first assertion would confirm the good paths while saying nothing about the bad one.

A row's identity is its command, not its nickname

Revision 1 did not compile in CI's money-path job. Two PostJobRequest initializer sites behind #[cfg(feature = "live-mints")] were never given the three new fields, and error[E0063] killed that job seven seconds in, before any test ran. Every other CI job passed.

The fix is two lines; the cause is a habit:

  • CI's money-path job runs cargo test -p maxplayer-core --release --no-default-features --features gateway,git-delivery,wallet,live-mints --locked.
  • What was run locally and called "the money-path row" was cargo test -p maxplayer-core --lib --features wallet.

Different feature set, different profile, different target selection. live-mints is on in that job and nowhere else in CI (#720, stated in the workflow's own comment), so it is the single feature no other row covers — and it is exactly the one that carried the broken sites. The local row was named after the CI job, and the name asserted an equivalence the command never had.

A row's identity is its command plus its feature set plus its tree. Every row below is stamped with all three, and the money-path row is CI's command copied verbatim rather than approximated.

There is a second, sharper half. Revision 1 certified its rows by name rather than by total, precisely because a feature set that skips a module still prints a healthy green. That instrument is sound, and it could not have caught this: name-certification proves a test ran, and a test in a module that never compiled emits no name at all. Absent and not-in-this-row are the same output. The instrument was fine; the row set was not.

Every PostJobRequest, OfferView and ParsedOffer initializer in the crate was then enumerated across all cfg branches rather than fixing only the two sites the compiler named — a compiler reports what the current feature set compiles, which is the same blind spot one layer down. The enumeration was validated against the previous head, where it correctly reported exactly the two known-missing sites before being trusted on the new one.

Docs, and how many places the wrong claim had reached

docs/protocol-v1.md §6.1 gains the three params and a new §6.1.1 defining the request: absent passes everything, both award paths apply it identically, the offer is the authority, a model needs the agent preset, a stated family must agree with the preset's family and an unstated one is derived from it, a family alone stays valid as a seat filter, capability is one multi-value tag, and the display-only fields are not requestable.

Revision 1 also asserted, in a comment, the exact property the review proved absent: that a family request binds dispatch and that a family and a preset are both enforced. That sentence would have ended the search for anyone who read it. The review found it from the code instead.

Correcting it turned out to be much larger than the three comments first identified — 19 hunks across 6 files, and the ones initially missed were the normative ones. Two reasons, both worth stating:

  1. The claim had 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 one author's own wording cannot match the others.
  2. The first sweep indexed only Rust doc comments, so it could not see the spec at all — and docs/protocol-v1.md outranks any comment. §4.5.3 called the family "the only one of the three backed by a mechanism"; that sentence predates this work (7aa40e4). §6.1.1 used the same false inheritance argument as the justification for the model rule; that one was mine.

§4.5.3 now says what is true: no filterable field is enforced at dispatch. The three are one echo and two silences.

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. That is the same property the satisfiability gate has, and it is why both were free.

Caller-facing descriptions

harness_family, model and capabilities are hard filters, so the post_job schema says so. post_job_award_filter_descriptions_match_enforcement asserts per axis rather than in aggregate, and pins the two model facts that are load-bearing and easy to drop: that it requires the harness preset, and that it does not pin what executes.

Display-only fields stay unrequestable

harness_variant and hardware must never become filterable — they are operator-declared free text nothing can contradict, so filtering on them would decide money on an unfalsifiable claim. This change opens a new filter surface (the offer's request), and the existing guard only covered the seat's advertisement. The new assertion is made against the AwardFilters declaration itself rather than a list of param names, because a test naming params would keep passing if a display-only axis were added straight to the filter struct — the one place a filter can actually read. It carries a positive control.

Compatibility, measured

Stated from checks run against the diff, not from intent.

  • The offer params are additive and conditional. Each tag is emitted only when its axis is set, so an offer that requests nothing is byte-identical to a pre-buyer: offers carry no capability request, so award filtering never refuses anything #897 offer — asserted by an_absent_capability_request_posts_a_byte_identical_offer and post_job_emits_the_capability_request_it_was_given, including the blank and all-whitespace forms. An older reader sees an offer it already understands; a param it does not know is one it already ignores.
  • No new config keys. Grepping the diff for config-shaped additions returns two hits, and reading both shows a function parameter (allow_real_mints: bool on the new constructor) and a test literal. Neither is a config key. Read individually rather than counted, because a count would have reported "2 new config keys".
  • Nothing new is persisted. crates/maxplayer-core/src/buyer/store.rs is untouched by this diff (empty git diff --stat), and OfferView does not appear in it. The store's schema and its put_pending_award row are unchanged.
  • OfferView is serialize-only. It derives Serialize and not Deserialize, so it is an RPC output view rather than a round-tripped record — no older binary deserializes it, and the new fields additionally carry skip_serializing_if, so an absent request emits no key at all.
  • Rebased onto main at d022bf54, not onto the base this branch was cut from. Every commit replayed clean, rc=0; the branch is nine commits on that base. The rebase was not cosmetic: main moved repeatedly today, and one of those commits (a2b9403f) added a field to home::SandboxConfig, which turns any exhaustive struct literal of that type into error[E0063]. That broke main itself for about eight hours and stalled several PRs; it was fixed in fix: decide the Codex session value for the doctor engine-floor test #909, which is why this branch sits on d022bf54 rather than on the base it was cut from. This branch was never exposed, and that was verified rather than assumed: the two SandboxConfig literals in a file this PR touches (capability.rs:386 and :501) both use ..Default::default(), and this PR's only change to that file is an insertion far from either. A file-list check would have cleared this branch for the wrong reason — a list of files is not a list of literals — so the compile matrix below is what actually settles it.
  • A green is a statement about the base it ran on. Every row below was re-run at the rebased head. No pre-rebase number is carried forward, including the baseline, which moved with the base.

Verification

Every row states its tree and its exact command. maxplayer-core is default = [], so a bare cargo test compiles none of the gated modules and still prints a healthy green.

  • Baseline, unmodified main, union features: 1244 passed / 0 failed / 2 ignored / rc=0. Measured on a detached checkout at c2bb4cae, and it carries to d022bf54 by byte-identity rather than by inference: git diff c2bb4cae d022bf54 -- crates/maxplayer-core/ is 0 bytes, and the whole merge in between touches exactly one file, crates/maxplayer/src/doctor.rs. The crate under test is unchanged, so the baseline is not stale.
  • cargo fmt deliberately not run: there is no fmt gate in CI here and it reformats pre-existing code.

Rows at head 610ef42, each log opening with its own TREE= and PORCELAIN=:

  • Feature unioncargo test -p maxplayer-core --lib --features acp,gateway,git-delivery,wallet: 1260 passed / 0 failed / 2 ignored / rc=0. Against the 1244 baseline that is +16, matching the measured set-difference exactly.
  • maxplayer crate, both feature rows — cargo test -p maxplayer --locked --no-run and --features acp,wallet: rc=0 each, 0 errors, 0 occurrences of E0063.
  • Compile matrix, every feature row in ci.yml, each built with --no-run: 8 rows, all rc=0, 0 errors. In full — -p maxplayer-core --release --no-default-features --features gateway,git-delivery,wallet,live-mints --locked · -p maxplayer-core --release --features acp,gateway,git-delivery,wallet --locked · -p maxplayer-core --locked · -p maxplayer-core --features acp --locked · -p maxplayer --locked · -p maxplayer --features acp,wallet --locked · plus cargo build --workspace --locked and the executing union row above. The driver refuses to start a row when free disk is under its floor, so a truncated matrix cannot be mistaken for a clean one.

Two of those rows were red earlier today, and it is worth recording why they were not this PR's.

cargo test -p maxplayer did not compile on main for roughly eight hours: a2b9403f added a field to home::SandboxConfig and updated one of the workspace's two exhaustive literals of that type, missing the other because it lives in a different crate. The literal it missed is exhaustive on purpose — its own comment says it is written longhand "so that adding another sandbox field breaks this test and makes someone decide what it should be here." The tripwire fired as designed and went unanswered.

That was established here on a detached checkout of unmodified main, with no PR involved: rc=101, same file, same line, same error, while git diff <main> HEAD -- crates/maxplayer/src/doctor.rs was empty at zero bytes. The fix landed in #909 and the rows above are green at d022bf54.

Two things from that are worth more than the incident. A fired tripwire left unanswered inverts its own attribution — the longer it sits, the more it reads as "every PR is broken" rather than "main is broken." And this PR's stored merge base predated the breaking commit entirely, so it could never have shown that error; "main was broken" would have been the wrong explanation for any red seen here, and a reassuring explanation closes a question exactly as fast as a correct one.

The delta is +16: 18 tests added and 2 removed (one renamed when the rule moved from the family to the preset, one replaced because it asserted the pre-#897 inert behaviour). Measured as a set-difference over test names against the baseline tree, not counted by hand, and it cross-foots against the run counts independently — 1260 − 1244 is also +16, from an instrument that knows nothing about test names.

The predicted figure was +14 while this branch stood at eight commits; blocker 3 then added its two tests. That is stated rather than back-fitted, because a prediction quietly restated to match a later measurement is not a prediction.

The money-path row is COMPILE-ONLY here, deliberately. Its live-mints tests reach a live third-party mint over the public internet and include payment_wallet worker sends, so this lane compiles that row with --no-run and does not execute it. CI is the execution evidence for it, and the compile check is what closes the defect that broke revision 1. cargo test -p maxplayer-core --release --no-default-features --features gateway,git-delivery,wallet,live-mints --locked --no-runrc=0, 0 errors, CI's command copied verbatim with --no-run appended.

Warnings: 5 at the baseline c2bb4cae and 5 at head 610ef42 — measured at both trees under the same command, not carried forward from either. Zero new. Revision 1's sixth warning, an unused import: AwardFilters, is gone: 0 occurrences in the head log.

A correction to revision 1's own numbers. Its body claimed "Warnings: 5 … Zero new warnings" and a money-path row of 1199 / 0 / rc=0. Both were wrong at the head it described. The filed head generated six warnings — the sixth was an unused import: AwardFilters left behind when the last hand-rolled literal was removed — and that money-path row had been measured two commits earlier, so it certified a tree that was never filed. Neither error was visible without re-measuring, because CI denies no warnings here (no -D warnings, no RUSTFLAGS, no clippy step in any of the three workflow files; checked with a positive control). A remedial commit invalidates a verification row exactly like a feature commit does, and it does not feel like it, because remediation reads as consolidation. Every row log now opens with its own tree.

The tests this change adds, certified by name in the rows that compile and run them:

a_family_that_contradicts_the_preset_is_refused_on_a_seat_that_advertises_both · a_foreign_model_only_offer_is_refused_at_award_and_parks_saying_why · a_model_request_without_a_harness_preset_is_refused_not_ignored · an_absent_capability_request_posts_a_byte_identical_offer · an_offer_sourced_request_refuses_a_non_matching_claim_on_both_paths · a_preset_and_a_model_derive_the_family_instead_of_refusing · a_request_matching_no_claim_parks_with_an_actionable_reason · both_award_paths_read_the_capability_request_off_the_offer · display_only_fields_never_reach_the_award_filter · offer_carries_the_capability_request_across_the_wire · post_job_emits_the_capability_request_it_was_given · post_job_refuses_a_request_no_seat_could_satisfy · the_capability_clause_survives_the_real_deadline_demotion · the_capability_request_reader_normalizes_a_hand_written_offer · the_park_reason_declines_to_blame_capability_when_it_was_not_the_obstacle · the_post_time_gate_refuses_exactly_what_the_predicate_can_never_pass

Three flaky observations in this suite, cited rather than hidden

  • Flaky: an_accept_naming_another_seats_claim_never_binds_the_loser fails intermittently in Money-path tests #894an_accept_naming_another_seats_claim_never_binds_the_loser. Did not fire in any run here.

  • flaky: a_genuine_wrong_p_restricted_stays_removed races the CLOSED under suite load #901 — a second, distinct intermittent: seller_node::run::tests::a_genuine_wrong_p_restricted_stays_removed went red in one full-suite run and green in a rerun on the same tree, having also passed on the unmodified baseline tree and 5/5 in isolation. Its failure is a timeout expiring, not a wrong value, and the test self-documents the race.

  • A third single observation, now discriminated rather than left as a suspicion. Revision 1 saw seller_node::tests::restart_then_award_rebinds_the_parked_claim panic once with Lock(Held), where the test drops a SellerNode to fake a crash and the reopen races the lock release. One observation is not an intermittent, and diff-scoping could not settle it — adding tests changes parallel scheduling, so "the failing code is untouched" and "this change perturbed a latent race" can both be true. Only a baseline row in the same feature set separates them, and that row had never been run in this lane.

    It has now been run. Wallet-only features (no acp), the same command at both trees, N=3 each:

    • The suspect test passed 6 of 6. Lock(Held) appears 0 times in any of the six runs. It did not reproduce at either tree.
    • Baseline c2bb4cae went red 1 of 3 — on unmodified main, containing none of this diff.
    • This head was 3 of 3 green (1219 passed each).
    • A second cross-foot falls out of it: 1219 − 1205 = +14, the same contribution measured in a different feature set from the union row.

    So the row is flaky at baseline, and this change is not implicated. ⚠ One honest limit: the baseline failure is unattributed — the harness recorded the summary and the suspect's status but deleted the per-run log, so the failing test's name went with it. The corrected harness keeps every log. The conclusion above rests on the suspect passing 6 of 6 and on the baseline reddening without this diff present, neither of which needs that name.

None of the three is being used to excuse this diff; all are named so a future red on any of them is recognised rather than re-investigated.

Red-prove

Every guard was made to fail on purpose, then reverted and re-verified green. Revision 1 ran seven cases and six bit; revision 2 adds five more for the new guards, and all five bit, plus a control on the selection itself.

Revision 1:

  1. Unwire one award site's model axis — tripwire red, found 1 against 2.
  2. Drift the test mirror only — passed. The guard was worthless; it is now deleted rather than strengthened. See the constructor section above.
  3. Hand-roll AwardFilters at one site — tripwire red, found 1 against 2.
  4. Gate refuses more than the predicate — coupling test red, naming the offending shape.
  5. Gate refuses nothing — coupling test and post-path gate test red, independently.
  6. Drop the capability arm from named_claim_awardable — both acceptance tests red, and only the manual-path assertions, so they discriminate which path broke rather than merely noticing that something did.
  7. Break the display-only field parser — its control red with Found: [], which is a parser inspecting nothing while reporting clean.

Revision 2:

  1. Remove the family-contradicts-preset check — the predicate test red and the gate's positive control red, so the review's own counterexample (agent=claude with family=codex, on a seat advertising both) is proven refused at both layers.
  2. Remove the model-requires-preset check — the model test red and the gate control red.
  3. Narrow the gate's synthesized claim so it no longer advertises the preset's family — the coupling comparison red on the derived-family row, Some(HarnessFamily { requested: "codex" }) against None, which is the gate refusing more than the predicate.
  4. Remove the demotion reversal — blocker 2's regression red, with the park row degrading to exactly the bare offer deadline passed before an awardable claim appeared that the review identified in production.
  5. Remove the unknown-family check (blocker 3) — 0 passed, 3 failed, and the three that bit span all three layers rather than clustering in one: the predicate test an_unknown_harness_family_is_refused_even_when_the_claim_advertises_it, the post-time gate's the_post_time_gate_refuses_exactly_what_the_predicate_can_never_pass, and a_foreign_offer_naming_a_bogus_family_is_refused_on_both_paths_and_parks_blaming_the_request. With the check restored, the full union row at the filed head is 1260 / 0 / rc=0.
  6. Baseline and restore rows around cases 8–11: 5 tests selected, 5 passed, rc=0 both times — so the filter that selects them is itself controlled, and a filter matching nothing would not have been mistaken for a pass.

The most useful result is from cases 8 and 9, and it changes how the coupling test should be read. In both, the coupling test's main comparison loop could not see the break. The gate and the predicate both end in claim_meets_capability_request, so when the predicate is what broke, the two sides moved together, the comparison still held, and the loop reported a pass through the exact fault it appears to be watching for. Only the positive controls went red.

So a reviewer should not credit that loop with protecting the predicate. The general form, because it is not specific to this test: whenever an oracle is computed from the subject, the controls carry the whole of the coverage. The loop checks that two things agree, and two things derived from the same broken source agree perfectly. That is the revision-1 self-matching needle one layer out — in both cases the test consulted the thing under test for its own answer, and in both cases agreement read as a pass.

The controls are therefore load-bearing rather than belt-and-braces, and adding a rule to the predicate means adding a control here — the loop will not notice. That warning is now written into the test itself, not just recorded here, because a body outlives no one and a comment sits where the next person edits.

Not merged, not tagged. No seat touched.

@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mobee Ready Ready Preview Aug 25, 2026 8:55pm

Request Review

orveth and others added 9 commits August 25, 2026 12:43
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@orveth
orveth merged commit b45f865 into main Aug 25, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

buyer: offers carry no capability request, so award filtering never refuses anything

1 participant