Skip to content

feat(service): give the triage claim gesture a Claim Case button - #1594

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-1144-claim-case-action
Sep 5, 2026
Merged

feat(service): give the triage claim gesture a Claim Case button#1594
os-steve merged 3 commits into
mainfrom
claude/issue-1144-claim-case-action

Conversation

@os-steve

@os-steve os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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 as Part 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_case action type: 'flow', locations: ['record_header', 'list_item'], refreshAfter
claim_case flow screen flow, runAs: 'user', one update_record writing status and nothing else
guard test/claim-case-one-owner-writer.test.ts — the flow's payload never carries owner_id
i18n four locale packs (en · zh-CN · es-ES · ja-JP)
docs the built-in flow table row in three locales, plus the (27)(28) numeral

Two things measured rather than copied

① The visible predicate is the GRANT's, not the card's. The card asked for record.owner_id == null && record.is_closed == false. Re-derived from src/objects/_case-assignment.tssrc/sharing/case.sharing.ts as the ruling's execution note instructed, case_unassigned_triage_sharing reads:

record.owner_id == null && record.status != "resolved" && record.status != "closed"

is_closed is derived as status === 'closed' and never flips on resolved, 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_claim returns early for a write with no user and again for ctx.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 stays user; 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 claim node's field map — each proven on disk by blob hash before the run it justifies, each restored by git checkout HEAD -- <path> and the restore proven by the file's blob hash matching HEAD: and by an empty git diff HEAD.

mutation added beside status claim-case-one-owner-writer flow-case-actions
owner_id: '{$user.id}' REDclaim (update_record).status,owner_id GREEN
owner_id: 'agent-literal' RED REDexpected 'agent-literal' to be null

Restored 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 in test/flow-case-actions.test.ts says 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 — a ROW_LABEL ledger entry. A shipped flow with no Chinese row label reds the 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.tstest/runtime-coverage.test.ts requires every registered flow to be named in a test/flow-*.test.ts. claim_case joins its two siblings in the file that already owns the case screen actions, rather than taking a PENDING_FLOWS entry — that list may only ever shrink, and a new flow's first act should not be to grow it.
  • README.md + docs/STATUS.mddocs-metadata-counts and docs-declared-versions read 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) or content/docs/reference/faq* (#1402) is touched. src/objects/_case-assignment.ts is read but not edited — see below.

Verification

Union run on the final commit 63c2890 (git rev-parse --short HEAD read from that same tree), the heavy legs through the shared verify lock with OS_VERIFY_LOCK_SLOT=hotcrm-1144, every exit code captured by redirect-then-read and never across a pipe.

  • Full suitepnpm exec vitest run --maxWorkers=2Test Files 160 passed (160) / Tests 3391 passed | 1 skipped (3392), lock line VERDICT command-exit 0 · held the lock 169s.
  • pnpm validate && pnpm typecheckVERDICT command-exit 0. pnpm build && pnpm lint✓ Build complete, VERDICT command-exit 0; no lint finding names claim_case.
  • Gate scriptscheck-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.
  • Control bytes over all 17 changed files → grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' exit 1, zero matches.

⚠️ Budget note for the reviewer. The business semantics ratchet moved ~84,018~84,571 (+553), leaving ~429 tokens of headroom under the 85,000 ceiling. Clean, but the next src/objects|flows|actions|hooks addition 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), but case_self_claim's guard-4 comment still says the sharing rule "draws with is_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

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
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 5, 2026 3:45pm UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Sep 5, 2026
@os-steve
os-steve marked this pull request as ready for review September 5, 2026 15:49
@os-steve
os-steve added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 3497c88 Sep 5, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document the triage claim gesture, and give it a Claim button

2 participants