Repository navigation
Description
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.
Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: ae56041a44c844e4b8683da11fdd30b3
Branch: dev/3-approval-grant-rollback
Base: f2ef4df
At: 2026-11-10T01:03:43Z
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.
Written-by: FlyBot Lead
Bot: FlyBot Product
answerApproval with "Always allow" writes the standing grant into the in-memory state before it saves, so a save the store refuses leaves a grant the owner never accepted in force: the next card of that kind is answered allowed-once by itself, and any later successful write persists the grant to disk.
Where
core/src/host/approvals.js at f2ef4dfaccedd40315bd585474d0902118dc0ba8:
grantStanding(:132-149):target.standingApprovals = [...grants, grant]at:142, thenawait rt.save()at:143. A throw fromsave()leaves the assignment in place.removeStandingApproval(:153-164):delete target.standingApprovals/target.standingApprovals = afterat:160-161, thenawait rt.save()at:162. A throw leaves the removal in place, so a grant that is still on disk disappears from memory.standingGrant(:102-109) only compareskind, andstandingCovers(:117-133) calls it before a card is created, so an in-memory grant answers the next request with no card.
rt.save() throws for real, store-side reasons: core/src/host/store.js:1022 (Not saved: <temp> is not a plain file of its own, the temp-name sweep's own guard), :1029 (ELOCKLOST, another dsh took the lock over), and a full disk or a permission error on the stage/rename.
Reproduction
A probe with the real install() and the real handler, on a state whose temp name is a symlink so store.js:1022 refuses the write:
1. card opened: true | pending cards: 1 | answerable: true
grant before answering: []
2. answerApproval rejected: Not saved: /home/u/.dsh/bot/state.json.4242.tmp is not a plain file of its own, so the Bot …
cards still waiting: 1
grant in memory after the failed save: ["bash:escalate:danger-full-access"]
3. next ask of the same kind answered: allowed-once
pending cards now: 1
Step 2's first two lines are already right (the card stays pending and the error reaches the client). What is wrong is the third line of step 2 and step 3: the grant is live and answers the next card, and one later unrelated successful write puts it on disk.
Reach as reported: approval-answer is in the channel read set (core/src/host/channel.js:242), but the client's read-only window does not offer the decision (#2372/#2382), so the practical trigger is the owner answering a card in a group chat while the state write fails. Rare, and the consequence is a standing grant nobody accepted taking effect in the background — this is why it is filed as a defect and not as an enhancement.
Expected
The same rule this family already uses (#2761, tasks.js): snapshot, save, and only count the write as taken when it succeeds; on a refusal restore the snapshot and rethrow the store's own error.
grantStanding: nostandingApprovalsentry in memory after a refused save; the card stays pending; the next ask of the same kind still produces a card.removeStandingApproval: a refused save leaves the grant exactly as it was in memory (still present) and rethrows; a successful save still removes it (and deletes the field when the list empties).state.revisionis not restored:store.js:1015increments it before the write and a refused write does not rewind it.- A successful grant behaves exactly as today, including answering the other pending cards the new grant already covers (
:146-148) — those answers must stay on the success path only.
Slices and red-first
- Bot scope (
always-bot): with the store refusing the save,answerApprovalthrows, the card is still pending,bots.<id>.standingApprovalsis absent/empty, and a second ask of the same kind opens a card (today: answeredallowed-once). - Room scope (
always-room): the same shape onrooms.<id>.standingApprovals. removeStandingApprovalrefusal: throws and the grant is still there (today: gone from memory while still on disk).
Keep the fix inside approvals.js; no rt.mutate() change and no new store API. Do not change the approval card's shape, the allowed-once outcome or the room-vs-Bot scope rules.
Dedupe
Checked live main f2ef4dfa and the open/closed issue list: #2761 is the same defect shape but all five save points are in core/src/host/tasks.js; #1048 (closed) moved inline writers into rt.mutate(), which is itself change-then-save with no rollback and never covered these two calls; #608/#544 (closed) are the persistence feature and its first fix; no open issue covers a standing-approval rollback. Family rule: one issue, one file — so this is a separate issue after #2761, not folded into it.
Risk tier: T2 (approval persistence and the outcome a card answers with).
Activity
fix(host): serialize two answers on one approval record (ThinkFlowLab…