T-HUB/scripts/e2e/governance-sim.mjs
glass beadian 4a0d398a76 fix: Matrix SSO via dedicated hub appservice (AS register + get_token impersonation)
The live tuwunel does not offer m.login.jwt (the jwt.* flat config keys were
inert), so the JWT broker flow could never work. Pivot: appservice.hub owns
the exclusive hub_* namespace on the homeserver; POST /matrix/session now
registers @hub_<username> with the as_token and mints a single-use
m.login.token via login/get_token (?user_id= impersonation). Optional
MATRIX_SSO_HS_TOKEN authenticates inbound appservice transactions, acked by
the new MatrixAppserviceController sink. Response shape unchanged.
2026-10-13 15:29:16 +00:00

474 lines
30 KiB
JavaScript
Raw 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 });
}
}
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("Governed Mastodon boost/reblog: drafts only dispatch via connector.publish (status, optional providerMetadata.inReplyToId). connector-mastodon has amplify(repost) but no API route reaches it for Mastodon (POST /archive/items/:id/amplify is Telegram-only). Scenarios therefore execute a reply to the source post.");
gap("No auto-dispatch on poll pass: E2 moves the draft to 'approved' only; execution needs a separate POST /publishing/drafts/:id/publish by a publication.publish holder.");
gap("LIVE DEPLOY: publish workflows are started on Temporal task queue 'tmbh-publishing' (current API default, TEMPORAL_TASK_QUEUE unset) but the deployed scheduler worker (image built 2026-10-10) polls 'superposter-publishing'. Workflows sit Running with an unclaimed first task and scheduled_publications stay 'pending' — approved actions never reach Mastodon. Fix: set TEMPORAL_TASK_QUEUE identically on api+scheduler, or redeploy the scheduler from current source.");
gap("No read route for scheduled_publications / publication receipts (remote status id); execution is verified on the Mastodon side via the radar token.");
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); }
} catch (e) {
fatal = e.message;
console.error(e);
if (current) check("step completed without error", "ok", String(e.message), e.data);
} finally {
writeReport(fatal);
}