Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/trigger-deploy
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1786099674
1786104345
2 changes: 1 addition & 1 deletion .github/trigger-test
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1786099674
1786104809
59 changes: 48 additions & 11 deletions .github/workflows/paid-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
32 changes: 27 additions & 5 deletions scripts/test-canary-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 32 additions & 12 deletions scripts/test-wish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -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)})`);
}
Expand Down Expand Up @@ -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);
19 changes: 17 additions & 2 deletions src/wish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 };
}

/**
Expand Down