fix(chat): withhold Trust from the composer when no slot can record it (#5486) - #8194
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of The change is a small, honest fix: the composer's Trust buttons ("Trust reads", the Trust dropdown) are now withheld when no slot exists to record a standing grant — previously a Trust click silently ran the tool once via the one-shot endpoint while the UI claimed a persistent grant. "Allow once" and Reject remain, so the approval can still be resolved. No new user-facing strings, no screenshots, and the behavior matches the sibling fixes on the spawn card and collapsed tool row. That is my review: UX-Verdict: PASS Removes a Trust control that lied — clicking it never recorded a grant — while keeping the honest Allow once / Reject paths intact. [UX-REVIEWED] 6e1460f |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Fail-closed gate plus narrowed mapping is the right shape, mirrors the settled sibling (#5485), strictly narrows authorization, and defers the chokepoint refactor legitimately (#8193). Suggestions
[DESIGN-REVIEWED] 6e1460f |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified: the routing at First-Principles-Verdict: PASS Third and last surface feeding trust verbs into an endpoint with no trust verb; harm named, siblings counted to zero, alternative considered and filed. What this change shipsIntent: stop the composer claiming a standing Trust grant the backend never recorded — a FIX.
WatchThe trust-verb set is now spelled in three places — [FIRST-PRINCIPLES-REVIEWED] 6e1460f |
#5486) ChatInput's own toApiDecision mapped trust/trust_reads to a one-shot approve for the api.resolveApproval fallback, so a Trust click landing with no activeSlot ran the tool once while finish() dispatched decision: 'trust' -- the composer reported a standing grant the backend never recorded. Mirrors the fix already merged for the same defect class at ChatPage (#5400, PR #5433) and the collapsed tool row (#5434, PR #5485): offer only trust verbs the resolve path honors, and keep the mapping fail-closed behind it. Refs #5486
7641b16 to
6e1460f
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Approving on the strength of a full readiness audit of every open PR against main, not a
line-by-line reading of this diff — recording that plainly so the next reader knows what this
stamp does and does not cover.
Verified against this exact head SHA:
readiness: passedpresent, andPR Readiness— the one required status context onmain
(rulesetprotected-branches) — issuccesson this head.- No check run on this head is
failure,cancelled,timed_outor still in flight. Skipped
jobs are path-filtered conditionals, none of them required. mergeable: true, and the head is not far enough behindmainfor its green CI to describe a
base that no longer exists.- No surviving reviewer
CHANGES_REQUESTED: any such review is on an older commit and therefore
already dismissed bydismiss_stale_reviews_on_push. - Every issue comment, inline review comment and review thread was read and classified. Nothing
left is an unresolved human change request — the remainder is bot review-lane output, resolved
or outdated threads, explicitly non-blocking suggestions, and author status notes.
Auto-merge (squash) is armed, so this lands once every other ruleset requirement is met.
Problem / Motivation
ChatInput(the composer's approval bar) kept its owntoApiDecision, and it mappedtrustandtrust_readsto a plainapprove:That mapping feeds the one-shot
api.resolveApprovalfallback.handleApprovalActionroutes a trust verb to the slot-scopedapi.approveChatSlotonly whenactiveSlotis set; with no slot it falls through to the one-shot endpoint, which honors exactlyapprove | reject | reject_onceand records no standing grant (dashboard/handlers/sessions.py:1507). Meanwhilefinish()dispatchesresolveByApprovalId({ decision: 'trust' }), so the row renders as a standing grant.Net effect in that state: the tool runs once, the composer says the user granted standing trust, and the backend recorded nothing. The user's next identical action prompts again, which reads as the grant having been forgotten rather than never made.
Compounding it, the same function already failed CLOSED for the other two verbs from the same dropdown:
trust_commandandtrust_basefell toreturn 'reject'. Two of four trust verbs rejected, two silently approved, on one code path -- which is what marks thetrust/trust_readsarm as an artifact rather than a policy.Why it matters
This is a consent path, and the failure is quiet in the direction that overstates consent. A user who clicks Trust believes they have widened what runs without asking; the dashboard agrees with them; nothing was recorded. The gap is only observable later, as a prompt they thought they had silenced.
It is the third surface to ship this same defect independently -- #5400 (spawn-approval card, PR #5433) and #5434 (collapsed tool row, PR #5485) -- so the rule was evidently not written anywhere a third author had to read it.
What changed (motivation -> approach -> change)
The issue named two candidate readings and asked an owner to pick: the branch is dead, so delete the trust mapping; or the branch is reachable, so gate the Trust controls. The already-merged sibling resolves that by having done both: PR #5485 narrowed
ChatPage'stoApiDecisiontoaction === 'approved' ? 'approve' : 'reject'AND madeCollapsibleToolGroup's Trust button fail-closed behind an opt-incanTrust. This PR mirrors that shape, so nothing here is a fresh policy choice:toApiDecisionnarrowed --approved -> approve,rejected_once -> reject_once, everything elsereject. It carries the constraint comment PR fix(chat): offer only resolvable decisions on collapsed tool rows (#5434) #5485 added atChatPage's copy, which the issue said applies verbatim.approvalTrustGrantable-- one predicate,!!activeSlot && !approvalIsUnattended, gating both Trust affordances (Trust reads; the Trust dropdown carrying trust / trust_command / trust_base). Previously they were gated on!approvalIsUnattendedalone. So the mapping's trust arm is now unreachable from the DOM, and the narrowing is defence in depth behind a user-visible fail-closed gate.What the user sees, before and after. With a slot present -- every state reachable from the shipped UI -- there is no change at all: Trust still routes to
api.approveChatSlotand still records a standing grant. With no slot, the Trust affordances are absent and the bar offers Allow once / Reject / Reject once, which are exactly the decisions that path can honor.What gets authorized, before and after. Strictly narrower, never wider. Before, a trust click in the no-slot state authorized one execution while claiming a standing grant. After, it authorizes nothing, because it cannot be clicked. No decision becomes auto-approved, and no set of commands newly runs without a prompt.
Reachability, stated honestly. Not reachable from today's grid:
SessionGridView.renderLeafmountsChatPaneonly forleaf.kind === 'session' && leaf.slot, anddeleteSlot.fulfilledclearsstate.messagesin the same reducer that nullsactiveSlot. It is one mount away:selectSlotPendingApproval(state, null)falls back to the globalstate.chat.messages, andSlotContextdocuments<SlotProvider slotId={null}>as a supported "intentionally empty pane" -- such a pane's composer would show the globally-active slot's pending approval withactiveSlotnull. The fix is fail-closed either way, which is why it does not depend on settling that.Deliberate deviations
toApiDecisionis kept, not deleted in favour of the canonical one. It is not a pure duplicate: it also mapsrejected_once -> reject_once, a third verbChatPage's copy has no notion of and the only producer of which isRejectDropdown's "Reject once -- keep asking about the rest". Deleting it would silently downgrade that tier to a plain reject.ChatPage's version is also a component-privateuseCallback, so it cannot be imported. The two now agree on trust, andChatInput.trustOneShot.test.tsxpins the agreement.apps/mochi/panel/approvalActions.tsalready holds a canonical routing predicate (TRUST_ACTIONS+approvalRoute), and promoting it out ofapps/mochiforChatPage/ChatInput/ the tool-group row to consume is the right end state -- but it is a cross-app refactor on a consent path, which is a maintainer's call, not a rider on a bug fix. Filed as No chokepoint for the trust-verb/one-shot rule after #5400/#5434/#5486 are fixed #8193 with the audit behind it.Tests
website/src/test/ChatInput.trustOneShot.test.tsx(new) -- source contract on the narrowed mapping. It brace-matches the shippedtoApiDecisionbody out ofChatInput.tsxand executes it, so the pin is on the MAPPING rather than its spelling: all four trust verbs answerreject,approvedandrejected_oncestill answer their own verbs, no unknown verb answersapprove, and there is still exactly one call site and it is the one-shot endpoint. A source contract for the same reason the sibling'sChatPage.collapsedGroupTrust.test.tsxis one -- after the render gate the arm is unreachable from the DOM, so a render test could not reach it.website/src/test/ChatInput.approval.test.tsx-- the pin that locked the old behaviour (trust action falls back to resolveApproval when no activeSlot, assertingresolveApproval('ap-123', 'approve')) is replaced by two that lock the new one, one per render site: the Trust dropdown is withheld with no slot, and Trust reads is withheld with no slot. The first also asserts Allow once and Reject are still present, so withholding the tier cannot silently take the bar with it.Replacing that pin is the same contract change PR #5485 made at
ChatPageand is on the same grounds; it was the second reason #5486 was held for a human, and the sibling merging is what settles it.Mutation-verified, one per enforcement site, each read as a failure MESSAGE rather than an exit code:
toApiDecisionmaps trust/trust_reads back toapproveAssertionError: expected 'approve' to be 'reject'!approvalIsUnattendedexpect(element).not.toBeInTheDocument(), found the BookOpen "Trust reads" button!approvalIsUnattendedexpect(element).not.toBeInTheDocument(), found the Handshake "Trust" buttonapprovalTrustGrantabledrops!!activeSlotA third candidate pin (nothing is resolved on render) was written and then dropped: it passed with the fix reverted, so it pinned nothing about this defect.
Manual verification
N/A -- and deliberately so rather than for convenience. The state where behaviour differs cannot be produced from the shipped UI (see Reachability above), so there is no click path to walk. The DOM evidence that the gate is the thing controlling those buttons comes from the mutation runs, which print the exact
<button>that reappears when each gate is reverted.Local, on
5c8ddc6: 26 files / 497 tests acrossChatInput*,TrustDropdown,ApprovalCard; 8 files / 340 tests across the remaining Trust-asserting specs (ActivityViewerCoverage,ApprovalModePicker,ChannelPageCoverage,MochiChatPanel*,MochiSettingsPanel,mochiTrustGrantability);i18nLintExemptions43 tests.eslintclean on the three files,tsc -bexit 0,check_focus_cue/check_feature_map/check_changelog_history/leaf_test_scope/ratchet_scope/check_harness_parityall pass withBASE_REFset to the merge-base.Screenshots / video
None, and not omitted for convenience: with a slot present the rendered bar is unchanged, and the state that differs is not reachable from the shipped UI, so a capture would either show no difference or show a state a user cannot reach. The reappearing buttons are evidenced as serialized DOM in the mutation table above instead.
Related Issues
Closes #5486
Refs #5400, #5434, #8193
Pattern harvest
Rule candidate: review-prompt
Pattern: a decision string crossing into an endpoint that cannot honor every value it can carry, with the UI reporting the value it SENT rather than the one that was recorded. Three surfaces shipped this same shape independently (#5400, #5434, #5486), and the tell each time was an else-approve fallback (
x === 'reject' ? 'reject' : 'approve', or an OR-chain ontoapprove) reachable from a richer verb set than the endpoint's. The generalizable question for a reviewer: for each verb this control can emit, does the endpoint it lands on record that verb -- and does the UI report what was recorded or what was clicked? The static form of the guard, plus whether one predicate should own the routing, is #8193.