diff --git a/.github/trigger-deploy b/.github/trigger-deploy index 95b7d23c..1012d205 100644 --- a/.github/trigger-deploy +++ b/.github/trigger-deploy @@ -1 +1 @@ -1786099674 +1786104345 diff --git a/.github/trigger-test b/.github/trigger-test index 95b7d23c..81821101 100644 --- a/.github/trigger-test +++ b/.github/trigger-test @@ -1 +1 @@ -1786099674 +1786104809 diff --git a/.github/workflows/paid-canary.yml b/.github/workflows/paid-canary.yml index ca209eb7..3377317a 100644 --- a/.github/workflows/paid-canary.yml +++ b/.github/workflows/paid-canary.yml @@ -42,25 +42,55 @@ jobs: id: fresh if: github.event_name == 'schedule' env: - GH_TOKEN: ${{ github.token }} + TARGET_URL: https://agent402.tools run: | set -euo pipefail - LAST=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow paid-canary.yml \ - --status success --limit 1 --json createdAt --jq '.[0].createdAt // empty' || true) - if [ -z "$LAST" ]; then - echo "no successful run on record - proceeding with the buy" + # Ask production when a canary last actually BOUGHT. The previous + # version asked GitHub when this workflow last concluded green, and a + # run whose gate SKIPS the buy also concludes green - so every skip + # refreshed the timestamp the next gate reads, and the gate ratcheted + # itself shut. Measured: the gate shipped 2026-08-02 and not one + # SCHEDULED run bought after it. Every real purchase from then on came + # from a manual dispatch, which bypasses this step entirely. The + # workflow reported success the whole time, because skipping is a + # success; only /status noticed, by reporting the settlement component + # stale, which is the alarm that survives this class of bug. + # + # The settlement observation is the honest signal: it is written ONLY + # by a canary that ran, in the "Record settlement on /status" step + # below, and it is the same fact the public page reports. Gate and + # page can no longer disagree. + SNAP=$(curl -sf --max-time 20 "$TARGET_URL/api/status" || true) + if [ -z "$SNAP" ]; then + echo "status unreachable - proceeding with the buy (a gate that cannot check must not block)" exit 0 fi - AGE=$(( $(date -u +%s) - $(date -u -d "$LAST" +%s) )) - echo "last successful canary: $LAST (${AGE}s ago)" + # `|| echo none` on both: jq exits 5 on a body that is not JSON (an + # error page, an interstitial, a truncated response), and under + # `set -e` that would fail this step, fail the gate job, and - see the + # canary job's `if` below - stop the monitor because its own guard + # broke. Every parse failure lands on the "no observation, so buy" + # path instead. This step cannot fail. + AGE=$(printf '%s' "$SNAP" | jq -r '(.components[]? | select(.key=="settlement") | .current.ageMs) // "none"' 2>/dev/null || echo none) + STATE=$(printf '%s' "$SNAP" | jq -r '(.components[]? | select(.key=="settlement") | .current.state) // "none"' 2>/dev/null || echo none) + if [ "$AGE" = "none" ] || [ "$AGE" = "null" ]; then + echo "no settlement observation on record - proceeding with the buy" + exit 0 + fi + echo "last proven purchase: ${AGE}ms ago (state=$STATE)" # 20h, not 24h: deliveries drift by hours, and a 24h window would let # a late run suppress the next day's on-time one, quietly halving the # cadence instead of protecting it. - if [ "$AGE" -lt 72000 ]; then - echo "already settled within 20h - skipping this redundant attempt (not a failure)" + # + # state must be operational, not merely fresh: a recent observation + # that recorded a FAILED purchase is not proof that buying works, and + # suppressing the next attempt on the strength of it would turn the + # three-attempt design into one attempt to fail per day. + if [ "$STATE" = "operational" ] && [ "$AGE" -lt 72000000 ]; then + echo "a real purchase settled within 20h - skipping this redundant attempt (not a failure)" echo "skip=true" >> "$GITHUB_OUTPUT" else - echo "stale (>20h) - this attempt will buy" + echo "no proven purchase in the last 20h - this attempt will buy" fi canary: @@ -69,7 +99,14 @@ jobs: # leaves `skip` empty and the canary RUNS - a monitor that silently stops # monitoring because its own guard broke is the failure this whole change # exists to remove. - if: needs.gate.outputs.skip != 'true' + # + # `!cancelled()` is what makes that comment TRUE rather than merely + # intended. A job-level `if` with no status check function still carries + # the implicit success() on `needs`, so a FAILED gate would have skipped + # this job - the opposite of what the comment claimed, and never verified. + # A status function overrides that implicit check. Not always(): a + # cancelled run should stay cancelled, not go spend money. + if: ${{ !cancelled() && needs.gate.outputs.skip != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 # Wallet/pow secrets are NOT at job scope (audit R-07): job-scope env is in diff --git a/scripts/test-canary-coverage.js b/scripts/test-canary-coverage.js index 5f8e8c87..2883b61b 100644 --- a/scripts/test-canary-coverage.js +++ b/scripts/test-canary-coverage.js @@ -146,13 +146,35 @@ if (rn) { // step-level condition would have to be repeated on every step, and the one // that got forgotten would spend ~$1.50 of real USDC anyway. ok(/\n\s{2}gate:/.test(wf), "a gate JOB decides whether this attempt spends anything"); - ok(/needs:\s*gate/.test(wf) && /if:\s*needs\.gate\.outputs\.skip\s*!=\s*'true'/.test(wf), + const canaryIf = wf.split("\n").find((l) => l.includes("if:") && l.includes("needs.gate.outputs.skip")) || ""; + ok(/needs:\s*gate/.test(wf) && canaryIf !== "", "the buying job is gated at JOB level, not per step"); - // Fail toward RUNNING. A monitor that stops monitoring because its own guard - // broke is the exact failure this change removes. - ok(/!=\s*'true'/.test(wf) && !/==\s*'false'/.test(wf), - "an errored or skipped gate leaves the canary RUNNING, never silently disabled"); + // Fail toward RUNNING, asserted STRUCTURALLY rather than by polarity. + // + // The previous version checked only that the comparison reads `!= 'true'` + // rather than `== 'false'`. That says nothing about a gate that FAILED: a + // job-level `if` with no status check function still carries the implicit + // success() on `needs`, so a failed gate SKIPS the canary. The assertion + // passed for weeks against exactly that code, claiming a property it had no + // way to see. What actually makes the claim true is a status function + // overriding the implicit check. + ok(/!=\s*'true'/.test(canaryIf) && !/==\s*'false'/.test(canaryIf), + "the gate's skip is opt-IN: an empty or missing output leaves the canary running"); + ok(/!\s*cancelled\(\)|always\(\)/.test(canaryIf), + `a FAILED gate cannot silently disable the canary - the if carries a status function (got: ${canaryIf.trim()})`); + + // The gate must ask PRODUCTION when a canary last BOUGHT, never GitHub when + // this workflow last concluded green. A run whose gate skips the buy also + // concludes green, so keying on run history makes every skip refresh the + // window the next gate reads, and the gate ratchets itself permanently shut. + // Measured: shipped 2026-08-02, and not one scheduled run bought afterwards. + const gateJob = wf.slice(wf.indexOf("\n gate:"), wf.indexOf("\n canary:")); + ok(/\/api\/status/.test(gateJob) && !/gh run list/.test(gateJob), + "the gate keys on a real settlement observation, not on this workflow's own run history"); + const jqReads = gateJob.split("\n").filter((l) => l.includes("jq -r")); + ok(jqReads.length > 0 && jqReads.every((l) => /\|\|\s*echo\s+none/.test(l)), + `every jq read in the gate falls back instead of failing the step (jq exits non-zero on a non-JSON body) (${jqReads.length} read${jqReads.length === 1 ? "" : "s"})`); // A human asking for a buy - usually right after a deploy - must never be // suppressed by the freshness window. diff --git a/scripts/test-wish.js b/scripts/test-wish.js index 794bac09..87d9a2dd 100644 --- a/scripts/test-wish.js +++ b/scripts/test-wish.js @@ -28,7 +28,8 @@ function freshFile(tag) { { freshFile("basic"); const r = recordWish({ need: "convert stl files to obj", source: "api", ip: "10.0.0.1" }); - ok(r.recorded === true && r.cluster.count === 1, `new need → recorded, cluster.count=1 (got ${JSON.stringify(r)})`); + ok(r.recorded === true, `new need → recorded (got ${JSON.stringify(r)})`); + ok(getWishesAggregate({ detailed: true }).clusters[0].count === 1, "the cluster holds one signal (read from the token-gated board, not the response)"); } // --- validation: 400s --- @@ -55,9 +56,9 @@ function freshFile(tag) { { freshFile("dedup"); recordWish({ need: " Convert STL to OBJ ", source: "api", ip: "10.0.0.4" }); - const r2 = recordWish({ need: "convert stl to obj", source: "mcp", ip: "10.0.0.5" }); - ok(r2.cluster.count === 2, `case/whitespace variants collapse into one cluster (got count=${r2.cluster.count})`); + recordWish({ need: "convert stl to obj", source: "mcp", ip: "10.0.0.5" }); const agg = getWishesAggregate({ detailed: true }); + ok(agg.clusters[0].count === 2, `case/whitespace variants collapse into one cluster (got count=${agg.clusters[0].count})`); ok(agg.distinctClusters === 1, `dedup: exactly one distinct cluster (got ${agg.distinctClusters})`); ok(agg.clusters[0].sources.api === 1 && agg.clusters[0].sources.mcp === 1, `sources breakdown attributes each call correctly (got ${JSON.stringify(agg.clusters[0].sources)})`); } @@ -241,20 +242,39 @@ for (const f of tmpFiles) { } -console.log(`\n${pass} passed, ${fail} failed`); -process.exit(fail ? 1 : 0); - -// --- confidentiality: the PUBLIC (default) aggregate must be a beacon only, -// never the itemized board. Regression guard for the 2026-07-21 lockdown. +// --- confidentiality: what the public may learn about the demand board ------- +// +// THIS BLOCK NEVER RAN. It sat below `process.exit()`, so it was unreachable, +// and it called a `__resetWishes` that does not exist with a positional +// signature recordWish has never had - it would have thrown on its first line +// if it had ever executed. The regression guard for the 2026-07-21 lockdown +// was, in effect, a comment. Moved above the summary and rewritten against the +// real API. { - __resetWishes(); - for (let i = 0; i < 6; i++) recordWish(`secret sauce tool ${i % 2}`, "find-miss"); + freshFile("confidentiality"); + for (let i = 0; i < 6; i++) { + recordWish({ need: `secret sauce tool ${i % 2}`, source: "find-miss", ip: `10.0.9.${i}` }); + } const pub = getWishesAggregate(); // default detailed:false ok(pub.clusters === undefined, "public aggregate exposes NO per-cluster array"); ok(typeof pub.qualifiedClusters === "number", "public aggregate exposes qualified COUNT (beacon)"); ok(typeof pub.totalWishes === "number" && typeof pub.distinctClusters === "number", "public aggregate keeps headline totals"); - const raw = JSON.stringify(pub); - ok(!raw.includes("secret sauce"), "public aggregate leaks no wish text"); + ok(!JSON.stringify(pub).includes("secret sauce"), "public aggregate leaks no wish text"); const det = getWishesAggregate({ detailed: true }); ok(Array.isArray(det.clusters) && det.clusters.length > 0, "detailed aggregate still returns the itemized board"); + + // The WRITE path must not answer what the read path refuses to answer. The + // response is an acknowledgement: no count, no text, nothing that says how + // hot the cluster is. Asserted on a cluster with a known non-trivial count, + // so a leak would have something to leak. + const hot = recordWish({ need: "secret sauce tool 0", source: "api", ip: "10.0.9.99" }); + ok(hot.recorded === true, "an explicit wish on an existing cluster is still recorded"); + const raw = JSON.stringify(hot); + ok(!/\d/.test(raw), `the write response carries no number at all (got ${raw})`); + ok(hot.cluster === undefined, "the write response exposes no cluster object"); + ok(getWishesAggregate({ detailed: true }).clusters.some((c) => c.count >= 4), + "…while the token-gated board still knows the real count (so the assertion above had something to hide)"); } + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/src/wish.js b/src/wish.js index 382f5187..558be9d8 100644 --- a/src/wish.js +++ b/src/wish.js @@ -318,7 +318,7 @@ export function recordWish({ need, context, source, ip } = {}) { // Never throw here: this path runs inside /api/find and the MCP find_tool, // and a search must not fail because the demand board is busy. Stop // RECORDING instead, and say so in the return value. - return { recorded: false, reason: "find-miss volume limit for this source", cluster: null }; + return { recorded: false, reason: "find-miss volume limit for this source" }; } const now = Date.now(); @@ -331,7 +331,22 @@ export function recordWish({ need, context, source, ip } = {}) { appendLine({ type: "threshold", key, ts: now }); } - return { recorded: true, cluster: { count: cluster.count } }; + // ACKNOWLEDGEMENT ONLY - never the cluster's count. + // + // This used to return { cluster: { count } }, the number of signals the + // cluster now holds. That is exactly the field the PUBLIC read deliberately + // withholds: getWishesAggregate({detailed:false}) is a beacon (totals and how + // many clusters qualify, never which or how hot), and the itemized board sits + // behind the operator token. The write path was answering the question the + // read path refuses to answer, about the same data. + // + // Concretely: submit a phrase, learn how many others asked for that exact + // phrase, and since WISH_THRESHOLD is public, learn how close it is to being + // built. The clustering key is only lowercase + collapsed whitespace, so this + // confirms a phrase you already guessed rather than enumerating the board - + // narrow, but free to close and inconsistent to keep. The submitter loses + // nothing they need: the caller asked us to record a gap, and we did. + return { recorded: true }; } /**