fix(dashboard): single-source the one-shot approval decision mapping - #8742
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of This PR is entirely an internal refactor: it collapses three private copies of the approval-decision mapping into one shared
The fail-closed direction (unknown verb → reject, never approve) preserves the UX truth-telling property the earlier bugs (#5400/#5434/#5486) violated — the UI can no longer report a standing trust grant the backend never recorded. UX-Verdict: PASS Pure internal single-sourcing; no user-facing string, control, flow, or pixel changes, and the fail-closed mapping keeps the UI honest about trust grants. [UX-REVIEWED] 3a8ce2b |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- website/eslint-rules/approval-one-shot-decision.js:195 -- False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right shape: the mapping is the defect, so single-sourcing it at the only layer that still sees the verb, plus a fail-closed allowlist lint, is root-cause and proportionate. [DESIGN-REVIEWED] 3a8ce2b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsConfirmed: the two buttons pass Assessment of CANDIDATE 1: The technical claim about the lint rule is accurate — the No new grounded findings surfaced in Step 2. No findings. [OPUS-REVIEWED] 3a8ce2b Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified: the shared First-Principles-Verdict: PASS Cause-level fix: the mapping is made single at the only layer that still sees the trust verb, and the gate is derived from three counted shipped defects. What this change shipsIntent: stop a standing-trust click from ever again being silently downgraded to a one-shot approve, by single-sourcing the decision mapping and gating re-introduction. This is a FIX (of a defect class; the three live instances were already fixed, and the PR says so).
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 3a8ce2b |
036d2cb to
d8bc0a6
Compare
d8bc0a6 to
f7c159b
Compare
|
Design Review's BLOCK on What happened. PR Hygiene caps a PR at two commits and reserves the second for a mechanical follow-up, so after the round-2 review fixes I squashed with the recipe that check's own error text suggests: I ran it without the What that silently reverted, none of it intended and none of it in the description:
The consequence the review named is the one that matters: the regression tests went with the hardening, so nothing would have gone red on a reopened protected-branch escape route. The fix. Rebuilt the branch directly on current Verified after: Nothing about the reviewed change itself moved -- the nine files are byte-identical to what was on |
The one-shot endpoint POST /api/approvals/{id}/{action} honors exactly approve,
reject and reject_once, and records no standing grant. A trust verb has no
representation there. Three surfaces shipped the same defect independently --
#5400 (spawn-approval card), #5434 (collapsed tool row), #5486 (ChatInput) --
each by computing its own decision, which mapped a trust verb onto approve: the
tool ran once, the UI reported a standing grant, and the backend recorded
nothing. The user's next identical action prompted again, which reads as the
grant having been forgotten rather than never made.
Two chokepoints already existed and neither could fire. api.resolveApproval is
typed to the three honored actions, and api_approval_resolve returns 400 for
anything else -- but both only see a verb SENT to the endpoint, and the verb
never arrives as itself. A call-site mapping converts it into approve BEFORE the
request is made, so by the time either check runs the evidence that standing
trust was requested is gone. A guard cannot recover what the caller erased before
calling; the mapping is the defect, so nothing downstream of it can see the class.
Source is the only layer where the verb still exists as itself, so the mapping is
made single and a lint rule keeps it that way.
- utils/approvalDecision.ts now owns toApiDecision, beside the isRejectedDecision
predicate that already single-sources this vocabulary.
- ChatInput and ChatPage drop their private copies and import it. Both were
already fail-closed, and CollapsibleToolGroup emits only approved / trust /
rejected (never rejected_once), so the two spellings agreed over every
reachable input: behaviour unchanged.
- ActivityViewer's ApprovalEntry replaces an inline fail-OPEN ternary
(action === 'rejected' ? 'reject' : 'approve'). Reachability was measured
first: the card renders exactly two buttons and the complete reachable
argument set is {approve, reject}, so behaviour is unchanged today. It was
nonetheless the last in-tree instance of the shape the three regressions took,
safe only because its two callers pass literals.
- eslint-rules/approval-one-shot-decision.js checks the decision argument
against a fail-closed ALLOWLIST, registered 'error' so it cannot ride the
--max-warnings 0 ratchet.
The rule is an allowlist rather than a denylist of ternaries because a
ternary-only rule would have caught none of the three defects it cites: each was
a module-private mapper, and hoisting a ternary one line up into a local changes
the argument node type and nothing else. Enumerating every resolveApproval
decision argument in the repo gives Literal x3, Identifier x6, CallExpression x4,
so the allowlist admits exactly those three shapes and reports anything else:
- a literal, which must also name an action the endpoint honors, so a literal
trust verb is caught too;
- a call, which must be the shared toApiDecision and not a private mapper;
- an identifier, allowed when it relies on its declared type (parameter, import,
destructure) and reported when scope analysis shows it assigned a computed
decision, whether by declarator initializer or a later write.
Admitting pre-narrowed identifiers is deliberate: a stricter literal-or-mapper
rule would flag six legitimate call sites already typed 'approve' | 'reject',
and routing those through a string-accepting mapper would be a downgrade.
What the rule cannot do is stated in its header rather than implied: with no type
information it cannot tell a narrowed identifier from a widened one, so a
parameter declared string and passed straight through still passes. It catches a
decision COMPUTED at the call site -- inline, hoisted, or delegated to a private
mapper. Closing the remainder needs a type-aware lane, which is a separate change.
Tests lead with violations, because a suite that only lints the clean tree passes
when the rule works, when it is broken, when it is unwired, and when it does not
run. Nine of twelve cases assert on fixtures that must be flagged; three are
negative controls. Every check here was proven able to fail: the four bypass
tests went RED against the denylist before the allowlist made them GREEN;
removing the fail-closed arm reddens exactly "flags an unrecognized argument
shape"; making isSharedMapperCall always true reddens exactly "flags a private
mapping helper"; unwiring the rule from eslint.config.js reddens the wiring test;
and reapplying the original ternary to ActivityViewer makes eslint error at the
call site. ChatInput.trustOneShot no longer brace-matches the mapper out of the
component to rebuild it with new Function -- it imports what ships, and pins that
ChatInput does not re-declare the mapping, the one regression the rule cannot see.
Verification: 88 targeted tests passed / 0 failed, tsc -b exit 0, and
eslint src/ --max-warnings 0 exit 0 (CI's exact invocation) with all 13 real call
sites passing the stricter rule.
Refs #8193
f7c159b to
3a8ce2b
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review: APPROVE.
Correctness of the mapping, per call site, against the reachable input set. The shared toApiDecision is approved -> approve, rejected_once -> reject_once, else reject (fail-closed).
ChatInput.tsx-- the deleted private copy was byte-identical to the shared one. Zero delta.ChatPage.tsx-- the old mapping wasaction === 'approved' ? 'approve' : 'reject'; the new one adds arejected_oncearm. Verified at source that bothCollapsibleToolGroupmounts (7897, 9218) omitcanTrust, and that the component emits onlyapproved/trust/rejectedliterals (submitDecisionat CollapsibleToolGroup.tsx:246-248) -- neverrejected_once. The two spellings therefore agree over every reachable input, includingtrust, which both send toreject.ActivityViewer.tsx-- the replaced ternary was fail-OPEN (action === 'rejected' ? 'reject' : 'approve').ApprovalEntryrenders exactly two buttons passing the literalsapprovedandrejected; both map identically old and new. The only delta is what a future widened caller receives:rejectinstead ofapprove. On a consent path that is the correct direction, and it removes the last in-tree instance of the shape #5400/#5434/#5486 each shipped.
Also checked: the vocabulary stays consistent with the isRejectedDecision predicate already in this module (rejected_once is a reject in both), and dropping toApiDecision from the ChatPage useCallback deps at 7904 is correct now that it is a stable module import.
Signals. PR Readiness success; all 64 check-runs success or skipped, including Frontend Lint & Type Check with the new rule registered at error on the whole tree; all five AI lanes PASS; zero review comments; zero prior reviews.
The one advisory finding is real and non-blocking. GPT is right that writtenExpressions admits a lookup or switch assignment (const d = MAP[action] is a MemberExpression, in neither COMPUTED_DECISION_TYPES nor the call branch), so that re-introduction path passes the rule. This is a gap in the new guard's reach, not a defect in the shipped mapping, and the description pre-discloses the class ("Closing the remainder needs a type-aware lane") -- though its "hoisted into a local" phrasing is broader than what the rule actually covers. Worth a follow-up that either adds MemberExpression to the reported shapes or tightens that sentence; it does not gate this merge, since the runtime behaviour of all four consumers is unchanged and the rule is strictly better than the three private mappings it replaces.
1. What is the problem?
The one-shot approval endpoint
POST /api/approvals/{id}/{action}honors exactlyapprove,rejectandreject_once, and records no standing grant. A trust verb (trust,trust_reads,trust_command,trust_base) has no representation there at all -- it belongs on a grant-recording endpoint instead.Three surfaces shipped the same defect independently, each by computing its own decision that mapped a trust verb onto
approve:ChatInput's own mapping#8193 asks for "a chokepoint" so a fourth author cannot reintroduce it. The finding of this PR is that a chokepoint as literally specified is unbuildable, and why.
Two chokepoints already exist and are already single:
website/src/api/client.ts:3172resolveApprovaltyped'approve' | 'reject' | 'reject_once'src/kiro_crew/dashboard/handlers/sessions.py:1507Both reject a trust verb sent to the one-shot endpoint -- but the verb never arrives as itself. A call-site mapping converts it into
approvebefore the request is made, so by the time either check runs, the information that standing trust was requested has been destroyed. A guard cannot recover what the caller erased before calling. The mapping is the defect, so nothing downstream of the mapping can see it.What was actually wrong was that the mapping was spelled three times, none importable by the others:
components/ChatInput.tsx:193toApiDecision, fail-closedpages/ChatPage.tsx:5433toApiDecisionuseCallback, fail-closedpages/chat/ActivityViewer.tsx:2972. Why this issue matters to the user
The failure mode is quiet and it sits on a consent path. The tool runs once, the UI says standing trust was granted, 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. Three surfaces shipped it independently, which is the signal that the rule was not written anywhere a fourth author would have to read.
3. How our fix solves it
Source is the only layer where the verb still exists as itself, so the mapping is made single and a lint rule keeps it that way.
utils/approvalDecision.tsnow ownstoApiDecision-- besideisRejectedDecision, the predicate that already single-sources this exact vocabulary and already carries the "adding the next token must be a one-line change here" doctrine.ChatInputandChatPagedrop their private copies and import it. Both were already fail-closed, andCollapsibleToolGroupnever emitsrejected_once(onlyapproved/trust/rejected), so the two mappings were equivalent over their reachable inputs: behaviour unchanged.ActivityViewer'sApprovalEntryreplaces its inline fail-OPEN ternary. Measured reachability first: the card renders exactly two buttons and the complete reachable argument set at the endpoint is{approve, reject}, so behaviour is unchanged today. It was nonetheless the last in-tree instance of the shape all three regressions took, safe only by the accident that its two callers pass literals -- and a render gate is one edit away from being widened.eslint-rules/approval-one-shot-decision.jschecks the decision argument against a fail-closed allowlist. Registered'error', not'warn', so it fails the build rather than riding the--max-warnings 0ratchet.Why the rule is an allowlist
A denylist of ternaries would have caught none of the three defects it cites: each was a module-private mapper, not an inline ternary, and hoisting a ternary one line up into a local changes the argument's node type and nothing else. Enumerating every
resolveApprovaldecision argument in the repo givesLiteralx3,Identifierx6,CallExpressionx4 -- so the rule admits exactly those three shapes and reports anything else:toApiDecision, not a private mapperAdmitting pre-narrowed identifiers is deliberate. A stricter literal-or-mapper rule would flag six legitimate call sites already typed
'approve' | 'reject', and routing those through astring-accepting mapper would be a downgrade, not a fix.What the rule does not catch, stated rather than implied: with no type information it cannot distinguish a narrowed identifier from a widened one, so a parameter declared
stringand passed straight through still passes. What it catches is a decision computed at the call site -- inline, hoisted into a local, or delegated to a private mapper. Closing the remainder needs a type-aware lane (parserOptions.project), which is a separate change with its own cost. The rule's header says so too, so the next reader is not misled about its reach.Deliberately not in this PR: the routing predicate (
TRUST_ACTIONS/approvalRouteinapps/mochi, plusChatInput's inline 4-verb array). Two spellings of a 4-verb array is a real smell, but they already agree, unifying them changes no behaviour, and it crosses an app boundary. It is not this item.4. What tests we did
Every check here was proven able to fail before being relied on.
The premise first. #8193's premise is conditional on #5400/#5434/#5486 being fixed. Verified on a fresh detached worktree at clean
origin/main6d1b51704withgit diff origin/main= 0 lines: all three behaviours are gone, and no fourth live consumer exists (useSceneInteraction, bothProjectDetailPagemutations,NotificationFeedandNotificationDetailPanelall pass literals or pre-narrowed unions). The one deliberate trust-to-one-shot downgrade (ChatInput:1115, unattended sources) is unreachable, becauseapprovalTrustGrantable = !!activeSlot && !approvalIsUnattendedgates every trust button.Reachability of
:297, by construction rather than by reading. Mounted the card, enumerated every button it renders, clicked each one, and captured every argument reachingapi.resolveApproval. Result: buttons[Approve, Reject], reachable arguments{approve, reject}-- no trust verb. That is why this is a hardening, not a live-defect fix, and the PR says so.Tests lead with violations.
approvalOneShotDecisionRule.test.tslints snippets through the realeslint.config.jswithallowInlineConfig: false. A suite that only lints the clean tree passes when the rule works, when it is broken, when it is unwired, and when it does not run -- four states with one green. So nine of the twelve cases assert on fixtures that must be flagged; three are negative controls.Five proofs that those tests can fail for the right reason:
flags an unrecognized argument shape, no collateralisSharedMapperCallforced trueflags a private mapping helper, no collateraleslint.config.jsActivityViewervia a file-based patchnpx eslinterrors at the call siteRegression runs (targeted, never the full suite):
approvalOneShotDecisionRule(12),ChatInput.trustOneShot(5),ChatPage.collapsedGroupTrust(2),ActivityViewerCoverage(48),CollapsibleToolGroupCov80(21) -- 88 passed, 0 failed; plusChatInput.approval,ChatInput.approvalNudge,ChatMessageList,toolApproval(102 passed).npx tsc -bexit 0.npx eslint src/ --max-warnings 0exit 0, exactly as CI runs it, with all 13 real call sites passing the stricter rule.check-i18n-strings.mjsOK at 460/1015.The
ChatInput.trustOneShotcontract was rewritten rather than deleted: it used to brace-matchtoApiDecisionout of the component and rebuild it withnew Function, a hack its own docstring apologised for because the helper was private. It now imports the function that actually ships, and adds an assertion thatChatInputdoes not re-declare the mapping locally -- the one regression the lint rule cannot catch, since the rule inspects the argument at the call site, not the file for a second definition.5. Any other suggestions on the work
Both advisory CONCERNS on the first revision were real and are fixed. Design Review found that the rule as first written was a denylist of two node types and therefore could not back the guarantee the description claimed -- a private mapper or a hoisted ternary passed it in silence, and the private-mapper shape is what all three cited defects actually were. That falsified a sentence in this description, so the rule was inverted to the allowlist above and the claim narrowed to what it actually guarantees. Its suggested remedy (literal or
toApiDecisiononly) was directionally right but too strict as written, which the shape census showed: it would have flagged six already-narrowed call sites. First Principles Review foundSHARED_MAPPERexported with zero consumers and unused even inside its own file; it is now the constant the mapper check tests against, with the export gone, andOneShotActiondrops its export for the same reason.Do not read the base reds as this PR's.
mainis red on6d1b51704, the sha this branch is cut from, withBackend Tests (3.12, 3),Backend Tests (Windows) (3)andCoverage Gatefailing in shell command-shape parsing. This is a frontend-only diff with zero Python lines. In the event, those three did not run at all.What remains open on #8193, and why this uses
Refsrather thanCloses: the issue also asks whether the routing predicate should be promoted out ofapps/mochi. That is a cross-app refactor on a consent path with no behavioural win, and it stays a maintainer decision.#8193should stay open for it.One correction to the issue's own table. It lists
ActivityViewer'sApprovalEntryas constrained by "two buttons only,approved/rejected". True of the render site, but silent about the mapping -- and the issue's own hazard sentence ("nothing stops a new component from writingresolveApproval(id, action === 'reject' ? 'reject' : 'approve')") described a shape that was already in the tree at that exact line.Pattern harvest
Rule candidate: eslint -- shipped in this PR as
website/eslint-rules/approval-one-shot-decision.jsPattern: a call site computes a value into a privileged enum, upstream of every layer that would have rejected the original value
The generalizable shape is not "a trust verb becomes
approve". It is an enum narrowing performed at the call site while the validating layers sit downstream of it. Both guards here were already single and already correct -- a typed client and a 400-returning handler -- and both were structurally blind, because the caller destroyed the distinguishing value before either could observe it. That is the part worth carrying forward: a chokepoint can only enforce what still reaches it.Any endpoint whose action vocabulary is narrower than its callers' decision vocabulary has this shape, and the tell is a per-call-site mapping between the two. Such a mapping is invisible to the type at the boundary (it returns the narrow type, so it type-checks) and invisible to the validator behind it (it emits a legal value). Three independent surfaces reached for it without seeing each other, which is what makes this a shape rather than an accident -- and why the fix is a source-level rule plus a single fail-closed mapping, not another runtime check.
A second, sharper lesson came out of review: the first version of that rule reproduced the very error it was written to prevent. It matched the shape the defect had in the description (an inline ternary) rather than the shape it had in the three real regressions (a private mapper), so a denylist of ternaries would have caught none of them. When harvesting a rule from a set of defects, check the rule against each defect's actual source, not against the summary.
Scope note: the rule is deliberately narrow -- it governs
resolveApproval's decision argument, not every narrow-enum boundary in the dashboard. Generalizing needs an inventory of those boundaries, which does not exist yet; this is the first entry.Why no screenshot: Nothing rendered changes, and that was measured rather than assumed. For
ActivityViewer'sApprovalEntry-- the only site whose mapping semantics differ from what it replaces -- the card was mounted, every button it renders was enumerated and clicked, and every argument reachingapi.resolveApprovalwas captured: buttons[Approve, Reject], arguments{approve, reject}, identical before and after. The rendered decision label is driven by the rawlocalDecision, which this diff does not touch.ChatInputandChatPageswap a private mapping for a byte-equivalent shared one (CollapsibleToolGroupemits onlyapproved/trust/rejected, neverrejected_once, so their two spellings agreed over every reachable input). The remaining changes are a lint rule, its config registration, comments and tests, none of which render. The behavioural delta is confined to what a future widened caller would receive:rejectinstead ofapprove.Refs #8193