feat(service): give the triage claim gesture a Claim Case button - #1594
Merged
Conversation
Piece 2 of #1144, ruled Option A in decision batch #21 (2026-09-03). The claim already worked: an agent takes an unowned case out of triage by moving it to a status that means a person is on it, and `case_self_claim` stamps `owner_id` with the caller. Nothing on the screen said so. This adds the affordance and nothing else. - `claim_case` action (`type: 'flow'`, record header + triage row menu), visible on exactly the set `case_unassigned_triage_sharing` grants edit on; - `claim_case` screen flow performing the STATUS MOVE only, `runAs: 'user'` because an elevated run makes the claim seam inert; - one guard proving ownership keeps exactly one writer; - four locale packs and the automation flow table in three doc locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
`test/runtime-coverage.test.ts` requires every registered flow to be named in a `test/flow-*.test.ts` runtime test; `claim_case` joins its two siblings in `test/flow-case-actions.test.ts` rather than taking a PENDING_FLOWS entry. The harness runs flows against a store with no hooks, which is what makes it the right place to measure the negative half: after a claim run the case's `owner_id` still holds no user, because the flow is not what writes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
The flow harness binds no user, so a `{$user.id}` payload resolves to nothing
and reads as unowned — measured. The metadata guard is what catches that form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-steve
marked this pull request as ready for review
September 5, 2026 15:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1144
Piece 2 of #1144, ruled Option A in decision batch #21 (2026-09-03, verbatim 「批次 #21 同意」, ruling comment
5527983203). Piece 1 — the docs — landed 2026-08-31 in PR #1409 asPart of. This one closes the card.What ships
Claiming an unowned case already worked and had done since the claim seam shipped: an agent moves the case to In Progress, Waiting on Customer or Waiting on Support, and
case_self_claim(priority 260) stamps them as owner. Nothing on the screen said so. This adds the affordance.claim_caseactiontype: 'flow',locations: ['record_header', 'list_item'],refreshAfterclaim_caseflowrunAs: 'user', oneupdate_recordwritingstatusand nothing elsetest/claim-case-one-owner-writer.test.ts— the flow's payload never carriesowner_id(27)→(28)numeralTwo things measured rather than copied
① The
visiblepredicate is the GRANT's, not the card's. The card asked forrecord.owner_id == null && record.is_closed == false. Re-derived fromsrc/objects/_case-assignment.ts→src/sharing/case.sharing.tsas the ruling's execution note instructed,case_unassigned_triage_sharingreads:is_closedis derived asstatus === 'closed'and never flips onresolved, so the card's version would have offered a Claim button on resolved ownerless cases the agent is not shared and cannot claim — a button that answers FORBIDDEN. The action carries the grant's expression, and the guard compares it against the shipped rule rather than a literal, so a future re-derivation of the grant moves both or fails.②
runAs: 'user'is load-bearing here, not boilerplate.case_self_claimreturns early for a write with no user and again forctx.session.isSystem. An elevated run would move the status and claim nothing — the case leaves triage still ownerless, which is worse than a refusal because it looks like it worked. House rule 9 already says a screen flow staysuser; this is the case where that is functional. Declared explicitly and guarded.Ownership keeps exactly one writer, and it is proven able to fail
The ruling asked for the guard, and the dispatch asked that its ability to fail be demonstrated before its green was believed. Two mutation legs were run at the same site — the
claimnode's field map — each proven on disk by blob hash before the run it justifies, each restored bygit checkout HEAD -- <path>and the restore proven by the file's blob hash matchingHEAD:and by an emptygit diff HEAD.statusclaim-case-one-owner-writerflow-case-actionsowner_id: '{$user.id}'claim (update_record).status,owner_idowner_id: 'agent-literal'expected 'agent-literal' to be nullRestored run, both files:
Test Files 2 passed (2)/Tests 22 passed (22).⭐ The two legs did not agree, and that is written into the runtime file rather than smoothed over. The flow harness binds no user, so
{$user.id}resolves to nothing and the column still reads empty — which is precisely the form an author would reach for. The metadata guard is what catches the realistic mistake; the runtime cases are its behavioural companion, not its replacement. A note intest/flow-case-actions.test.tssays so, so nobody later deletes the metadata guard on the grounds that "the runtime test covers it".Surface: three files beyond the declared list, each mechanically forced
The dispatch scoped this to the action, the flow, four packs, three
automation*.mdx, one new guard, barrels and the changeset. Three more files were required by gates, not chosen — declared here rather than smuggled:test/automation-docs-coverage.test.ts— aROW_LABELledger entry. A shipped flow with no Chinese row label redsthe ledger answers exactly the flows this app ships; the ruling itself names this file as the gate the docs rows satisfy, so the entry is the other half of the ruled work.test/flow-case-actions.test.ts—test/runtime-coverage.test.tsrequires every registered flow to be named in atest/flow-*.test.ts.claim_casejoins its two siblings in the file that already owns the case screen actions, rather than taking aPENDING_FLOWSentry — that list may only ever shrink, and a new flow's first act should not be to grow it.README.md+docs/STATUS.md—docs-metadata-countsanddocs-declared-versionsread the flow and action counts off the compiled stack. 27 → 28 flows, 30 → 31 actions, five claims across the two files.Nothing under
src/actions/lead.actions.ts(#1578) orcontent/docs/reference/faq*(#1402) is touched.src/objects/_case-assignment.tsis read but not edited — see below.Verification
Union run on the final commit
63c2890(git rev-parse --short HEADread from that same tree), the heavy legs through the shared verify lock withOS_VERIFY_LOCK_SLOT=hotcrm-1144, every exit code captured by redirect-then-read and never across a pipe.pnpm exec vitest run --maxWorkers=2→Test Files 160 passed (160)/Tests 3391 passed | 1 skipped (3392), lock lineVERDICT command-exit 0 · held the lock 169s.pnpm validate && pnpm typecheck→VERDICT command-exit 0.pnpm build && pnpm lint→✓ Build complete,VERDICT command-exit 0; no lint finding namesclaim_case.check-source-hygiene.mjs→✓ source hygiene clean;check-source-token-ratchet.mjs→✓ source token ratchet clean;check-lint-i18n-gate.mjs→✓ i18n lint gate: 0 i18n/missing-* issues.grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'exit 1, zero matches.business semanticsratchet moved~84,018→~84,571(+553), leaving ~429 tokens of headroom under the 85,000 ceiling. Clean, but the nextsrc/objects|flows|actions|hooksaddition of any size will not fit. Comments and blank lines are stripped, so this is real code; the four locale packs are outside the ratchet by ruling and cost nothing here.One finding, filed not fixed
#1593 — #1327 closed with its acceptance criterion half met. Its header fix landed (
git grep 'two layers'is empty today), butcase_self_claim's guard-4 comment still says the sharing rule "draws withis_closed == false", which it has not since #1145. Same file, same two layers, one screenful below where the fix landed. #1327 is closed, so this is its residue rather than a duplicate; a targeted search returned 44 results and no open card names it. ⛔ Not fixed here — outside this card's surface, and the predicates themselves are correct and deliberately asymmetric.Generated by Claude Code