fix(service): the triage tab and its sharing rule mean live work, not not-yet-closed - #1323
Merged
Merged
Conversation
…-yet-closed `unassigned_triage` and `case_unassigned_triage_sharing` both keyed "no longer live work" on `is_closed`, which `case_sla_defaults` derives as `effStatus === 'closed'` — so it never flips on `resolved`. A resolved, still-ownerless case satisfied both filters and stayed in the triage tab indefinitely, and the sharing rule handed every service agent `edit` on it for the same reason. Both now use the predicate the load-balancing hooks and `case_sla_monitor` already use: `status` is neither `resolved` nor `closed`. The view spells it `operator: 'not_in'`, which lowers to `$nin`; the sharing rule spells it as an `!=` chain, which lowers to a conjunction of `$ne` — the membership form lowers to a top-level `$not` wrapping an `$in`, a combination absent from the operator matrix in `test/sharing-seeding.test.ts`, and an unexecutable sharing condition is dropped silently rather than failing loudly. The sharing half is a TIGHTENING of access: agents no longer hold edit on resolved ownerless cases. Resolving an unowned case out of triage is unaffected (access resolves against the stored row, which is still open at that moment); what narrows is reopening an already-resolved unowned case, which now matches how a closed unowned case has behaved since #1134. `test/live-work-predicate-parity.test.ts` holds the five consumers to one set, by name — and pins the boundary by name too, so `case_workflow`, `my_open_cases`, `sla_at_risk` and the two critical-escalation sharing rules cannot drift into the set without saying so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ED4cusQ7pxZ7d7TCLKrZb
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-trump
marked this pull request as ready for review
August 26, 2026 07:18
This was referenced Aug 26, 2026
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 #1145
Option A as ruled on 2026-08-25: the
unassigned_triageview and thecase_unassigned_triage_sharingrule both stop keying "no longer live work" onis_closedand start keying it onstatus.The premise, re-measured on
origin/main@d863d54Verbatim, and it holds:
src/views/case.view.ts:191{ field: 'is_closed', operator: 'equals', value: false }onunassigned_triagesrc/sharing/case.sharing.ts:94P`record.owner_id == null && record.is_closed == false`src/objects/case.hook.ts:268input.is_closed = effStatus === 'closed'— never flips onresolvedsrc/objects/_case-assignment.ts:300,:445status: { $nin: ['resolved', 'closed'] }src/flows/case-sla-monitor.flow.ts:43status: { $nin: ['resolved', 'closed'] }src/objects/case.object.ts:420'resolved': ['closed', 'in_progress']— reopening is a declared transitionTwo of the four consumers already expressed the concept as a status set, and the
tab's declared purpose is "work waiting for a human". A resolved, still-ownerless
case satisfied both of the tab's filters and stayed in it indefinitely.
This PR takes a permission away.
case_unassigned_triage_sharinggrants everyholder of the Service Agent position
editon unowned cases so they can pullone out of the queue. Keyed on the derived flag, it was also granting every agent
editon every resolved ownerless case, permanently — a grant nobodyintended and nothing ever revoked. Those grants disappear on the next reconcile.
What an agent loses, precisely: reopening an already-resolved unowned case.
That now matches how a closed unowned case has behaved since #1134. Nothing
else narrows.
Verification of the claim seam (the #1134 / PR #1143 condition)
The seam still holds, and it is now pinned rather than argued:
resolved against the stored row, which is still open at the moment of the
write — so the agent reaches it, the write is allowed, and PR feat(service): let an agent claim an unowned case out of triage (#1096) #1143's
"finishing a case is not picking it up" is untouched. Driven on both drivers in
test/unassigned-case-triage-reach.test.ts, with the failure message spellingout that blocking the resolve itself would be the wrong fix.
staying in it. The same test now reconciles the rule after that resolve and
asserts the share count is zero.
stays performable by everyone who can reach the row (an admin via
viewAllRecords; the owner via own-scope; a manager or director on a criticalcase).
resolvedsimply joins the bucketclosedwas already in.here.
case_self_claim's guard 4 excludesclosedonly, and its header saysthat guard and the sharing predicate are "the SAME rule stated at two layers".
After this change the sharing layer excludes two statuses and the hook layer
one. The behaviour is correct either way — reopening a resolved case is
picking the work up, so an admin who does it should become the owner, and
resolvedwas never a claimable target. What is now stale is one sentence ofprose in
src/objects/_case-assignment.ts, which is outside this PR's filesurface. Reported rather than edited.
The guard: one predicate, pinned BY NAME
test/live-work-predicate-parity.test.tsholds five consumers toCLOSED_CASE_STATUSES:unassigned_triage·case_unassigned_triage_sharing·case_auto_assign·case_escalation_reassign·case_sla_monitorEach is looked up by name, and a name that fails to resolve — or that stops
excluding any status at all — is a failure, not a skipped row. A count-based
guard would have been satisfied by any N and would have let a consumer walk out
of the assertion silently. Each is normalised through the platform's own lowering
first (
parseFilterASTfor the view rule,compileCelToFilterfor the CELcondition — the exact function the sharing seeder calls), so what is compared is
the set each consumer excludes, not its source text; the four surfaces have
four authoring schemas and always will.
The file also pins the boundary by name, so nothing drifts into the set
without saying so:
case_workflow,my_open_cases,sla_at_risk,case_escalation_sharing,case_director_sharing.Reverse-verification (four legs, each mutation confirmed on disk by blob hash, each restore by an empty
git diff HEAD)unassigned_triageback onis_closedcase_sla_monitornarrowed to['closed']case_auto_assignrenamed so the roster lookup missesLeg B was run twice: the first attempt's anchor matched zero times, so it was a
no-op and its green reading is void. It is reported here from the re-run that
actually landed (blob
462087achanged to3ecc731).case_workflow— measured, and deliberately NOT changedThe ruling said "and
case_workflowif it is the same disease". Measured, it isnot.
KanbanConfigSchemacarries onlygroupByFieldand a cardcolumnslist — noswimlane roster — so the board's columns come from the
statusfield's ownoptions. Resolved is a column on that board, and it is where a card lands
when an agent drags one across. Excluding it would empty a swimlane the board
still renders and turn the resolve gesture's destination into a hole. The triage
tab's contract is "work waiting for a human"; this board's contract is the
lifecycle itself, which is why it already hides only the terminal state. Left
alone, and pinned as a deliberate non-consumer.
Mechanism correction
The dispatch assumed
$ninis expressible in a viewfilter[], from evidencegathered on hooks. It is — with a per-surface spelling.
$ninitself is thelowered AST form and is not authorable in a view filter; the view-layer spelling
is
operator: 'not_in', a canonical member ofVIEW_FILTER_OPERATORSwhosevalue is a list, and
parseFilterAST(['status', 'not_in', [...]])lowers toexactly
{ status: { $nin: [...] } }. Nothing here touches the open question onview filters and disjunction.
The sharing condition is a CEL surface, so it gets a third spelling: an
!=chain. That choice is measured, not stylistic —
!(record.status in [...])alsocompiles, but it lowers to a top-level
$notwrapping an$in, acombination absent from the operator matrix at the head of
test/sharing-seeding.test.tsand on the one operator that file records a driverregression for. The chain lowers to a plain conjunction of
$ne. Anuntranslatable or unexecutable sharing condition is not a loud failure — the
seeder drops the rule and the tab goes empty again — so this rule takes the
portable form.
Files
src/views/case.view.ts— theunassigned_triagefilter (itslabelisuntouched)
src/sharing/case.sharing.ts— the triage rule's condition. The twocritical-escalation rules at
:15and:32are untouchedtest/live-work-predicate-parity.test.ts— new guardtest/unassigned-case-triage-reach.test.ts— a resolved ownerless fixture, thebehavioural pin for the tightening, and the claim-seam pin
test/case-assignment.test.ts,test/authorization-coverage.test.ts,test/sharing-seeding.test.ts— three existing pins that asserted the oldpredicate verbatim and had to move with it
.changeset/triage-tab-is-live-work-not-unclosed.mdGates
pnpm verifygreen onc0b6ac0, the head of this branch:✓ i18n lint gate·✓ source hygiene clean·✓ source token ratchet clean(interaction layer 37,424 to 37,428 tokens) ·
✓ Build complete·Test Files 141 passed (141)·Tests 2986 passed | 1 skipped (2987).Generated by Claude Code
Generated by Claude Code