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:
+102
-4
@@ -1068,17 +1068,114 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
|
||||
return Boolean(m.correlation_id && m.correlation_id === candidate.correlation_id);
|
||||
});
|
||||
|
||||
/** Directed asks addressed to this device with no terminal reply from it. */
|
||||
/**
|
||||
* Has the ORIGINAL REQUESTER explicitly withdrawn this candidate?
|
||||
*
|
||||
* 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, and a requester
|
||||
* must not be able to delete an obligation the recipient genuinely has.
|
||||
*
|
||||
* It is wrong in exactly one case: the requester withdrawing its OWN ask.
|
||||
*
|
||||
* MEASURED COST, 2026-09-08/09. pi@mbp-m1-2020 withdrew a v1.8.13 rollout ask
|
||||
* to pi@tor-ms22 (evt seq 119: terminal `superseded`, same correlation_id,
|
||||
* `metadata.closes` naming it, body "DO NOT SPEND A MINUTE ON v1.8.13") and
|
||||
* recorded the withdrawal as done. It had no effect: seq 119's `from_agent` is
|
||||
* mbp, so it could never satisfy a join that only looks at tor-ms22's own
|
||||
* events. tor-ms22's next wake-up — 8h later, on the first boot of the image
|
||||
* shipping this very derivation — still listed the ask as owed, 41h old, for a
|
||||
* release that had been superseded and never installed there. It had to spend a
|
||||
* write closing an ask nobody wanted answered. The asymmetry is also INVISIBLE
|
||||
* from the sender's side: mbp did everything a sender is told to do and got a
|
||||
* result indistinguishable from success.
|
||||
*
|
||||
* WHY AN EXPLICIT MARKER AND NOT "ANY TERMINAL EVENT FROM THE REQUESTER".
|
||||
* This file's standing rule is that every failure mode stays on the
|
||||
* noisy-but-visible side, because a spuriously resurfacing item is one turn of
|
||||
* human correction whereas a suppressed unanswered ask is silent and permanent.
|
||||
* Inferring release from any terminal event would breach that: 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, not inferred. `withdraws` is the canonical key;
|
||||
* `closes` is honoured because it is already this fleet's de-facto marker (mbp
|
||||
* seq 119, emb seq 117/118 all use it), and either must name this exact ask —
|
||||
* its `correlation_id` or its event `id`. Prose does not count.
|
||||
*
|
||||
* WHY THIS DOES NOT BREAK THE FIXED POINT IN RFC 003 §9.2. §9.2 rejects letting
|
||||
* terminal directed events into the owed set, because then every closure mints a
|
||||
* fresh obligation and the loop never terminates. That argument is about
|
||||
* CANDIDATES. This adds a CLEARER. A withdrawal carries a terminal status, so it
|
||||
* can never be a candidate, and nothing new becomes owed. The asserting shape
|
||||
* (`open`) and the clearing shape (terminal) stay disjoint. A reader who reaches
|
||||
* for §9.2 to object is answering a different question.
|
||||
*/
|
||||
const isWithdrawn = (candidate: LogEvent, inbound: LogEvent[]): boolean => {
|
||||
const requester = candidate.from_agent;
|
||||
if (!requester) return false;
|
||||
// Names THIS ask specifically: its correlation or its id. A marker naming
|
||||
// something else, or carrying prose, is not a withdrawal of this ask.
|
||||
const names = (v: unknown): boolean =>
|
||||
typeof v === "string" &&
|
||||
v.length > 0 &&
|
||||
((Boolean(candidate.correlation_id) && v === candidate.correlation_id) ||
|
||||
(Boolean(candidate.id) && v === candidate.id));
|
||||
return inbound.some((e) => {
|
||||
// Only the party that ASKED may retract. A third party writing a terminal
|
||||
// event on a shared correlation must never clear my obligation.
|
||||
if (e.from_agent !== requester) return false;
|
||||
// Addressed to the party being released. `to_agent=<me>` also matches '*'
|
||||
// at the SQL level (RFC 003 §7.4), and a broadcast must not be able to
|
||||
// quietly empty every machine's mailbox at once.
|
||||
if (e.to_agent !== mailboxAddress) return false;
|
||||
if (!TERMINAL_STATUS.has((e.status ?? "").toLowerCase())) return false;
|
||||
// Same ordering guard, same reason as isAnswered: a withdrawal must not
|
||||
// retire an ask the requester sent LATER on the same correlation.
|
||||
if (!isStrictlyAfter(e, candidate)) return false;
|
||||
const ackOf = e.metadata?.ack_of;
|
||||
const joins =
|
||||
(typeof ackOf === "string" && Boolean(candidate.id) && ackOf === candidate.id) ||
|
||||
Boolean(e.correlation_id && e.correlation_id === candidate.correlation_id);
|
||||
if (!joins) return false;
|
||||
return names(e.metadata?.withdraws) || names(e.metadata?.closes);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Directed asks addressed to this device with no terminal reply from it, and
|
||||
* not explicitly withdrawn by whoever sent them (see isWithdrawn).
|
||||
*/
|
||||
async function deriveOwed(): Promise<LogEvent[]> {
|
||||
if (!mailboxEnabled || !available) return [];
|
||||
try {
|
||||
const [candidatesRaw, mineRaw] = await Promise.all([
|
||||
const [candidatesRaw, mineRaw, inboundRaw] = await Promise.all([
|
||||
client.callTool("mempalace_event_list", {
|
||||
to_agent: mailboxAddress,
|
||||
status: "open",
|
||||
limit: 50,
|
||||
}),
|
||||
client.callTool("mempalace_event_list", { from_agent: mailboxAddress, limit: 100 }),
|
||||
// `order: "desc"` is a fix, not a flourish. event_list defaults to `asc`
|
||||
// (append order), so this asked for the OLDEST 100 events this device
|
||||
// ever wrote — meaning that 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 rather than theoretical: tor-ms22
|
||||
// was at ~20 authored events when this was written. The window has to be
|
||||
// anchored at the newest end for the same reason the join uses `hlc`.
|
||||
client.callTool("mempalace_event_list", {
|
||||
from_agent: mailboxAddress,
|
||||
limit: 100,
|
||||
order: "desc",
|
||||
}),
|
||||
// Inbound with NO status filter, deliberately: a withdrawal carries a
|
||||
// TERMINAL status and is therefore structurally invisible to the
|
||||
// `status: "open"` candidate query above — the same omission deriveClosed
|
||||
// is built on. No amount of polling the open set could ever see one.
|
||||
client.callTool("mempalace_event_list", {
|
||||
to_agent: mailboxAddress,
|
||||
limit: 100,
|
||||
order: "desc",
|
||||
}),
|
||||
]);
|
||||
const candidates = parseEvents(candidatesRaw).filter((c) => {
|
||||
// `to_agent: <me>` ALSO matches `*` broadcasts, per the tool contract.
|
||||
@@ -1094,7 +1191,8 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
|
||||
});
|
||||
if (candidates.length === 0) return [];
|
||||
const mine = parseEvents(mineRaw);
|
||||
return candidates.filter((c) => !isAnswered(c, mine));
|
||||
const inbound = parseEvents(inboundRaw);
|
||||
return candidates.filter((c) => !isAnswered(c, mine) && !isWithdrawn(c, inbound));
|
||||
} catch {
|
||||
// Fail silent and open: a mailbox read must never break a session.
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user