#!/usr/bin/env node // End-to-end governance simulation against the LIVE deployment. // // RADAR_PASSWORD=... node scripts/e2e/governance-sim.mjs // // Env: API_URL (default https://api.occupymedia.cc), MASTODON_URL // (default https://social.occupymedia.cc), RADAR_EMAIL, RADAR_PASSWORD // (Mastodon web sign-in, used only to click "Authorize" in the real OAuth // consent flow), RADAR_TOKEN (else read over ssh), REPORT // (default /tmp/opencode/workers/GOV-SIM-REPORT.md). // // All created data is prefixed "e2e-gov-". Re-runnable: existing users, // memberships and the Mastodon connection are reused. import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; const API = (process.env.API_URL ?? "https://api.occupymedia.cc").replace(/\/$/, ""); const MASTO = (process.env.MASTODON_URL ?? "https://social.occupymedia.cc").replace(/\/$/, ""); const REPORT = process.env.REPORT ?? "/tmp/opencode/workers/GOV-SIM-REPORT.md"; const PASSWORD = "GovSim!2026x"; const RUN = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15); const TAG = "acampada26s"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const log = (...a) => console.log(`[${new Date().toISOString().slice(11, 19)}]`, ...a); // --------------------------------------------------------------------------- // Report bookkeeping // --------------------------------------------------------------------------- const calls = []; // per-section API call log const sections = []; const gaps = []; const deviations = []; let current = null; function section(name, meta = {}) { current = { name, ...meta, calls: [], checks: [], notes: [] }; sections.push(current); return current; } function check(label, expected, actual, body) { const pass = JSON.stringify(expected) === JSON.stringify(actual); current.checks.push({ label, expected, actual, pass, ...(pass ? {} : { body }) }); log(`${pass ? "PASS" : "FAIL"} ${current.name} — ${label}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`); return pass; } const note = (text) => { current?.notes.push(text); log(` note: ${text}`); }; const gap = (text) => { if (!gaps.includes(text)) gaps.push(text); }; // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- function summarize(body) { if (body === undefined || body === null) return ""; const keys = ["username", "workspaceName", "role", "provider", "instanceUrl", "draftId", "kind", "ballot", "connectionId", "scope", "accountId"]; const out = {}; for (const k of keys) if (body[k] !== undefined) out[k] = body[k]; if (body.rule) out.rule = body.rule; if (body.policy) out.policy = body.policy; if (body.post) out.post = { text: String(body.post.text).slice(0, 40) + "…", providerMetadata: body.post.providerMetadata }; return JSON.stringify(out); } async function api(method, path, opts = {}) { const r = await apiOnce(method, path, opts); // Membership/role writes bump the workspace authorization epoch: re-mint // the caller's session once and retry. if (r.status === 401 && /epoch stale/.test(JSON.stringify(r.data)) && opts.as && ctx.workspaceId) { const key = opts.as === "owner" ? "owner" : opts.as.replace(/^e2e-gov-/, ""); const name = key === "owner" ? "e2e-gov-owner" : `e2e-gov-${key}`; ctx.tokens[key] = (await apiOnce("POST", "/auth/login", { body: { username: name, password: PASSWORD, workspaceId: ctx.workspaceId }, as: name })).data.token; return apiOnce(method, path, { ...opts, token: ctx.tokens[key] }); } if ((opts.ok ?? true) && r.status >= 400) { const err = new Error(`${method} ${path} -> ${r.status}: ${JSON.stringify(r.data).slice(0, 500)}`); err.status = r.status; err.data = r.data; throw err; } return r; } async function apiOnce(method, path, { token, body, as } = {}) { const res = await fetch(`${API}${path}`, { method, headers: { ...(body === undefined ? {} : { "content-type": "application/json" }), ...(token ? { authorization: `Bearer ${token}` } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), redirect: "manual", }); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; } const entry = { method, path, as, status: res.status, req: summarize(body) }; (current ? current.calls : calls).push(entry); return { status: res.status, data, headers: res.headers }; } function radarToken() { if (process.env.RADAR_TOKEN) return process.env.RADAR_TOKEN.trim(); return execFileSync("ssh", ["-i", `${process.env.HOME}/.ssh/devserver`, "root@192.168.13.22", "pct exec 150 -- cat /root/.occ-mastodon/radar_token"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } async function masto(token, path) { const res = await fetch(`${MASTO}${path}`, { headers: { authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`mastodon ${path} -> ${res.status}`); return res.json(); } // Minimal cookie jar for the Mastodon web consent flow. class Jar { constructor() { this.c = new Map(); } take(res) { for (const line of res.headers.getSetCookie?.() ?? []) { const [pair] = line.split(";"); const i = pair.indexOf("="); this.c.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()); } } header() { return [...this.c].map(([k, v]) => `${k}=${v}`).join("; "); } } const hidden = (html) => Object.fromEntries([...html.matchAll(/]*>/g)].map((m) => m[0]) .filter((t) => /type=["']hidden["']/.test(t)) .map((t) => [t.match(/name=["']([^"']+)["']/)?.[1], (t.match(/value=["']([^"']*)["']/)?.[1] ?? "").replace(/&/g, "&")]) .filter(([k]) => k)); // --------------------------------------------------------------------------- // Identity helpers // --------------------------------------------------------------------------- async function registerOrLogin(username, displayName, workspaceName) { const body = { username, password: PASSWORD, displayName, ...(workspaceName ? { workspaceName } : {}) }; const reg = await api("POST", "/auth/register", { body, ok: false, as: username }); if (reg.status < 300) return { ...reg.data, reused: false }; if (reg.status !== 409) throw new Error(`register ${username}: ${reg.status} ${JSON.stringify(reg.data)}`); const login = await api("POST", "/auth/login", { body: { username, password: PASSWORD }, as: username }); return { ...login.data, reused: true }; } async function loginTo(username, workspaceId) { return (await api("POST", "/auth/login", { body: { username, password: PASSWORD, workspaceId }, as: username })).data; } const wsOf = (me) => me?.workspace?.id ?? me?.workspaceId ?? me?.activeWorkspace?.id; const uidOf = (me) => me?.user?.id ?? me?.userId ?? me?.id; // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const ctx = { users: {}, tokens: {} }; async function setup() { section("Setup: identities, membership, roles"); const owner = await registerOrLogin("e2e-gov-owner", "GovSim Owner", "GovSim Collective"); ctx.workspaceId = wsOf(owner.me); ctx.ownerId = uidOf(owner.me); ctx.tokens.owner = owner.token; note(`owner ${owner.reused ? "reused (login)" : "registered"}; workspace ${ctx.workspaceId}; me keys: ${Object.keys(owner.me ?? {}).join(",")}`); if (!ctx.workspaceId) throw new Error(`cannot find workspaceId in me: ${JSON.stringify(owner.me)}`); const members = async () => (await api("GET", "/team/members", { token: ctx.tokens.owner, as: "owner" })).data; let roster = await members(); const list = Array.isArray(roster) ? roster : roster.members ?? []; note(`member row keys: ${Object.keys(list[0] ?? {}).join(",")}`); let link = null; for (let i = 1; i <= 9; i++) { const name = `e2e-gov-u${i}`; const solo = await registerOrLogin(name, `GovSim U${i}`); const uid = uidOf(solo.me); ctx.users[`u${i}`] = { name, id: uid }; const already = list.find((m) => (m.userId ?? m.user_id ?? m.user?.id) === uid || m.username === name); if (!already) { link ??= (await api("POST", "/team/invite-links", { token: ctx.tokens.owner, as: "owner", body: { role: "Editor", expiresInHours: 1, maxUses: 9 } })).data; const claim = await api("POST", `/invites/${encodeURIComponent(link.token)}/claim`, { token: solo.token, as: name, body: {}, ok: false }); if (claim.status >= 300 && claim.status !== 409) throw new Error(`claim ${name}: ${claim.status} ${JSON.stringify(claim.data)}`); } ctx.tokens[`u${i}`] = (await loginTo(name, ctx.workspaceId)).token; } roster = await members(); const all = Array.isArray(roster) ? roster : roster.members ?? []; const find = (u) => all.find((m) => (m.userId ?? m.user_id ?? m.user?.id) === u.id || m.username === u.name); for (const k of ["u1", "u2", "u3"]) { const m = find(ctx.users[k]); if (!m) throw new Error(`${k} not a member after claim`); if (m.role !== "Admin") await api("PATCH", `/team/members/${m.id ?? m.membershipId}/role`, { token: ctx.tokens.owner, as: "owner", body: { role: "Admin" } }); } // Role changes bump the membership epoch: re-mint sessions. for (let i = 1; i <= 9; i++) ctx.tokens[`u${i}`] = (await loginTo(`e2e-gov-u${i}`, ctx.workspaceId)).token; ctx.tokens.owner = (await loginTo("e2e-gov-owner", ctx.workspaceId)).token; const finalRoster = await members(); const fr = Array.isArray(finalRoster) ? finalRoster : finalRoster.members ?? []; const roles = Object.fromEntries(Object.entries(ctx.users).map(([k, u]) => [k, find.call(null, u) && fr.find((m) => (m.userId ?? m.user_id ?? m.user?.id) === u.id || m.username === u.name)?.role])); check("u1..u3 Admin, u4..u9 Editor (chatgov.vote via migration 024)", { u1: "Admin", u2: "Admin", u3: "Admin", u4: "Editor", u5: "Editor", u6: "Editor", u7: "Editor", u8: "Editor", u9: "Editor" }, roles, fr); check("active member count (owner + 9)", 10, fr.filter((m) => (m.status ?? "active") === "active").length, fr); } async function connectMastodon() { section("Setup: Mastodon connection (OAuth authorization-code flow)"); const existing = (await api("GET", "/connections", { token: ctx.tokens.owner, as: "owner" })).data; const conns = Array.isArray(existing) ? existing : existing.connections ?? []; note(`connection row keys: ${Object.keys(conns[0] ?? {}).join(",")}`); const live = conns.find((c) => c.provider === "mastodon" && !c.disconnectedAt && !c.disconnected_at); if (live) { ctx.connectionId = live.id; note(`reusing mastodon connection ${live.id}`); return; } gap("No route accepts a pre-issued Mastodon access_token (POST /connections only has /authorize → OAuth consent → /connections/callback, plus telegram/bot-token). The sim drives the real consent flow as radar instead."); const auth = await api("POST", "/connections/authorize", { token: ctx.tokens.owner, as: "owner", body: { provider: "mastodon", instanceUrl: MASTO } }); const apiJar = new Jar(); apiJar.take({ headers: auth.headers }); const redirectUrl = auth.data.redirectUrl; if (!process.env.RADAR_PASSWORD) throw new Error("RADAR_PASSWORD is required to approve the OAuth consent as radar"); const jar = new Jar(); const get = async (url) => { const r = await fetch(url, { headers: { cookie: jar.header() }, redirect: "manual" }); jar.take(r); return r; }; const post = async (url, form) => { const r = await fetch(url, { method: "POST", headers: { cookie: jar.header(), "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form), redirect: "manual" }); jar.take(r); return r; }; const signIn = await (await get(`${MASTO}/auth/sign_in`)).text(); const csrf = signIn.match(/name="csrf-token" content="([^"]+)"/)?.[1] ?? hidden(signIn).authenticity_token; const s = await post(`${MASTO}/auth/sign_in`, { authenticity_token: csrf, "user[email]": process.env.RADAR_EMAIL ?? "radar@occupymedia.cc", "user[password]": process.env.RADAR_PASSWORD }); current.calls.push({ method: "POST", path: `${MASTO}/auth/sign_in`, status: s.status, req: "radar web sign-in" }); if (s.status !== 302 && s.status !== 303) throw new Error(`mastodon sign-in failed: ${s.status}`); let r = await get(redirectUrl); current.calls.push({ method: "GET", path: `${MASTO}/oauth/authorize`, status: r.status, req: "consent page" }); let callback = r.headers.get("location"); if (r.status === 200) { const html = await r.text(); const form = html.split(/
/action="\/oauth\/authorize"/.test(f) && !/name="_method" value="delete"/.test(f)); if (!form) throw new Error("consent form not found"); r = await post(`${MASTO}/oauth/authorize`, hidden(form)); current.calls.push({ method: "POST", path: `${MASTO}/oauth/authorize`, status: r.status, req: "approve consent" }); callback = r.headers.get("location"); } if (!callback?.startsWith(`${API}/connections/callback`)) throw new Error(`unexpected consent redirect: ${r.status} ${callback}`); const cb = await fetch(callback, { headers: { cookie: apiJar.header() }, redirect: "manual" }); const landing = cb.headers.get("location") ?? ""; current.calls.push({ method: "GET", path: "/connections/callback?code&state", status: cb.status, req: `→ ${landing.replace(/\?.*/, "?…")}` }); note(`callback landing: ${landing}`); const after = (await api("GET", "/connections", { token: ctx.tokens.owner, as: "owner" })).data; const list = Array.isArray(after) ? after : after.connections ?? []; const conn = list.find((c) => c.provider === "mastodon" && !c.disconnectedAt && !c.disconnected_at); check("mastodon connection exists after OAuth", true, conn !== undefined, after); if (!conn) throw new Error("mastodon connection was not created"); ctx.connectionId = conn.id; } async function sources() { section("Setup: source list (#acampada26s as radar)"); ctx.radar = radarToken(); ctx.radarAccount = await masto(ctx.radar, "/api/v1/accounts/verify_credentials"); const tl = await masto(ctx.radar, `/api/v1/timelines/tag/${TAG}?limit=20`); current.calls.push({ method: "GET", path: `${MASTO}/api/v1/timelines/tag/${TAG}`, status: 200, req: "radar token" }); ctx.sources = tl.map((s) => ({ id: s.id, url: s.url, acct: s.account.acct, text: s.content.replace(/<[^>]+>/g, "").slice(0, 80) })); note(`${ctx.sources.length} source posts: ${ctx.sources.slice(0, 8).map((s) => s.id).join(", ")}`); check("hashtag timeline has source posts", true, ctx.sources.length > 0, tl); } // One governed action: draft (reply to a source post) → submit → poll → votes. async function governedDraft(id, source) { const marker = `e2e-gov-${RUN}-${id}`; const post = { text: `[${marker}] Governance sim ${id}: re ${source.url} #${TAG}`, mediaAssetIds: [], visibility: "unlisted", providerMetadata: { connectionId: ctx.connectionId, inReplyToId: source.id }, }; const draft = (await api("POST", "/publishing/drafts", { token: ctx.tokens.owner, as: "owner", body: { post } })).data; await api("POST", `/publishing/drafts/${draft.id}/submit`, { token: ctx.tokens.owner, as: "owner" }); return { draft, marker }; } async function openPoll(draftId, rule, question) { return (await api("POST", "/chatgov/polls", { token: ctx.tokens.owner, as: "owner", body: { draftId, kind: "yes_no", rule, question } })).data; } async function vote(user, pollId, ballot) { return (await api("POST", `/chatgov/polls/${pollId}/vote`, { token: ctx.tokens[user], as: user, body: { ballot } })).data; } async function pollCard(pollId) { return (await api("GET", `/chatgov/polls/${pollId}`, { token: ctx.tokens.owner, as: "owner" })).data; } async function waitClosed(pollId, closesAt) { const wait = Math.max(0, Date.parse(closesAt) - Date.now()) + 1500; log(` waiting ${Math.round(wait / 1000)}s for timebox`); await sleep(wait); return pollCard(pollId); } async function draftState(draftId) { const list = (await api("GET", "/publishing/drafts?limit=100", { token: ctx.tokens.owner, as: "owner" })).data; return (Array.isArray(list) ? list : list.drafts ?? []).find((d) => d.id === draftId)?.state ?? "unknown"; } async function findOnMastodon(marker, timeoutMs = 90_000) { const until = Date.now() + timeoutMs; while (Date.now() < until) { const statuses = await masto(ctx.radar, `/api/v1/accounts/${ctx.radarAccount.id}/statuses?limit=40`); const hit = statuses.find((s) => s.content.includes(marker)); if (hit) return hit; await sleep(3000); } return null; } // After an approval: dispatch, then confirm the status landed as a reply. async function executeAndVerify(draftId, marker, source) { const pub = await api("POST", `/publishing/drafts/${draftId}/publish`, { token: ctx.tokens.owner, as: "owner", body: { connectionId: ctx.connectionId }, ok: false }); check("publish accepted (202)", 202, pub.status, pub.data); if (pub.status !== 202) return; const hit = await findOnMastodon(marker); check("status appeared on Mastodon", true, hit !== null, null); if (hit) { check("status is a reply to the source post", source.id, hit.in_reply_to_id, hit); note(`Mastodon status ${hit.url}`); } } // After a rejection: dispatch must be refused and nothing may appear. async function refuseAndVerify(draftId, marker) { const pub = await api("POST", `/publishing/drafts/${draftId}/publish`, { token: ctx.tokens.owner, as: "owner", body: { connectionId: ctx.connectionId }, ok: false }); check("publish refused for non-approved draft (4xx)", true, pub.status >= 400 && pub.status < 500, pub.data); const hit = await findOnMastodon(marker, 8000); check("nothing posted on Mastodon", null, hit?.url ?? null, hit); } function evalState(cardData) { return { state: cardData.poll.state, reason: cardData.poll.close_reason ?? cardData.evaluation?.reason ?? null }; } const TB = (duration, onExpire) => ({ duration, onExpire }); const SCENARIOS = [ { id: "S1", title: "admin_only, PT10S reject — u1 yes", rule: { mode: "admin_only", timebox: TB("PT10S", "reject"), minDwellSeconds: 0 }, votes: [["u1", "yes"]], expect: { state: "passed", reason: "quorum_early" }, draft: "approved" }, { id: "S2", title: "admin_only, PT10S reject — nobody votes", rule: { mode: "admin_only", timebox: TB("PT10S", "reject"), minDwellSeconds: 0 }, votes: [], wait: true, expect: { state: "failed", reason: "timebox" }, draft: "rejected" }, { id: "S3", title: "quorum [{admins,x:2}] any, PT15S reject — u1+u2 yes", rule: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], join: "any", timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, votes: [["u1", "yes"], ["u2", "yes"]], expect: { state: "passed", reason: "quorum_early" }, draft: "approved" }, { id: "S4", title: "quorum [{admins,x:3}] all, PT15S reject — u1,u2 yes, u3 no", rule: { mode: "quorum", arms: [{ kind: "admins", x: 3 }], join: "all", timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, votes: [["u1", "yes"], ["u2", "yes"], ["u3", "no"]], wait: true, expect: { state: "failed", reason: "timebox" }, draft: "rejected", why: "A 'no' never closes a poll early (ballots can be recast). With possible admins = 3 (Owner is the draft author and excluded by allowSelfApproval=false), quorum stays reachable, so the poll fails only at the timebox." }, { id: "S5", title: "quorum [{admins,x:3}] all, PT15S reject — u1,u2,u3 yes", rule: { mode: "quorum", arms: [{ kind: "admins", x: 3 }], join: "all", timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, votes: [["u1", "yes"], ["u2", "yes"], ["u3", "yes"]], expect: { state: "passed", reason: "quorum_early" }, draft: "approved" }, { id: "S6", title: "quorum [{admins,x:1}] + allowVeto, PT15S reject — u1 yes, then u4 veto (spec order)", rule: { mode: "quorum", arms: [{ kind: "admins", x: 1 }], allowVeto: true, timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, votes: [["u1", "yes"], ["u4", "veto"]], specExpect: { state: "vetoed", reason: "veto" }, expect: { state: "passed", reason: "quorum_early" }, draft: "approved", why: "poll-evaluator.ts: with minDwellSeconds=0, u1's yes satisfies the arm and closes the poll 'passed' (quorum_early) inside the same vote transaction; u4's later veto hits a closed poll and is a no-op (PollService.vote returns the frozen card). The spec's 'vetoed' expectation is unreachable in this order." }, { id: "S6b", title: "quorum [{admins,x:1}] + allowVeto, PT15S reject — u4 veto first, then u1 yes", rule: { mode: "quorum", arms: [{ kind: "admins", x: 1 }], allowVeto: true, timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, votes: [["u4", "veto"], ["u1", "yes"]], expect: { state: "vetoed", reason: "veto" }, draft: "rejected", why: "Veto is checked before quorum, so a counted veto (u4 = Editor, holds chatgov.vote) closes the poll 'vetoed' immediately." }, { id: "S8", title: "quorum [{participants,x:4,of:eligible}], PT10S approve — only u4,u5 vote yes", rule: { mode: "quorum", arms: [{ kind: "participants", x: 4, of: "eligible" }], timebox: TB("PT10S", "approve"), minDwellSeconds: 0 }, votes: [["u4", "yes"], ["u5", "yes"]], wait: true, expect: { state: "failed", reason: "timebox" }, draft: "rejected", why: "poll-evaluator.ts line `state = quorum && choice && onExpire === 'approve' ? 'passed' : 'failed'`: onExpire=approve only approves at expiry when quorum IS met; it never substitutes for quorum. Possible eligible = 9 ≥ 4, so it is not quorum_unreachable early. Expected: failed / timebox (gate reason TIMEBOX_EXPIRED_AUTO_REJECT)." }, ]; async function runScenario(sc, sourceIdx) { section(`${sc.id} — ${sc.title}`, { rule: sc.rule, why: sc.why }); const source = ctx.sources[sourceIdx % ctx.sources.length]; const { draft, marker } = await governedDraft(sc.id, source); note(`draft ${draft.id} → reply to ${source.url}`); let card = await openPoll(draft.id, sc.rule, `${marker}: approve reply to ${source.id}?`); note(`poll ${card.poll.id} closes_at ${new Date(card.poll.closes_at).toISOString()}`); for (const [user, ballot] of sc.votes) { const res = await api("POST", `/chatgov/polls/${card.poll.id}/vote`, { token: ctx.tokens[user], as: user, body: { ballot }, ok: false }); if (res.status >= 400) note(`vote ${user}=${ballot} → ${res.status} ${JSON.stringify(res.data)}`); else { card = res.data; note(`after ${user}=${ballot}: state=${card.poll.state} arms=${JSON.stringify(card.evaluation.arms.map((a) => `${a.kind}:${a.got}/${a.needed}`))}`); } } if (sc.wait && card.poll.state === "open") card = await waitClosed(card.poll.id, card.poll.closes_at); else card = await pollCard(card.poll.id); const actual = evalState(card); if (sc.specExpect) check("poll state vs ORIGINAL SPEC", sc.specExpect, actual, card); check("poll state/reason (per poll-evaluator.ts)", sc.expect, actual, card); const counted = card.votes.map((v) => `${v.voter_handle}:${v.ballot}:${v.counted ? "counted" : v.ineligible_reason}`); note(`ballots: ${counted.join(", ")}`); check("draft state after E2", sc.draft, await draftState(draft.id), null); const gates = (await api("GET", `/publishing/drafts/${draft.id}/gates`, { token: ctx.tokens.owner, as: "owner" })).data; const e2 = gates.find((g) => g.gate === "E2"); check("E2 gate decision recorded", sc.draft === "approved" ? "fired" : "blocked", e2?.outcome ?? null, gates); if (e2) note(`E2 reasonCodes ${JSON.stringify(e2.reasonCodes)} policyVersion ${e2.policyVersion}`); if (sc.draft === "approved") await executeAndVerify(draft.id, marker, source); else await refuseAndVerify(draft.id, marker); } async function runImmediate(sourceIdx) { section("S7 — immediate mode: action executes without any poll", { why: "Immediate is a compiled destination policy (POST /chatgov/rules). draftPollRule() drops immediate rules, so submit opens no poll; E1 fires on approve. The draft state machine still requires editing→in_review→approved before dispatch.", }); const rules = (await api("GET", `/chatgov/rules?accountId=${ctx.connectionId}`, { token: ctx.tokens.owner, as: "owner" })).data; let rule = (Array.isArray(rules) ? rules : []).find((r) => r.scope === "account" && !r.supersededBy); if (!rule) rule = (await api("POST", "/chatgov/rules", { token: ctx.tokens.owner, as: "owner", body: { scope: "account", accountId: ctx.connectionId, policy: { mode: "immediate" } } })).data; ctx.ruleId = rule.id; try { const source = ctx.sources[sourceIdx % ctx.sources.length]; const { draft, marker } = await governedDraft("S7", source); const gatesBefore = (await api("GET", `/publishing/drafts/${draft.id}/gates`, { token: ctx.tokens.owner, as: "owner" })).data; check("no E2 (poll) decision exists after submit", 0, gatesBefore.filter((g) => g.gate === "E2").length, gatesBefore); const approve = await api("POST", `/publishing/drafts/${draft.id}/approve`, { token: ctx.tokens.owner, as: "owner", ok: false }); check("approve succeeds without a poll", true, approve.status < 300, approve.data); const gates = (await api("GET", `/publishing/drafts/${draft.id}/gates`, { token: ctx.tokens.owner, as: "owner" })).data; const e1 = gates.find((g) => g.gate === "E1"); check("E1 fired (immediate policy)", "fired", e1?.outcome ?? null, gates); check("still no poll/E2 for the draft", 0, gates.filter((g) => g.gate === "E2").length, gates); check("draft state", "approved", await draftState(draft.id), null); await executeAndVerify(draft.id, marker, source); } finally { // Leave the workspace without live posting_rules (opt-out of chatgov E1). await api("DELETE", `/chatgov/rules/${ctx.ruleId}`, { token: ctx.tokens.owner, as: "owner", ok: false }); } } // S9 — the governed boost: approved draft + the amplify route = a real reblog. async function runBoost(sourceIdx) { section("S9 — governed boost: approved draft + amplify route executes a reblog", { why: "POST /publishing/drafts/:draftId/amplify { connectionId, kind: 'repost' }: the governance gate is the draft state (approved only) and the target is providerMetadata.sourceRemoteRef recorded at creation — never a caller-supplied id.", }); const source = ctx.sources[sourceIdx % ctx.sources.length]; const before = await masto(ctx.radar, `/api/v1/statuses/${source.id}`); const reblogsBefore = before.reblogs_count ?? 0; note(`source ${source.id} reblogs_before ${reblogsBefore}`); // The immediate rule (re-created; runImmediate's finally removes it) makes // approve fire E1 directly, so the draft reaches 'approved' without a poll. const rules = (await api("GET", `/chatgov/rules?accountId=${ctx.connectionId}`, { token: ctx.tokens.owner, as: "owner" })).data; let rule = (Array.isArray(rules) ? rules : []).find((r) => r.scope === "account" && !r.supersededBy); if (!rule) rule = (await api("POST", "/chatgov/rules", { token: ctx.tokens.owner, as: "owner", body: { scope: "account", accountId: ctx.connectionId, policy: { mode: "immediate" } } })).data; ctx.ruleId = rule.id; const draft = await api("POST", "/publishing/drafts", { token: ctx.tokens.owner, as: "owner", body: { post: { text: `[e2e-gov-${RUN}-S9] Boost proposal for ${source.url} #${TAG}`, mediaAssetIds: [], visibility: "provider_default", providerMetadata: { connectionId: ctx.connectionId, sourceRemoteRef: source.id } } }, }); const draftId = draft.data.id; note(`boost draft ${draftId}`); await api("POST", `/publishing/drafts/${draftId}/submit`, { token: ctx.tokens.owner, as: "owner" }); const approve = await api("POST", `/publishing/drafts/${draftId}/approve`, { token: ctx.tokens.owner, as: "owner", ok: false }); check("approve succeeds (immediate rule)", true, approve.status < 300, approve.data); const amp = await api("POST", `/publishing/drafts/${draftId}/amplify`, { token: ctx.tokens.owner, as: "owner", body: { connectionId: ctx.connectionId, kind: "repost" }, }); check("amplify accepted (200)", 200, amp.status, amp.data); const after = await masto(ctx.radar, `/api/v1/statuses/${source.id}`); check("source reblogged", true, after.reblogged === true || (after.reblogs_count ?? 0) > reblogsBefore, after); note(`reblogs ${reblogsBefore} → ${after.reblogs_count ?? "?"}`); } function writeReport(fatal) { const lines = []; const scen = sections.filter((s) => /^S\d/.test(s.name)); const passCount = scen.filter((s) => s.checks.length && s.checks.every((c) => c.pass)).length; lines.push(`# Governance simulation report — ${new Date().toISOString()}`, ""); lines.push(`- API: ${API}`, `- Mastodon: ${MASTO} (acting account @${ctx.radarAccount?.acct ?? "radar"})`, `- Run id: ${RUN} (status markers \`e2e-gov-${RUN}-Sx\`)`, `- Workspace: ${ctx.workspaceId ?? "?"} ("GovSim Collective"), connection ${ctx.connectionId ?? "?"}`, `- Scenarios fully passing: ${passCount}/${scen.length}`, ""); if (fatal) lines.push(`**FATAL:** ${fatal}`, ""); lines.push("## Summary", "", "| Section | Checks | Result |", "|---|---|---|"); for (const s of sections) { const failed = s.checks.filter((c) => !c.pass).length; lines.push(`| ${s.name} | ${s.checks.length - failed}/${s.checks.length} | ${s.checks.length === 0 ? "—" : failed ? "**FAIL**" : "PASS"} |`); } lines.push(""); for (const s of sections) { lines.push(`## ${s.name}`, ""); if (s.rule) lines.push("Policy rule:", "```json", JSON.stringify(s.rule), "```"); if (s.why) lines.push(`Expected-state rationale: ${s.why}`, ""); lines.push("API calls:", ""); for (const c of s.calls) lines.push(`- \`${c.method} ${c.path}\` → ${c.status}${c.as ? ` (as ${c.as})` : ""}${c.req ? ` ${c.req}` : ""}`); lines.push("", "| Check | Expected | Actual | Result |", "|---|---|---|---|"); for (const c of s.checks) lines.push(`| ${c.label} | \`${JSON.stringify(c.expected)}\` | \`${JSON.stringify(c.actual)}\` | ${c.pass ? "PASS" : "**FAIL**"} |`); for (const c of s.checks.filter((x) => !x.pass && x.body !== undefined && x.body !== null)) { lines.push("", `Response body for failed check "${c.label}":`, "```json", JSON.stringify(c.body, null, 2).slice(0, 6000), "```"); } if (s.notes.length) lines.push("", "Notes:", ...s.notes.map((n) => `- ${n}`)); lines.push(""); } lines.push("## GAPS (missing routes / features)", "", ...gaps.map((g) => `- ${g}`), ""); lines.push("## Deviations from the task spec", "", ...deviations.map((d) => `- ${d}`), ""); mkdirSync(dirname(REPORT), { recursive: true }); writeFileSync(REPORT, lines.join("\n")); log(`report written to ${REPORT}`); } gap("No auto-dispatch on poll pass: E2 moves the draft to 'approved' only; execution needs a separate POST /publishing/drafts/:id/publish (or /amplify) by a publication.schedule holder. Whether a passing poll should auto-execute is a governance-model decision (execute-on-vote vs editor-release), not a missing route."); deviations.push("Connection created through the real OAuth authorization-code flow (radar web sign-in + consent), not with {instanceUrl, access_token} — no such route exists."); deviations.push("Invited members join as 'Editor' (an existing role holding chatgov.vote per migration 024); u1–u3 are then PATCHed to 'Admin' via /team/members/:membershipId/role."); deviations.push("Polls are opened explicitly (POST /chatgov/polls with draftId + supplied rule, kind yes_no) because the workspace has no posting_rules; with no live rules draftPollRule() accepts the supplied rule."); deviations.push("S6 is run twice: in the spec's vote order (S6, expected 'vetoed' per spec but 'passed' per code) and with the veto first (S6b) to exercise the veto path."); deviations.push("Statuses are posted with visibility 'unlisted' to keep the public timeline clean."); let fatal = null; try { await setup(); await connectMastodon(); await sources(); // ONLY=S7,S8 re-runs a subset (e.g. after a transient deploy outage). const only = process.env.ONLY ? new Set(process.env.ONLY.split(",")) : null; let i = 0; for (const sc of SCENARIOS) { i++; if (only && !only.has(sc.id)) continue; try { await runScenario(sc, i - 1); } catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } } if (!only || only.has("S7")) try { await runImmediate(i++); } catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } if (!only || only.has("S9")) try { await runBoost(i++); } catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } } catch (e) { fatal = e.message; console.error(e); if (current) check("step completed without error", "ok", String(e.message), e.data); } finally { writeReport(fatal); }