Revert "feat(pi): wire lazy lease promotion into the mutator gate"

This reverts 939f2e04. Keeping the revert rather than dropping the commit,
because the failed attempt is the most useful record on this branch.

The wiring worked mechanically — verified with a live model on sb-it-1-dt: the
receipt was emitted verbatim as a whole message, and the broker token was minted
AND consumed, so observe_receipt and promote_lease both succeeded and the lease
reached VERIFIED.

It failed as a DESIGN, for reasons that are properties of the protocol rather
than of this wiring:

  * It puts control-plane traffic in the user-facing conversation channel. An
    operator asking "what model are you?" received a receipt string instead of an
    answer — the model tried a tool, was blocked, complied with the receipt
    instruction, and in one-shot mode that text turn BECAME the reply. Observed
    twice, non-deterministically.
  * The lease TTL is hard-capped at 300s (MAX_LEASE_TTL_SECONDS; ttl_seconds >
    cap raises INVALID_LEASE_TTL). Measured: allowed at T+0, LEASE_EXPIRED at
    T+310. So the visible cost recurs every five minutes of mutator activity.
  * Model compliance is not guaranteed — one run retried the command instead of
    emitting the receipt.

Any model emission is user-visible, so this is not fixable by better wiring; it
needs a design answer about how promotion is triggered and paid for. That is
under adversarial review (docs/scratchpads/lease-remediation/07-liveness-design-brief.md
in the operator's repo). Promotion triggering will return on its own branch once
that lands.

What remains here is independently sound and unblocked: harness-home isolation,
the immutable launch record, the skills relocation, the promotion client itself
(steps 1/4/5), and the #1087 prefix guard.
This commit is contained in:
Jason Woltje
2026-08-06 17:48:43 -05:00
parent db16de1a81
commit 018db7e083
@@ -38,27 +38,8 @@ const RECEIPT_OBSERVER_CLIENT = join(
'lease-broker',
'receipt-observer-client.py',
);
const LEASE_PROMOTE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'lease_promote.py');
const RECOVERY_TOOL = 'mosaic_context_recover';
// Lazy lease promotion: a lease is promoted on the FIRST DENIED MUTATOR, not at
// session start. Two reasons that matter:
//
// 1. Promotion costs a whole model turn, because the receipt must be the entire
// message (hmac.compare_digest, "not a transcript substring"). Doing it at
// session start would collide with the Constitution's first-response mode
// declaration — the two cannot share a message. Deferring it means the mode
// declaration happens first and the receipt gets its own later turn, so no
// governance change is required.
// 2. A read-only session never pays for it at all.
//
// Bounded so a model that will not emit the receipt verbatim degrades to the
// current behaviour (denied mutators) rather than looping forever.
const MAX_PROMOTION_ATTEMPTS = 3;
let pendingReceiptChallenge: string | null = null;
let pendingReceiptText: string | null = null;
let promotionAttempts = 0;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -145,63 +126,6 @@ function runPiLeaseRevoker(args: string[]): boolean {
return result.status === 0;
}
/**
* Mint a promotion challenge and return the instruction the model must follow.
*
* The receipt has to be the model's ENTIRE next message: the broker compares it
* with `hmac.compare_digest`, so any surrounding prose fails. Returning it as
* the tool_call block `reason` is what puts it in front of the model — pi feeds
* that string back as the tool result.
*
* This never posts the receipt itself. `receipt-observer-client.py` would accept
* any string, so self-posting would satisfy the broker while proving nothing;
* the whole point of the exchange is that a live model echoes the challenge.
*/
function beginPiPromotion(): string | undefined {
if (promotionAttempts >= MAX_PROMOTION_ATTEMPTS) return undefined;
const result = spawnSync('python3', [LEASE_PROMOTE, '--begin'], {
encoding: 'utf8',
timeout: 10_000,
env: process.env,
});
if (result.status !== 0) return undefined;
try {
const reply = JSON.parse(String(result.stdout)) as {
ok?: boolean;
receipt?: string;
receipt_challenge?: string;
};
if (reply.ok !== true || !reply.receipt || !reply.receipt_challenge) return undefined;
pendingReceiptChallenge = reply.receipt_challenge;
pendingReceiptText = reply.receipt;
promotionAttempts += 1;
return reply.receipt;
} catch {
return undefined;
}
}
/** Complete promotion after the model emitted the receipt and message_end
* shipped it to the observer. Returns true when the lease reached VERIFIED. */
function completePiPromotion(): boolean {
const challenge = pendingReceiptChallenge;
if (!challenge) return false;
pendingReceiptChallenge = null;
pendingReceiptText = null;
const result = spawnSync('python3', [LEASE_PROMOTE, '--complete', challenge], {
encoding: 'utf8',
timeout: 10_000,
env: process.env,
});
if (result.status !== 0) return false;
try {
const reply = JSON.parse(String(result.stdout)) as { state?: string };
return reply.state === 'VERIFIED';
} catch {
return false;
}
}
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
input: `${JSON.stringify({ tool_name: toolName })}\n`,
@@ -213,29 +137,6 @@ function checkPiMutatorGate(toolName: string): { block: true; reason: string } |
const detail = String(result.stderr ?? '')
.trim()
.split('\n')[0];
// Only an UNVERIFIED lease is promotable. Any other denial (GATE_UNAVAILABLE,
// STALE_GENERATION, LEASE_EXPIRED, ANCESTRY_MISMATCH) means something is wrong
// that a receipt cannot fix — minting there would thrash the broker, since
// begin_verification revokes before it mints.
if (detail.includes('MUTATOR_UNVERIFIED') && pendingReceiptChallenge === null) {
const receipt = beginPiPromotion();
if (receipt) {
return {
block: true,
reason:
`${detail}\n\n` +
'This session holds an UNVERIFIED lease, so mutators are denied. To ' +
'promote it, reply with the following text and NOTHING ELSE — no ' +
'preamble, no explanation, no code fence, no trailing text. It is ' +
'compared byte-for-byte, so any extra character fails:\n\n' +
`${receipt}\n\n` +
'Emit exactly that as your entire next message. The lease will then be ' +
'VERIFIED and you can retry this tool.',
};
}
}
return {
block: true,
reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.',
@@ -469,29 +370,7 @@ export default function register(pi: ExtensionAPI) {
// Pi records only a finalized assistant entry at message_end. It never uses
// after_provider_response, which occurs before stream consumption.
pi.on('message_end', async (event) => {
const message = (event as unknown as { message?: unknown }).message;
recordPiMessageEnd(message);
// Complete ONLY when the message just observed IS the receipt.
//
// message_end also fires for the message that CONTAINED the blocked tool
// call — i.e. one turn BEFORE the model emits the receipt. Completing there
// makes observe_receipt compare against the wrong text, fail, and burn the
// challenge before the model ever answers. Gating on an exact text match
// mirrors the broker's own hmac.compare_digest semantics and waits for the
// right turn.
//
// Order still matters within this handler: recordPiMessageEnd must have
// shipped the message to the observer before observe_receipt asks about it.
if (pendingReceiptChallenge !== null && pendingReceiptText !== null) {
if (assistantMessageText(message) === pendingReceiptText) {
if (completePiPromotion()) {
promotionAttempts = 0;
}
// On failure the challenge is cleared, so the next denied mutator mints
// a fresh one — bounded by MAX_PROMOTION_ATTEMPTS, after which the
// session simply stays UNVERIFIED rather than looping.
}
}
recordPiMessageEnd((event as unknown as { message?: unknown }).message);
});
// The recovery custom tool is the only Pi invocation that maps to the