feat(mailbox): let a requester withdraw its own ask, with an explicit marker
RFC 003 §3.3 clause 3 clears an ask only on "no event OF YOURS", and the skill states the consequence outright: "there is nothing anyone can do about it from the other end". That asymmetry is deliberate and mostly right — owed-ness is a statement about the RECIPIENT's accountability. It is wrong in exactly one case: the requester retracting its own ask. MEASURED COST. pi@mbp-m1-2020 withdrew a v1.8.13 rollout ask to pi@tor-ms22 at seq 119 — terminal `superseded`, same correlation_id, metadata.closes naming the thread, body "DO NOT SPEND A MINUTE ON v1.8.13" — and recorded it as done. It had no effect: seq 119's from_agent is mbp, so it could never satisfy a join that only inspects tor-ms22's own events. tor-ms22's next wake-up, 8h later and on the first boot of the image shipping this very derivation, still listed the ask as owed, 41h old, for a release it never installed. The failure is invisible from the sender's side, which is why it went unnoticed for 41h. WHY AN EXPLICIT MARKER RATHER THAN "ANY TERMINAL EVENT FROM THE REQUESTER". This file's standing rule is that every failure stays on the noisy-but-visible side: a resurfacing item costs one turn of human correction, a suppressed unanswered ask is silent and permanent. Under the naive rule a requester appending `applied` for its own bookkeeping — on the correlation, addressed to me, before I ever replied — would silently delete a real obligation. So release must be STATED: metadata.withdraws (canonical) or metadata.closes (already this fleet's de-facto marker), whose value must NAME the ask — its correlation_id or its event id. Prose does not count. Five further guards, each pinned by a mutation test: only the original requester; addressed to this device exactly, never '*'; terminal status; strictly after the ask by hlc; and joined by ack_of or correlation_id. This does NOT break the fixed point in §9.2. That section rejects letting terminal directed events into the owed set, because then every closure mints a fresh obligation. That is about CANDIDATES; this adds a CLEARER. A withdrawal is terminal, so it can never be a candidate, and the asserting shape (open) and the clearing shape (terminal) stay disjoint. Also fixed here, because the new rule depends on the same windowing: the `mine` query used event_list's DEFAULT `asc` order with limit 100, i.e. the OLDEST 100 events this device ever wrote. Once a device passes 100 authored events its most RECENT replies fall out of the join window and every ask it just answered resurfaces as owed. Latent, not theoretical — tor-ms22 was at ~20. Both windows are now anchored at the newest end with order: "desc". Verification: scripts/test-owed-withdrawal.sh, 17 assertions over VERBATIM fixtures from the real log (seq 112/119/120/122). It extracts the predicates from mempalace.ts by brace matching and runs the SHIPPED text rather than a pasted copy — this repo has already paid for a divergent second copy. Sensitivity proven by four mutations: removing the marker requirement flips exactly the 3 marker assertions, and disabling the third-party / broadcast / ordering guards each flip exactly their own. Control passes.
This commit is contained in:
Executable
+271
@@ -0,0 +1,271 @@
|
||||
#!/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)
|
||||
#
|
||||
# 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.
|
||||
node --experimental-strip-types --check "$SRC" \
|
||||
|| { echo "FAIL: $SRC does not parse" >&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"
|
||||
Reference in New Issue
Block a user