From daedcc4f86c4cde83521e28f13fd491cfb883a55 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 02:17:23 +0100 Subject: [PATCH 001/145] Fix EF migration discovery metadata --- .../Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs | 2 ++ .../Migrations/20260609143000_AddPatronSupportDerivation.cs | 2 ++ .../20260609160000_AddExternalPatronProviderIntake.cs | 2 ++ .../20260622142000_AddBridgeActionIdentityProvenance.cs | 3 +++ .../Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs | 2 ++ .../20260622202000_AddBridgeActionTargetSurfaceName.cs | 3 +++ .../20260705190000_AddPatronSupportProjectAttribution.cs | 3 +++ 7 files changed, 17 insertions(+) diff --git a/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs b/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs index b964789..38a3bd1 100644 --- a/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs +++ b/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Bifrost.Web.Data.Migrations { /// + [DbContext(typeof(BifrostDbContext))] [Migration("20260608210000_AddHeimdallUserIdentity")] public partial class AddHeimdallUserIdentity : Migration { diff --git a/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs b/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs index f32aa95..740aec9 100644 --- a/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs +++ b/src/Bifrost.Web/Data/Migrations/20260609143000_AddPatronSupportDerivation.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Bifrost.Web.Data.Migrations { /// + [DbContext(typeof(BifrostDbContext))] [Migration("20260609143000_AddPatronSupportDerivation")] public partial class AddPatronSupportDerivation : Migration { diff --git a/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs b/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs index 172647c..e74c226 100644 --- a/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs +++ b/src/Bifrost.Web/Data/Migrations/20260609160000_AddExternalPatronProviderIntake.cs @@ -1,10 +1,12 @@ using Bifrost.Web.Domain; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace Bifrost.Web.Data.Migrations { + [DbContext(typeof(BifrostDbContext))] [Migration("20260609160000_AddExternalPatronProviderIntake")] public partial class AddExternalPatronProviderIntake : Migration { diff --git a/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs b/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs index 1425791..a1aed1c 100644 --- a/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs +++ b/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +6,8 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Bifrost.Web.Data.Migrations { /// + [DbContext(typeof(BifrostDbContext))] + [Migration("20260622142000_AddBridgeActionIdentityProvenance")] public partial class AddBridgeActionIdentityProvenance : Migration { /// diff --git a/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs b/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs index efbb170..6885ce0 100644 --- a/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs +++ b/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Bifrost.Web.Data.Migrations { /// + [DbContext(typeof(BifrostDbContext))] [Migration("20260622165000_AddBifrostNativeIdentity")] public partial class AddBifrostNativeIdentity : Migration { diff --git a/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs b/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs index 0eceae2..c5b08b7 100644 --- a/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs +++ b/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +6,8 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Bifrost.Web.Data.Migrations { /// + [DbContext(typeof(BifrostDbContext))] + [Migration("20260622202000_AddBridgeActionTargetSurfaceName")] public partial class AddBridgeActionTargetSurfaceName : Migration { /// diff --git a/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs b/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs index 5373d4d..2168de2 100644 --- a/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs +++ b/src/Bifrost.Web/Data/Migrations/20260705190000_AddPatronSupportProjectAttribution.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Bifrost.Web.Data.Migrations { /// + [DbContext(typeof(BifrostDbContext))] + [Migration("20260705190000_AddPatronSupportProjectAttribution")] public partial class AddPatronSupportProjectAttribution : Migration { /// From 4619939e64e7f9bb6c0c497b3f33921554b98fe6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:13:43 +0200 Subject: [PATCH 002/145] Retire publish-idunn-alarm and the pump's discord-dm verb Delivery-crossing ruling: Bifrost reads Idunn's incident projection with its own delivery journal, so Idunn no longer publishes alarms into Bifrost. - Delete tools/operator-notification.mjs (259 lines); its only callers were gamecult-ops notify-idunn-operator-alarm scripts. - cultmesh-bridge-commands.mjs: drop discord-dm from supportedBridgeVerbs and the recipient-id arm; --channel-id is always passed. - bifrost-bridge.mjs discord-dm is unchanged (docs/bridge.md, BridgeCliTests). Builds: yes. Co-Authored-By: Claude Sonnet 5.5 --- tools/cultmesh-bridge-commands.mjs | 7 +- tools/operator-notification.mjs | 259 ----------------------------- 2 files changed, 3 insertions(+), 263 deletions(-) delete mode 100644 tools/operator-notification.mjs diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 20b98a5..576ace4 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -149,9 +149,8 @@ async function processOneCommand(node, command, options) { const args = [ "tools/bifrost-bridge.mjs", verb, - ...(verb === "discord-dm" - ? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")] - : ["--channel-id", requireString(payload.channelId, "payload.channelId")]), + "--channel-id", + requireString(payload.channelId, "payload.channelId"), "--content-file", contentPath, "--cultmesh-command-id", @@ -224,7 +223,7 @@ async function processOneCommand(node, command, options) { } } -const supportedBridgeVerbs = new Set(["discord-post", "discord-dm"]); +const supportedBridgeVerbs = new Set(["discord-post"]); function resolveBridgeVerb(command) { const verb = optionalString(command.command) ?? "discord-post"; diff --git a/tools/operator-notification.mjs b/tools/operator-notification.mjs deleted file mode 100644 index 7b98030..0000000 --- a/tools/operator-notification.mjs +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { createHash } from "node:crypto"; -import { - discordPostCommandDefinition, - discordPostCommandDocumentType as commandDocumentType, - discordPostCommandSchemaId as commandSchemaId, - discordPostReceiptDefinition, - discordPostReceiptDocumentType as receiptDocumentType, -} from "./bifrost-discord-command-documents.mjs"; - -const repoRoot = resolve(import.meta.dirname, ".."); -const projectsRoot = resolve(repoRoot, ".."); - -// The gate's own provider store. Bifrost owns the Discord crossing, so an alarm -// is a command addressed to the gate, not a document dropped into somebody -// else's state. cultmesh-bridge-commands.mjs reads this same path by default. -const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); - -async function main() { - const [verb, ...rawArgs] = process.argv.slice(2); - const options = parseArgs(rawArgs); - - if (!verb || verb === "help" || verb === "--help" || verb === "-h") { - printHelp(); - return; - } - - switch (verb) { - case "publish-idunn-alarm": - await publishIdunnAlarm(options); - return; - default: - throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`); - } -} - -async function publishIdunnAlarm(options) { - const storePath = resolve(options.store ?? defaultStorePath); - const alarm = { - alarmId: readOptionOrEnv(options, "alarm-id", "IDUNN_ALARM_ID", "unknown-alarm"), - daemonId: readOptionOrEnv(options, "daemon-id", "IDUNN_ALARM_DAEMON_ID", "unknown-daemon"), - severity: readOptionOrEnv(options, "severity", "IDUNN_ALARM_SEVERITY", "operator-action-required"), - reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), - raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), - }; - // DISCORD_OWNER_ID is the established GameCult name for this, used by VoidBot's - // config schema. Read it first rather than minting another spelling. - const recipientId = - optionalString(options["recipient-id"]) ?? - optionalString(process.env.DISCORD_OWNER_ID) ?? - optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID); - if (!recipientId) { - throw new Error( - "Set --recipient-id, DISCORD_OWNER_ID, or IDUNN_OPERATOR_DISCORD_ID so Bifrost knows which operator to reach.", - ); - } - - const command = buildDiscordDmCommand(alarm, recipientId); - const waitSeconds = - Number.parseInt(readOptionOrEnv(options, "wait-seconds", "IDUNN_ALARM_WAIT_SECONDS", "0"), 10) || 0; - - // Build the runtime and definitions before honouring --dry-run. A dry run that - // skips the runtime cannot notice a broken document definition, which is how - // this publisher shipped unable to publish anything at all. - const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); - const commandDefinition = discordPostCommandDefinition(defineDocumentType); - const receiptDefinition = discordPostReceiptDefinition(defineDocumentType); - - if (options["dry-run"] === "true") { - printJson({ - dryRun: true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - command, - }); - return; - } - - const node = await CultMesh.createNode(storePath, { - documents: [commandDefinition, receiptDefinition], - }); - await node.put(commandDefinition, command.commandId, command); - await node.flush?.(); - - const receipt = - waitSeconds > 0 - ? await waitForReceipt(node, receiptDefinition, command.commandId, waitSeconds * 1000) - : undefined; - - printJson({ - ok: receipt ? receipt.ok === true : true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - commandId: command.commandId, - delivery: receipt - ? { - status: receipt.status, - ok: receipt.ok, - messageId: receipt.messageId, - transport: receipt.transport, - error: receipt.error, - } - : { status: "pending", note: "Not awaited. Pass --wait-seconds to confirm delivery." }, - }); - - // A receipt that says the crossing failed must fail the caller. Idunn reads - // the exit code; reporting ok:false on stdout and exiting 0 would hand it a - // success it can act on. - if (receipt && receipt.ok !== true) { - process.exitCode = 1; - } -} - -function buildDiscordDmCommand(alarm, recipientId) { - const commandId = `idunn-alarm-${createHash("sha1").update(JSON.stringify(alarm)).digest("hex").slice(0, 16)}`; - const content = [ - "Idunn needs operator intervention.", - "", - `Service: ${alarm.daemonId}`, - `Severity: ${alarm.severity}`, - `Reason: ${alarm.reason}`, - `Alarm: ${alarm.alarmId}`, - `Raised: ${alarm.raisedAt}`, - "", - "Automatic recovery either was not authorized or failed.", - ].join("\n"); - const requestedAt = new Date().toISOString(); - - const command = { - schemaName: commandDocumentType, - schemaVersion: commandSchemaId, - commandId, - command: "discord-dm", - status: "pending", - requestedBy: "idunn", - requestedAt, - updatedAt: requestedAt, - source: { kind: "idunn-operator-alarm", id: alarm.alarmId }, - actor: { id: "idunn", displayName: "Idunn" }, - payload: { recipientId, content }, - }; - - const commandUri = optionalString(process.env.BIFROST_CULTMESH_COMMAND_URI); - if (commandUri) { - command.commandUri = commandUri; - } - return command; -} - -async function waitForReceipt(node, definition, commandId, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt <= timeoutMs) { - await node.cache?.pullAllBackingStores?.(); - const receipt = await node.get(definition, commandId); - if (receipt) { - return receipt; - } - await new Promise((done) => setTimeout(done, 250)); - } - throw new Error( - `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a ${receiptDocumentType} for ${commandId}. ` + - "Is the Bifrost bridge command pump running?", - ); -} - -function loadCultMeshRuntime() { - const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), - resolve(projectsRoot, "CultMeshTS", "package.json"), - ]; - - for (const packageJson of candidates) { - if (!existsSync(packageJson)) { - continue; - } - try { - const requireCult = createRequire(packageJson); - const { CultMesh } = requireCult("cultmesh-ts"); - const { defineDocumentType } = requireCult("cultcache-ts"); - if (CultMesh && defineDocumentType) { - return { CultMesh, defineDocumentType }; - } - } catch { - } - } - - throw new Error("CultMesh/CultCache TypeScript runtime is unavailable."); -} - -function readOptionOrEnv(options, optionName, envName, fallback) { - return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback; -} - -function parseArgs(args) { - const options = {}; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (!token.startsWith("--")) { - throw new Error(`Unexpected argument "${token}".`); - } - const key = token.slice(2); - const next = args[index + 1]; - if (!next || next.startsWith("--")) { - options[key] = "true"; - continue; - } - options[key] = next; - index += 1; - } - return options; -} - -function optionalString(value) { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function printHelp() { - process.stdout.write(`Bifrost operator notification CultMesh publisher - -Publishes an Idunn operator alarm as a ${commandSchemaId} document addressed to -the Bifrost Discord gate. Bifrost owns the crossing; this tool only asks. The -bridge command pump turns the command into a DM and writes back a -${receiptDocumentType}.v1 receipt. - -Commands: - publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command - -Options: - --recipient-id Operator Discord user id (or DISCORD_OWNER_ID / - IDUNN_OPERATOR_DISCORD_ID) - --store Gate provider store (default: .bifrost/provider-store.cc) - --wait-seconds Wait for the delivery receipt instead of returning at publish - --dry-run Build the command and definitions without writing - -Examples: - node tools/operator-notification.mjs publish-idunn-alarm --dry-run - node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30 -`); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); From 9c954e7502bcea700acff8d8b826ba1fa52f5f89 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:59:51 +0200 Subject: [PATCH 003/145] Bifrost pump: one verb, one path; refuse anything else with a failed receipt With discord-dm gone the verb set, listCommandRecords filter, resolveBridgeVerb throw and the verb variable decided nothing, and the filter hid non-discord-post commands so they stayed pending with no receipt. Delete all of it: a command whose verb is not discord-post now reaches processOneCommand and is refused with a failed receipt (error names no input value). Payload staging moves inside the try so a refusal cannot escape as a thrown error. Adds tests/cultmesh-bridge-commands.test.mjs: discord-dm refused with failed receipt and non-ok result, canary-free error, discord-post still reaches the actuator. Co-Authored-By: Claude Sonnet 5.5 --- tests/cultmesh-bridge-commands.test.mjs | 89 +++++++++++++++++++++++++ tools/cultmesh-bridge-commands.mjs | 38 ++++------- 2 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 tests/cultmesh-bridge-commands.test.mjs diff --git a/tests/cultmesh-bridge-commands.test.mjs b/tests/cultmesh-bridge-commands.test.mjs new file mode 100644 index 0000000..2c0d56f --- /dev/null +++ b/tests/cultmesh-bridge-commands.test.mjs @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { + discordPostCommandDefinition, + discordPostCommandDocumentType, + discordPostCommandSchemaId, +} from "../tools/bifrost-discord-command-documents.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const cultLib = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib")); +const tool = resolve(root, "tools", "cultmesh-bridge-commands.mjs"); +const canary = "CANARY-9f3a1c77"; + +function loadRuntime() { + const mesh = resolve(cultLib, "packages", "cultmesh-ts", "dist", "index.js"); + const cache = resolve(cultLib, "packages", "cultcache-ts", "dist", "index.js"); + const requireCult = createRequire(mesh); + return { CultMesh: requireCult(mesh).CultMesh, defineDocumentType: requireCult(cache).defineDocumentType }; +} + +async function putCommand(store, commandId, command) { + const { CultMesh, defineDocumentType } = loadRuntime(); + const definition = discordPostCommandDefinition(defineDocumentType); + const node = await CultMesh.createNode(store, { documents: [definition] }); + await node.put(definition, commandId, { + schemaName: discordPostCommandDocumentType, + schemaVersion: discordPostCommandSchemaId, + commandId, + command, + status: "pending", + requestedBy: "test", + requestedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + source: { kind: "test", id: commandId }, + payload: { channelId: "123", content: canary }, + }); + await node.flush?.(); +} + +function run(...args) { + const env = { ...process.env, BIFROST_SKIP_LOCAL_ENV: "true", VOIDBOT_CULTLIB_ROOT: cultLib }; + delete env.BIFROST_DISCORD_BOT_TOKEN; + delete env.DISCORD_BOT_TOKEN; + const result = spawnSync(process.execPath, [tool, ...args], { cwd: root, encoding: "utf8", env }); + return { ...result, json: JSON.parse(result.stdout) }; +} + +test("a non-discord-post command is refused with a failed receipt, never left pending", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "dm1", "discord-dm"); + + const processed = run("process", "--store", store); + assert.equal(processed.json.commandCount, 1); + assert.equal(processed.json.ok, false); + assert.equal(processed.json.processed[0].status, "failed"); + + const receipt = run("receipt", "--store", store, "--command-id", "dm1").json; + assert.equal(receipt.ok, false); + assert.equal(receipt.receipt.status, "failed"); + assert.equal(receipt.receipt.ok, false); + assert.match(receipt.receipt.error, /only discord-post is supported/); + + const again = run("process", "--store", store); + assert.equal(again.json.commandCount, 0, "a refused command is not pending or running afterwards"); +}); + +test("a refusal echoes no input value", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "dm2", `${canary}-verb`); + const processed = run("process", "--store", store, "--command-id", "dm2"); + assert.equal(processed.json.processed[0].status, "failed"); + assert.equal(processed.json.processed[0].error.includes(canary), false); +}); + +test("a discord-post command still reaches the actuator", async () => { + const store = resolve(await mkdtemp(resolve(tmpdir(), "bifrost-pump-")), "store.cc"); + await putCommand(store, "post1", "discord-post"); + const processed = run("process", "--store", store); + assert.equal(processed.json.commandCount, 1); + const receipt = processed.json.processed[0]; + assert.equal(receipt.action, "discord-post"); + assert.match(receipt.error, /Bifrost Discord actuator failed/); + assert.doesNotMatch(receipt.error, /only discord-post is supported/); +}); diff --git a/tools/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 576ace4..8d4885b 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -131,7 +131,6 @@ async function deleteCommand(options) { } async function processOneCommand(node, command, options) { - const verb = resolveBridgeVerb(command); const running = { ...command, status: "running", @@ -142,13 +141,16 @@ async function processOneCommand(node, command, options) { const payload = command.payload && typeof command.payload === "object" ? command.payload : {}; const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId); const contentPath = resolve(tempDir, "content.md"); - await mkdir(tempDir, { recursive: true }); - await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); try { + if ((optionalString(command.command) ?? "discord-post") !== "discord-post") { + throw new Error("Unsupported Bifrost bridge command; only discord-post is supported."); + } + await mkdir(tempDir, { recursive: true }); + await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); const args = [ "tools/bifrost-bridge.mjs", - verb, + "discord-post", "--channel-id", requireString(payload.channelId, "payload.channelId"), "--content-file", @@ -162,11 +164,9 @@ async function processOneCommand(node, command, options) { "--identity", optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost", ]; - if (verb === "discord-post") { - pushOption(args, "--persona-name", payload.personaName); - pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); - pushOption(args, "--reply-to-message-id", payload.replyToMessageId); - } + pushOption(args, "--persona-name", payload.personaName); + pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); + pushOption(args, "--reply-to-message-id", payload.replyToMessageId); pushOption(args, "--receipt-store", options["receipt-store"]); const result = spawnSync(process.execPath, args, { @@ -181,11 +181,11 @@ async function processOneCommand(node, command, options) { if (result.status !== 0 || result.error) { throw new Error(renderSpawnFailure(result)); } - const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`); + const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt"); const receipt = buildReceipt(command, { status: "completed", ok: true, - action: verb, + action: "discord-post", channelId: posted.channelId, messageId: posted.messageId, transport: posted.transport, @@ -205,7 +205,7 @@ async function processOneCommand(node, command, options) { const receipt = buildReceipt(command, { status: "failed", ok: false, - action: verb, + action: "discord-post", canonicalReceiptId: `crossing_${command.commandId}`, error: error instanceof Error ? error.message : String(error), }); @@ -223,18 +223,6 @@ async function processOneCommand(node, command, options) { } } -const supportedBridgeVerbs = new Set(["discord-post"]); - -function resolveBridgeVerb(command) { - const verb = optionalString(command.command) ?? "discord-post"; - if (!supportedBridgeVerbs.has(verb)) { - throw new Error( - `Unsupported Bifrost bridge command "${verb}". Supported: ${[...supportedBridgeVerbs].join(", ")}.`, - ); - } - return verb; -} - function buildReceipt(command, result) { const completedAt = new Date().toISOString(); return { @@ -266,7 +254,7 @@ function listCommandRecords(node) { } return node.cache.getAll(commandDefinition()) .map(unwrapRecord) - .filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post")); + .filter((entry) => entry && entry.commandId); } async function openCommandNode(storePath) { From 417454c28f8776b5ec0b56a52d411194e6ebce40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:15:18 +0200 Subject: [PATCH 004/145] Watchdog notice reader: Idunn incidents become owner DMs through Bifrost's own journal tools/watchdog-notice.mjs is a one-shot reader. It reads Idunn's incident store unlocked with SingleFileMessagePackBackingStore.pullAll, sends one opening and one closing DM per incident through bifrost-bridge.mjs, and keeps a bifrost.watchdog_notice_execution.v1 journal: a running entry becomes unknown and is never re-posted, a failed one is retried with the same nonce up to five times, terminal entries are pruned once Idunn retires the incident. The exit status is the delivery-failure signal. discord-dm accepts --nonce (at most 25 chars) and sends nonce with enforce_nonce so a retry inside Discord's window returns the original message. The tests run the CLI against the store bytes Idunn wrote (fixture from GameCult/Idunn 7f7528b3) with a counting bridge double, and once through the real bridge with fetch stubbed. docs/watchdog-notice.md states the CultLib >= 8cb3b728 floor. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 39 +++ tests/fixtures/idunn.operator_incident.v1.cc | Bin 0 -> 737 bytes tests/watchdog-notice.test.mjs | 323 +++++++++++++++++++ tools/bifrost-bridge.mjs | 9 +- tools/watchdog-notice.mjs | 284 ++++++++++++++++ 5 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 docs/watchdog-notice.md create mode 100644 tests/fixtures/idunn.operator_incident.v1.cc create mode 100644 tests/watchdog-notice.test.mjs create mode 100644 tools/watchdog-notice.mjs diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md new file mode 100644 index 0000000..50e8235 --- /dev/null +++ b/docs/watchdog-notice.md @@ -0,0 +1,39 @@ +# Watchdog notice reader + +`tools/watchdog-notice.mjs` is a one-shot reader. It turns Idunn's operator +incidents into Discord DMs to the owner. Run it from a timer. + +``` +node tools/watchdog-notice.mjs process --incident-store P --journal-store J --receipt-store R [--bridge-cli B] +node tools/watchdog-notice.mjs status --journal-store J +``` + +## Requirements + +- **CultLib at `8cb3b728` or later.** The reader loads `@gamecult/cultcache-ts`, + the name CultLib gave the package in `8cb3b728` (2026-09-04), from + `$VOIDBOT_CULTLIB_ROOT` or the sibling `../CultLib`. An older CultLib fails at + import with `MODULE_NOT_FOUND`; the reader reports `cultlib-unavailable`. The + same floor applies to every Bifrost tool that loads CultCache + (`tools/persona-feedback.mjs` and the others). A deployment pairs a Bifrost + release with a CultLib commit at or after this one. +- `DISCORD_OWNER_ID` in the environment: the only source of the recipient. The + reader exits non-zero before writing the journal if it is missing. +- `BIFROST_DISCORD_BOT_TOKEN` in the environment, read only by + `bifrost-bridge.mjs`, which the reader spawns. + +## Behaviour + +- P is Idunn's `idunn.operator_incident.v1` store. The reader reads it without a + lock and never writes it. An absent P is a clean no-op. +- One notice when an incident opens and one when it closes, each at most once. + The closing notice is sent only after the opening notice completed. +- J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed + `#opened` or `#closed`. A `running` entry found at + start becomes `unknown` and is never sent again. A `failed` entry is retried on + later runs with the same nonce, up to five attempts. Terminal entries are + deleted once Idunn no longer lists the incident. +- The exit status is non-zero while any entry is `unknown` or out of attempts, or + while P holds a record of this schema that breaks its contract. systemd shows + that; `status` lists the entries. +- Notice text is built from `condition`, `subject` and ISO UTC times only. diff --git a/tests/fixtures/idunn.operator_incident.v1.cc b/tests/fixtures/idunn.operator_incident.v1.cc new file mode 100644 index 0000000000000000000000000000000000000000..180c6f0f2ae325984fa3c77bc4f3d193916ac76d GIT binary patch literal 737 zcmbQdDY-PKBsnoTBUP`sB)=$Cugq}b^j(=LrFnUJ`30#(i9o^l%)I2xl+?TupyYNO zQhRZT1GNAV9s_S?R4Ww&tZVp?B$gJJq^4LU=j0csrs$R=7Nw__SQ(ld zAOIfob{ZKN8S5Gv>KdAa7#Ud^npzncXd4&+$s@h^f|qnhY{BO0wJ5GW&%nUgeg0_% z1BCJH-GY@xsmb|esYR(NQ*P>EGr$TM_jzcZHU#^eJWp5P^)!i2!0P4ING^u^^S}WB D;7=HM literal 0 HcmV?d00001 diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs new file mode 100644 index 0000000..3bf1dd2 --- /dev/null +++ b/tests/watchdog-notice.test.mjs @@ -0,0 +1,323 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, stat, writeFile, copyFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// The fixture is the store Idunn's incident cut wrote: GameCult/Idunn branch +// hands/watchdog-idunn-incident, commit 7f7528b33e8e5c607c19cc7f8f8a8da4def50fca +// ("Add the idunn.operator_incident.v1 wire fixture"), tests/fixtures/ there. +// It holds two records: closed-target (closed, recovered) and open-target. +const FIXTURE = resolve(import.meta.dirname, "fixtures", "idunn.operator_incident.v1.cc"); +const FIXTURE_SHA256 = "f446770b07da99204e9036ef521f3136be6251e5b0a68e4e9533a89f25f18cab"; +const CLOSED_KEY = "continuity-exhausted:closed-target:1700000000000"; +const OPEN_KEY = "continuity-exhausted:open-target:1700000100000"; +const TOOL = resolve(import.meta.dirname, "..", "tools", "watchdog-notice.mjs"); +const BRIDGE = resolve(import.meta.dirname, "..", "tools", "bifrost-bridge.mjs"); +const RECIPIENT = "424242RECIPIENTCANARY"; + +const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on ${subject} at ${at}`; +const T_OPEN = "2023-11-14T22:13:20.000Z"; +const T_CLOSE = "2023-11-14T22:15:00.000Z"; +const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on closed-target, opened ${T_OPEN}, closed ${T_CLOSE}`; + +// CultLib is the sibling ../CultLib, like the other tests. +const cult = resolve(import.meta.dirname, "..", "..", "CultLib"); +const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json"))("@gamecult/cultcache-ts"); +const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); + +// A bridge double that logs each call (with the content it was handed) and +// behaves as the `mode` file says: ok, fail, or crash (kills its parent, the +// reader, after logging, as a power cut between send and journal would). +const FAKE_BRIDGE = ` +import { appendFileSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const here = dirname(fileURLToPath(import.meta.url)); +const args = process.argv.slice(2); +const opt = (name) => args[args.indexOf("--" + name) + 1]; +const calls = resolve(here, "calls.jsonl"); +const n = (() => { try { return readFileSync(calls, "utf8").trim().split("\\n").length; } catch { return 0; } })(); +appendFileSync(calls, JSON.stringify({ args, content: readFileSync(opt("content-file"), "utf8") }) + "\\n"); +const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); +if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } +if (mode === "fail") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(1); } +process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); +`; + +async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { + const dir = await mkdtemp(resolve(tmpdir(), prefix)); + const w = { + dir, + incidents: resolve(dir, "idunn", "incidents.cc"), + journal: resolve(dir, "bifrost", "journal.cc"), + receipts: resolve(dir, "bifrost", "receipts.cc"), + bridge: resolve(dir, "fake-bridge.mjs"), + calls: resolve(dir, "calls.jsonl"), + }; + await mkdir(resolve(dir, "idunn")); + await writeFile(w.bridge, FAKE_BRIDGE); + await writeFile(resolve(dir, "mode"), mode); + if (incidents) await copyFile(incidents, w.incidents); + w.setMode = (value) => writeFile(resolve(dir, "mode"), value); + w.run = (extra = {}) => run(w, extra); + w.status = () => runStatus(w); + w.posts = async () => (existsSync(w.calls) ? (await readFile(w.calls, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); + return w; +} + +function run(w, { env = {}, bridge = w.bridge } = {}) { + return spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { + encoding: "utf8", + env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT, ...env }, + }); +} + +function runStatus(w) { + const result = spawnSync(process.execPath, [TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: process.env }); + return { ...result, rows: result.status === null || !result.stdout ? [] : JSON.parse(result.stdout) }; +} + +const opt = (post, name) => post.args[post.args.indexOf(`--${name}`) + 1]; +const notice = (post) => opt(post, "cultmesh-command-id").split("#")[1]; + +// Builds an incident store from the fixture's own envelopes with the payload +// tuples rewritten by `edit`. +async function variantStore(path, edit) { + const source = await new cache.SingleFileMessagePackBackingStore(FIXTURE).pullAll(); + const tuples = edit(source.map((envelope) => msgpack.decode(envelope.payload))); + const envelopes = tuples.map((tuple, index) => ({ ...source[0], key: `variant-${index}-${tuple[1]}`, payload: msgpack.encode(tuple), schemaId: source[0].schemaId, catalogEntry: source[0].catalogEntry })); + await new cache.SingleFileMessagePackBackingStore(path).pushAll(envelopes); +} + +test("the Idunn fixture decodes as the incidents Idunn wrote", async () => { + const bytes = await readFile(FIXTURE); + assert.equal(createHash("sha256").update(bytes).digest("hex"), FIXTURE_SHA256); + const w = await world("wn-fixture-"); + const result = w.run(); + assert.equal(result.status, 0, result.stderr); + const posts = await w.posts(); + const byKey = Object.fromEntries(posts.map((post) => [opt(post, "cultmesh-command-id"), post.content])); + assert.deepEqual(byKey, { + [`${CLOSED_KEY}#opened`]: OPENED_TEXT("closed-target", T_OPEN), + [`${CLOSED_KEY}#closed`]: CLOSED_TEXT, + [`${OPEN_KEY}#opened`]: OPENED_TEXT("open-target", T_CLOSE), + }); +}); + +test("one incident yields one opening post across runs", async () => { + const w = await world("wn-once-"); + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + const posts = await w.posts(); + const openings = posts.filter((post) => notice(post) === "opened"); + assert.equal(openings.length, 2); + assert.deepEqual(openings.map((post) => opt(post, "source-id")).sort(), [CLOSED_KEY, OPEN_KEY]); + assert.equal(posts.length, 3, "no second run posts again"); + for (const post of posts) { + const key = opt(post, "cultmesh-command-id"); + assert.equal(opt(post, "recipient-id"), RECIPIENT); + assert.equal(opt(post, "nonce"), createHash("sha256").update(key).digest("hex").slice(0, 25)); + assert.equal(opt(post, "receipt-store"), w.receipts); + assert.equal(opt(post, "source-kind"), "idunn-operator-incident"); + } +}); + +test("closure is posted once and only after a completed opening", async () => { + const w = await world("wn-closure-"); + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + const closures = (await w.posts()).filter((post) => notice(post) === "closed"); + assert.equal(closures.length, 1); + assert.equal(opt(closures[0], "source-id"), CLOSED_KEY); + + // An opening that never completes blocks its closure, now and later. + const failing = await world("wn-closure-failed-", { mode: "fail" }); + for (let run = 0; run < 5; run += 1) failing.run(); + await failing.setMode("ok"); + failing.run(); + failing.run(); + assert.equal((await failing.posts()).filter((post) => notice(post) === "closed").length, 0); + const rows = (await failing.status()).rows; + assert.deepEqual(rows.map((row) => `${row.notice}:${row.status}:${row.attempts}`), ["opened:failed:5", "opened:failed:5"]); + + const crashed = await world("wn-closure-unknown-", { mode: "crash" }); + assert.equal(crashed.run().status, null, "the reader died mid-send"); + await crashed.setMode("ok"); + crashed.run(); + crashed.run(); + assert.equal((await crashed.posts()).filter((post) => notice(post) === "closed").length, 0); +}); + +test("a running entry becomes unknown and is never re-posted", async () => { + const w = await world("wn-running-", { mode: "crash" }); + assert.equal(w.run().status, null); + assert.equal((await w.status()).rows[0].status, "running"); + await w.setMode("ok"); + const recovered = w.run(); + assert.notEqual(recovered.status, 0, "an unknown entry is a delivery failure"); + assert.equal(JSON.parse(recovered.stdout).unknown, 1); + const posts = await w.posts(); + assert.equal(posts.filter((post) => opt(post, "cultmesh-command-id") === `${CLOSED_KEY}#opened`).length, 1, "the interrupted notice is not sent again"); + const rows = (await w.status()).rows; + assert.equal(rows.find((row) => row.incidentKey === CLOSED_KEY && row.notice === "opened").status, "unknown"); + assert.notEqual(w.run().status, 0, "still failing on the next run"); + assert.equal((await w.posts()).length, posts.length); +}); + +test("a failed send is retried with the same nonce up to five attempts", async () => { + const w = await world("wn-retry-", { mode: "fail" }); + const exits = []; + for (let run = 0; run < 5; run += 1) exits.push(w.run().status); + assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted"); + const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); + assert.equal(open.length, 5); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1); + const before = (await w.posts()).length; + const again = w.run(); + assert.notEqual(again.status, 0); + assert.equal((await w.posts()).length, before, "no spawn after the fifth attempt"); + const status = await w.status(); + assert.notEqual(status.status, 0); + for (const text of [again.stdout, again.stderr, status.stdout, await readFile(w.journal, "latin1")]) { + assert.ok(!text.includes("RECIPIENTCANARY"), "bridge output and recipient never reach the reader's output or journal"); + } +}); + +test("the reader never writes or locks the incident store", async () => { + const w = await world("wn-readonly-"); + const before = { bytes: await readFile(w.incidents), mtime: (await stat(w.incidents)).mtimeMs }; + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + assert.deepEqual(await readFile(w.incidents), before.bytes); + assert.equal((await stat(w.incidents)).mtimeMs, before.mtime); + assert.deepEqual(await readdir(resolve(w.dir, "idunn")), ["incidents.cc"], "no lock or temp file beside the store"); +}); + +test("notice content carries no sensitive text", async () => { + const w = await world("wn-egress-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + const [closed, open] = tuples; + const longer = [...open, "extra-CANARYFIELD"]; + const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH", open[4], null, null]; + const otherSchema = ["idunn.operator_incident.v0", "CANARYSCHEMA", "continuity-exhausted", "x", 1, null, null]; + return [closed, longer, pathSubject, otherSchema]; + }); + const result = w.run(); + assert.notEqual(result.status, 0, "refused records fail the run"); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 }); + const posts = await w.posts(); + assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); + for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); + for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { + assert.ok(!text.includes("CANARY"), "no byte of a refused record is echoed"); + } +}); + +test("no recipient means no journal write and a non-zero exit", async () => { + for (const value of [undefined, "", " "]) { + const w = await world("wn-recipient-"); + const env = value === undefined ? { DISCORD_OWNER_ID: undefined } : { DISCORD_OWNER_ID: value }; + const result = spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", w.bridge], { encoding: "utf8", env: { ...process.env, ...env } }); + assert.notEqual(result.status, 0); + assert.ok(!existsSync(w.journal)); + assert.deepEqual(await w.posts(), []); + } +}); + +test("an absent incident store is a clean no-op", async () => { + const w = await world("wn-absent-", { incidents: null }); + const result = w.run(); + assert.equal(result.status, 0, result.stderr); + assert.ok(!existsSync(w.journal)); + assert.deepEqual(await w.posts(), []); +}); + +test("terminal entries are pruned only after Idunn retires the incident", async () => { + const w = await world("wn-prune-"); + assert.equal(w.run().status, 0); + const keys = async () => (await w.status()).rows.map((row) => `${row.incidentKey}#${row.notice}`).sort(); + const all = [`${CLOSED_KEY}#closed`, `${CLOSED_KEY}#opened`, `${OPEN_KEY}#opened`]; + assert.deepEqual(await keys(), all); + assert.equal(w.run().status, 0); + assert.deepEqual(await keys(), all, "entries stay while Idunn still lists the incident"); + await variantStore(w.incidents, (tuples) => [tuples[1]]); + assert.equal(w.run().status, 0); + assert.deepEqual(await keys(), [`${OPEN_KEY}#opened`]); + await variantStore(w.incidents, () => []); + assert.equal(w.run().status, 0); + assert.deepEqual(await keys(), []); + assert.equal((await w.posts()).length, 3, "retirement never reposts"); +}); + +test("errors name a code and never echo a path or an input", async () => { + const w = await world("wn-canary-", { incidents: null }); + await mkdir(resolve(w.dir, "CANARYDIR")); + const corrupt = resolve(w.dir, "CANARYDIR", "incidents.cc"); + await writeFile(corrupt, "not a cultcache store CANARYBYTES"); + const result = spawnSync(process.execPath, [TOOL, "process", "--incident-store", corrupt, "--journal-store", resolve(w.dir, "CANARYDIR", "j.cc"), "--receipt-store", w.receipts, "--bridge-cli", w.bridge], { encoding: "utf8", env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT } }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /incident-store-unreadable/); + assert.ok(!(result.stdout + result.stderr).includes("CANARY")); +}); + +// The real bridge, reached through the real reader, with Discord's HTTP API +// stubbed at fetch (NODE_OPTIONS reaches the spawned bridge). +async function fetchStub(dir) { + const log = resolve(dir, "fetch.jsonl"); + const stub = resolve(dir, "fetch-stub.mjs"); + await writeFile(stub, ` +import { appendFileSync } from "node:fs"; +globalThis.fetch = async (url, init) => { + appendFileSync(${JSON.stringify(log)}, JSON.stringify({ url: String(url), body: JSON.parse(init.body) }) + "\\n"); + const body = String(url).endsWith("/users/@me/channels") ? { id: "dm-channel" } : { id: "message-1" }; + return new Response(JSON.stringify(body), { status: 200 }); +}; +`); + return { log, options: `--import=${pathToFileURL(stub).href}` }; +} +const fetches = async (log) => (existsSync(log) ? (await readFile(log, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); + +test("discord-dm carries nonce and enforce_nonce when asked", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "wn-bridge-")); + const stub = await fetchStub(dir); + const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; + const dm = (...extra) => spawnSync(process.execPath, [BRIDGE, "discord-dm", "--recipient-id", "1", "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc"), ...extra], { encoding: "utf8", env }); + + const with_nonce = dm("--nonce", "abc123"); + assert.equal(with_nonce.status, 0, with_nonce.stderr); + let posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); + assert.equal(posted.at(-1).body.nonce, "abc123"); + assert.equal(posted.at(-1).body.enforce_nonce, true); + + const without = dm(); + assert.equal(without.status, 0, without.stderr); + posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); + assert.ok(!("nonce" in posted.at(-1).body) && !("enforce_nonce" in posted.at(-1).body)); + + const before = (await fetches(stub.log)).length; + const tooLong = dm("--nonce", "x".repeat(26)); + assert.notEqual(tooLong.status, 0); + assert.equal((await fetches(stub.log)).length, before, "an over-long nonce posts nothing"); +}); + +test("the reader posts through the real bridge with its nonce", async () => { + const w = await world("wn-real-bridge-"); + const stub = await fetchStub(w.dir); + const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); + assert.equal(result.status, 0, result.stderr); + const posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); + assert.deepEqual(posted.map((entry) => entry.body.content).sort(), [CLOSED_TEXT, OPENED_TEXT("closed-target", T_OPEN), OPENED_TEXT("open-target", T_CLOSE)].sort()); + for (const entry of posted) { + assert.equal(entry.body.nonce.length, 25); + assert.equal(entry.body.enforce_nonce, true); + } + assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); + assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); +}); + diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 5b4179b..1c4f0cd 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -475,6 +475,10 @@ async function sendDiscordDm(options) { const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN; const recipientId = requireOption(options, "recipient-id"); const content = await readOptionText(options, "content", "content-file"); + const nonce = optionalString(options.nonce); + if (options.nonce !== undefined && (!nonce || nonce.length > 25)) { + throw new Error("--nonce must be 1 to 25 characters."); + } const dryRun = options["dry-run"] === "true"; if (dryRun) { @@ -506,7 +510,7 @@ async function sendDiscordDm(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); } channelId = await openDiscordDmChannel(token, recipientId); - result = await postDiscordBotMessage(token, channelId, content, undefined); + result = await postDiscordBotMessage(token, channelId, content, undefined, nonce); await bridgeAction?.complete({ receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`, externalReceiptId: result.id, @@ -755,7 +759,7 @@ async function openDiscordDmChannel(token, recipientId) { return channel.id; } -async function postDiscordBotMessage(token, channelId, content, replyToMessageId) { +async function postDiscordBotMessage(token, channelId, content, replyToMessageId, nonce) { const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { @@ -773,6 +777,7 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId allowed_mentions: { parse: [], }, + ...(nonce ? { nonce, enforce_nonce: true } : {}), }), }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs new file mode 100644 index 0000000..18fa6a7 --- /dev/null +++ b/tools/watchdog-notice.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// One-shot reader: turns Idunn's operator incidents into owner DMs. +// +// process --incident-store P --journal-store J --receipt-store R [--bridge-cli B] +// status --journal-store J +// +// Owner of the decision to send, the journal (J), the recipient +// (DISCORD_OWNER_ID) and the token (BIFROST_DISCORD_BOT_TOKEN, read only by the +// bridge it spawns). Idunn's store P is read unlocked and never written: no +// CultMesh node, no lock, only SingleFileMessagePackBackingStore.pullAll(). +// +// Requires CultLib 8cb3b728 or later (package @gamecult/cultcache-ts); see +// docs/watchdog-notice.md. +// +// Nothing here echoes an input value. Errors are fixed codes, and the +// journal's lastError is a fixed code, never bridge output. +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib")); + +const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; +const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; +const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; +const MAX_ATTEMPTS = 5; +const BRIDGE_TIMEOUT_MS = 60_000; +const STATUSES = ["running", "completed", "failed", "unknown"]; +const NAME = /^[a-z][a-z-]{0,63}$/; +const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +class NoticeError extends Error {} +const fail = (code) => new NoticeError(code); + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); + process.exitCode = 1; +} + +async function main(args) { + const [verb, ...rest] = args; + const options = parseArgs(rest); + if (verb === "status") return status(options); + if (verb === "process") return processIncidents(options); + throw fail("unknown-verb"); +} + +async function processIncidents(options) { + const incidentStore = requireOption(options, "incident-store"); + const journalStore = requireOption(options, "journal-store"); + const receiptStore = requireOption(options, "receipt-store"); + const bridgeCli = resolve(options["bridge-cli"] || resolve(root, "tools", "bifrost-bridge.mjs")); + const recipientId = (process.env.DISCORD_OWNER_ID ?? "").trim(); + if (!recipientId) throw fail("recipient-missing"); + + const runtime = loadRuntime(); + const { records, skipped, refused } = await readIncidents(runtime, incidentStore); + if (records === null) return 0; + + const journal = await openJournal(runtime, journalStore); + for (const entry of journal.cache.getAll(journal.definition)) { + if (entry.status === "running") { + await journal.put({ ...entry, status: "unknown", lastError: "interrupted-before-outcome", updatedAt: now() }); + } + } + + const deliver = async (record, notice) => { + const key = `${record.incidentKey}#${notice}`; + const previous = journal.cache.get(journal.definition, key); + const attempts = (previous?.attempts ?? 0) + 1; + const nonce = createHash("sha256").update(key).digest("hex").slice(0, 25); + const base = { schemaVersion: JOURNAL_SCHEMA, incidentKey: record.incidentKey, notice, attempts, nonce }; + await journal.put({ ...base, status: "running", messageId: "", lastError: "", updatedAt: now() }); + const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }); + const entry = outcome.messageId + ? { ...base, status: "completed", messageId: outcome.messageId, lastError: "" } + : { ...base, status: "failed", messageId: "", lastError: outcome.error }; + await journal.put({ ...entry, updatedAt: now() }); + }; + const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS); + const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); + + for (const record of records) { + if (retryable(entryFor(record, "opened"))) await deliver(record, "opened"); + if (record.closedAt !== null && entryFor(record, "opened")?.status === "completed" && retryable(entryFor(record, "closed"))) { + await deliver(record, "closed"); + } + } + + const present = new Set(records.map((record) => record.incidentKey)); + for (const entry of journal.cache.getAll(journal.definition)) { + if (isTerminal(entry) && !present.has(entry.incidentKey)) { + await journal.cache.delete(journal.definition, `${entry.incidentKey}#${entry.notice}`); + } + } + + const entries = journal.cache.getAll(journal.definition); + const unknown = entries.filter((entry) => entry.status === "unknown").length; + const exhausted = entries.filter(isExhausted).length; + process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, exhausted })}\n`); + return unknown + exhausted + refused > 0 ? 1 : 0; +} + +async function status(options) { + const runtime = loadRuntime(); + const journal = await openJournal(runtime, requireOption(options, "journal-store")); + const entries = journal.cache.getAll(journal.definition); + const rows = entries + .map(({ incidentKey, notice, status, attempts, updatedAt, lastError }) => ({ incidentKey, notice, status, attempts, updatedAt, lastError })) + .sort((a, b) => (a.incidentKey + a.notice).localeCompare(b.incidentKey + b.notice)); + process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + return entries.some((entry) => entry.status === "unknown" || isExhausted(entry)) ? 1 : 0; +} + +const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS; +const isTerminal = (entry) => entry.status === "completed" || entry.status === "unknown" || isExhausted(entry); +const now = () => new Date().toISOString(); + +// Reads P unlocked. An absent file is "nothing to do" (records: null). A +// record of another schema is counted as skipped; a record of this schema +// that breaks the contract is counted as refused. Neither is echoed. +async function readIncidents(runtime, path) { + try { + await stat(path); + } catch (error) { + if (error?.code === "ENOENT") return { records: null, skipped: 0, refused: 0 }; + throw fail("incident-store-unreadable"); + } + let envelopes; + try { + envelopes = await new runtime.SingleFileMessagePackBackingStore(path).pullAll(); + } catch { + throw fail("incident-store-unreadable"); + } + const records = []; + let skipped = 0; + let refused = 0; + for (const envelope of envelopes) { + let tuple; + try { + tuple = runtime.decode(envelope.payload); + } catch { + skipped += 1; + continue; + } + if (!Array.isArray(tuple) || tuple[0] !== INCIDENT_SCHEMA) { + skipped += 1; + continue; + } + const record = parseIncident(tuple); + if (record) records.push(record); + else refused += 1; + } + records.sort((a, b) => a.openedAt - b.openedAt || a.incidentKey.localeCompare(b.incidentKey)); + return { records, skipped, refused }; +} + +// [schema_version, incident_key, condition, subject, opened_at, closed_at, close_reason] +function parseIncident(tuple) { + if (tuple.length !== 7) return null; + const [, incidentKey, condition, subject, openedAt, closedAt, closeReason] = tuple; + if (typeof condition !== "string" || !NAME.test(condition)) return null; + if (typeof subject !== "string" || !SUBJECT.test(subject)) return null; + if (!Number.isSafeInteger(openedAt) || openedAt <= 0) return null; + if (incidentKey !== `${condition}:${subject}:${openedAt}`) return null; + if (closedAt === null) { + if (closeReason !== null) return null; + } else { + if (!Number.isSafeInteger(closedAt) || closedAt < openedAt) return null; + if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null; + } + return { incidentKey, condition, subject, openedAt, closedAt, closeReason }; +} + +function noticeText(record, notice) { + const opened = new Date(record.openedAt).toISOString(); + if (notice === "opened") return `Idunn incident opened: ${record.condition} on ${record.subject} at ${opened}`; + const closed = new Date(record.closedAt).toISOString(); + return `Idunn incident closed (${record.closeReason}): ${record.condition} on ${record.subject}, opened ${opened}, closed ${closed}`; +} + +// The one send primitive. The outcome carries a message id, or a fixed error +// code; bridge stdout and stderr are never copied into the journal. +async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }) { + const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); + try { + const contentFile = resolve(dir, "content.txt"); + await writeFile(contentFile, noticeText(record, notice), "utf8"); + const result = spawnSync(process.execPath, [ + bridgeCli, "discord-dm", + "--recipient-id", recipientId, + "--content-file", contentFile, + "--nonce", nonce, + "--cultmesh-command-id", key, + "--receipt-store", receiptStore, + "--source-kind", "idunn-operator-incident", + "--source-id", record.incidentKey, + ], { encoding: "utf8", env: process.env, windowsHide: true, timeout: BRIDGE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }); + if (result.error) return { error: "bridge-spawn-failed" }; + if (result.status !== 0) return { error: "bridge-exit-nonzero" }; + try { + const value = JSON.parse(result.stdout); + if (value.action === "discord-dm" && value.ok === true && typeof value.messageId === "string" && value.messageId) { + return { messageId: value.messageId }; + } + } catch { + // falls through to the fixed code + } + return { error: "bridge-output-invalid" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function openJournal(runtime, path) { + const definition = runtime.defineDocumentType({ + type: JOURNAL_TYPE, + schemaName: JOURNAL_TYPE, + schemaId: JOURNAL_SCHEMA, + schemaVersion: JOURNAL_SCHEMA, + contentHash: JOURNAL_SCHEMA, + global: false, + schema: { parse: parseEntry }, + members: ["schemaVersion", "incidentKey", "notice", "status", "attempts", "nonce", "messageId", "lastError", "updatedAt"] + .map((memberName, slot) => ({ slot, memberName, typeName: memberName === "attempts" ? "uint32" : "string" })), + }); + const cache = runtime.CultCache.builder() + .withDocumentType(definition) + .withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)) + .build(); + try { + await cache.pullAllBackingStores(); + } catch { + throw fail("journal-unreadable"); + } + return { cache, definition, put: (entry) => cache.put(definition, `${entry.incidentKey}#${entry.notice}`, entry) }; +} + +function parseEntry(value) { + const ok = value && value.schemaVersion === JOURNAL_SCHEMA + && typeof value.incidentKey === "string" && value.incidentKey + && (value.notice === "opened" || value.notice === "closed") + && STATUSES.includes(value.status) + && Number.isSafeInteger(value.attempts) && value.attempts >= 0 + && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string"); + if (!ok) throw fail("journal-entry-invalid"); + return value; +} + +function loadRuntime() { + try { + const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json")); + const net = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json")); + const cc = cache("@gamecult/cultcache-ts"); + return { + CultCache: cc.CultCache, + SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore, + defineDocumentType: cc.defineDocumentType, + decode: net("@msgpack/msgpack").decode, + }; + } catch { + throw fail("cultlib-unavailable"); + } +} + +function parseArgs(args) { + const options = {}; + for (let i = 0; i < args.length; i += 2) { + if (!args[i].startsWith("--") || args[i + 1] === undefined) throw fail("bad-arguments"); + options[args[i].slice(2)] = args[i + 1]; + } + return options; +} + +function requireOption(options, name) { + if (!options[name]) throw fail(`missing-option-${name}`); + return options[name]; +} From ef867f3e28d64f6cccbfac2cf94054bd241c0df2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:19:42 +0200 Subject: [PATCH 005/145] Watchdog notice reader: start the verb after the module finishes initialising The top-level await ran main() before the const declarations below it, so every process run died in the temporal dead zone with unexpected-error. Co-Authored-By: Claude Sonnet 5.5 --- tools/watchdog-notice.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 18fa6a7..df773c6 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -36,13 +36,6 @@ const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; class NoticeError extends Error {} const fail = (code) => new NoticeError(code); -try { - process.exitCode = await main(process.argv.slice(2)); -} catch (error) { - process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); - process.exitCode = 1; -} - async function main(args) { const [verb, ...rest] = args; const options = parseArgs(rest); @@ -282,3 +275,10 @@ function requireOption(options, name) { if (!options[name]) throw fail(`missing-option-${name}`); return options[name]; } + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); + process.exitCode = 1; +} From 7e92687716cd63e80104e5e0c70c20272b8e22ab Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:20:47 +0200 Subject: [PATCH 006/145] Watchdog notice tests: canary the refused record's bytes, not the recipient The egress test searched for the substring CANARY, which the recipient double also carries, so it failed on its own input. Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 3bf1dd2..3fa8827 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -215,7 +215,7 @@ test("notice content carries no sensitive text", async () => { assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { - assert.ok(!text.includes("CANARY"), "no byte of a refused record is echoed"); + for (const canary of ["CANARYFIELD", "CANARYPATH", "CANARYSCHEMA"]) assert.ok(!text.includes(canary), "no byte of a refused record is echoed"); } }); From 1f001e11d20d0a7b4945edfb92c78b30112afeef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 21:54:23 +0200 Subject: [PATCH 007/145] Watchdog notice reader: the journal owns "sent"; only exit 75 is retried Fix batch for Soul pass s1 (spec r3): - Delete prune-on-absence and its test. Absence from Idunn's store no longer deletes a journal entry, so an empty or restored store never re-sends. - discord-dm exits 75 only when no message can have been sent (arguments, token, DM channel open, non-2xx on the message POST). The reader records failed for 75 only; any other non-success without a message id is unknown and is never re-posted. - Subject grammar is Idunn's require_id; the notice puts it in backticks. - Times are bounded to the Date range; notice text is built before the running entry is written. Builds and passes on Linux. On Windows the two tests that SIGKILL the reader cannot pass (process.kill does not deliver a signal there). Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 15 +++- tests/watchdog-notice.test.mjs | 123 ++++++++++++++++++++++++++++----- tools/bifrost-bridge.mjs | 23 +++++- tools/watchdog-notice.mjs | 49 ++++++------- 4 files changed, 161 insertions(+), 49 deletions(-) diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 50e8235..a1c7004 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -31,9 +31,18 @@ node tools/watchdog-notice.mjs status --journal-store J - J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed `#opened` or `#closed`. A `running` entry found at start becomes `unknown` and is never sent again. A `failed` entry is retried on - later runs with the same nonce, up to five attempts. Terminal entries are - deleted once Idunn no longer lists the incident. + later runs with the same nonce, up to five attempts. The journal is never + pruned: what Bifrost has sent is Bifrost's memory, whatever P holds. +- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 only when it knows no + message was sent: argument or token errors, a failure opening the DM channel, + or a non-2xx answer to the message POST. The reader records `failed` for + exit 75 only. Any other non-success without a message id (another exit code, + a signal, the spawn timeout, unreadable output) is `unknown`: never sent again, + counted by the exit status. - The exit status is non-zero while any entry is `unknown` or out of attempts, or while P holds a record of this schema that breaks its contract. systemd shows that; `status` lists the entries. -- Notice text is built from `condition`, `subject` and ISO UTC times only. +- Notice text is built from `condition`, `subject` and ISO UTC times only. The + subject follows Idunn's `require_id` (1-256 bytes of `[A-Za-z0-9-_.:/]`) and + is placed in inline code. A time outside the ECMAScript Date range refuses the + record. diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 3fa8827..21a9223 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -21,10 +21,10 @@ const TOOL = resolve(import.meta.dirname, "..", "tools", "watchdog-notice.mjs"); const BRIDGE = resolve(import.meta.dirname, "..", "tools", "bifrost-bridge.mjs"); const RECIPIENT = "424242RECIPIENTCANARY"; -const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on ${subject} at ${at}`; +const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on \`${subject}\` at ${at}`; const T_OPEN = "2023-11-14T22:13:20.000Z"; const T_CLOSE = "2023-11-14T22:15:00.000Z"; -const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on closed-target, opened ${T_OPEN}, closed ${T_CLOSE}`; +const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on \`closed-target\`, opened ${T_OPEN}, closed ${T_CLOSE}`; // CultLib is the sibling ../CultLib, like the other tests. const cult = resolve(import.meta.dirname, "..", "..", "CultLib"); @@ -32,8 +32,9 @@ const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.j const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); // A bridge double that logs each call (with the content it was handed) and -// behaves as the `mode` file says: ok, fail, or crash (kills its parent, the -// reader, after logging, as a power cut between send and journal would). +// behaves as the `mode` file says: ok; fail (exit 75, known not sent); die +// (exit 1 after "sending"); signal (killed after "sending"); or crash (kills its +// parent, the reader, after logging, as a power cut between send and journal would). const FAKE_BRIDGE = ` import { appendFileSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -46,7 +47,8 @@ const n = (() => { try { return readFileSync(calls, "utf8").trim().split("\\n"). appendFileSync(calls, JSON.stringify({ args, content: readFileSync(opt("content-file"), "utf8") }) + "\\n"); const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } -if (mode === "fail") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(1); } +if (mode === "fail" || mode === "die") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(mode === "fail" ? 75 : 1); } +if (mode === "signal") process.kill(process.pid, "SIGKILL"); process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); `; @@ -170,11 +172,12 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal((await w.posts()).length, posts.length); }); -test("a failed send is retried with the same nonce up to five attempts", async () => { +test("only a known-unsent bridge exit is retried, with the same nonce, up to five attempts", async () => { const w = await world("wn-retry-", { mode: "fail" }); const exits = []; for (let run = 0; run < 5; run += 1) exits.push(w.run().status); assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted"); + assert.deepEqual((await w.status()).rows.map((row) => row.lastError), ["bridge-not-sent", "bridge-not-sent"]); const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); assert.equal(open.length, 5); assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1); @@ -204,7 +207,7 @@ test("notice content carries no sensitive text", async () => { await variantStore(w.incidents, (tuples) => { const [closed, open] = tuples; const longer = [...open, "extra-CANARYFIELD"]; - const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH", open[4], null, null]; + const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH x:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH x", open[4], null, null]; const otherSchema = ["idunn.operator_incident.v0", "CANARYSCHEMA", "continuity-exhausted", "x", 1, null, null]; return [closed, longer, pathSubject, otherSchema]; }); @@ -213,7 +216,7 @@ test("notice content carries no sensitive text", async () => { assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 }); const posts = await w.posts(); assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); - for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on closed-target at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on closed-target, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); + for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on `closed-target` at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on `closed-target`, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { for (const canary of ["CANARYFIELD", "CANARYPATH", "CANARYSCHEMA"]) assert.ok(!text.includes(canary), "no byte of a refused record is echoed"); } @@ -238,21 +241,39 @@ test("an absent incident store is a clean no-op", async () => { assert.deepEqual(await w.posts(), []); }); -test("terminal entries are pruned only after Idunn retires the incident", async () => { - const w = await world("wn-prune-"); +test("an emptied or restored incident store never re-sends", async () => { + const w = await world("wn-restore-"); assert.equal(w.run().status, 0); const keys = async () => (await w.status()).rows.map((row) => `${row.incidentKey}#${row.notice}`).sort(); const all = [`${CLOSED_KEY}#closed`, `${CLOSED_KEY}#opened`, `${OPEN_KEY}#opened`]; assert.deepEqual(await keys(), all); - assert.equal(w.run().status, 0); - assert.deepEqual(await keys(), all, "entries stay while Idunn still lists the incident"); await variantStore(w.incidents, (tuples) => [tuples[1]]); - assert.equal(w.run().status, 0); - assert.deepEqual(await keys(), [`${OPEN_KEY}#opened`]); + w.run(); await variantStore(w.incidents, () => []); + w.run(); + await writeFile(w.incidents, ""); + w.run(); + assert.deepEqual(await keys(), all, "absence from Idunn's store deletes nothing"); + await copyFile(FIXTURE, w.incidents); assert.equal(w.run().status, 0); - assert.deepEqual(await keys(), []); - assert.equal((await w.posts()).length, 3, "retirement never reposts"); + assert.equal((await w.posts()).length, 3, "exactly one post per notice across all views of the store"); +}); + +test("a bridge that dies after sending is unknown and never re-posted", async () => { + for (const mode of ["die", "signal"]) { + const w = await world(`wn-${mode}-`, { mode }); + const first = w.run(); + assert.notEqual(first.status, 0); + assert.equal(JSON.parse(first.stdout).unknown, 2); + await w.setMode("ok"); + assert.notEqual(w.run().status, 0); + assert.notEqual(w.run().status, 0); + const posts = await w.posts(); + assert.equal(posts.length, 2, "one opening post per incident, no retry, no closure"); + assert.ok(posts.every((post) => notice(post) === "opened")); + assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"]); + assert.ok(!(await readFile(w.journal, "latin1")).includes("RECIPIENTCANARY")); + } }); test("errors name a code and never echo a path or an input", async () => { @@ -275,8 +296,12 @@ async function fetchStub(dir) { import { appendFileSync } from "node:fs"; globalThis.fetch = async (url, init) => { appendFileSync(${JSON.stringify(log)}, JSON.stringify({ url: String(url), body: JSON.parse(init.body) }) + "\\n"); - const body = String(url).endsWith("/users/@me/channels") ? { id: "dm-channel" } : { id: "message-1" }; - return new Response(JSON.stringify(body), { status: 200 }); + const channels = String(url).endsWith("/users/@me/channels"); + const behavior = process.env.STUB_BEHAVIOR; + if (channels && behavior === "dm-channel-500") return new Response("{}", { status: 500 }); + if (!channels && behavior === "message-400") return new Response("{}", { status: 400 }); + if (!channels && behavior === "message-network-error") throw new TypeError("fetch failed"); + return new Response(JSON.stringify(channels ? { id: "dm-channel" } : { id: "message-1" }), { status: 200 }); }; `); return { log, options: `--import=${pathToFileURL(stub).href}` }; @@ -306,6 +331,68 @@ test("discord-dm carries nonce and enforce_nonce when asked", async () => { assert.equal((await fetches(stub.log)).length, before, "an over-long nonce posts nothing"); }); +test("discord-dm exits 75 only before delivery", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "wn-exit75-")); + const stub = await fetchStub(dir); + const dm = (behavior, { token = "test-token-not-real", recipient = "1" } = {}) => { + const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: token, DISCORD_BOT_TOKEN: token }; + if (behavior) env.STUB_BEHAVIOR = behavior; + if (!token) { delete env.BIFROST_DISCORD_BOT_TOKEN; delete env.DISCORD_BOT_TOKEN; } + const args = ["discord-dm", ...(recipient ? ["--recipient-id", recipient] : []), "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc")]; + return spawnSync(process.execPath, [BRIDGE, ...args], { encoding: "utf8", env }); + }; + const messagePosts = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).length; + + assert.equal(dm(undefined, { token: "" }).status, 75, "missing token"); + assert.equal(dm(undefined, { recipient: "" }).status, 75, "missing argument"); + assert.equal(dm("dm-channel-500").status, 75, "DM channel failure"); + assert.equal(await messagePosts(), 0, "nothing was posted by any of those"); + assert.equal(dm("message-400").status, 75, "definite non-2xx on the message POST"); + assert.equal(await messagePosts(), 1); + const lost = dm("message-network-error"); + assert.equal(lost.status, 1, "a network error after the message POST was sent is not known-unsent"); + assert.equal(await messagePosts(), 2); + assert.equal(dm().status, 0); +}); + +test("every subject Idunn accepts is delivered, inside backticks", async () => { + const good = ["gamecult/odin", "odin:store", "_odin", "a".repeat(256), "Odin.v2-x"]; + const bad = ["a".repeat(257), "has space", "back`tick", "new\nline", "", "ün"]; + const w = await world("wn-subject-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + return [...good, ...bad].map((subject, index) => { + const opened = 1_700_000_200_000 + index; + return ["idunn.operator_incident.v1", `continuity-exhausted:${subject}:${opened}`, "continuity-exhausted", subject, opened, null, null]; + }); + }); + const stub = await fetchStub(w.dir); + const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, exhausted: 0 }); + const contents = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).map((entry) => entry.body.content).sort(); + const at = (index) => new Date(1_700_000_200_000 + index).toISOString(); + assert.deepEqual(contents, good.map((subject, index) => OPENED_TEXT(subject, at(index))).sort()); + for (const content of contents) assert.match(content, /on `[A-Za-z0-9_.:/-]{1,256}` at /); +}); + +test("an out-of-range time refuses the record without aborting the run", async () => { + const w = await world("wn-time-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + const [closed, open] = tuples; + const hugeClose = [...closed]; + hugeClose[5] = 8.7e15; + hugeClose[1] = closed[1]; + const hugeOpen = ["idunn.operator_incident.v1", "continuity-exhausted:huge-open:8700000000000000", "continuity-exhausted", "huge-open", 8.7e15, null, null]; + const edge = ["idunn.operator_incident.v1", "continuity-exhausted:edge:8640000000000000", "continuity-exhausted", "edge", 8.64e15, null, null]; + return [hugeClose, hugeOpen, edge, open]; + }); + const result = w.run(); + assert.equal(result.stderr, "", "no unexpected-error"); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, exhausted: 0 }); + assert.notEqual(result.status, 0, "refused records fail the run"); + assert.deepEqual((await w.posts()).map((post) => opt(post, "source-id")).sort(), ["continuity-exhausted:edge:8640000000000000", OPEN_KEY]); + assert.ok((await w.status()).rows.every((row) => row.status === "completed")); +}); + test("the reader posts through the real bridge with its nonce", async () => { const w = await world("wn-real-bridge-"); const stub = await fetchStub(w.dir); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 1c4f0cd..3d9e7db 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -470,7 +470,25 @@ async function postDiscordMessage(options) { }); } +// discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered: +// anything before the message POST, or a definite non-2xx answer to it. A +// failure after the POST was sent (network error, receipt completion, output) +// keeps exit 1, so the caller treats the outcome as unknown. +const NOT_SENT_EXIT = 75; +class NotSentError extends Error {} +class DiscordRejection extends Error {} + async function sendDiscordDm(options) { + const state = { postStarted: false }; + try { + await deliverDiscordDm(options, state); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw state.postStarted && !(error instanceof DiscordRejection) ? error : new NotSentError(message); + } +} + +async function deliverDiscordDm(options, state) { ensureBridgeReceiptGate(options); const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN; const recipientId = requireOption(options, "recipient-id"); @@ -510,6 +528,7 @@ async function sendDiscordDm(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); } channelId = await openDiscordDmChannel(token, recipientId); + state.postStarted = true; result = await postDiscordBotMessage(token, channelId, content, undefined, nonce); await bridgeAction?.complete({ receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`, @@ -783,7 +802,7 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId const text = await response.text(); if (!response.ok) { - throw new Error(`Discord post failed with ${response.status}: ${text}`); + throw new DiscordRejection(`Discord post failed with ${response.status}: ${text}`); } const message = JSON.parse(text); @@ -1450,5 +1469,5 @@ Provenance note: main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; + process.exitCode = error instanceof NotSentError ? NOT_SENT_EXIT : 1; }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index df773c6..9fbee59 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -31,7 +31,13 @@ const MAX_ATTEMPTS = 5; const BRIDGE_TIMEOUT_MS = 60_000; const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; -const SUBJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +// Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): +// 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code. +const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/; +const MAX_DATE_MS = 8.64e15; +// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) only when it knows the +// message was not sent. Every other non-success leaves the outcome unknown. +const BRIDGE_NOT_SENT = 75; class NoticeError extends Error {} const fail = (code) => new NoticeError(code); @@ -68,13 +74,11 @@ async function processIncidents(options) { const previous = journal.cache.get(journal.definition, key); const attempts = (previous?.attempts ?? 0) + 1; const nonce = createHash("sha256").update(key).digest("hex").slice(0, 25); + const content = noticeText(record, notice); const base = { schemaVersion: JOURNAL_SCHEMA, incidentKey: record.incidentKey, notice, attempts, nonce }; await journal.put({ ...base, status: "running", messageId: "", lastError: "", updatedAt: now() }); - const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }); - const entry = outcome.messageId - ? { ...base, status: "completed", messageId: outcome.messageId, lastError: "" } - : { ...base, status: "failed", messageId: "", lastError: outcome.error }; - await journal.put({ ...entry, updatedAt: now() }); + const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); + await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); }; const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS); const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); @@ -86,13 +90,6 @@ async function processIncidents(options) { } } - const present = new Set(records.map((record) => record.incidentKey)); - for (const entry of journal.cache.getAll(journal.definition)) { - if (isTerminal(entry) && !present.has(entry.incidentKey)) { - await journal.cache.delete(journal.definition, `${entry.incidentKey}#${entry.notice}`); - } - } - const entries = journal.cache.getAll(journal.definition); const unknown = entries.filter((entry) => entry.status === "unknown").length; const exhausted = entries.filter(isExhausted).length; @@ -112,7 +109,6 @@ async function status(options) { } const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS; -const isTerminal = (entry) => entry.status === "completed" || entry.status === "unknown" || isExhausted(entry); const now = () => new Date().toISOString(); // Reads P unlocked. An absent file is "nothing to do" (records: null). A @@ -160,12 +156,12 @@ function parseIncident(tuple) { const [, incidentKey, condition, subject, openedAt, closedAt, closeReason] = tuple; if (typeof condition !== "string" || !NAME.test(condition)) return null; if (typeof subject !== "string" || !SUBJECT.test(subject)) return null; - if (!Number.isSafeInteger(openedAt) || openedAt <= 0) return null; + if (!Number.isSafeInteger(openedAt) || openedAt <= 0 || openedAt > MAX_DATE_MS) return null; if (incidentKey !== `${condition}:${subject}:${openedAt}`) return null; if (closedAt === null) { if (closeReason !== null) return null; } else { - if (!Number.isSafeInteger(closedAt) || closedAt < openedAt) return null; + if (!Number.isSafeInteger(closedAt) || closedAt < openedAt || closedAt > MAX_DATE_MS) return null; if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null; } return { incidentKey, condition, subject, openedAt, closedAt, closeReason }; @@ -173,18 +169,19 @@ function parseIncident(tuple) { function noticeText(record, notice) { const opened = new Date(record.openedAt).toISOString(); - if (notice === "opened") return `Idunn incident opened: ${record.condition} on ${record.subject} at ${opened}`; + if (notice === "opened") return `Idunn incident opened: ${record.condition} on \`${record.subject}\` at ${opened}`; const closed = new Date(record.closedAt).toISOString(); - return `Idunn incident closed (${record.closeReason}): ${record.condition} on ${record.subject}, opened ${opened}, closed ${closed}`; + return `Idunn incident closed (${record.closeReason}): ${record.condition} on \`${record.subject}\`, opened ${opened}, closed ${closed}`; } -// The one send primitive. The outcome carries a message id, or a fixed error -// code; bridge stdout and stderr are never copied into the journal. -async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, notice }) { +// The one send primitive. The outcome is completed with a message id; failed +// only when the bridge reports exit 75 (known not sent); otherwise unknown. The +// error is a fixed code: bridge stdout and stderr never reach the journal. +async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) { const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); try { const contentFile = resolve(dir, "content.txt"); - await writeFile(contentFile, noticeText(record, notice), "utf8"); + await writeFile(contentFile, content, "utf8"); const result = spawnSync(process.execPath, [ bridgeCli, "discord-dm", "--recipient-id", recipientId, @@ -195,17 +192,17 @@ async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, re "--source-kind", "idunn-operator-incident", "--source-id", record.incidentKey, ], { encoding: "utf8", env: process.env, windowsHide: true, timeout: BRIDGE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }); - if (result.error) return { error: "bridge-spawn-failed" }; - if (result.status !== 0) return { error: "bridge-exit-nonzero" }; + if (result.status === BRIDGE_NOT_SENT) return { status: "failed", error: "bridge-not-sent" }; + if (result.error || result.status !== 0) return { status: "unknown", error: "bridge-exit-unknown" }; try { const value = JSON.parse(result.stdout); if (value.action === "discord-dm" && value.ok === true && typeof value.messageId === "string" && value.messageId) { - return { messageId: value.messageId }; + return { status: "completed", messageId: value.messageId }; } } catch { // falls through to the fixed code } - return { error: "bridge-output-invalid" }; + return { status: "unknown", error: "bridge-output-invalid" }; } finally { await rm(dir, { recursive: true, force: true }); } From ff087a554b260305941c7d9987b2e1f1929cfd0f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 22:56:27 +0200 Subject: [PATCH 008/145] Watchdog notice reader: pin timeout, spawn-error, 5xx/429 and guard behaviour; say what non-2xx is Tests only, plus wording. No change to what the reader or bridge does. - A spawn timeout and a spawn error (ENOBUFS from a bridge that floods stdout) record unknown, are never re-posted, and block the closure. WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS overrides the 60 s timeout so the test reaches that branch; the default is unchanged. - A 500/502/503/429 answer to the message POST exits 75 from the bridge, and through the reader is retried with the same nonce until it completes. - allowed_mentions {parse: []} is asserted in the posted body; status exits non-zero while an entry is unknown and zero when none is. - The bridge comment and docs/watchdog-notice.md no longer call a non-2xx answer "definite": it is retried with the same nonce, relying on enforce_nonce inside Discord's window, and may double-send if Discord acted on a 5xx outside it. Builds. Two tests fail on Windows (SIGKILL status), as before; they pass on Yggdrasil. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 12 ++++--- tests/watchdog-notice.test.mjs | 64 ++++++++++++++++++++++++++++++---- tools/bifrost-bridge.mjs | 9 +++-- tools/watchdog-notice.mjs | 10 +++--- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index a1c7004..610f46c 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -33,10 +33,14 @@ node tools/watchdog-notice.mjs status --journal-store J start becomes `unknown` and is never sent again. A `failed` entry is retried on later runs with the same nonce, up to five attempts. The journal is never pruned: what Bifrost has sent is Bifrost's memory, whatever P holds. -- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 only when it knows no - message was sent: argument or token errors, a failure opening the DM channel, - or a non-2xx answer to the message POST. The reader records `failed` for - exit 75 only. Any other non-success without a message id (another exit code, +- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 for argument or token + errors, a failure opening the DM channel, or any non-2xx answer to the message + POST. A non-2xx is retried, not proven unsent: the retry carries the same nonce + and relies on Discord's `enforce_nonce` inside its window, so a 5xx that Discord + answered after creating the message can be delivered twice if the retry falls + outside that window. A 5xx stays retryable because parking it as `unknown` + would lose the notice on every routine Discord outage. The reader records + `failed` for exit 75 only. Any other non-success without a message id (another exit code, a signal, the spawn timeout, unreadable output) is `unknown`: never sent again, counted by the exit status. - The exit status is non-zero while any entry is `unknown` or out of attempts, or diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 21a9223..ff7c6ae 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -32,8 +32,9 @@ const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.j const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); // A bridge double that logs each call (with the content it was handed) and -// behaves as the `mode` file says: ok; fail (exit 75, known not sent); die -// (exit 1 after "sending"); signal (killed after "sending"); or crash (kills its +// behaves as the `mode` file says: ok; fail (exit 75, retryable); die +// (exit 1 after "sending"); signal (killed after "sending"); hang (never answers after +// "sending"); flood (answers with more stdout than spawnSync buffers); or crash (kills its // parent, the reader, after logging, as a power cut between send and journal would). const FAKE_BRIDGE = ` import { appendFileSync, readFileSync } from "node:fs"; @@ -49,6 +50,9 @@ const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } if (mode === "fail" || mode === "die") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(mode === "fail" ? 75 : 1); } if (mode === "signal") process.kill(process.pid, "SIGKILL"); +if (mode === "hang") setTimeout(() => {}, 30000); +else if (mode === "flood") process.stdout.write("x".repeat(2 * 1024 * 1024)); +else process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); `; @@ -172,7 +176,7 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal((await w.posts()).length, posts.length); }); -test("only a known-unsent bridge exit is retried, with the same nonce, up to five attempts", async () => { +test("only a bridge exit 75 is retried, with the same nonce, up to five attempts", async () => { const w = await world("wn-retry-", { mode: "fail" }); const exits = []; for (let run = 0; run < 5; run += 1) exits.push(w.run().status); @@ -265,6 +269,7 @@ test("a bridge that dies after sending is unknown and never re-posted", async () const first = w.run(); assert.notEqual(first.status, 0); assert.equal(JSON.parse(first.stdout).unknown, 2); + assert.notEqual((await w.status()).status, 0, "status exits non-zero while an entry is unknown"); await w.setMode("ok"); assert.notEqual(w.run().status, 0); assert.notEqual(w.run().status, 0); @@ -276,6 +281,22 @@ test("a bridge that dies after sending is unknown and never re-posted", async () } }); +test("a spawn timeout and a spawn error are unknown, never failed, never re-posted", async () => { + for (const mode of ["hang", "flood"]) { + const w = await world(`wn-${mode}-`, { mode }); + const first = w.run({ env: { WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS: "1500" } }); + assert.notEqual(first.status, 0, mode); + assert.equal(JSON.parse(first.stdout).unknown, 2, mode); + await w.setMode("ok"); + assert.notEqual(w.run().status, 0); + assert.notEqual(w.run().status, 0); + const posts = await w.posts(); + assert.equal(posts.length, 2, `${mode}: one opening post per incident, no retry, no closure`); + assert.ok(posts.every((post) => notice(post) === "opened")); + assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"], mode); + } +}); + test("errors name a code and never echo a path or an input", async () => { const w = await world("wn-canary-", { incidents: null }); await mkdir(resolve(w.dir, "CANARYDIR")); @@ -299,7 +320,8 @@ globalThis.fetch = async (url, init) => { const channels = String(url).endsWith("/users/@me/channels"); const behavior = process.env.STUB_BEHAVIOR; if (channels && behavior === "dm-channel-500") return new Response("{}", { status: 500 }); - if (!channels && behavior === "message-400") return new Response("{}", { status: 400 }); + const rejected = /^message-(\\d{3})$/.exec(behavior ?? ""); + if (!channels && rejected) return new Response("{}", { status: Number(rejected[1]) }); if (!channels && behavior === "message-network-error") throw new TypeError("fetch failed"); return new Response(JSON.stringify(channels ? { id: "dm-channel" } : { id: "message-1" }), { status: 200 }); }; @@ -319,6 +341,7 @@ test("discord-dm carries nonce and enforce_nonce when asked", async () => { let posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); assert.equal(posted.at(-1).body.nonce, "abc123"); assert.equal(posted.at(-1).body.enforce_nonce, true); + assert.deepEqual(posted.at(-1).body.allowed_mentions, { parse: [] }, "no mention is ever parsed"); const without = dm(); assert.equal(without.status, 0, without.stderr); @@ -347,14 +370,40 @@ test("discord-dm exits 75 only before delivery", async () => { assert.equal(dm(undefined, { recipient: "" }).status, 75, "missing argument"); assert.equal(dm("dm-channel-500").status, 75, "DM channel failure"); assert.equal(await messagePosts(), 0, "nothing was posted by any of those"); - assert.equal(dm("message-400").status, 75, "definite non-2xx on the message POST"); + assert.equal(dm("message-400").status, 75, "a 4xx answer to the message POST"); assert.equal(await messagePosts(), 1); + for (const status of [500, 502, 503, 429]) { + const before = await messagePosts(); + assert.equal(dm(`message-${status}`).status, 75, `a ${status} answer is retryable, not unknown`); + assert.equal(await messagePosts(), before + 1); + } const lost = dm("message-network-error"); - assert.equal(lost.status, 1, "a network error after the message POST was sent is not known-unsent"); - assert.equal(await messagePosts(), 2); + assert.equal(lost.status, 1, "a network error after the message POST was sent is not exit 75"); + assert.equal(await messagePosts(), 6); assert.equal(dm().status, 0); }); +test("a 5xx or 429 on the message POST is retried with the same nonce", async () => { + for (const status of [502, 429]) { + const w = await world(`wn-${status}-`); + const stub = await fetchStub(w.dir); + const env = { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; + const messages = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); + for (let tick = 1; tick <= 2; tick += 1) { + const result = w.run({ bridge: BRIDGE, env: { ...env, STUB_BEHAVIOR: `message-${status}` } }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), [`failed:${tick}`, `failed:${tick}`]); + } + const sent = await messages(); + assert.equal(sent.length, 4, "two openings, each tried twice"); + const nonces = Map.groupBy(sent, (entry) => entry.body.content); + assert.equal(nonces.size, 2); + for (const group of nonces.values()) assert.equal(new Set(group.map((entry) => entry.body.nonce)).size, 1, "a retry reuses its nonce"); + assert.equal(w.run({ bridge: BRIDGE, env }).status, 0); + assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); + } +}); + test("every subject Idunn accepts is delivered, inside backticks", async () => { const good = ["gamecult/odin", "odin:store", "_odin", "a".repeat(256), "Odin.v2-x"]; const bad = ["a".repeat(257), "has space", "back`tick", "new\nline", "", "ün"]; @@ -406,5 +455,6 @@ test("the reader posts through the real bridge with its nonce", async () => { } assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); + assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or exhausted"); }); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 3d9e7db..e51b2f0 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -471,9 +471,12 @@ async function postDiscordMessage(options) { } // discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered: -// anything before the message POST, or a definite non-2xx answer to it. A -// failure after the POST was sent (network error, receipt completion, output) -// keeps exit 1, so the caller treats the outcome as unknown. +// anything before the message POST, or a non-2xx answer to it. A 5xx or 429 +// is not proof of non-delivery (Discord may have created the message before +// answering): the caller retries with the same nonce and relies on +// enforce_nonce inside Discord's window. A failure after the POST was sent +// (network error, receipt completion, output) keeps exit 1, so the caller +// treats the outcome as unknown. const NOT_SENT_EXIT = 75; class NotSentError extends Error {} class DiscordRejection extends Error {} diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 9fbee59..485a59d 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -28,15 +28,17 @@ const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; const MAX_ATTEMPTS = 5; -const BRIDGE_TIMEOUT_MS = 60_000; +// The override exists so a test can reach the timeout branch without a 60 s wait. +const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; // Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): // 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code. const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/; const MAX_DATE_MS = 8.64e15; -// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) only when it knows the -// message was not sent. Every other non-success leaves the outcome unknown. +// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) for failures before +// the message POST and for any non-2xx answer to it: retried with the same nonce. +// Every other non-success leaves the outcome unknown. const BRIDGE_NOT_SENT = 75; class NoticeError extends Error {} @@ -175,7 +177,7 @@ function noticeText(record, notice) { } // The one send primitive. The outcome is completed with a message id; failed -// only when the bridge reports exit 75 (known not sent); otherwise unknown. The +// only when the bridge reports exit 75 (retryable, same nonce); otherwise unknown. The // error is a fixed code: bridge stdout and stderr never reach the journal. async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) { const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); From 69e12e3867a9e4136164fa24e8fd6e1c0b07e7e4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 01:48:44 +0200 Subject: [PATCH 009/145] Watchdog notice reader: a failed send is retried forever with capped backoff Ruling notice-retry-policy. The five-attempt cap and its terminal "exhausted" state are gone. A failed entry is eligible again once min(2^(attempts-1), 60) minutes have passed since its last journal update; a run inside the delay skips it without spawning the bridge. Every attempt keeps the same nonce. The exit status is non-zero while any entry is unknown or has failed 3 or more times, and retries go on. status shows nextEligibleAt for failed entries. The clock is one now() that WATCHDOG_NOTICE_NOW_MS can override, like the bridge timeout override, so tests drive the backoff without sleeping. The process summary field "exhausted" is renamed "failing". A journal entry whose updatedAt is not a date is now refused as journal-entry-invalid, since the delay is measured from it. All commits build. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 19 +++++-- tests/watchdog-notice.test.mjs | 99 ++++++++++++++++++++++++++-------- tools/watchdog-notice.mjs | 35 ++++++++---- 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 610f46c..5419673 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -22,6 +22,15 @@ node tools/watchdog-notice.mjs status --journal-store J - `BIFROST_DISCORD_BOT_TOKEN` in the environment, read only by `bifrost-bridge.mjs`, which the reader spawns. +## Retry + +A notice whose send failed (bridge exit 75) is never dropped. It is eligible +again once `min(2^(attempts-1), 60)` minutes have passed since its last attempt +in the journal: 1, 2, 4, 8, 16, 32, then every 60 minutes. A run that finds it +inside its delay skips it without spawning the bridge. Every attempt carries the +same nonce, so a Discord outage of any length costs a late notice, not a lost +one, and a retry that succeeds is the one post. + ## Behaviour - P is Idunn's `idunn.operator_incident.v1` store. The reader reads it without a @@ -31,7 +40,7 @@ node tools/watchdog-notice.mjs status --journal-store J - J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed `#opened` or `#closed`. A `running` entry found at start becomes `unknown` and is never sent again. A `failed` entry is retried on - later runs with the same nonce, up to five attempts. The journal is never + later runs with the same nonce, never given up on (see Retry). The journal is never pruned: what Bifrost has sent is Bifrost's memory, whatever P holds. - The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 for argument or token errors, a failure opening the DM channel, or any non-2xx answer to the message @@ -43,9 +52,11 @@ node tools/watchdog-notice.mjs status --journal-store J `failed` for exit 75 only. Any other non-success without a message id (another exit code, a signal, the spawn timeout, unreadable output) is `unknown`: never sent again, counted by the exit status. -- The exit status is non-zero while any entry is `unknown` or out of attempts, or - while P holds a record of this schema that breaks its contract. systemd shows - that; `status` lists the entries. +- The exit status is non-zero while any entry is `unknown` or has failed three or + more times, or while P holds a record of this schema that breaks its contract. + systemd shows that; `status` lists the entries, with `attempts` and, for a + `failed` entry, `nextEligibleAt`. The non-zero status is the signal only: the + entry keeps being retried. - Notice text is built from `condition`, `subject` and ISO UTC times only. The subject follows Idunn's `require_id` (1-256 bytes of `[A-Za-z0-9-_.:/]`) and is placed in inline code. A time outside the ECMAScript Date range refuses the diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index ff7c6ae..5ce633a 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -77,6 +77,11 @@ async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { return w; } +// The reader's clock is WATCHDOG_NOTICE_NOW_MS; a test drives the backoff by +// naming the minute (from T0) each run happens at. +const T0 = 1_800_000_000_000; +const at = (minutes) => ({ WATCHDOG_NOTICE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); + function run(w, { env = {}, bridge = w.bridge } = {}) { return spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { encoding: "utf8", @@ -144,13 +149,14 @@ test("closure is posted once and only after a completed opening", async () => { // An opening that never completes blocks its closure, now and later. const failing = await world("wn-closure-failed-", { mode: "fail" }); - for (let run = 0; run < 5; run += 1) failing.run(); - await failing.setMode("ok"); - failing.run(); - failing.run(); + for (const minute of [0, 1, 3, 7, 15]) failing.run({ env: at(minute) }); assert.equal((await failing.posts()).filter((post) => notice(post) === "closed").length, 0); const rows = (await failing.status()).rows; assert.deepEqual(rows.map((row) => `${row.notice}:${row.status}:${row.attempts}`), ["opened:failed:5", "opened:failed:5"]); + await failing.setMode("ok"); + assert.equal(failing.run({ env: at(31) }).status, 0); + assert.equal(failing.run({ env: at(32) }).status, 0); + assert.deepEqual((await failing.posts()).filter((post) => notice(post) === "closed").map((post) => opt(post, "source-id")), [CLOSED_KEY], "the closure follows the completed opening, once"); const crashed = await world("wn-closure-unknown-", { mode: "crash" }); assert.equal(crashed.run().status, null, "the reader died mid-send"); @@ -176,26 +182,75 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal((await w.posts()).length, posts.length); }); -test("only a bridge exit 75 is retried, with the same nonce, up to five attempts", async () => { +const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; + +test("a failed notice is retried after its backoff and never exhausted", async () => { const w = await world("wn-retry-", { mode: "fail" }); - const exits = []; - for (let run = 0; run < 5; run += 1) exits.push(w.run().status); - assert.deepEqual(exits, [0, 0, 0, 0, 1], "non-zero once attempts are exhausted"); - assert.deepEqual((await w.status()).rows.map((row) => row.lastError), ["bridge-not-sent", "bridge-not-sent"]); + // Run at every expected spawn minute and one minute before it: only the former may spawn. + const probes = [...new Set(backoffMinutes.flatMap((minute) => [minute - 1, minute]).filter((minute) => minute >= 0))].sort((a, b) => a - b); + const spawned = []; + for (const minute of probes) { + const before = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY).length; + w.run({ env: at(minute) }); + const after = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY).length; + if (after > before) spawned.push(minute); + } + assert.deepEqual(spawned, backoffMinutes, "attempts at 1, 2, 4, 8, 16, 32 then every 60 minutes after the last attempt"); const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); - assert.equal(open.length, 5); - assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1); - const before = (await w.posts()).length; - const again = w.run(); - assert.notEqual(again.status, 0); - assert.equal((await w.posts()).length, before, "no spawn after the fifth attempt"); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1, "every attempt carries the same nonce"); + const rows = (await w.status()).rows; + assert.deepEqual(rows.map((row) => `${row.status}:${row.attempts}:${row.lastError}`), ["failed:9:bridge-not-sent", "failed:9:bridge-not-sent"], "never a terminal state"); + const final = w.run({ env: at(183) }); const status = await w.status(); + assert.notEqual(final.status, 0); assert.notEqual(status.status, 0); - for (const text of [again.stdout, again.stderr, status.stdout, await readFile(w.journal, "latin1")]) { + for (const text of [final.stdout, final.stderr, status.stdout, await readFile(w.journal, "latin1")]) { assert.ok(!text.includes("RECIPIENTCANARY"), "bridge output and recipient never reach the reader's output or journal"); } }); +test("a failed notice is not retried before its delay", async () => { + const w = await world("wn-delay-", { mode: "fail" }); + const count = async () => (await w.posts()).length; + w.run({ env: at(0) }); + assert.equal(await count(), 2, "both openings tried once"); + w.run({ env: at(0.99) }); + w.run({ env: at(0) }); + assert.equal(await count(), 2, "a run inside the delay spawns nothing"); + const row = (await w.status()).rows[0]; + assert.equal(row.attempts, 1); + assert.equal(row.nextEligibleAt, new Date(T0 + 60_000).toISOString(), "status names when the entry is next eligible"); + w.run({ env: at(1) }); + assert.equal(await count(), 4, "eligible once the delay has passed"); + assert.equal((await w.status()).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); +}); + +test("the unit signals from the third failed attempt while still retrying", async () => { + const w = await world("wn-signal-", { mode: "fail" }); + const exits = []; + for (const minute of [0, 1, 3]) exits.push(w.run({ env: at(minute) }).status); + assert.deepEqual(exits, [0, 0, 1], "zero after attempts 1 and 2, non-zero from 3"); + assert.equal((await w.status()).status, 1); + const before = (await w.posts()).length; + assert.equal(w.run({ env: at(7) }).status, 1); + assert.equal((await w.posts()).length, before + 2, "attempt 4 still spawns"); + assert.ok((await w.status()).rows.every((row) => row.status === "failed" && row.attempts === 4)); +}); + +test("a notice that finally sends after many failures posts once", async () => { + const w = await world("wn-finally-", { mode: "fail" }); + for (const minute of [0, 1, 3, 7, 15]) w.run({ env: at(minute) }); + await w.setMode("ok"); + assert.equal(w.run({ env: at(31) }).status, 0); + const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); + assert.equal(open.length, 6, "five failures and one success"); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1, "the success carries the same nonce"); + const before = (await w.posts()).length; + for (const minute of [32, 100, 1000]) assert.equal(w.run({ env: at(minute) }).status, 0); + assert.equal((await w.posts()).length, before, "completed is never posted again"); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:6", "completed:6", "completed:6"]); +}); + test("the reader never writes or locks the incident store", async () => { const w = await world("wn-readonly-"); const before = { bytes: await readFile(w.incidents), mtime: (await stat(w.incidents)).mtimeMs }; @@ -217,7 +272,7 @@ test("notice content carries no sensitive text", async () => { }); const result = w.run(); assert.notEqual(result.status, 0, "refused records fail the run"); - assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, exhausted: 0 }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, failing: 0 }); const posts = await w.posts(); assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on `closed-target` at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on `closed-target`, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); @@ -390,7 +445,7 @@ test("a 5xx or 429 on the message POST is retried with the same nonce", async () const env = { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; const messages = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); for (let tick = 1; tick <= 2; tick += 1) { - const result = w.run({ bridge: BRIDGE, env: { ...env, STUB_BEHAVIOR: `message-${status}` } }); + const result = w.run({ bridge: BRIDGE, env: { ...env, ...at(tick === 1 ? 0 : 1), STUB_BEHAVIOR: `message-${status}` } }); assert.equal(result.status, 0, result.stderr); assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), [`failed:${tick}`, `failed:${tick}`]); } @@ -399,7 +454,7 @@ test("a 5xx or 429 on the message POST is retried with the same nonce", async () const nonces = Map.groupBy(sent, (entry) => entry.body.content); assert.equal(nonces.size, 2); for (const group of nonces.values()) assert.equal(new Set(group.map((entry) => entry.body.nonce)).size, 1, "a retry reuses its nonce"); - assert.equal(w.run({ bridge: BRIDGE, env }).status, 0); + assert.equal(w.run({ bridge: BRIDGE, env: { ...env, ...at(3) } }).status, 0); assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); } }); @@ -416,7 +471,7 @@ test("every subject Idunn accepts is delivered, inside backticks", async () => { }); const stub = await fetchStub(w.dir); const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); - assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, exhausted: 0 }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, failing: 0 }); const contents = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).map((entry) => entry.body.content).sort(); const at = (index) => new Date(1_700_000_200_000 + index).toISOString(); assert.deepEqual(contents, good.map((subject, index) => OPENED_TEXT(subject, at(index))).sort()); @@ -436,7 +491,7 @@ test("an out-of-range time refuses the record without aborting the run", async ( }); const result = w.run(); assert.equal(result.stderr, "", "no unexpected-error"); - assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, exhausted: 0 }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, failing: 0 }); assert.notEqual(result.status, 0, "refused records fail the run"); assert.deepEqual((await w.posts()).map((post) => opt(post, "source-id")).sort(), ["continuity-exhausted:edge:8640000000000000", OPEN_KEY]); assert.ok((await w.status()).rows.every((row) => row.status === "completed")); @@ -455,6 +510,6 @@ test("the reader posts through the real bridge with its nonce", async () => { } assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); - assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or exhausted"); + assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or failing"); }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 485a59d..741a851 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -27,9 +27,15 @@ const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "Cu const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; -const MAX_ATTEMPTS = 5; -// The override exists so a test can reach the timeout branch without a 60 s wait. +// A failed notice is retried forever: 1, 2, 4, ... minutes after its last +// attempt, capped at 60 (ruling notice-retry-policy). From FAILING_ATTEMPTS +// failures on, the exit status is the delivery-failure signal; retries go on. +const MAX_DELAY_MINUTES = 60; +const FAILING_ATTEMPTS = 3; +// The overrides exist so a test can reach the timeout branch without a 60 s wait +// and can drive the backoff without sleeping. const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; +const clockMs = () => Number(process.env.WATCHDOG_NOTICE_NOW_MS) || Date.now(); const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; // Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): @@ -82,7 +88,7 @@ async function processIncidents(options) { const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); }; - const retryable = (entry) => !entry || (entry.status === "failed" && entry.attempts < MAX_ATTEMPTS); + const retryable = (entry) => !entry || (entry.status === "failed" && !(clockMs() < nextEligibleMs(entry))); const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); for (const record of records) { @@ -94,9 +100,9 @@ async function processIncidents(options) { const entries = journal.cache.getAll(journal.definition); const unknown = entries.filter((entry) => entry.status === "unknown").length; - const exhausted = entries.filter(isExhausted).length; - process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, exhausted })}\n`); - return unknown + exhausted + refused > 0 ? 1 : 0; + const failing = entries.filter(isFailing).length; + process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, failing })}\n`); + return unknown + failing + refused > 0 ? 1 : 0; } async function status(options) { @@ -104,14 +110,20 @@ async function status(options) { const journal = await openJournal(runtime, requireOption(options, "journal-store")); const entries = journal.cache.getAll(journal.definition); const rows = entries - .map(({ incidentKey, notice, status, attempts, updatedAt, lastError }) => ({ incidentKey, notice, status, attempts, updatedAt, lastError })) + .map((entry) => ({ + incidentKey: entry.incidentKey, notice: entry.notice, status: entry.status, attempts: entry.attempts, + updatedAt: entry.updatedAt, lastError: entry.lastError, + nextEligibleAt: entry.status === "failed" ? new Date(nextEligibleMs(entry)).toISOString() : null, + })) .sort((a, b) => (a.incidentKey + a.notice).localeCompare(b.incidentKey + b.notice)); process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); - return entries.some((entry) => entry.status === "unknown" || isExhausted(entry)) ? 1 : 0; + return entries.some((entry) => entry.status === "unknown" || isFailing(entry)) ? 1 : 0; } -const isExhausted = (entry) => entry.status === "failed" && entry.attempts >= MAX_ATTEMPTS; -const now = () => new Date().toISOString(); +const isFailing = (entry) => entry.status === "failed" && entry.attempts >= FAILING_ATTEMPTS; +// Measured from the failed entry's last attempt in the journal. +const nextEligibleMs = (entry) => Date.parse(entry.updatedAt) + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; +const now = () => new Date(clockMs()).toISOString(); // Reads P unlocked. An absent file is "nothing to do" (records: null). A // record of another schema is counted as skipped; a record of this schema @@ -240,7 +252,8 @@ function parseEntry(value) { && (value.notice === "opened" || value.notice === "closed") && STATUSES.includes(value.status) && Number.isSafeInteger(value.attempts) && value.attempts >= 0 - && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string"); + && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string") + && Number.isFinite(Date.parse(value.updatedAt)); if (!ok) throw fail("journal-entry-invalid"); return value; } From c468f05eaf53f5de177bd405dc48a3f64dafefbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 01:53:18 +0200 Subject: [PATCH 010/145] Watchdog notice reader test: the closure posted beside a late opening has one attempt The finally-sends test expected six attempts on all three journal rows, but the closure entry is first tried in the run that completes its opening, so it has one. Test-only fix; the previous commit's test failed on this line. The previous commit (69e12e3867a9e4136164fa24e8fd6e1c0b07e7e4) builds; its suite has one failing test, fixed here. Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 5ce633a..1817f7e 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -248,7 +248,7 @@ test("a notice that finally sends after many failures posts once", async () => { const before = (await w.posts()).length; for (const minute of [32, 100, 1000]) assert.equal(w.run({ env: at(minute) }).status, 0); assert.equal((await w.posts()).length, before, "completed is never posted again"); - assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:6", "completed:6", "completed:6"]); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:1", "completed:6", "completed:6"]); }); test("the reader never writes or locks the incident store", async () => { From 0312e8bbac7906a0b660057940dbd6650e4d4b45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 02:19:01 +0200 Subject: [PATCH 011/145] Watchdog notice reader: a future stamp is eligible now; no production clock seam A failed entry stamped ahead of the clock (host clock stepped forward then corrected) is eligible now instead of waiting out the skew; the next attempt rewrites the stamp. The WATCHDOG_NOTICE_NOW_MS env var is deleted: the reader reads Date.now(), and tests fix it with a --import preload (tests/fake-clock.mjs). The unknown-entry tests now rerun far past the backoff so a retried unknown fails. Co-Authored-By: Claude Sonnet 5.5 --- docs/watchdog-notice.md | 2 +- tests/fake-clock.mjs | 5 +++++ tests/watchdog-notice.test.mjs | 39 +++++++++++++++++++++++----------- tools/watchdog-notice.mjs | 17 +++++++++------ 4 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 tests/fake-clock.mjs diff --git a/docs/watchdog-notice.md b/docs/watchdog-notice.md index 5419673..b6e47de 100644 --- a/docs/watchdog-notice.md +++ b/docs/watchdog-notice.md @@ -27,7 +27,7 @@ node tools/watchdog-notice.mjs status --journal-store J A notice whose send failed (bridge exit 75) is never dropped. It is eligible again once `min(2^(attempts-1), 60)` minutes have passed since its last attempt in the journal: 1, 2, 4, 8, 16, 32, then every 60 minutes. A run that finds it -inside its delay skips it without spawning the bridge. Every attempt carries the +inside its delay skips it without spawning the bridge. An entry stamped ahead of the clock (a host clock stepped forward, then corrected) is eligible now, and its next attempt rewrites the stamp. Every attempt carries the same nonce, so a Discord outage of any length costs a late notice, not a lost one, and a retry that succeeds is the one post. diff --git a/tests/fake-clock.mjs b/tests/fake-clock.mjs new file mode 100644 index 0000000..e64aae5 --- /dev/null +++ b/tests/fake-clock.mjs @@ -0,0 +1,5 @@ +// Test preload: fixes Date.now() to FAKE_NOW_MS when a test sets it. +if (process.env.FAKE_NOW_MS) { + const fixed = Number(process.env.FAKE_NOW_MS); + Date.now = () => fixed; +} diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 1817f7e..931c579 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -72,25 +72,27 @@ async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { if (incidents) await copyFile(incidents, w.incidents); w.setMode = (value) => writeFile(resolve(dir, "mode"), value); w.run = (extra = {}) => run(w, extra); - w.status = () => runStatus(w); + w.status = (env) => runStatus(w, env); w.posts = async () => (existsSync(w.calls) ? (await readFile(w.calls, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); return w; } -// The reader's clock is WATCHDOG_NOTICE_NOW_MS; a test drives the backoff by -// naming the minute (from T0) each run happens at. +// The reader reads Date.now(). A test drives the backoff the way it stubs fetch: +// a preload (fake-clock.mjs, passed to the reader as --import) fixes Date.now +// to the minute (from T0) the run happens at. The reader has no clock seam. const T0 = 1_800_000_000_000; -const at = (minutes) => ({ WATCHDOG_NOTICE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); +const CLOCK = resolve(import.meta.dirname, "fake-clock.mjs"); +const at = (minutes) => ({ FAKE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); function run(w, { env = {}, bridge = w.bridge } = {}) { - return spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { + return spawnSync(process.execPath, ["--import", CLOCK, TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { encoding: "utf8", env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT, ...env }, }); } -function runStatus(w) { - const result = spawnSync(process.execPath, [TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: process.env }); +function runStatus(w, env = {}) { + const result = spawnSync(process.execPath, ["--import", CLOCK, TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: { ...process.env, ...env } }); return { ...result, rows: result.status === null || !result.stdout ? [] : JSON.parse(result.stdout) }; } @@ -178,10 +180,23 @@ test("a running entry becomes unknown and is never re-posted", async () => { assert.equal(posts.filter((post) => opt(post, "cultmesh-command-id") === `${CLOSED_KEY}#opened`).length, 1, "the interrupted notice is not sent again"); const rows = (await w.status()).rows; assert.equal(rows.find((row) => row.incidentKey === CLOSED_KEY && row.notice === "opened").status, "unknown"); - assert.notEqual(w.run().status, 0, "still failing on the next run"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0, "still failing, and not re-posted, long past any backoff"); assert.equal((await w.posts()).length, posts.length); }); +test("an entry stamped ahead of the clock is eligible now, not deferred by the skew", async () => { + const w = await world("wn-skew-", { mode: "fail" }); + w.run({ env: at(1000) }); + assert.equal((await w.posts()).length, 2, "both openings tried once, stamped at minute 1000"); + w.run({ env: at(0) }); + assert.equal((await w.posts()).length, 4, "the clock is behind the stamp: retried at once"); + assert.equal((await w.status(at(0))).rows[0].nextEligibleAt, new Date(T0 + 2 * 60_000).toISOString(), "the retry rewrote the stamp from the corrected clock"); + w.run({ env: at(1.5) }); + assert.equal((await w.posts()).length, 4, "the cadence holds from the rewritten stamp"); + w.run({ env: at(2) }); + assert.equal((await w.posts()).length, 6); +}); + const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; test("a failed notice is retried after its backoff and never exhausted", async () => { @@ -326,8 +341,8 @@ test("a bridge that dies after sending is unknown and never re-posted", async () assert.equal(JSON.parse(first.stdout).unknown, 2); assert.notEqual((await w.status()).status, 0, "status exits non-zero while an entry is unknown"); await w.setMode("ok"); - assert.notEqual(w.run().status, 0); - assert.notEqual(w.run().status, 0); + assert.notEqual(w.run({ env: at(1000) }).status, 0, "far past any backoff"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0); const posts = await w.posts(); assert.equal(posts.length, 2, "one opening post per incident, no retry, no closure"); assert.ok(posts.every((post) => notice(post) === "opened")); @@ -343,8 +358,8 @@ test("a spawn timeout and a spawn error are unknown, never failed, never re-post assert.notEqual(first.status, 0, mode); assert.equal(JSON.parse(first.stdout).unknown, 2, mode); await w.setMode("ok"); - assert.notEqual(w.run().status, 0); - assert.notEqual(w.run().status, 0); + assert.notEqual(w.run({ env: at(1000) }).status, 0, "far past any backoff"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0); const posts = await w.posts(); assert.equal(posts.length, 2, `${mode}: one opening post per incident, no retry, no closure`); assert.ok(posts.every((post) => notice(post) === "opened")); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs index 741a851..1e6dffc 100644 --- a/tools/watchdog-notice.mjs +++ b/tools/watchdog-notice.mjs @@ -32,10 +32,8 @@ const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; // failures on, the exit status is the delivery-failure signal; retries go on. const MAX_DELAY_MINUTES = 60; const FAILING_ATTEMPTS = 3; -// The overrides exist so a test can reach the timeout branch without a 60 s wait -// and can drive the backoff without sleeping. +// The override exists so a test can reach the timeout branch without a 60 s wait. const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; -const clockMs = () => Number(process.env.WATCHDOG_NOTICE_NOW_MS) || Date.now(); const STATUSES = ["running", "completed", "failed", "unknown"]; const NAME = /^[a-z][a-z-]{0,63}$/; // Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): @@ -88,7 +86,7 @@ async function processIncidents(options) { const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); }; - const retryable = (entry) => !entry || (entry.status === "failed" && !(clockMs() < nextEligibleMs(entry))); + const retryable = (entry) => !entry || (entry.status === "failed" && Date.now() >= nextEligibleMs(entry)); const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); for (const record of records) { @@ -121,9 +119,14 @@ async function status(options) { } const isFailing = (entry) => entry.status === "failed" && entry.attempts >= FAILING_ATTEMPTS; -// Measured from the failed entry's last attempt in the journal. -const nextEligibleMs = (entry) => Date.parse(entry.updatedAt) + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; -const now = () => new Date(clockMs()).toISOString(); +// Measured from the failed entry's last attempt in the journal. A stamp ahead of +// the clock is skew (a host clock stepped forward, then corrected), not a delay +// to wait out: that entry is eligible now, and its next attempt rewrites the stamp. +const nextEligibleMs = (entry) => { + const stamp = Date.parse(entry.updatedAt); + return stamp > Date.now() ? Date.now() : stamp + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; +}; +const now = () => new Date(Date.now()).toISOString(); // Reads P unlocked. An absent file is "nothing to do" (records: null). A // record of another schema is counted as skipped; a record of this schema From fcf81ea564cf077a8bf054d08c889772054de9bb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 02:19:14 +0200 Subject: [PATCH 012/145] Watchdog notice reader test: no environment variable moves the clock Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 931c579..aeb5368 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -197,6 +197,14 @@ test("an entry stamped ahead of the clock is eligible now, not deferred by the s assert.equal((await w.posts()).length, 6); }); +test("the reader takes time from Date.now() alone, never from the environment", async () => { + const w = await world("wn-noseam-", { mode: "fail" }); + w.run({ env: { ...at(0), WATCHDOG_NOTICE_NOW_MS: "-5" } }); + w.run({ env: { ...at(0), WATCHDOG_NOTICE_NOW_MS: "8000000000000000" } }); + assert.equal((await w.posts()).length, 2, "no environment variable moves the clock"); + assert.equal((await w.status({ WATCHDOG_NOTICE_NOW_MS: "8000000000000000", ...at(0) })).rows[0].nextEligibleAt, new Date(T0 + 60_000).toISOString()); +}); + const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; test("a failed notice is retried after its backoff and never exhausted", async () => { From 9b28e9b27ad6a2a7e7fabba3645468cdb67268e7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 1 Oct 2026 02:25:13 +0200 Subject: [PATCH 013/145] Watchdog notice reader test: status reads the same fake clock as the runs Co-Authored-By: Claude Sonnet 5.5 --- tests/watchdog-notice.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index aeb5368..21d27aa 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -240,12 +240,12 @@ test("a failed notice is not retried before its delay", async () => { w.run({ env: at(0.99) }); w.run({ env: at(0) }); assert.equal(await count(), 2, "a run inside the delay spawns nothing"); - const row = (await w.status()).rows[0]; + const row = (await w.status(at(0))).rows[0]; assert.equal(row.attempts, 1); assert.equal(row.nextEligibleAt, new Date(T0 + 60_000).toISOString(), "status names when the entry is next eligible"); w.run({ env: at(1) }); assert.equal(await count(), 4, "eligible once the delay has passed"); - assert.equal((await w.status()).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); + assert.equal((await w.status(at(1))).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); }); test("the unit signals from the third failed attempt while still retrying", async () => { From 20d5efa9c6bba80ff07bd50862f5f942fcbe038e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 04:55:42 +0200 Subject: [PATCH 014/145] Concept Mill votes: target and body facts The Imagination map of 2026-10-08, committed as the target document of the concept-mill-votes campaign. Typed invariants, rulings and cut specs live in the eureka mind. Co-Authored-By: Claude Opus 5.5 --- docs/concept-mill-votes-target.md | 217 ++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/concept-mill-votes-target.md diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md new file mode 100644 index 0000000..b2b9272 --- /dev/null +++ b/docs/concept-mill-votes-target.md @@ -0,0 +1,217 @@ +# Concept Mill votes through Bifrost: map + +Status: Imagination mapping pass, 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign; its invariants, rulings and cut specs are typed state in the eureka mind, and this file keeps the body facts and rationale. Where they differ, the mind is right. + +Operator's words: "I want to track upvotes and downvotes on there, if possible." Then: "Yggdrasil is the obvious place. Doesn't Bifrost have discussions and voting?" Rulings: "anyone can vote, votes are public (we can filter by members if we wanted to)", then "anonymous, too, I mean anyone". + +Short answer: Bifrost has two voting-like things, and neither fits the Mill as built. Governance topics take support and objection only as free-text comment stances. Those live in a command-line CultCache file the web app never reads, and they are not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today. It also needs an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that has to be settled first. + +## Body facts + +Bifrost repo head: `160fac2` (2026-10-01). Paths are relative to `F:\Projects\Bifrost` unless named. + +### What a topic is in code + +1. Topics and comments exist only in a Node CLI, `tools/governance-threads.mjs`. Its store is a CultCache file, `.bifrost/governance-threads.cc` (`tools/governance-threads.mjs:18`), and its types are `bifrost.governance.topic` and `bifrost.governance.topic-comment` (`:48-111`). The commands are `open`, `comment`, `approve`, `promote`, `list`, `show` and `digest` (`:113-155`, `:582`). +2. A topic is an agent-dispatch work thread. It has `jurisdictionRepoName` (required), `jurisdictionAgentIdentity`, a status lifecycle `open → consensus_ready → approved → dispatched → closed` (`:52`), and `promote`, which renders the topic into an agent update request (`:324-405`). +3. Support and objection are comment stances, not votes. The valid set is `comment, proposal, support, objection, question, approval, summary, receipt` (`:53`). A comment has `authorKind` (default `"agent"`) and `authorId`, a free string from `--author` (`:232-241`). Nothing enforces one stance per author: each call writes a new `comment_` (`:233`). Retraction is not possible. `authorId` is not tied to a Bifrost `UserAccount`. +4. The web app has no topic type, no topic page and no topic store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). +5. No topic store exists anywhere I looked. `F:\Projects\Bifrost\.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc`. That `find` ran under `sudo -n` with errors silenced, so the absence there is likely but not proven. + +### How motions and votes differ + +6. Motions and votes are EF Core entities in Postgres: `Motion` (`BifrostModels.cs:665-700`) and `Vote` (`:702-721`, with `VoteChoice For/Against/Abstain` at `:110-115`). +7. A vote is one per account per motion: there is a unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting a vote upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). +8. Votes are weighted. `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`). That weight is 0 unless the membership is `Active`, and otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). Patron tier is the voting weight. +9. A motion has a category with an approval threshold, `ClosesAtUtc`, and a pass/fail resolution (`MotionGovernanceService.cs:95-131`, `MotionCategoryPolicy.cs`). It is a decision, not a standing rating. + +### Who can act + +10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). The motions page and its vote handler are at `Pages/App/Motions/Index.cshtml.cs:56`. +11. An account becomes `Active` in one of three ways: + - bootstrap admin GitHub logins (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`); + - admin approval on the Members page (`Pages/App/Members/Index.cshtml.cs:92-105`); + - a Heimdall member-access claim, meaning the Discord cult-member role or an active Patreon tier (`Features/Heimdall/HeimdallAuthService.cs:158,186`). + + GitHub sign-in with an invitation lands in `PendingApproval`, and without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). +12. Anyone can register natively, and doing so proves nothing. `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`). It takes an identity name and a display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). It asks for no credential, and there is no native sign-in route. A native account is a single cookie: as a voter it is indistinguishable from anonymous, and it is lost when the cookie expires. +13. No public read path exists: + - Anonymous routes are only health, readiness, auth, webhooks, the patron intake and the patronage checkout (`Program.cs:211-522`). + - The Eve governance surface requires `ActiveMember` (`Program.cs:473-488`). + - No CORS policy is configured anywhere in `src/Bifrost.Web` (grep for `Cors` returns nothing). + +### Deployed versus repo + +14. The live release is `/srv/bifrost/app/releases/20260723-daedcc4`, and `bifrost.service` is active. I checked both read-only over SSH alias `yggdrasil` on 2026-10-08. `https://bifrost.gamecult.org/healthz` and `/readyz` both return 200. +15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on `main`. It exists only on `origin/codex/bifrost-migration-metadata`, which branched from `bd3a5a9`. Compared with `daedcc4`, `main` has `cde3a78`, which deletes 369 lines of HTTP bridge endpoints from `Program.cs`. `main` also lacks the migration-metadata attribute lines `daedcc4` added to seven migrations. Production therefore runs code that `main` has deleted, and `main` may still carry the EF migration-discovery bug that `daedcc4` fixed. +16. Deploys are manual. `gamecult-ops/scripts/deploy-bifrost-alpha.sh` creates a timestamped release directory, and Idunn does not manage Bifrost (`gamecult-ops/runbooks/bifrost-alpha-deploy.md:162-164`). The inventory entry (`gamecult-ops/inventory.md:941-952`) records no revision. + +### The Mill page + +17. The Mill is being built uncommitted in worktree `F:\Projects\AetheriaLore-wt-concept-mill`. It consists of `Aetheria/Game Design/Concept Mill.md` (397 lines, hand-authored HTML in Markdown), `Aetheria/media/concept-mill/` (41 files) and a `site/quartz/styles/custom.scss` change. +18. Each card is `
`, and there are 19 today (`Concept Mill.md:12` for the first, `bison`). The slug is already independent of the display name: for example, `vasuki2` is shown as "Vasuki". The ship roll links to `#`. +19. The site has a pattern for client script. A Quartz component sets `afterDOMLoaded` (`site/quartz/components/AetheriaEpubReader.tsx:110`) and re-runs on the `nav` event (`site/quartz/components/scripts/aetheriaEpubReader.inline.ts:228`). Deploys go through `.github/workflows/deploy-quartz.yml` to GitHub Pages. + +### Anonymous-voter substrate + +20. Bifrost has no anonymous actor. Every write path resolves a `CurrentBifrostActor` with a `UserAccount` and throws without one (`MotionGovernanceService.cs:18,54`). Anonymous votes therefore need a voter identity that is not a `UserAccount`. +21. Bifrost sees no client IP. nginx sends `X-Real-IP` and `X-Forwarded-For` (`gamecult-ops/nginx/bifrost.gamecult.org.conf:15-16`), but `Program.cs` never calls `UseForwardedHeaders`, so `RemoteIpAddress` is the loopback proxy for every request. Until that is fixed, any per-IP limit puts every visitor in one bucket. +22. Data Protection keys can be persisted (`Program.cs:113-117`, `DataProtection:KeysPath`). A device token signed with them would stay valid across restarts and releases, provided the live env sets the path. Cut 0 checks this. + +## Rulings in force + +- **Who may vote:** anyone, including anonymous visitors. There is no membership gate. +- **Votes are public:** up and down counts are shown, so downvotes are visible. +- **Members are a filter, not a separate system.** Each vote records the voter's class at cast time (anonymous, account, or Active member). Member-only views are a filter over the same rows. + +Derived from these rulings, not asked separately: + +- **Counts are raw: one voter, one mark.** Patron weight cannot apply to anonymous voters, and the member filter replaces it as the "members' opinion" view. +- **Concept art stays out of governance.** Mill votes are their own record, not governance topics or motions. Topics are agent-dispatch threads, and motions are tier-weighted, deadline-closing decisions (facts 2, 8, 9). Neither can admit an anonymous voter (fact 20). +- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains the standing doctrine debt it already is. This feature does not pay it down. + +Interpretation for Self to confirm, not a fork: "votes are public" is read as public counts, split by voter class. The export carries no per-voter identities. Anonymous voters have none to show, and publishing account names was not asked for. + +## Authority map + +- **Concept identity.** Owner: the Mill page in AetheriaLore. The card `id` slug is the key (fact 18). Bifrost stores keys and never invents one. Renaming a ship changes its title, never its key. +- **Concept subjects.** Owner: a Bifrost `ConceptSubject` record: `(collection, key)` unique, plus `title`, `registeredAt` and `retiredAt`. Writer: one idempotent admin registration command, fed from the Mill's card list and run by the render-round agent. The vote path cannot create subjects, because otherwise anonymous visitors could mint keys. +- **Voter identity.** Owner: a Bifrost `VoterKey` resolver, the one place that answers "who is casting this?": + - signed in: `account:`; + - otherwise: `device:`, where the id comes from a signed, HttpOnly, first-party device-token cookie that Bifrost issues on the vote page. + + The `VoterKey` resolver is not an owner of membership status. It reads `CurrentBifrostActor.IsActiveMember` at cast time. +- **Votes.** Owner: a `ConceptAppraisal` record with these fields: + - `subjectId` and `voterKey`, with `(subjectId, voterKey)` unique; + - `stance` (`Up` or `Down`) and `castAt`; + - `voterClass` (`Anonymous`, `Account` or `Member`), stamped at cast; + - `userAccountId`, nullable; + - `networkTag`, an HMAC of the client's /24 (IPv4) or /64 (IPv6) prefix, stored for audit and purge. + + The one writer is `ConceptAppraisalService` (Cast, Change, Retract, and a Purge for admins). Rate limiting happens before the service, and uniqueness is enforced inside it and by the index. +- **Tallies.** Derived only. Per subject, per stance, per voter class, they are computed from rows and never stored as counters. The "all" and "members" views are filters over the same rows. +- **Public tally export.** This is the JSON xenos boundary. It is one anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject: + - no voter keys, account ids or network tags; + - CORS allows exactly `https://aetheria.gamecult.org`, `GET` only, without credentials; + - `Cache-Control` is about 60 s. + + The export owns nothing. +- **The Mill page.** Read only. One fetch fills each card's counts, and a "Vote on Bifrost" link goes to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. That means no `SameSite=None`, no cross-site POST and no exposure to browser third-party-cookie blocking. +- **Demotions:** + - Governance topic support and objection are not vote owners. + - `Vote.Weight` and patron tiers stay governance-only. + - The static page owns no count. + - The client IP is not an identity: it is a rate-limit bucket and an audit tag, never a vote key. + +## Anti-stuffing defence (implementation duty) + +Prior art on lightweight anonymous voting, from memory and not freshly searched: + +- Strawpoll-style polls offer duplicate checks by IP, by browser cookie, or by sign-in. IP-only checks wrongly block shared networks, and cookie-only checks are reset by a private window. +- Proof-of-work challenges (Hashcash; today ALTCHA, mCaptcha, Friendly Captcha) make each vote cost CPU without any third party. They slow bulk scripts and barely touch a determined attacker with hardware. +- Third-party challenges (Cloudflare Turnstile, reCAPTCHA) are strong. They also send visitor data to an outside service and add an external dependency to the vote path. +- Large sites rely on after-the-fact detection more than on gates: they look for clusters by network, timing and new devices, then discount or purge. + +Recommendation: a layered defence that lives entirely inside Bifrost, with proof-of-work held back as an escalation. + +1. **Device token: one vote per device per concept.** On the first vote-page visit, Bifrost mints a random device id, signs it with Data Protection, and sets it as `bifrost_voter`: HttpOnly, Secure, SameSite=Lax, valid for one year. The vote key is `device:`. Clearing cookies resets it, and that is accepted and bounded by layer 2. +2. **Network rate limits.** Configure `UseForwardedHeaders`, trusting only loopback (fact 21 makes this a prerequisite). Then put the in-box ASP.NET Core rate limiter (`Microsoft.AspNetCore.RateLimiting`) on two paths, partitioned by network prefix: + - token minting: a small number of new devices per prefix per hour; + - vote casts: a modest number per prefix per minute. + + Signed-in accounts are partitioned by account, not by network. The limits are configuration values, and the first values are Hands' to propose and Soul's to probe. +3. **Audit and purge.** Every anonymous row carries `networkTag` and `castAt`. An admin view lists subjects whose recent votes cluster on one tag. A Purge command removes rows by tag and time window, leaving an audit event. Public counts can then be corrected after the fact, without a gate that blocks honest shared networks. +4. **Escalation, not built now:** a self-hosted proof-of-work challenge on token minting, using an ALTCHA-style library served from Bifrost. It is switched on by configuration only if layer 3 shows stuffing. It needs no third party, so votes stay Bifrost-owned. Turnstile and reCAPTCHA are refused, because they add an outside owner to the vote path. + +Where it lives: + +- Layers 1 to 3 are Bifrost web code: the `VoterKey` resolver, the rate-limiter policies in `Program.cs`, and the service's Purge. +- nginx gets nothing new, and `limit_req` is not used. Edge limits would be a second, unobservable owner of the same budget. +- The static site gets nothing at all. + +## Cuts, in order + +Each cut is one Hands brief. Bifrost .NET builds and tests run on Yggdrasil per the workstation load budget. + +**Cut 0: Reconcile the Bifrost deploy with `main`.** Repos: Bifrost and gamecult-ops. + +- Land `daedcc4`'s migration-metadata fix on `main`. +- Confirm nothing live still calls the bridge endpoints that `cde3a78` deleted (fact 15). +- Confirm that the live env sets `DataProtection__KeysPath` (fact 22). This is read-only; if it is unset, Cut 6 adds it. +- Deploy `main` per the runbook and record the revision in `inventory.md`. +- Verify: + - migrations list completely against a scratch database; + - `/healthz` returns 200; + - the admin can load the motions page; + - the inventory names the revision. + +**Cut 1: Forwarded headers.** Repo: Bifrost. + +- Call `UseForwardedHeaders` with `X-Forwarded-For` and `X-Forwarded-Proto`, trusting `127.0.0.1` and `::1` only. +- Tests: + - behind a loopback proxy, `RemoteIpAddress` is the forwarded client; + - a forwarded header from a non-loopback peer is ignored, which is the negative check against spoofing. + +**Cut 2: Subjects, voter keys and appraisals.** Repo: Bifrost. + +- Add `ConceptSubject`, `ConceptAppraisal`, a migration, the `VoterKey` resolver with the device token, and `ConceptAppraisalService` (Cast, Change, Retract, Purge, Tally). +- Tests: + - a second cast by the same voter key changes the stance and adds no row; + - Retract removes the row; + - a vote on an unknown or retired key is refused; + - `voterClass` and `userAccountId` are stamped correctly for anonymous, account and Active-member actors; + - a tampered or unsigned device cookie is rejected and a new one is minted; + - Purge removes only rows in the tag and window; + - the tally by class equals the row counts. +- Negative check: nothing outside the service writes `ConceptAppraisal`. + +**Cut 3: Subject registration.** Repo: Bifrost. + +- Add an idempotent admin command that upserts `(collection, key, title)` and can retire a key. Use whichever existing admin idiom Hands finds, `/App` page handler or `tools/` CLI. +- Tests: a rerun is a no-op; renaming a title keeps the votes; a retired key keeps its rows but leaves the export. +- Seed it with the 19 slugs. + +**Cut 4: Public export and rate limits.** Repo: Bifrost. + +- Add the anonymous tally `GET` with the one-origin CORS policy. +- Add the two rate-limiter policies, for minting and for casting. +- Tests: + - an anonymous request gets 200; + - CORS headers are present only for `https://aetheria.gamecult.org`; + - the payload contains no voter keys, account ids or tags; + - a burst from one prefix gets 429 past the limit while another prefix is unaffected; + - signed-in accounts are limited per account. + +**Cut 5: The vote page.** Repo: Bifrost. + +- Add `/mill/{collection}/{key}`, outside `/App`. It shows the title, the counts (all and members), a back link to the card, and Up, Down and Retract buttons for everyone, as HTMX posts with antiforgery. A first visit mints the device token. +- Tests: + - an anonymous visitor can cast, change and retract; + - a signed-in visitor's vote is keyed to the account; + - a cast without the antiforgery token is refused. + +**Cut 6: Deploy.** Repo: gamecult-ops. + +- Deploy per the runbook. Set `DataProtection__KeysPath` if Cut 0 found it unset, and update the inventory. +- Verify from outside: + - the export, requested with `Origin: https://aetheria.gamecult.org`, returns CORS headers and 19 keys; + - an anonymous vote from a clean browser shows in the export after the cache expires; + - a scripted burst gets 429. + +**Cut 7: The Mill reads the counts.** Repo: AetheriaLore. + +- On each card, add `data-concept-key=""`, a count slot and the vote link. +- Add one Quartz component script (`afterDOMLoaded`, re-run on `nav`, fact 19) that fetches the export once and fills the slots. On failure the slots stay hidden and the links still work. +- Add "register new slugs with the Cut 3 command" to the render-round routine. +- Verify: + - the Quartz build passes; + - the counts render in a browser; + - with JavaScript disabled the links still work; + - with Bifrost blocked there are no layout holes. + +This cut lands with or after the Mill page's own commit. + +## Open questions + +None remain from the operator. Self should confirm one interpretation, that the export carries counts by class and no voter names. If Hands finds that the live env lacks persisted Data Protection keys, that is a deploy fix inside Cut 6, not a fork. + From bf83a9321b1867f8b106e43a5e3221e1e48793f1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:01:18 +0200 Subject: [PATCH 015/145] Research: prior art on anonymous-vote stuffing defences Served concept-mill-votes Cut 4 (anti-stuffing defence). Facts with source pointers on PoW captchas, device cookies, network buckets, ePrivacy status of anti-abuse cookies, and after-the-fact purge at HN, Stack Exchange and Reddit. Co-Authored-By: Claude Opus 5.5 --- .../anonymous-vote-stuffing-prior-art.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/research/anonymous-vote-stuffing-prior-art.md diff --git a/docs/research/anonymous-vote-stuffing-prior-art.md b/docs/research/anonymous-vote-stuffing-prior-art.md new file mode 100644 index 0000000..e48b0dd --- /dev/null +++ b/docs/research/anonymous-vote-stuffing-prior-art.md @@ -0,0 +1,98 @@ +# Anonymous vote stuffing: prior art + +Serves the fork: concept-mill-votes Cut 4, anti-stuffing defence; campaign concept-mill-votes; 2026-10-10. +Subject text: "Anti-stuffing defence" in `F:\Projects\Bifrost\docs\concept-mill-votes-target.md` (lines ~106-130). +Gathered by Eyes. Facts with pointers; no recommendation. Items marked (secondary) were not read at the primary source. Search-engine summaries were not verified against the pages unless stated. + +Gaps: (a) a query for residential-proxy and CAPTCHA-farm pricing was refused by the search tool, so no measured cost of a headless/proxy farm is here; (b) StrawPoll's own duplicate-check docs were not found; (c) Friendly Captcha's own licence/docs pages were not read; (d) ALTCHA's server-verification and PoW doc pages returned 404/empty; (e) national ePrivacy guidance beyond CNIL and ICO was not searched. + +## 1. Anonymous and low-friction voting in established systems + +- Poll software generally: duplicate checks are IP locking, cookie, or sign-in. Vendor docs say IP checks block shared networks (offices, schools), cookie checks are beaten by clearing cookies or switching browsers, and sign-in is strongest but still needs multi-account safeguards. Kentico: https://docs.kentico.com/k8/securing-websites/designing-secure-websites/securing-and-protecting-the-system/preventing-duplicate-poll-voting-and-content-rating . Academic restatement ("IP locking, cookie-based methods"): https://www.scitepress.org/PublishedPapers/2004/14026/14026.pdf (2004). WordPress poll-plugin thread reporting repeat votes despite "cookie and ip" logging: https://wordpress.org/support/?p=1278907 +- StrawPoll: its own option documentation was not found; the design doc's StrawPoll statement is not confirmed by this pass. +- Reddit: "vote fuzzing" described by staff only via press (Daily Dot, https://dailydot.com/news/reddit-vote-count-percent-like/ , undated): displayed totals altered as an anti-spam measure. Mechanics (offsetting detected bot votes, paired fake votes) come from user/moderator posts only; no official algorithm page found (secondary). +- Hacker News: a "voting ring detector" demotes rather than kills (dang, 2014, https://news.ycombinator.com/item?id=7542985 ; community list https://github.com/minimaxir/hacker-news-undocumented ). Moderators have publicly rolled back a ring penalty as a false positive (https://hn.svelte.dev/item/47457689 ). Internals unpublished. +- Stack Exchange: help page "serial voting reversed" (https://stackoverflowteams.com/help/serial-voting-reversed): one user voting on many of another user's posts in a short window is judged invalid and removed; some removal by a daily automated process, some by staff after investigation. Thresholds not public. The page says a "voting corrected" event does not imply wrongdoing. +- Product Hunt: staff reply in https://www.producthunt.com/p/producthunt/vote-selling-on-product-hunt says they will not reveal vote weights or all signals; a zero-point rule for voters with no history was under consideration. Weighting by account age/activity is asserted only by third-party guides (secondary). +- Academic, Sybil resistance: + - Douceur, "The Sybil Attack" (IPTPS 2002): absent a logically centralized authority, Sybil attacks can always be mounted barring unrealistic resource parity. https://courses.csail.mit.edu/6.895/fall02/papers/Douceur/iptps02-douceur.pdf . Identifying nodes by hash of IP address (CFS) is noted as implicitly relying on an authority. + - SybilGuard/SybilLimit (Yu, Gibbons, Kaminsky, Xiao, 2008): bound accepted Sybil nodes using social-network structure (fast mixing). https://www.comp.nus.edu.sg/~yuhf/sybillimit-tr.pdf + - SumUp (Tran, Min, Li, Subramanian, NSDI 2009): content-vote aggregation capping adversary votes at the number of attack edges in a trust graph, plus feedback-based reduction of misbehaving voters; applied to a Digg vote trace, found strong evidence of attack on many "popular" articles. https://static.usenix.org/events/nsdi09/tech/full_papers/tran/tran.pdf + - All three need a social/trust graph or authority; none targets graph-less anonymous visitors. + +## 2. Proof-of-work challenges + +| Project | Licence | Self-host | .NET | Pointers | +|---|---|---|---|---| +| ALTCHA | MIT (widget README) | Yes | Official server list: TS, Dart, PHP, Go, Python, Java, Ruby, Elixir, Rust, C++ (no C#). Community: Ixnas.AltchaNet (0.4.x; SHA-256; replay store interface; bundled in-memory store "for testing only") and unofficial Altcha.Net 1.2.0 (replay store interface; in-memory not advised multi-server) | https://raw.githubusercontent.com/altcha-org/altcha/main/README.md ; https://www.nuget.org/packages/Altcha.Net ; https://www.nuget.org/packages/Ixnas.AltchaNet (via search, not opened) | +| mCaptcha | AGPL-3.0 | Yes, Docker Compose; separate Rust service | None found | https://github.com/mCaptcha/mCaptcha ; README: "active development", no releases listed, SHA-256 PoW, difficulty scales with load (about 2 s under attack), proof configs expire after 30 s and are single-use, cookie-free, IP-independent | +| Cap | Apache-2.0 | Yes, Docker/workers | None stated | https://github.com/tiagozip/cap ; README claims "gpu-resistant" PoW plus instrumentation challenges; no algorithm parameters; size claims are marketing | +| Friendly Captcha | Third-party listings: front end MPL-2.0 (unconfirmed); back end hosted commercial, a "lite" self-host server mentioned (unconfirmed) | Hosted service | Not checked | https://friendlycaptcha.com/insights/open-source-captcha/ ; https://openalternative.co/friendly-captcha (secondary) | +| Anubis | MIT, Go | Yes, reverse-proxy sidecar | n/a | https://github.com/TecharoHQ/anubis ; aimed at scrapers; README calls itself "a bit of a nuclear response" and notes it can block good bots | + +- ALTCHA widget: PBKDF2/SHA variants bundled; Argon2id and Scrypt (memory-bound, "harder to accelerate on ASICs or GPUs") are separate imports; `minDuration` default 500 ms; `timeout` default 90 s; `humanInteractionSignature` on by default (mechanism not documented in the README). Ixnas.AltchaNet defaults: complexity 50000-100000, expiry 120 s (search summary). +- Argued or measured attacker cost: + - Laurie and Clayton, "Proof-of-Work Proves Not to Work" (WEIS 2004): difficulty high enough to stop spam would stop significant numbers of legitimate senders, since attackers use hijacked machines and run continuously. https://www.cl.cam.ac.uk/~rnc1/proofwork2.pdf . Rebuttal, Liu and Camp, "Proof of Work can Work" (2006): works if work is weighted by a reputation function. https://sites.cs.ucsb.edu/~rich/class/cs293b-cloud/papers/lui-pow.pdf + - Anubis: its author acknowledges PoW works best against crawlers that cannot run JavaScript; an operator with a solver keeps the authorization cookie and continues (LWN, https://lwn.net/Articles/1028558 ). A July 2026 CLI client (anubis-fetch) automates the challenge (secondary: https://runtimewire.com/article/anubis-bypass-proof-of-work-bot-wall-limits ). + - No numbers (seconds per solve on commodity vs GPU hardware, attacker cost per 1000 votes) found for ALTCHA, mCaptcha or Cap. +- Reuse shape: mCaptcha tokens are single-use and short-lived; Anubis issues a cookie that covers later requests. + +## 3. ASP.NET Core rate limiting and forwarded headers + +Docs read: https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit (ms.date 2026-09-03; covers 7.0-12.0) and https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer (ms.date 2026-04-23). + +- Rate limiter: `AddRateLimiter`, `GlobalLimiter` or named policies, `PartitionedRateLimiter.Create`, `RateLimitPartition.GetFixedWindowLimiter` etc. The doc's "By IP address" sample keys on `Connection.RemoteIpAddress?.ToString() ?? "unknown"`. The page contains no IPv6 prefix guidance. +- Doc warnings: partitioning on user-controlled input can exhaust memory (each distinct key creates and caches a limiter); "if you create partitions by using client IP addresses, your app becomes vulnerable to Denial of Service attacks that use IP Source Address Spoofing" (cites BCP 38). +- `UseRateLimiter` must come after `UseRouting` when endpoint-specific policies/attributes are used; global-only may precede it. +- Time-based limiters do not refund a permit already taken when a later chained limiter rejects. +- `OnRejected`/`RejectionStatusCode` are needed for 429; the doc shows setting Retry-After manually or from lease metadata. +- Forwarded headers: + - Middleware "should run before other middleware" (before `UseHsts`; may follow diagnostics/error handling). Anything reading `RemoteIpAddress`, including the rate limiter, must come after it to see the forwarded address. + - Default `KnownNetworks` is a single loopback entry (`IPNetwork(IPAddress.Loopback, 8)`), and `KnownProxies` likewise loopback-only. Forwarded values are accepted only if the immediate peer matches one of the lists. + - Default `ForwardLimit` is 1: only the rightmost `X-Forwarded-For` entry is processed (right to left). Null is advised only with known proxies/networks configured. + - Doc: "Only allow trusted proxies and networks to forward headers. Otherwise, IP spoofing attacks are possible." + - Dual-mode sockets: IPv4 entries must be given in IPv4-mapped IPv6 form; check `RemoteIpAddress` to see which applies. + - `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` uses cloud-style settings and "doesn't enable features such as the KnownProxies option". + - Unverified: whether newer .NET renames `KnownNetworks`; the page read lists `KnownNetworks` with `IPNetwork`. +- IPv6 prefix practice (practitioner sources only; no standard or vendor guidance found): + - RFC 8981 privacy extensions rotate the interface id inside one /64 (preferred lifetime 1 day, valid 2 days, up to 3 concurrent). https://www.rfc-editor.org/rfc/rfc8981.html + - A residential customer can obtain /56 or /48 via DHCPv6-PD, so per-/64 buckets can be spread across by one household. v6ops https://psg.com/lists/v6ops/v6ops.2010/msg00152.html ; NANOG https://mailman.nanog.org/pipermail/nanog/2015-October/079735.html + - One ISP policy quoted: /56 broadband, /48 business, /64 mobile; aggregating too high can block tens of thousands of mobile users. Suggested escalating aggregation /64 to /48 to /40 to /36 to /32 on observed abuse. Nextcloud thread weighing /56 default: https://help.nextcloud.com/t/is-bruteforce-protection-ipv6-ready/215084 + - No source found for a /24 IPv4 partition choice. + +## 4. Device/cookie identifiers + +- Data Protection (https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/default-settings ; `.../configuration/overview`): + - Default key storage: Azure Apps `%HOME%`, else user profile (`%LOCALAPPDATA%`, DPAPI on Windows), else IIS registry; "If none of these conditions match, keys aren't persisted outside of the current process. When the process shuts down, all generated keys are lost." + - Docker: persist keys to a volume or external store. `SetApplicationName` must match across deployments (default discriminator is content-root path). + - Default key lifetime 90 days with auto-roll; retired keys still decrypt while kept. "Deleting a key makes its protected data permanently inaccessible"; docs recommend not deleting. + - A custom key repository disables automatic encryption of keys at rest. + - Payload algorithms: AES-256-CBC + HMACSHA256 (encrypted and authenticated, not only signed). +- SameSite: Chrome treats cookies without a SameSite attribute as Lax from v80 (https://web.dev/articles/samesite-cookies-explained ). Chrome kept third-party cookies; Privacy Sandbox APIs retired Oct 2025 (secondary: https://engadget.com/cybersecurity/google-has-killed-privacy-sandbox-130029899.html ). Safari blocks third-party cookies by default, Firefox blocks known trackers (secondary, 2019). A same-origin vote page is first-party throughout. +- Safari ITP: 7-day cap applies to cookies written via `document.cookie`; WebKit's Wilander stated Secure HttpOnly server-set cookies are not affected (https://simoahava.com/privacy/first-party-cookies-webkit-revisited/ ; WebKit bugs 237350, 237474). Sources disagree on later script-cookie handling; none found capping server-set cookies. +- Private/incognito windows discard the cookie jar on close (browser behaviour; no primary source fetched). +- Regulators: + - ICO, "What are the exceptions" (https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/what-are-the-exceptions/ ): PECR examples include "ensuring the security of terminal equipment" and "preventing or detecting fraud"; necessity is assessed "from the point of view of the subscriber or user, not your own"; a second purpose voids the exception; advertising never qualifies, including click-fraud detection. + - CNIL cookies FAQ (https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/FAQ), Q16: fraud-prevention trackers are in general not exempt, but may be in particular cases such as securing a user authentication mechanism. Q17 (CAPTCHA): no consent if the sole purpose is securing the site or an authentication mechanism for the user's benefit; consent if data is reused for the provider's own ends (reCAPTCHA named) or purposes unspecified; where consent is required, refusal bypasses the protection. + - EDPB Guidelines 2/2023 on Art. 5(3) ePrivacy (v2.0, 7 Oct 2024): applies to any storage/access on terminal equipment, technology-neutral, including identifiers other than cookies; lists "tracking based on IP only" as a use case. https://www.edpb.europa.eu/documents/guideline/guidelines-22023-on-technical-scope-of-art-53-of-eprivacy-directive_en . Its exemption text on identifiers was not read. + - GDPR status of an HMAC'd network tag stored per row was not searched. + +## 5. Known failure modes per layer + +- Device token: cleared cookies, private windows and fresh profiles reset it (poll vendors, Kentico); a scripted client omits or discards it. Unpersisted Data Protection keys invalidate every issued token on each restart; deleted keys invalidate tokens signed with them. +- IP/prefix limits: + - Shared NAT: poll vendors report schools and offices tripping IP checks. CGN measurement: Livadariu et al. infer 4.1K ASes deploy CGN (2014-2016 data); Mandalari et al. found CGN in 10% of 60+ tested ISPs; Richter et al. https://arxiv.org/abs/1605.05606 ; CAIDA https://www-old.caida.org/publications/papers/2018/inferring_carrier_grade_nat/ . Blocklists and address reuse: https://steel.isi.edu/members/sivaram/papers/blocklisting_impact.pdf . No CGN false-positive rate for voting found. + - Mobile IPv6: carriers commonly assign a /64 per device (forum, secondary); whether a named carrier changes the prefix per session was not found. RFC 8981 rotates addresses within the /64. + - Delegated prefixes larger than /64 span many buckets (v6ops/NANOG above). + - Header trust: forged `X-Forwarded-For` is accepted only when the immediate peer is in KnownProxies/Networks; without `UseForwardedHeaders`, `RemoteIpAddress` is the proxy for every client (one bucket). IP-keyed partitions carry the doc's DoS/memory warning. +- Audit-and-purge: HN and Stack Exchange both reverse or demote by pattern and both document false positives (above); neither publishes thresholds. +- PoW: Laurie/Clayton and Anubis (above): attackers with solvers or botnets pay CPU; legitimate low-end devices pay too; a reusable cookie spreads one solve over many requests. .NET libraries require a replay store; in-memory stores are test-only/single-instance. Headless-farm cost: not gathered. +- Third-party challenge: CNIL Q17 names reCAPTCHA as requiring consent where the provider reuses data. + +## Where the current design's assumptions are contradicted by evidence + +1. The design lists proof-of-work as slowing scripts yet barely touching determined attackers; the cited literature agrees, but the design also says ALTCHA "needs no third party" and is "ALTCHA-style library served from Bifrost": ALTCHA has no official C# server library, only community packages that need a custom replay store for production. +2. Data Protection default key persistence is process-lifetime unless a profile/registry/volume exists; default key lifetime is 90 days, not one year. The one-year device cookie depends on retired keys being kept and `DataProtection__KeysPath` set (the doc's fact 22 flags the path; the doc does not state the key-loss consequence for tokens). +3. The design's /64 partition for IPv6 is described by practitioner sources as bypassable by one household holding a /56 or /48 and by privacy-extension rotation inside a /64; the opposite risk (aggregating above /64 on mobile) is documented as collateral damage. No evidence was found for the /24 IPv4 choice. +4. The design treats the device cookie as needing no consent notice; ICO and CNIL exempt security cookies only where security is the sole purpose and from the user's side, CNIL calls fraud-prevention trackers generally non-exempt (exception: securing an authentication mechanism), and ICO says users should still be informed. A vote-integrity cookie is not an authentication mechanism and was not addressed by either text found. +5. The design says large sites "rely on after-the-fact detection ... then discount or purge": confirmed for HN, Stack Exchange and (secondary) Reddit, but those systems also publish no thresholds, keep false-positive reversals, and Reddit/Product Hunt add vote fuzzing or voter-history weighting, which the design does not include. From e77d6220de036dcc912717dabe668e04060a8c4a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:16:45 +0200 Subject: [PATCH 016/145] Concept Mill votes target: re-verified body facts, authority map, cut order Imagination pass that admitted cut specs 0a through 7. Facts re-probed at Bifrost bf83a93 and on the host; the from-memory prior-art list is replaced by a citation of docs/research/anonymous-vote-stuffing-prior-art.md. Co-Authored-By: Claude Opus 5.5 --- docs/concept-mill-votes-target.md | 239 ++++++++++-------------------- 1 file changed, 75 insertions(+), 164 deletions(-) diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index b2b9272..91c92e6 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -1,217 +1,128 @@ # Concept Mill votes through Bifrost: map -Status: Imagination mapping pass, 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign; its invariants, rulings and cut specs are typed state in the eureka mind, and this file keeps the body facts and rationale. Where they differ, the mind is right. +Status: Imagination mapping pass 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign. Body facts re-verified and cut specs admitted 2026-10-10 by `imagination-mill-votes` against Bifrost `bf83a93`. Its invariants, rulings, questions and cut specs are typed state in the eureka mind; this file keeps the body facts and rationale. Where they differ, the mind is right. Operator's words: "I want to track upvotes and downvotes on there, if possible." Then: "Yggdrasil is the obvious place. Doesn't Bifrost have discussions and voting?" Rulings: "anyone can vote, votes are public (we can filter by members if we wanted to)", then "anonymous, too, I mean anyone". -Short answer: Bifrost has two voting-like things, and neither fits the Mill as built. Governance topics take support and objection only as free-text comment stances. Those live in a command-line CultCache file the web app never reads, and they are not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today. It also needs an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that has to be settled first. +Short answer: Bifrost has two voting-like things, and neither fits the Mill. Governance topics take support and objection only as free-text comment stances, in a command-line CultCache file the web app never reads, not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today, plus an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that is settled first. ## Body facts -Bifrost repo head: `160fac2` (2026-10-01). Paths are relative to `F:\Projects\Bifrost` unless named. +Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only). Paths are relative to `F:\Projects\Bifrost` unless named. Facts 1-13 were taken at `160fac2` on 2026-10-08; the source files they cite are unchanged at `bf83a93` (the two commits since touch only `docs/`). Facts 14 onward were re-probed on 2026-10-10 by `imagination-mill-votes`; scripts are in that agent's scratch directory (`ygg-probe*.sh`). ### What a topic is in code 1. Topics and comments exist only in a Node CLI, `tools/governance-threads.mjs`. Its store is a CultCache file, `.bifrost/governance-threads.cc` (`tools/governance-threads.mjs:18`), and its types are `bifrost.governance.topic` and `bifrost.governance.topic-comment` (`:48-111`). The commands are `open`, `comment`, `approve`, `promote`, `list`, `show` and `digest` (`:113-155`, `:582`). 2. A topic is an agent-dispatch work thread. It has `jurisdictionRepoName` (required), `jurisdictionAgentIdentity`, a status lifecycle `open → consensus_ready → approved → dispatched → closed` (`:52`), and `promote`, which renders the topic into an agent update request (`:324-405`). 3. Support and objection are comment stances, not votes. The valid set is `comment, proposal, support, objection, question, approval, summary, receipt` (`:53`). A comment has `authorKind` (default `"agent"`) and `authorId`, a free string from `--author` (`:232-241`). Nothing enforces one stance per author: each call writes a new `comment_` (`:233`). Retraction is not possible. `authorId` is not tied to a Bifrost `UserAccount`. -4. The web app has no topic type, no topic page and no topic store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). -5. No topic store exists anywhere I looked. `F:\Projects\Bifrost\.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc`. That `find` ran under `sudo -n` with errors silenced, so the absence there is likely but not proven. +4. The web app has no topic type, page or store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). +5. No topic store exists anywhere looked. `.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc` (ran under `sudo -n` with errors silenced, so likely but not proven). ### How motions and votes differ 6. Motions and votes are EF Core entities in Postgres: `Motion` (`BifrostModels.cs:665-700`) and `Vote` (`:702-721`, with `VoteChoice For/Against/Abstain` at `:110-115`). -7. A vote is one per account per motion: there is a unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting a vote upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). -8. Votes are weighted. `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`). That weight is 0 unless the membership is `Active`, and otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). Patron tier is the voting weight. +7. A vote is one per account per motion: unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). +8. Votes are weighted: `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`), 0 unless the membership is `Active`, otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). 9. A motion has a category with an approval threshold, `ClosesAtUtc`, and a pass/fail resolution (`MotionGovernanceService.cs:95-131`, `MotionCategoryPolicy.cs`). It is a decision, not a standing rating. ### Who can act -10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). The motions page and its vote handler are at `Pages/App/Motions/Index.cshtml.cs:56`. -11. An account becomes `Active` in one of three ways: - - bootstrap admin GitHub logins (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`); - - admin approval on the Members page (`Pages/App/Members/Index.cshtml.cs:92-105`); - - a Heimdall member-access claim, meaning the Discord cult-member role or an active Patreon tier (`Features/Heimdall/HeimdallAuthService.cs:158,186`). - - GitHub sign-in with an invitation lands in `PendingApproval`, and without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). -12. Anyone can register natively, and doing so proves nothing. `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`). It takes an identity name and a display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). It asks for no credential, and there is no native sign-in route. A native account is a single cookie: as a voter it is indistinguishable from anonymous, and it is lost when the cookie expires. -13. No public read path exists: - - Anonymous routes are only health, readiness, auth, webhooks, the patron intake and the patronage checkout (`Program.cs:211-522`). - - The Eve governance surface requires `ActiveMember` (`Program.cs:473-488`). - - No CORS policy is configured anywhere in `src/Bifrost.Web` (grep for `Cors` returns nothing). +10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). Admin-only page handlers additionally check `Actor.CanManageMembership` and return `Forbid()` (`Pages/App/Members/Index.cshtml.cs:37-39,95-97`). +11. An account becomes `Active` by bootstrap admin GitHub login (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`), admin approval (`Pages/App/Members/Index.cshtml.cs:92-105`), or a Heimdall member-access claim (`Features/Heimdall/HeimdallAuthService.cs:158,186`). GitHub sign-in with an invitation lands in `PendingApproval`, without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). +12. Anyone can register natively, and doing so proves nothing: `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`), takes an identity name and display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). No credential, no native sign-in route. As a voter a native account is indistinguishable from anonymous. +13. No public read path exists: anonymous routes are only health, readiness, auth, webhooks, patron intake and patronage checkout (`Program.cs:211-522`); the Eve governance surface requires `ActiveMember` (`Program.cs:473-488`); no CORS and no rate limiter are configured (`git grep -i -E 'AddCors|UseCors|RateLimit|ForwardedHeaders' bf83a93 -- 'src/*.cs'` returns nothing). ### Deployed versus repo -14. The live release is `/srv/bifrost/app/releases/20260723-daedcc4`, and `bifrost.service` is active. I checked both read-only over SSH alias `yggdrasil` on 2026-10-08. `https://bifrost.gamecult.org/healthz` and `/readyz` both return 200. -15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on `main`. It exists only on `origin/codex/bifrost-migration-metadata`, which branched from `bd3a5a9`. Compared with `daedcc4`, `main` has `cde3a78`, which deletes 369 lines of HTTP bridge endpoints from `Program.cs`. `main` also lacks the migration-metadata attribute lines `daedcc4` added to seven migrations. Production therefore runs code that `main` has deleted, and `main` may still carry the EF migration-discovery bug that `daedcc4` fixed. -16. Deploys are manual. `gamecult-ops/scripts/deploy-bifrost-alpha.sh` creates a timestamped release directory, and Idunn does not manage Bifrost (`gamecult-ops/runbooks/bifrost-alpha-deploy.md:162-164`). The inventory entry (`gamecult-ops/inventory.md:941-952`) records no revision. +14. Live release `/srv/bifrost/app/releases/20260723-daedcc4` (`readlink -f /srv/bifrost/app/current`, 2026-10-10 02:58Z), `bifrost.service` active since 2026-08-21 10:51 UTC, `ExecStart=/usr/bin/dotnet /srv/bifrost/app/current/Bifrost.Web.dll`, `EnvironmentFiles=/srv/bifrost/env/bifrost.env`, `ASPNETCORE_ENVIRONMENT=Production`, listening on `127.0.0.1:5080`. Earlier releases are bare UTC timestamps (`20260610073418` … `20260618144854`); the `-daedcc4` suffix was added by hand, the deploy script does not write it (fact 16). +15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on main: `git branch -a --contains daedcc4` lists only `origin/codex/bifrost-migration-metadata`, which is `bd3a5a9` (an ancestor of main) plus `daedcc4` alone. `git merge-tree --write-tree bf83a93 origin/codex/bifrost-migration-metadata` is clean. Main has `cde3a78`, which deleted the HTTP bridge routes `/bridge/actions/*`, `/dispatch/runs/*`, `/transport/receipts`, `/governance/receipts` and `POST /eve/governance/commands` (369 lines of `Program.cs`); main's `PlatformEndpointsTests.Removed_http_bridge_routes_are_not_mapped` pins their absence. +16. Deploys are manual: `gamecult-ops/scripts/deploy-bifrost-alpha.sh` (33 lines, gamecult-ops `1e95674`; the deploy files are unchanged since `44889b1`) unpacks `/srv/bifrost/bifrost-publish.tar` into `releases/$(date -u +%Y%m%d%H%M%S)`, repoints `current`, restarts. It takes no revision and records none. `runbooks/bifrost-alpha-deploy.md` step 3 publishes with `dotnet publish` from the operator's checkout `F:\Projects\Bifrost` (`:101-115`), step 2 lists the env keys (`:63-89`). Idunn does not manage Bifrost (`:162-164`). The inventory's "Bifrost alpha live state" block (`gamecult-ops/inventory.md:923-933`) records no revision. +17. No caller of the routes `cde3a78` deleted, over the whole retained nginx access log (2026-09-26 00:46 to 2026-10-10 02:58, 473,581 lines, all vhosts): zero requests to `/bridge/actions`, `/dispatch/runs`, `/transport/receipts`, `/governance/receipts` or `/eve/governance/commands`. The only `/governance/*` hits were ten scanner GETs on 2026-10-07 (`/governance/providers`, `/governance/budgets`, …, all 400). No systemd unit, timer or crontab on Yggdrasil names the bridge tools or `127.0.0.1:5080` except `bifrost.service` itself, and no non-nginx connection to `:5080` was established at probe time. The bifrost journal holds 1 line in 30 days, so the app's own logs cannot witness traffic. + +### Data Protection on the live host + +18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. +19. The service's working directory resolves to the release directory: `readlink /proc//cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty). + +### Proxy and client address + +20. nginx sends `X-Real-IP`, `X-Forwarded-For` (`$proxy_add_x_forwarded_for`), `X-Forwarded-Proto` and `X-Forwarded-Host` (live `/etc/nginx/sites-enabled/bifrost*:12-19`), and proxies to `127.0.0.1:5080`. `Program.cs` never calls `UseForwardedHeaders` (fact 13 grep), so `RemoteIpAddress` is `127.0.0.1` for every request and any per-client limit puts every visitor in one bucket. + +### Code and test substrate + +21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key. +22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`). +23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`). +24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`). +25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools. ### The Mill page -17. The Mill is being built uncommitted in worktree `F:\Projects\AetheriaLore-wt-concept-mill`. It consists of `Aetheria/Game Design/Concept Mill.md` (397 lines, hand-authored HTML in Markdown), `Aetheria/media/concept-mill/` (41 files) and a `site/quartz/styles/custom.scss` change. -18. Each card is `
`, and there are 19 today (`Concept Mill.md:12` for the first, `bison`). The slug is already independent of the display name: for example, `vasuki2` is shown as "Vasuki". The ship roll links to `#`. -19. The site has a pattern for client script. A Quartz component sets `afterDOMLoaded` (`site/quartz/components/AetheriaEpubReader.tsx:110`) and re-runs on the `nav` event (`site/quartz/components/scripts/aetheriaEpubReader.inline.ts:228`). Deploys go through `.github/workflows/deploy-quartz.yml` to GitHub Pages. - -### Anonymous-voter substrate - -20. Bifrost has no anonymous actor. Every write path resolves a `CurrentBifrostActor` with a `UserAccount` and throws without one (`MotionGovernanceService.cs:18,54`). Anonymous votes therefore need a voter identity that is not a `UserAccount`. -21. Bifrost sees no client IP. nginx sends `X-Real-IP` and `X-Forwarded-For` (`gamecult-ops/nginx/bifrost.gamecult.org.conf:15-16`), but `Program.cs` never calls `UseForwardedHeaders`, so `RemoteIpAddress` is the loopback proxy for every request. Until that is fixed, any per-IP limit puts every visitor in one bucket. -22. Data Protection keys can be persisted (`Program.cs:113-117`, `DataProtection:KeysPath`). A device token signed with them would stay valid across restarts and releases, provided the live env sets the path. Cut 0 checks this. +26. The Mill is committed and merged: AetheriaLore `origin/main` `6980087` ("Merge concept-mill-spa"). `Aetheria/Game Design/Concept Mill.md` is now 10 lines of front matter and prose; the viewer is the Quartz component `site/quartz/components/AetheriaConceptMill.tsx` with `scripts/aetheriaConceptMill.inline.ts` (417 lines) and `styles/aetheriaConceptMill.scss`. Its data source is `scripts/concept-mill/ships.yaml` (581 lines); `node scripts/concept-mill/build.mjs` writes the manifest `site/quartz/components/data/conceptMill.json` (embedded in the page) and render derivatives, skipping derivatives that already exist unless `--force` (`build.mjs:51-60`), and loads `js-yaml` and `sharp` from the sibling `F:\Projects\GameCult-Quartz` checkout (`:22-25`). +27. Concept identity is the ship `id` in `ships.yaml`; there are 27 ships today (`bison vasuki squatter raft gentle-giant shellback lamp-shy zera nakovalnya oboz shchit glushitel key-light shepherd rake dine-and-dash party-favors finders-keepers tattletale ghost-fare odd-job one-pass bulldog breakup-song 50-f live-wire homestead`). The build refuses duplicate ship ids (`build.mjs:94-96`). Each ship has one or more render variants with their own ids; the URL hash is `#ship` or `#ship/variant` (`aetheriaConceptMill.inline.ts:3,178-192`). The 2026-10-08 "19 card slugs" are gone. +28. The vote slot already exists and is switched off. `ships.yaml:22-27` holds `votes: { enabled: false, endpoint: null, bifrostUrl: null }`, copied into the manifest (`conceptMill.json:16-19`). When enabled, the script fetches `endpoint` once with `credentials: "omit"`, reads `tallies[ship.id]` as `{ up, down, members?: { up, down } }`, shows `▲ up ▼ down` (members not shown), and links `${bifrostUrl}/${ship.id}` as "Vote on Bifrost" (`inline.ts:163-174,284-287`). On fetch failure the slot shows `—`. Tallies are per ship, not per render. +29. The Aetheria site's origin is `https://aetheria.gamecult.org` (`site/quartz.config.ts:66` `baseUrl: "aetheria.gamecult.org"`). ## Rulings in force -- **Who may vote:** anyone, including anonymous visitors. There is no membership gate. -- **Votes are public:** up and down counts are shown, so downvotes are visible. -- **Members are a filter, not a separate system.** Each vote records the voter's class at cast time (anonymous, account, or Active member). Member-only views are a filter over the same rows. +The rulings are typed state: `aetheria-release:ruling:concept-mill-votes-open`, `aetheria-release:ruling:concept-mill-votes-anonymous`, and `concept-mill-votes:ruling:public-means-counts` (Self's defaulted reading that "public" means counts split by voter class, no per-voter identity). -Derived from these rulings, not asked separately: +Derived from them, not asked separately: - **Counts are raw: one voter, one mark.** Patron weight cannot apply to anonymous voters, and the member filter replaces it as the "members' opinion" view. -- **Concept art stays out of governance.** Mill votes are their own record, not governance topics or motions. Topics are agent-dispatch threads, and motions are tier-weighted, deadline-closing decisions (facts 2, 8, 9). Neither can admit an anonymous voter (fact 20). -- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains the standing doctrine debt it already is. This feature does not pay it down. - -Interpretation for Self to confirm, not a fork: "votes are public" is read as public counts, split by voter class. The export carries no per-voter identities. Anonymous voters have none to show, and publishing account names was not asked for. +- **Concept art stays out of governance.** Mill votes are their own record, not topics or motions (facts 2, 8, 9). Neither can admit an anonymous voter. +- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains standing doctrine debt; this feature does not pay it down. ## Authority map -- **Concept identity.** Owner: the Mill page in AetheriaLore. The card `id` slug is the key (fact 18). Bifrost stores keys and never invents one. Renaming a ship changes its title, never its key. -- **Concept subjects.** Owner: a Bifrost `ConceptSubject` record: `(collection, key)` unique, plus `title`, `registeredAt` and `retiredAt`. Writer: one idempotent admin registration command, fed from the Mill's card list and run by the render-round agent. The vote path cannot create subjects, because otherwise anonymous visitors could mint keys. -- **Voter identity.** Owner: a Bifrost `VoterKey` resolver, the one place that answers "who is casting this?": - - signed in: `account:`; - - otherwise: `device:`, where the id comes from a signed, HttpOnly, first-party device-token cookie that Bifrost issues on the vote page. - - The `VoterKey` resolver is not an owner of membership status. It reads `CurrentBifrostActor.IsActiveMember` at cast time. -- **Votes.** Owner: a `ConceptAppraisal` record with these fields: - - `subjectId` and `voterKey`, with `(subjectId, voterKey)` unique; - - `stance` (`Up` or `Down`) and `castAt`; - - `voterClass` (`Anonymous`, `Account` or `Member`), stamped at cast; - - `userAccountId`, nullable; - - `networkTag`, an HMAC of the client's /24 (IPv4) or /64 (IPv6) prefix, stored for audit and purge. - - The one writer is `ConceptAppraisalService` (Cast, Change, Retract, and a Purge for admins). Rate limiting happens before the service, and uniqueness is enforced inside it and by the index. -- **Tallies.** Derived only. Per subject, per stance, per voter class, they are computed from rows and never stored as counters. The "all" and "members" views are filters over the same rows. -- **Public tally export.** This is the JSON xenos boundary. It is one anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject: - - no voter keys, account ids or network tags; - - CORS allows exactly `https://aetheria.gamecult.org`, `GET` only, without credentials; - - `Cache-Control` is about 60 s. - - The export owns nothing. -- **The Mill page.** Read only. One fetch fills each card's counts, and a "Vote on Bifrost" link goes to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. That means no `SameSite=None`, no cross-site POST and no exposure to browser third-party-cookie blocking. +- **Concept identity.** Owner: the Mill's data in AetheriaLore, `scripts/concept-mill/ships.yaml`, ship `id` (fact 27). Bifrost stores keys and never invents one. Renaming a ship changes its name, never its key. A render variant is not a subject. +- **Concept subjects.** Owner: Bifrost `ConceptSubject` (`(Collection, Key)` unique, plus `Title`, `RegisteredAtUtc`, `RetiredAtUtc`). One writer, `ConceptSubjectRegistry`, reached from one admin page that takes the Mill's generated manifest (the owner's artifact) and applies an idempotent plan: add, rename, retire-on-explicit-choice, revive. The vote path cannot create subjects. +- **Voter identity.** Owner: `VoterIdentity.For(actor, deviceId)`, a pure function, plus a `VoterKeyResolver` that only reads or mints the device cookie and calls it: + - signed in: key `account:`, class `Member` if `CurrentBifrostActor.IsActiveMember` at cast time, else `Account`; + - otherwise: key `device:`, class `Anonymous`, where the id is a random GUID protected by Data Protection in an HttpOnly, Secure, SameSite=Lax, first-party cookie on `bifrost.gamecult.org`. + The resolver does not own membership; it reads it. When the cookie is minted is the open question `concept-mill-votes:question:device-cookie-consent`. +- **Votes.** Owner: `ConceptAppraisal` rows: `SubjectId`, `VoterKey` (`(SubjectId, VoterKey)` unique), `Stance` (`Up`/`Down`), `VoterClass` stamped at cast, `UserAccountId` nullable, `NetworkTag` and `CastAtUtc`. One writer, `ConceptAppraisalService` (Cast, Retract, Purge, plus read-only Tally and subject view). Rate limiting stands in front of it; uniqueness is enforced inside it and by the unique index, with a unique-violation on insert retried as a stance change. +- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists. +- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters. +- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing. +- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. +- **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key. +- **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path. - **Demotions:** - Governance topic support and objection are not vote owners. - `Vote.Weight` and patron tiers stay governance-only. - The static page owns no count. - - The client IP is not an identity: it is a rate-limit bucket and an audit tag, never a vote key. + - The client IP is not an identity. + - The content-root path is no longer the Data Protection discriminator; the fixed application name is. + - The `HOME`-default key ring is no longer an owner; the configured `DataProtection:KeysPath` is (pointed at the same directory, so existing keys are retained). ## Anti-stuffing defence (implementation duty) -Prior art on lightweight anonymous voting, from memory and not freshly searched: +Prior art: `docs/research/anonymous-vote-stuffing-prior-art.md` (Eyes, 2026-10-10, Bifrost `bf83a93`). It replaces the from-memory list this section used to carry. Its section "Where the current design's assumptions are contradicted" (`:92-98`) changed the design as follows: -- Strawpoll-style polls offer duplicate checks by IP, by browser cookie, or by sign-in. IP-only checks wrongly block shared networks, and cookie-only checks are reset by a private window. -- Proof-of-work challenges (Hashcash; today ALTCHA, mCaptcha, Friendly Captcha) make each vote cost CPU without any third party. They slow bulk scripts and barely touch a determined attacker with hardware. -- Third-party challenges (Cloudflare Turnstile, reCAPTCHA) are strong. They also send visitor data to an outside service and add an external dependency to the vote path. -- Large sites rely on after-the-fact detection more than on gates: they look for clusters by network, timing and new devices, then discount or purge. +1. **Device token: one vote per device per concept.** Kept. Its one-year life depends on persisted, retained keys and a stable discriminator (note `:65-69,95`; facts 18-19), so Cut 1 makes both configuration. Whether the cookie needs a notice or consent is the operator's question `device-cookie-consent` (note `:74-77,97`); the recommended option mints it only when a visitor casts a vote, with a one-line notice. +2. **Network rate limits.** Kept, in-box ASP.NET Core rate limiter, partitioned by `NetworkPrefix` for anonymous voters and by account when signed in. The prefix lengths are configuration, not design: the note found no evidence for a /24 IPv4 bucket, and per-/64 IPv6 buckets are spread across by delegated prefixes and privacy rotation while larger buckets catch mobile users (`:57-61,96`). Hands proposes defaults and limits; Soul probes them. Partition keys come only from `RemoteIpAddress` after loopback-only forwarded headers or from an account id, never from raw client input (note `:45`). +3. **Audit and purge.** Kept. The note confirms large sites rely on after-the-fact detection and also documents their false positives and unpublished thresholds (`:14-16,98`); Purge leaves an audit event so a correction is itself reviewable. +4. **Escalation, not built now:** proof of work. ALTCHA has no official .NET server, only community packages that need a production replay store (`:27,94`), and PoW is weak against determined attackers (`:34-37`). Not specified until audit shows stuffing. Turnstile and reCAPTCHA stay refused (third party on the vote path). -Recommendation: a layered defence that lives entirely inside Bifrost, with proof-of-work held back as an escalation. +Vote fuzzing and voter-history weighting (note `:98`) are not adopted: raw public counts are the ruling. -1. **Device token: one vote per device per concept.** On the first vote-page visit, Bifrost mints a random device id, signs it with Data Protection, and sets it as `bifrost_voter`: HttpOnly, Secure, SameSite=Lax, valid for one year. The vote key is `device:`. Clearing cookies resets it, and that is accepted and bounded by layer 2. -2. **Network rate limits.** Configure `UseForwardedHeaders`, trusting only loopback (fact 21 makes this a prerequisite). Then put the in-box ASP.NET Core rate limiter (`Microsoft.AspNetCore.RateLimiting`) on two paths, partitioned by network prefix: - - token minting: a small number of new devices per prefix per hour; - - vote casts: a modest number per prefix per minute. +Where it lives: Bifrost web code only. nginx gets nothing new (`limit_req` would be a second, unobservable owner of the same budget). The static site gets nothing. - Signed-in accounts are partitioned by account, not by network. The limits are configuration values, and the first values are Hands' to propose and Soul's to probe. -3. **Audit and purge.** Every anonymous row carries `networkTag` and `castAt`. An admin view lists subjects whose recent votes cluster on one tag. A Purge command removes rows by tag and time window, leaving an audit event. Public counts can then be corrected after the fact, without a gate that blocks honest shared networks. -4. **Escalation, not built now:** a self-hosted proof-of-work challenge on token minting, using an ALTCHA-style library served from Bifrost. It is switched on by configuration only if layer 3 shows stuffing. It needs no third party, so votes stay Bifrost-owned. Turnstile and reCAPTCHA are refused, because they add an outside owner to the vote path. +## Cut order and rationale -Where it lives: +The cut specs are typed state (`concept-mill-votes:cut_spec:cut-