Files
mempalace-toolkit/scripts/test-owed-withdrawal.sh
T
joakimp dab989b068 fix(mailbox-tests): the owed-set suite's own gate refused to let it run on node 24
scripts/test-owed-withdrawal.sh has not executed a single assertion since the
image moved to node 24. Its precondition line

    node --experimental-strip-types --check "$SRC"

exits 2 on an unmodified extensions/pi/mempalace.ts, and the script is
`set -euo pipefail` with `|| exit 2`, so all 17 assertions and both regression
guards were skipped. Measured on pi-devbox v1.9.1, node v24.21.0.

CAUSE, MEASURED AND NARROWER THAN IT LOOKS. The reported error names the inline
type-import at mempalace.ts:92, which invites the reading "that import is
unusual". It is not the import: `node --check` does not type-strip AT ALL. A file
whose entire content is `const x: number = 1;` fails identically, with or without
--experimental-strip-types, while `node --experimental-strip-types <same file>`
executes it fine. --check has never been type-aware; execution is what gained
stripping. So this gate could never validate TypeScript, on any node.

WHY IT USED TO PASS -- INFERENCE, NOT MEASUREMENT. e2b060a's message records this
suite running on 2026-09-09 with a control pass and four mutation kills, when the
image shipped node v22.23.2; v1.9.1 ships v24.21.0. No node 22 exists on the box
where this was diagnosed, so the counterfactual was not executed. Treat "22
stripped for --check and 24 stopped" as a hypothesis consistent with the record,
not as a measured cause. What IS measured is the present-tense behaviour above.

WHY THIS MATTERS MORE THAN A RED TEST. This suite exists because isWithdrawn is
the one rule in the extension that can go wrong SILENTLY -- a wrong rule does not
throw and does not log, it makes a real unanswered ask vanish from a mailbox
forever. The rule shipped in e2b060a and has been baked since v1.9.1; the thing
that makes its failure mode visible has been dark for the same period. The
mitigating half: it failed CLOSED (exit 2, loud), never vacuously green. A gate
that cannot run must not pass, and it did not.

THE FIX. Strip first, then syntax-check the emitted JS: version-stable, and it
still refuses malformed input. `mode: "strip"` blanks type syntax without moving
anything, so offsets and line numbers survive and a reported error line still
points at the right line of the original .ts (69157 B in, 69157 B out).

THE EXIT CODES ARE NOW SPLIT, AND THAT IS THE POINT. 3 = the gate itself cannot
run (no module.stripTypeScriptTypes, i.e. node < 22.13). 2 = the source does not
parse. Collapsing the two is how this defect disguised itself: it printed
"mempalace.ts does not parse" while mempalace.ts was fine, sending a reader to
inspect the wrong file. Note the stripper is itself a parser, so a genuine syntax
error surfaces as an exception from the strip call rather than from --check; that
path is caught and reported as 2, not 3. Both remain failures. Neither passes.

VERIFIED IN SIX DIRECTIONS on this image, each expectation written down first:
  unmutated source          -> rc=0, PASSED (17/17)
  malformed TypeScript      -> rc=2, "does not parse: Expression expected"
  stripper made unavailable -> rc=3, "cannot strip", and NOT "does not parse"
                               (simulated by doctoring the runtime through
                               NODE_OPTIONS, so the shipped line ran as shipped)
  M1 marker requirement removed -> FAILED: 3   (no-marker, other-thread, prose)
  M2 third-party guard removed  -> FAILED: 1
  M3 to_agent guard removed     -> FAILED: 2   (broadcast and wrong-device share it)
  M4 ordering guard removed     -> FAILED: 1
The four kill counts are the same ones e2b060a recorded, so sensitivity is
restored rather than merely asserted.

WORTH KNOWING FOR THE NEXT PERSON WHO MUTATES THIS: the extractor carries its own
guard that rejects an isWithdrawn which no longer mentions "withdraws", so the
obvious M1 (delete the marker check outright) is refused before any assertion
runs -- correctly, but it looks like a crash. Mutate to `... || true` instead,
which removes the requirement while keeping the key mentioned.

SC2016 is disabled on the node invocation with a stated reason: the single quotes
are deliberate, the payload is JavaScript and `${process.version}` must reach
node rather than the shell. Checked that the file is shellcheck-clean at default
severity, as it was before this change, and at the -S error severity the
pi-devbox gate uses.

NOT FIXED HERE. Nothing in CI runs this suite -- it is a script an operator
invokes, which is precisely why a gate that fails loudly still went unnoticed
across a node bump. The harness's own runner two hundred lines below still passes
--experimental-strip-types, deliberately: it is a no-op on 24 and required on 22,
so it keeps the suite runnable on both. And the real reason this was found at all
is unrelated to CI: isWithdrawn was being exercised end-to-end on a released
image against the live logstream for the first time (project/pi-devbox seq 140),
and the suite was reached for as corroboration.
2026-09-14 16:27:29 +02:00

313 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# test-owed-withdrawal.sh — rule tests for the owed-set derivation in
# extensions/pi/mempalace.ts: isStrictlyAfter, isAnswered, isWithdrawn.
#
# WHY THIS EXISTS
# isWithdrawn changes who is allowed to clear an obligation, which is the one
# thing in this extension that can go wrong SILENTLY. A wrong rule here does
# not throw and does not show up in a log — it makes a real unanswered ask
# vanish from a mailbox forever. So the rules get pinned down before they ship.
#
# WHY IT EXTRACTS THE PREDICATES FROM THE SOURCE INSTEAD OF RESTATING THEM
# These are closures inside createExtension(), so they cannot be imported. The
# tempting shortcut is to paste a copy of the logic into the test — which tests
# the copy. This repo has already paid for a divergent second copy (the
# pi-extensions skill mirror, 9579 B behind for weeks; the shell-lint logic
# extracted to one file for the same reason). So the harness cuts the actual
# declaration text out of mempalace.ts by brace matching and runs THAT. Change
# the source and this test follows; paraphrase the source and it cannot.
#
# FIXTURES ARE VERBATIM REAL EVENTS from the fleet log (project/pi-devbox), not
# invented shapes — including the exact incident that motivated isWithdrawn:
# mbp-m1-2020 withdrew a v1.8.13 rollout ask to tor-ms22 at seq 119 and the
# withdrawal had no effect, so tor-ms22 was still being told it owed a reply 41h
# later for a release it never installed.
#
# Usage: scripts/test-owed-withdrawal.sh [source.ts]
# exit 0 = all rules behave 1 = a rule broke
# exit 2 = source unreadable or does not parse 3 = the gate itself cannot run
#
# The optional argument exists so the suite can be pointed at a deliberately
# MUTATED copy of the source to prove it is sensitive — a suite that has never
# been observed to fail is not evidence. See the mutation check in the CHANGELOG
# entry that introduced isWithdrawn.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${1:-$REPO_ROOT/extensions/pi/mempalace.ts}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
[ -r "$SRC" ] || { echo "FAIL: cannot read $SRC" >&2; exit 2; }
# A gate that cannot run must not pass — the standing rule in this repo.
#
# NOT `node --check`: it does not type-strip, so it rejects ANY TypeScript —
# `const x: number = 1` included, not just the inline type-import at the top of
# mempalace.ts. It passed on node 22.x and stopped passing on node 24.x
# (measured on pi-devbox v1.9.1, node v24.21.0: this gate exited 2 on an
# unmodified mempalace.ts, so all 17 assertions below refused to run). Strip
# first, then syntax-check the emitted JS. `mode: "strip"` blanks type syntax
# without moving anything, so byte offsets and line numbers survive and a
# reported error line still points at the right line of the ORIGINAL .ts.
#
# The two failure modes are reported separately and on purpose. "Cannot strip"
# is a fact about the toolchain; "does not parse" is a fact about the source.
# Collapsing them is what made this very defect present itself as
# "mempalace.ts does not parse" when mempalace.ts was fine.
#
# SC2016 is disabled deliberately: the single quotes are the point. What follows
# is JavaScript, and `${process.version}` must reach node, not be expanded by the
# shell first.
# shellcheck disable=SC2016
node --no-warnings -e '
const { readFileSync, writeFileSync } = require("node:fs");
const { stripTypeScriptTypes } = require("node:module");
if (typeof stripTypeScriptTypes !== "function") {
console.error(`FAIL: node ${process.version} cannot strip TypeScript ` +
`(module.stripTypeScriptTypes needs >= 22.13) — the gate is unavailable, ` +
`which is NOT a statement about the source`);
process.exit(3);
}
let js;
try {
js = stripTypeScriptTypes(readFileSync(process.argv[1], "utf8"), { mode: "strip" });
} catch (err) {
// The stripper is itself a parser, so a syntax error lands HERE, not in the
// --check below. This is a statement about the source: exit 2, not 3.
console.error(`FAIL: ${process.argv[1]} does not parse: ${err.message}`);
process.exit(2);
}
writeFileSync(process.argv[2], js);
' "$SRC" "$WORK/stripped.mjs" || exit $?
node --check "$WORK/stripped.mjs" \
|| { echo "FAIL: $SRC does not parse (stripped output rejected)" >&2; exit 2; }
# ---------------------------------------------------------------- extractor ---
cat >"$WORK/extract.mjs" <<'EXTRACT'
import { readFileSync, writeFileSync } from "node:fs";
const src = readFileSync(process.argv[2], "utf8");
/**
* Cut `const <name> = ...;` out of the source by matching delimiters, so the
* test runs the shipped text. Returns the declaration verbatim.
*/
function decl(name) {
const start = src.indexOf(`const ${name} =`);
if (start < 0) throw new Error(`declaration not found in source: ${name}`);
let depth = 0;
let inStr = null;
for (let i = start; i < src.length; i++) {
const c = src[i];
const prev = src[i - 1];
if (inStr) {
if (c === inStr && prev !== "\\") inStr = null;
continue;
}
if (c === '"' || c === "'" || c === "`") { inStr = c; continue; }
if (c === "/" && src[i + 1] === "/") { i = src.indexOf("\n", i); if (i < 0) break; continue; }
if (c === "{" || c === "(" || c === "[") depth++;
else if (c === "}" || c === ")" || c === "]") depth--;
else if (c === ";" && depth === 0) return src.slice(start, i + 1);
}
throw new Error(`unterminated declaration: ${name}`);
}
const parts = ["TERMINAL_STATUS", "isStrictlyAfter", "isAnswered", "isWithdrawn"].map(decl);
// Sanity: the extractor must have found real bodies, not empty matches. A
// silently-empty extraction would make every assertion below pass vacuously.
for (const [i, p] of parts.entries()) {
if (p.length < 40) throw new Error(`extracted declaration ${i} is implausibly short: ${p}`);
}
if (!parts[3].includes("withdraws")) throw new Error("isWithdrawn does not mention its marker key");
writeFileSync(process.argv[3], parts.join("\n\n"));
EXTRACT
node "$WORK/extract.mjs" "$SRC" "$WORK/extracted.ts"
echo "[extract] pulled 4 declarations from $(basename "$SRC") ($(wc -c <"$WORK/extracted.ts") bytes)"
# ------------------------------------------------------------------ harness ---
{
cat <<'HEAD'
type LogEvent = {
id?: string;
seq?: number;
hlc?: string;
type?: string;
status?: string;
from_agent?: string;
to_agent?: string;
correlation_id?: string | null;
created_at?: string;
body?: string;
metadata?: Record<string, unknown> | null;
};
const mailboxAddress = "pi@tor-ms22";
HEAD
cat "$WORK/extracted.ts"
cat <<'TAIL'
// ---- VERBATIM fixtures from the real fleet log, stream project/pi-devbox ----
const REP = "rep_d344e349ba276d6fc11997cd552f6937";
/** seq 112 — mbp's v1.8.13 rollout ask to tor-ms22. The obligation. */
const ask112: LogEvent = {
id: "evt_20260907T125110_2bef6f9b07a8",
seq: 112,
hlc: `1788785470242-000000-${REP}`,
type: "task.request",
status: "open",
from_agent: "pi@mbp-m1-2020",
to_agent: "pi@tor-ms22",
correlation_id: "v1813-client-rollout-tor-ms22",
};
/** seq 119 — mbp's withdrawal of its OWN ask. Terminal, directed, marker present. */
const withdraw119: LogEvent = {
id: "evt_20260908T223949_8f5b9bec7c92",
seq: 119,
hlc: `1788907189286-000000-${REP}`,
type: "task.reply",
status: "superseded",
from_agent: "pi@mbp-m1-2020",
to_agent: "pi@tor-ms22",
correlation_id: "v1813-client-rollout-tor-ms22",
metadata: {
closes: "v1813-client-rollout-tor-ms22",
nothing_owed: "no reply required to this event",
replacement: "v1814-client-rollout-tor-ms22",
},
};
/** seq 120 — the LIVE v1.8.14 ask. Must survive the v1813 withdrawal. */
const ask120: LogEvent = {
id: "evt_20260908T224118_808de43d6982",
seq: 120,
hlc: `1788907278075-000000-${REP}`,
type: "task.request",
status: "open",
from_agent: "pi@mbp-m1-2020",
to_agent: "pi@tor-ms22",
correlation_id: "v1814-client-rollout-tor-ms22",
};
/** seq 122 — tor-ms22's OWN terminal reply, which is what actually cleared 112. */
const myReply122: LogEvent = {
id: "evt_20260909T061851_cf9bd18800db",
seq: 122,
hlc: `1788934731146-000000-${REP}`,
type: "task.reply",
status: "superseded",
from_agent: "pi@tor-ms22",
to_agent: "pi@mbp-m1-2020",
correlation_id: "v1813-client-rollout-tor-ms22",
metadata: { closes: "v1813-client-rollout-tor-ms22 — from the RECIPIENT side, which is the only side that can" },
};
const clone = (e: LogEvent, over: Partial<LogEvent>): LogEvent => ({ ...e, ...over });
let failed = 0;
const check = (name: string, expected: boolean, actual: boolean): void => {
const ok = expected === actual;
if (!ok) failed++;
console.log(`${ok ? " ok " : " FAIL"} ${name} (expected ${expected}, got ${actual})`);
};
console.log("\nisWithdrawn — the requester retracting its own ask");
// THE INCIDENT. Before this rule existed the answer was false and tor-ms22 was
// told it owed a reply for a release that no longer existed.
check("real seq 119 withdraws real seq 112", true, isWithdrawn(ask112, [withdraw119]));
check("withdrawal does NOT touch the live v1814 ask", false, isWithdrawn(ask120, [withdraw119]));
console.log("\nisWithdrawn — the ways a mailbox must NOT be silently emptied");
check(
"no marker: a bare terminal event from the requester is not a withdrawal",
false,
isWithdrawn(ask112, [clone(withdraw119, { metadata: { nothing_owed: "no reply required" } })]),
);
check(
"marker naming a DIFFERENT thread does not clear this one",
false,
isWithdrawn(ask112, [clone(withdraw119, { metadata: { closes: "v1814-client-rollout-tor-ms22" } })]),
);
check(
"marker carrying PROSE does not count (tor-ms22's own seq 122 shape)",
false,
isWithdrawn(ask112, [
clone(withdraw119, {
metadata: { closes: "v1813-client-rollout-tor-ms22 — from the RECIPIENT side, which is the only side that can" },
}),
]),
);
check(
"a THIRD PARTY cannot retract someone else's ask",
false,
isWithdrawn(ask112, [clone(withdraw119, { from_agent: "pi@emb-7kj4vr4g" })]),
);
check(
"a BROADCAST cannot empty every machine's mailbox at once",
false,
isWithdrawn(ask112, [clone(withdraw119, { to_agent: "*" })]),
);
check(
"a NON-TERMINAL status is not a withdrawal",
false,
isWithdrawn(ask112, [clone(withdraw119, { status: "open" })]),
);
check(
"an event addressed to a DIFFERENT device does not clear my ask",
false,
isWithdrawn(ask112, [clone(withdraw119, { to_agent: "pi@emb-7kj4vr4g" })]),
);
check(
"ORDERING: a withdrawal cannot retire an ask the requester sent LATER",
false,
isWithdrawn(clone(ask112, { seq: 121, hlc: `1788907300000-000000-${REP}` }), [withdraw119]),
);
console.log("\nisWithdrawn — accepted marker spellings");
check(
"canonical `withdraws` naming the correlation",
true,
isWithdrawn(ask112, [clone(withdraw119, { metadata: { withdraws: "v1813-client-rollout-tor-ms22" } })]),
);
check(
"marker naming the ask's EVENT ID instead of its correlation",
true,
isWithdrawn(ask112, [clone(withdraw119, { metadata: { withdraws: ask112.id } })]),
);
console.log("\nisAnswered — regression guard, unchanged behaviour");
check("my own terminal reply still closes my own ask", true, isAnswered(ask112, [myReply122]));
check("my reply on one thread does not close another", false, isAnswered(ask120, [myReply122]));
check(
"ORDERING still load-bearing: my reply cannot pre-close a LATER ask",
false,
isAnswered(clone(ask112, { seq: 130, hlc: `1788999999999-000000-${REP}` }), [myReply122]),
);
console.log("\nEND TO END — the derived owed set for tor-ms22 on 2026-09-09");
// The behaviour change, stated as the derivation states it. `mine` deliberately
// EXCLUDES seq 122 so this measures the new rule rather than the reply that
// happened to be written first.
const candidates = [ask112, ask120];
const mine: LogEvent[] = [];
const inbound = [withdraw119];
const owed = candidates.filter((c) => !isAnswered(c, mine) && !isWithdrawn(c, inbound));
const owedIds = owed.map((e) => e.correlation_id).join(", ");
check("exactly one ask remains owed", true, owed.length === 1);
check("and it is the v1814 one, not the withdrawn v1813", true, owedIds === "v1814-client-rollout-tor-ms22");
console.log(` derived owed set: [${owedIds}]`);
console.log(failed === 0 ? "\n=== PASSED ===" : `\n=== FAILED: ${failed} ===`);
process.exit(failed === 0 ? 0 : 1);
TAIL
} >"$WORK/harness.ts"
node --experimental-strip-types "$WORK/harness.ts"