← Community
bugfixed

A receipt-persist failure leaves the one-time staged send re-fireable: each retry re-fires the irreversible send after it already went out

ShwetaShweta#138d ago · 48 views
affected: station-v1.0.0fixed in: station-v1.3.0

studio_integration_send.approve advertises the staged send as one-time use
("cannot be re-fired"). It fires the irreversible provider action first, then
persists the receipt, then consumes the one-time staging file — but ONLY if the
receipt landed. When the receipt write fails, the staging file is deliberately
kept, and because the live send already happened, the caller's retry fires it
again.

approve() (station-v1.0.0):

with Saga(...) as saga:
result = integ.apply(plan, client, saga) # (A) the LIVE send fires
...
try:
os.makedirs(rdir, exist_ok=True)
with open(_rp, "w") as fh:
json.dump(receipt, fh, ...)
receipt_persisted = os.path.isfile(_rp) and os.path.getsize(_rp) > 0
except Exception as _e:
receipt_persisted = False # (B) persist failed
...
if receipt_persisted:
os.remove(spath) # one-time use: cannot be re-fired # (C) skipped on (B)

The comment at (C) states the intent: "Only consume the one-time-use staging
file if the receipt is safely on disk. If it isn't, KEEP it — losing both the
receipt and the staged record of an executed action is the worst outcome." That
reasoning protects the audit trail, but it overlooks that (A) has already run:
for mode=="live" the SMS / charge / webhook is already out. With (C) skipped the
staging file survives, approve() returns ok=False, and the natural response — the
caller retries, or the operator clicks the send again seeing it "failed" — re-
invokes approve() with the same staging_id. The re-run passes the same isfile +
integrity + signature checks and calls integ.apply again. There is no idempotency
key at this layer, so each retry is another real send.

This is a different defect from the two already on this function (the top-level
action_class read outside the seal preimage, and the concurrent-approve double-
fire): it is SEQUENTIAL and single-threaded, and its trigger is one receipt-write
failure — a full disk (ENOSPC), a read-only or wrong-permission receipts/capoff,
an antivirus/file-lock hiccup, or the receipt directory shadowed — followed by an
ordinary retry. The in-repo precedent it re-opens is the davelab.tech v0.65 fix
noted in the code: that fix stopped a swallowed persist failure from reporting
success with no receipt; it did so by keeping the staging file, which trades the
silent-no-receipt failure for a re-fireable one.

Reproduction steps:

  1. Extract the station-v1.0.0 tarball to a clean directory. Put workbench/ and

its parent on sys.path; import studio_integration_send and register a fake
external_send integration (counting apply) into workbench.primitives.
integration_registry.REGISTRY (the instance studio_integration_send imports);
stub client_from_vault, team_approval.gate (return proceed), signing, and an
auto_approve policy_gate.

  1. CONTROL: stage() then approve(); confirm the send fires once, the staging

file is consumed, and a second approve returns "unknown or expired
staging_id".

  1. Make <ws>/receipts/capoff read-only (chmod r-x) so the receipt write raises

PermissionError inside the persist try-block.

  1. stage() then approve() once; then call approve() with the same staging_id two

more times (the retries).

Expected: an irreversible send fires at most once per human approval; a receipt-
persistence failure must not leave the action re-fireable (fail such that the
send is not repeated on retry — e.g., consume the token before the effect, or
key the effect on an idempotency token the provider dedupes).

Actual:
CONTROL outcome=SENT, receipt_persisted=True, sends=1, staging consumed;
re-approve -> "unknown or expired staging_id"
ATTACK approve #1 -> outcome=SENT, ok=False, receipt_persisted=False,
staging remains=True, sends=1
approve #2 (retry) -> SENT, sends=2
approve #3 (retry) -> SENT, sends=3
(one staged approval, three live irreversible sends)

Root cause: the one-time-use token is consumed AFTER both the effect and the
receipt write, and only on receipt-write success, so any receipt-write failure
leaves an already-executed irreversible action still armed for re-execution.

Suggested fix: make the token single-use independently of receipt persistence.
Claim/consume the staging file BEFORE firing the effect (atomic os.rename to a
".consumed" name, as workflow_mcp.apply_workflow already does for its one-time
consent token), so a retry after any downstream failure finds nothing to re-fire;
persist the receipt against the consumed record. If the receipt genuinely cannot
be written, surface a reconciliation error naming the executed action rather than
leaving it re-fireable. Where the provider supports it, also pass an idempotency
key derived from staging_id so a duplicate fire is deduped provider-side.

3 pts

1 reply

Fixed in station-v1.3.0. studio_integration_send.approve now atomically reserves the staged send (os.replace to a .inflight marker under _APPROVE_LOCK) before apply(), so a receipt-persist failure can't leave the one-time send re-fireable on retry.

Thanks for the report — credited.

Sign in to reply.