From f11978ee2db56fdbb07efe490f318b292b6ee278 Mon Sep 17 00:00:00 2001 From: mikeypetrillo Date: Fri, 17 Jul 2026 13:54:31 -0400 Subject: [PATCH 1/2] [deploy] ship price tiebreak: known beats unknown Co-Authored-By: Claude Fable 5 --- .github/trigger-deploy | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/trigger-deploy b/.github/trigger-deploy index c4eeb6ac..2b6b599c 100644 --- a/.github/trigger-deploy +++ b/.github/trigger-deploy @@ -1,3 +1,4 @@ 2026-07-16T10:00:29Z 2026-07-17T16:15:00Z deploy openapi-fallback crawl 2026-07-17T17:20:00Z deploy manifest-merge + alias collapse +2026-07-17T18:00:00Z deploy price tiebreak From e8860255d6a1270a13ba51f37d472b631cc0958f Mon Sep 17 00:00:00 2001 From: mikeypetrillo Date: Fri, 17 Jul 2026 21:35:00 -0400 Subject: [PATCH 2/2] [test] wish bridge: gate auto-issues on qualified demand, not raw count The wish-to-issue bridge opened a GitHub issue for any cluster reaching count 5. That gate is trivially gamed: a single script POSTing the same string 5 times mints an issue. Observed 2026-07-17: one source drove three clusters to 18-103 identical hits in a few hours (synthora mesh / em spectrum intelligence), each of which auto-opened a junk issue. New clusterQualifies predicate (src/wish.js) requires, on top of the count threshold, a sign of independent demand: either two or more distinct sources (api / mcp / find-miss) or demand sustained past QUALIFY_MIN_SPAN_MS (24h). A genuine gap is hit by different agents across surfaces or recurs over days; a scripted burst is one source in one sitting and clears neither bar. getWishesAggregate exposes a per-cluster qualified flag, and the wish-issues workflow now selects on that flag instead of raw count. A cached pre-deploy response lacks the field, so the gate fails closed and opens nothing. Honest limit (same framing as the router's per-seller Sybil cap): a patient spammer can still drip over 24h or add a decoy hit on a second surface. This raises the cost from 5 curls to sustained-or-multi-surface; it is not full Sybil resistance. The wish is always recorded and visible on /api/wishes regardless; only auto-issue creation is gated. Tests: clusterQualifies unit cases (below-threshold, single-source burst, both synthora shapes, two-source, span boundary) plus end-to-end through recordWish + getWishesAggregate. Aggregate-shape and demand-radar consumer tests updated for the additive field. Co-Authored-By: Claude Fable 5 --- .github/trigger-test | 1 + .github/workflows/wish-issues.yml | 13 ++++++-- scripts/test-wish.js | 54 ++++++++++++++++++++++++++++++- src/wish.js | 28 ++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/.github/trigger-test b/.github/trigger-test index dad8065e..1f1d47f9 100644 --- a/.github/trigger-test +++ b/.github/trigger-test @@ -12,3 +12,4 @@ 2026-07-17T16:30:00Z openapi-fallback crawl for manifest-less sellers 2026-07-17T17:05:00Z manifest-path bazaar merge + router alias collapse 2026-07-17T17:45:00Z price tiebreak: known beats unknown +2026-07-18T01:40:00Z wish bridge: qualified-demand gate against single-source bursts diff --git a/.github/workflows/wish-issues.yml b/.github/workflows/wish-issues.yml index 049bbf6a..c8813983 100644 --- a/.github/workflows/wish-issues.yml +++ b/.github/workflows/wish-issues.yml @@ -4,6 +4,12 @@ # GitHub token, so THIS workflow closes the loop: it polls the public # aggregate and opens one issue per qualifying cluster. # +# The gate is the server-computed `qualified` flag, NOT raw count: a cluster +# qualifies only when it clears the count threshold AND shows independence +# (≥2 distinct sources, or demand sustained past the span window). This stops +# a single script minting issues by POSTing the same string 5 times — see +# clusterQualifies in src/wish.js. +# # Dedup is GitHub-side (label `tool-wish` + exact title match), so the server # needs no writable "issueOpened" state and re-runs are idempotent. At most # MAX_NEW issues per run — a spam wave can never flood the tracker. @@ -35,7 +41,10 @@ jobs: # "handled or rejected"; never re-open the same demand automatically). EXISTING=$(gh issue list --repo "$REPO" --label tool-wish --state all --limit 200 --json title --jq '.[].title') CREATED=0 - echo "$WISHES" | jq -c --argjson th "$THRESHOLD" '.clusters[] | select(.count >= $th)' | while read -r c; do + # Gate on the server-computed `qualified` flag (count + independence), + # not raw count. A cached response from before the flag shipped lacks + # the field, so `== true` fails closed and opens nothing — safe. + echo "$WISHES" | jq -c '.clusters[] | select(.qualified == true)' | while read -r c; do [ "$CREATED" -ge "$MAX_NEW" ] && { echo "cap reached ($MAX_NEW) — remaining clusters wait for the next run"; break; } TEXT=$(echo "$c" | jq -r '.text') COUNT=$(echo "$c" | jq -r '.count') @@ -47,7 +56,7 @@ jobs: echo "already tracked: $TITLE" continue fi - BODY=$(printf 'Agents asked for this **%s** times (sources: %s; first seen %s).\n\nNormalized request:\n\n> %s\n\nLive demand board: https://agent402.tools/api/wishes\n\nOpened automatically by the wish-issues bridge when a cluster crosses the signal threshold (%s).' "$COUNT" "$SOURCES" "$FIRST" "$TEXT" "$THRESHOLD") + BODY=$(printf 'Agents asked for this **%s** times (sources: %s; first seen %s).\n\nNormalized request:\n\n> %s\n\nLive demand board: https://agent402.tools/api/wishes\n\nOpened automatically by the wish-issues bridge: this cluster cleared the signal threshold (%s) and qualified as independent demand (multiple sources or sustained over time), not a single-source burst.' "$COUNT" "$SOURCES" "$FIRST" "$TEXT" "$THRESHOLD") gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" --label tool-wish CREATED=$((CREATED+1)) echo "opened: $TITLE" diff --git a/scripts/test-wish.js b/scripts/test-wish.js index 181204fb..40708f79 100644 --- a/scripts/test-wish.js +++ b/scripts/test-wish.js @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { recordWish, getWishesAggregate, WISH_THRESHOLD, + clusterQualifies, QUALIFY_MIN_SPAN_MS, __testSetFilePath, __testSetLineCap, __testState, } from "../src/wish.js"; @@ -107,6 +108,57 @@ function freshFile(tag) { ok(row && row.issueOpened === true, `cluster carries issueOpened:true after crossing the threshold (got ${JSON.stringify(row)})`); } +// --- qualification gate: raw count is necessary but not sufficient --- +// clusterQualifies is the exact predicate the wish-issues workflow selects on. +// Tested directly with synthetic clusters so timestamps are controllable. +{ + const T = 1_700_000_000_000; + const src = (o) => ({ api: 0, mcp: 0, "find-miss": 0, ...o }); + // Below threshold never qualifies, regardless of shape. + ok(!clusterQualifies({ count: WISH_THRESHOLD - 1, sources: src({ api: 2, mcp: 2 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS }), + "below count threshold never qualifies"); + // Single-source short burst (the synthora spam shape) does NOT qualify. + ok(!clusterQualifies({ count: 103, sources: src({ api: 103 }), firstSeen: T, lastSeen: T + 5 * 3_600_000 }), + "single-source burst (103 hits, 5h) does not qualify"); + ok(!clusterQualifies({ count: 18, sources: src({ "find-miss": 18 }), firstSeen: T, lastSeen: T + 5 * 3_600_000 }), + "single-source find-miss burst does not qualify"); + // Corroboration across two sources qualifies at threshold. + ok(clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 3, mcp: 2 }), firstSeen: T, lastSeen: T + 60_000 }), + "two distinct sources qualifies even in a short window"); + // Sustained single-source demand over the span window qualifies. + ok(clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 5 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS }), + "single source sustained past the span window qualifies"); + ok(!clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 5 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS - 1 }), + "single source just under the span window does not qualify"); +} + +// --- qualification, end-to-end through recordWish + getWishesAggregate --- +{ + // A single-source burst to the threshold in one sitting: recorded and + // counted, but the aggregate marks it unqualified so the workflow skips it. + freshFile("qualify-burst"); + for (let i = 0; i < WISH_THRESHOLD; i++) { + recordWish({ need: "synthora mesh 962 m2m services", source: "api", ip: `10.0.9.${i}` }); + } + let agg = getWishesAggregate(); + let row = agg.clusters.find((c) => c.text.includes("synthora")); + ok(row && row.count >= WISH_THRESHOLD && row.qualified === false, + `single-source burst is recorded but unqualified (got ${JSON.stringify(row)})`); + ok(agg.qualifyMinSpanHours === QUALIFY_MIN_SPAN_MS / 3_600_000, "aggregate advertises the span window in hours"); + + // The same count spread across two surfaces qualifies. + freshFile("qualify-multisource"); + recordWish({ need: "real gap tool", source: "api", ip: "10.0.10.1" }); + recordWish({ need: "real gap tool", source: "api", ip: "10.0.10.2" }); + recordWish({ need: "real gap tool", source: "mcp", ip: "10.0.10.3" }); + recordWish({ need: "real gap tool", source: "find-miss", ip: "10.0.10.4" }); + recordWish({ need: "real gap tool", source: "find-miss", ip: "10.0.10.5" }); + agg = getWishesAggregate(); + row = agg.clusters.find((c) => c.text.includes("real gap tool")); + ok(row && row.count === WISH_THRESHOLD && row.qualified === true, + `multi-source demand at threshold qualifies (got ${JSON.stringify(row)})`); +} + // --- file-line cap: accept + count clusters, stop appending raw lines, never crash --- { freshFile("filecap"); @@ -132,7 +184,7 @@ function freshFile(tag) { const agg = getWishesAggregate(); const row = agg.clusters[0]; const keys = Object.keys(row).sort(); - ok(JSON.stringify(keys) === JSON.stringify(["count", "firstSeen", "issueOpened", "lastSeen", "sources", "text"]), `aggregate row has exactly the documented keys, no raw context (got ${keys.join(",")})`); + ok(JSON.stringify(keys) === JSON.stringify(["count", "firstSeen", "issueOpened", "lastSeen", "qualified", "sources", "text"]), `aggregate row has exactly the documented keys, no raw context (got ${keys.join(",")})`); ok(!("context" in row), "aggregate row never carries a raw context field"); ok(row.text.includes("<script>") && !row.text.includes("