From 8263dced1e004511ae63268645b44e11a2ea5884 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 00:13:53 +0000 Subject: [PATCH 1/3] feat: fan board changes out to the feed that serves the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `projects_v2_item` reached only `.github-private`. But a card moved on org project #2 — a Status drag, a Score edit — changes the board's RANKING, and front-desk-feed queries the board itself, so nothing that wakes the private projection wakes the feed. Measured 2026-08-30: the projection updated at 22:52 and desk.bounded.tools was still serving the 21:25 snapshot, a full cron slot behind and by construction up to an hour. That was merely stale until desk gained Web Push. Desk now sends a payload-less push on a board change and the service worker fetches the board to learn what the push was about, so an unwoken feed turns a stale page into a wrong notification: it says the board changed and hands the reader a board that has not. Worse than not notifying at all. Claim-issue: bounded-systems/bounded.tools#44 Co-authored-by: Claude --- src/dispatch-events.test.ts | 54 ++++++++++++++++++++++++++++++------- src/dispatch-events.ts | 30 ++++++++++++++++----- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/dispatch-events.test.ts b/src/dispatch-events.test.ts index d6ef038..fc16bd3 100644 --- a/src/dispatch-events.test.ts +++ b/src/dispatch-events.test.ts @@ -92,16 +92,29 @@ describe("issues — a claim IS the label or the assignee", () => { }); describe("projects_v2_item — the signal that reaches no other event", () => { - test("card edits wake the private board projection only", () => { - // The public feed derives from the board projection rather than from - // ProjectV2 directly, so waking it here would be a second path to the same - // answer — and a second path is a second thing to keep in agreement. - expect(targets(decide("projects_v2_item", "edited"))).toEqual([".github-private:board-changed"]); - }); - - test("every card lifecycle action counts", () => { + test("card edits wake BOTH board consumers", () => { + // This pinned `.github-private` alone, on the belief that the public feed + // derives from the board projection and so a second wake-up would be a + // second path to the same answer. There is no first path: front-desk-feed's + // publish.yml queries org project #2 itself, and its own `on:` block records + // that dispatching the private projection lane leaves this feed untouched. + // The assertion was pinning the bug in place, which is why it changes here + // rather than being deleted as an obstacle. + expect(targets(decide("projects_v2_item", "edited"))).toEqual([ + ".github-private:board-changed", + "front-desk-feed:board-changed", + ]); + }); + + test("every card lifecycle action reaches both, not just `edited`", () => { + // `ok: true` was too weak to catch the missing target — it passed + // throughout the window where a card move reached the public feed through + // no path at all. Assert the fan-out per action instead. for (const action of ["created", "edited", "deleted", "reordered", "restored"]) { - expect(decide("projects_v2_item", action).ok).toBe(true); + expect(targets(decide("projects_v2_item", action))).toEqual([ + ".github-private:board-changed", + "front-desk-feed:board-changed", + ]); } }); @@ -112,6 +125,29 @@ describe("projects_v2_item — the signal that reaches no other event", () => { }); }); +describe("front-desk-feed — the branch a reader actually looks at", () => { + test("every type this sender can send wakes the feed, none private-only", () => { + // Pinned across the whole DispatchType union rather than case by case, + // because the defect was never one wrong line — it was a type that quietly + // had one consumer fewer than the others. desk.bounded.tools renders the + // `feed` branch and now pushes on a board change, so a type that wakes only + // the private side puts a notification in front of a board that has not + // moved yet. A fourth type should fail here until someone has decided, in + // the open, whether the public feed needs it. + const woken = new Set(); + for (const [event, action] of [ + ["pull_request", "opened"], + ["issues", "labeled"], + ["projects_v2_item", "edited"], + ] as const) { + const r = decide(event, action); + expect(r.ok).toBe(true); + if (r.ok) for (const t of r.targets) if (t.repo === "front-desk-feed") woken.add(t.eventType); + } + expect([...woken].sort()).toEqual(["board-changed", "claim-activity", "pr-activity"]); + }); +}); + describe("dispatch — transport", () => { const target: DispatchTarget = { owner: "bounded-systems", repo: ".github-private", eventType: "board-changed" }; diff --git a/src/dispatch-events.ts b/src/dispatch-events.ts index 1b6a797..337a032 100644 --- a/src/dispatch-events.ts +++ b/src/dispatch-events.ts @@ -21,8 +21,9 @@ /** The dispatch types the existing receivers already accept. These are not new * names — `pr-activity` and `claim-activity` are declared today in * `.github-private`'s `pr-projection.yml` and `front-desk-feed`'s - * `publish.yml`, and have simply never been sent. `board-changed` is the one - * addition, for ProjectV2 edits, which no receiver could observe before. */ + * `publish.yml`, and have simply never been sent. `board-changed` was the one + * addition, for ProjectV2 edits, which no receiver could observe before; both + * receivers declare it now. */ export type DispatchType = "pr-activity" | "claim-activity" | "board-changed"; export type DispatchTarget = { owner: string; repo: string; eventType: DispatchType }; @@ -67,7 +68,9 @@ const PROJECT_ITEM_ACTIONS = new Set(["created", "edited", "deleted", "reordered * `.github-private` holds the private projections a session reads; * `front-desk-feed` publishes the public feed at prs.bounded.tools, whose * "The backlog is drained" was false for a whole window while 88 PRs were - * open. Waking one and not the other fixes half the symptom. */ + * open — and the `feed` branch it force-pushes is also what desk.bounded.tools + * renders as the board. Waking one and not the other fixes half the symptom, + * and the half left broken is the one a reader is actually looking at. */ export function decide(event: string | null, action: string | undefined): DecideResult { const fanout = (eventType: DispatchType, repos: string[]): DecideResult => ({ ok: true, @@ -88,9 +91,24 @@ export function decide(event: string | null, action: string | undefined): Decide case "projects_v2_item": if (!action) return { ok: false, reason: "no-action" }; if (!PROJECT_ITEM_ACTIONS.has(action)) return { ok: false, reason: "action-not-watched" }; - // The board projection is private-side only; the public feed derives from - // it rather than from ProjectV2 directly. - return fanout("board-changed", [".github-private"]); + // BOTH — and this said private-side only, on a premise that was simply + // wrong: "the public feed derives from [the board projection] rather than + // from ProjectV2 directly". It does not. `front-desk-feed`'s publish.yml + // runs its own `scripts/project.sh` against org project #2, and its `on:` + // block says so in as many words: dispatching `.github-private`'s + // front-desk-projection.yml "does NOT refresh this feed … different repo, + // different branch". So a board change woke the private lane and reached + // the public one through no path at all — the same hole the absent sender + // had, one event further in. + // + // Measured 2026-08-30: the board projection updated at 22:52 and + // desk.bounded.tools went on serving the 21:25 snapshot. That was merely + // stale until desk gained Web Push. Now a board change also sends a + // payload-less push whose service worker fetches the board to learn what + // it was about — so an unwoken feed makes the notification actively + // wrong: it says the board changed and hands the reader a board that has + // not. Worse than not notifying. + return fanout("board-changed", [".github-private", "front-desk-feed"]); default: return { ok: false, reason: "event-not-watched" }; From 4d4f7850c3f1f7213eecba3297ab8efcf4c1d0f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 00:28:55 +0000 Subject: [PATCH 2/3] docs: record that fanning out to both targets narrows the skew, not closes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push comes from the first target's lane (front-desk-projection.yml is NOTIFY_WORKFLOW_REFS[0] in desk's src/oidc.js) and the board it points at is the second target's `feed` branch. This dispatches to both at once, so the fast lane can still notify before the slow one has published. front-desk-feed's publish.yml mints a broker token, queries the whole board, signs twice and force-pushes; it is not going to win that race. Fanning out here takes the skew from up to an hour down to the length of that job, which is all a sender can do — ordering two repos' Actions lanes is not something this Worker can express. Claim-issue: bounded-systems/bounded.tools#44 Co-authored-by: Claude --- src/dispatch-events.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/dispatch-events.ts b/src/dispatch-events.ts index 337a032..eef15f5 100644 --- a/src/dispatch-events.ts +++ b/src/dispatch-events.ts @@ -108,6 +108,17 @@ export function decide(event: string | null, action: string | undefined): Decide // it was about — so an unwoken feed makes the notification actively // wrong: it says the board changed and hands the reader a board that has // not. Worse than not notifying. + // + // BOTH TARGETS NARROW THAT; NEITHER CLOSES IT. The push comes from the + // FIRST target's lane (front-desk-projection.yml is NOTIFY_WORKFLOW_REFS[0] + // in desk's src/oidc.js) and the board it points at is the SECOND target's + // `feed` branch — and this dispatches to both at once, so the fast lane can + // still notify before the slow one has published. front-desk-feed's + // publish.yml mints a broker token, queries the whole board, signs twice + // and force-pushes; it is not going to win that race. Fanning out here + // takes the skew from up to an hour down to the length of that job, which + // is all a sender can do — ordering two repos' Actions lanes is not + // something this Worker can express. return fanout("board-changed", [".github-private", "front-desk-feed"]); default: From 0f5c5faf7adbbf22a7febb69afdf15ad592768e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 12:31:31 +0000 Subject: [PATCH 3/3] feat: announce this lane's ceremony to a phone, not only to a GitHub issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured 2026-08-31: a deploy ceremony here opened, waited its full window and reached no phone. infra#552 gave the seven infra ceremony lanes a push; this repo was not in that scope, so the only announcement was the notification issue the run opens — a GitHub notification, batched and routed wherever GitHub decides, against a window that can be as short as two minutes. Two gaps, and fixing either alone changes nothing: the lane had no notice step, and desk's NOTIFY_WORKFLOW_REFS did not carry it. Both close together. approve_url was a local shell variable, as it was in every infra lane before #552 — the notice reads steps.ceremony.outputs.approve_url, so without the echo it would send an empty URL and report "skipped" forever. No guard on the notice, because the ceremony above has none: this lane has no break-glass (infra#18), so if the ceremony ran, an approval is genuinely waiting. Non-fatal on every path — the gate is the Face ID, and a notice that failed to send must not fail a deploy that is otherwise authorized. THE RATCHET IS THE POINT. A test now reads the workflow FILES and asserts every lane containing authorize/start also notifies, before the wait, with approve_url published and id-token: write reachable. Reading files rather than a list of lane names means the next ceremony lane is covered the day it is added, not the day someone remembers. Mutation-tested, and the first pass was not evidence: two of four mutations were themselves broken (one missed because the id-token line carries a trailing comment). Redone, all four now go red. One found a real weakness — renaming the wait step made the ordering check silently skip itself, so a missing marker is now a failure rather than a reason to pass. 133 tests pass. Claim-issue: bounded-systems/infra#553 Co-authored-by: Claude Claim-issue: bounded-systems/.github#305 --- .github/workflows/deploy.yml | 50 ++++++++++++++++++++++++++++++++ src/ceremony-notice.test.ts | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/ceremony-notice.test.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aaedac9..cfd0dfa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -134,6 +134,7 @@ jobs: [ -n "$ceremony" ] && [ -n "$approve_url" ] || { echo "::error::keeper returned no ceremony"; exit 1; } echo "ceremony=$ceremony" >> "$GITHUB_OUTPUT" echo "request=$req" >> "$GITHUB_OUTPUT" + echo "approve_url=$approve_url" >> "$GITHUB_OUTPUT" # The issue is a NOTIFICATION, not a credential: commenting on it does nothing. # The approval is the Face ID at the URL. issue_url="$(gh issue create --repo "$GITHUB_REPOSITORY" \ @@ -142,6 +143,55 @@ jobs: echo "issue_url=$issue_url" >> "$GITHUB_OUTPUT" echo "ceremony open — approve at $approve_url" + # BEFORE the wait below. The issue above is a GitHub notification — batched, + # routed wherever GitHub decides, and no use against a window that can be as + # short as two minutes. infra#552 gave the seven infra ceremony lanes a push; + # this lane was not in that scope, and on 2026-08-31 a ceremony here opened, + # waited its full window and reached no phone (infra#553). + # + # No guard, because the ceremony above has none: this lane has no break-glass + # (infra#18), so if the ceremony ran, an approval is genuinely waiting. + # + # Non-fatal on every path. The gate is the Face ID; a notice that failed to + # send must not fail a deploy that is otherwise authorized. + - name: Notify subscribed devices that an approval is waiting + env: + APPROVE_URL: ${{ steps.ceremony.outputs.approve_url }} + NOTICE_TITLE: Approve bounded.tools deploy + NOTICE_BODY: >- + Mints an account-wide Workers Scripts:Edit token and deploys the GitHub App + receiver — the Worker that fans org activity out to the desk and PR feeds. + run: | + set -euo pipefail + summary() { echo "$1"; echo "$1" >> "$GITHUB_STEP_SUMMARY"; } + if [ -z "${APPROVE_URL:-}" ]; then + summary "Approval notice: skipped — no approve URL from the ceremony." + exit 0 + fi + tok="$(curl -sS --max-time 20 \ + -H "authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL:-}&audience=https://desk.bounded.tools" \ + | jq -r '.value // empty' || true)" + if [ -z "$tok" ]; then + echo "::warning::could not mint an OIDC token for desk; no approval notice sent" + summary "Approval notice: **skipped** — no OIDC token." + exit 0 + fi + payload="$(jq -nc --arg t "$NOTICE_TITLE" --arg b "$NOTICE_BODY" \ + --arg u "$APPROVE_URL" --arg r "$GITHUB_RUN_ID" \ + '{title:$t, body:($b + " Run " + $r + "."), url:$u}')" + code="$(curl -sS --max-time 30 -o /tmp/appr.json -w '%{http_code}' -X POST \ + -H "authorization: Bearer $tok" -H 'content-type: application/json' \ + -d "$payload" https://desk.bounded.tools/approval || true)" + case "$code" in + 200) summary "$(jq -r '"Approval notice: sent \(.sent)/\(.total) — pruned \(.pruned), failed \(.failed)"' /tmp/appr.json)" ;; + 403) summary "Approval notice: desk has not allowlisted this lane yet (HTTP 403 — add this workflow's ref to NOTIFY_WORKFLOW_REFS in desk's src/oidc.js and deploy desk)." ;; + 404|405|503) summary "Approval notice: not available on the deployed Worker yet (HTTP $code)." ;; + *) echo "::warning::desk /approval answered ${code:-no response}" + summary "Approval notice: **failed** — desk answered ${code:-no response}." ;; + esac + exit 0 + - name: Wait for the Face ID, then redeem — fail closed env: KEEPER_URL: https://keeper.bounded.tools diff --git a/src/ceremony-notice.test.ts b/src/ceremony-notice.test.ts new file mode 100644 index 0000000..41c534f --- /dev/null +++ b/src/ceremony-notice.test.ts @@ -0,0 +1,56 @@ +// Every lane that opens a keeper ceremony must also announce it (infra#553). +// +// The failure this pins is not a broken notice — it is a lane that never had +// one. infra#552 gave seven lanes a push; this repo was not in that scope, and +// on 2026-08-31 a ceremony here opened, waited its full window and reached no +// phone. Nothing was red, because nothing was checked. +// +// It reads the workflow FILES rather than a list of lane names, so a new +// ceremony lane is covered the day it is added rather than the day someone +// remembers to add it here. +import { test, expect } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const DIR = ".github/workflows"; +const workflows = readdirSync(DIR).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")); + +test("every lane that opens a ceremony pushes an approval notice before waiting", () => { + const missing: string[] = []; + for (const f of workflows) { + const src = readFileSync(join(DIR, f), "utf8"); + if (!src.includes("authorize/start")) continue; + // The notice must exist, and must come BEFORE the wait — a notice after the + // wait is sent to a human who has already approved, or never sent at all. + const notice = src.indexOf("desk.bounded.tools/approval"); + const wait = src.indexOf("Wait for the Face ID"); + // `wait === -1` is a FAILURE, not a reason to skip the ordering check. A + // ceremony lane always waits; if this marker stops matching, the ordering + // assertion would silently disable itself and this test would go on + // passing while proving nothing — found by mutating the step's name. + if (notice === -1 || wait === -1 || notice > wait) missing.push(f); + } + expect(missing).toEqual([]); +}); + +test("the ceremony step publishes the approve URL the notice needs", () => { + // approve_url was a local shell variable in every lane that had this bug: the + // notice step reads steps.ceremony.outputs.approve_url, so a lane that never + // echoes it sends a notice with an empty URL and reports "skipped" forever. + for (const f of workflows) { + const src = readFileSync(join(DIR, f), "utf8"); + if (!src.includes("authorize/start")) continue; + expect(src).toContain('echo "approve_url=$approve_url" >> "$GITHUB_OUTPUT"'); + } +}); + +test("the lane can mint the OIDC token the notice is authorized by", () => { + // A job-level `permissions:` block silently overrides the workflow-level + // grant, and the notice then reports "skipped — no OIDC token" on every run. + // infra#552 found this shape by inspection; here it is a test. + for (const f of workflows) { + const src = readFileSync(join(DIR, f), "utf8"); + if (!src.includes("desk.bounded.tools/approval")) continue; + expect(src).toContain("id-token: write"); + } +});