T-HUB/scripts/e2e/governance-sim.mjs
glass beadian a8c691924d e2e: S9 governed boost scenario in the governance sim
S9: boost draft (providerMetadata.sourceRemoteRef) → immediate-rule approve
→ POST /publishing/drafts/:id/amplify { connectionId, kind: repost } →
verify the source status reblogged on Mastodon. ONLY=S9 runs it standalone.
Gap list updated: boost + Temporal-queue + status-read gaps closed
(auto-publish remains a governance-model decision, not a missing route).
2026-10-13 19:40:01 +00:00

513 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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(/<input[^>]*>/g)].map((m) => m[0])
.filter((t) => /type=["']hidden["']/.test(t))
.map((t) => [t.match(/name=["']([^"']+)["']/)?.[1], (t.match(/value=["']([^"']*)["']/)?.[1] ?? "").replace(/&amp;/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(/<form/).find((f) => /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);
}