Skip to content

fix(service): the triage tab and its sharing rule mean live work, not not-yet-closed - #1323

Merged
os-trump merged 1 commit into
mainfrom
claude/issue-1145-triage-live-work-predicate
Aug 26, 2026
Merged

fix(service): the triage tab and its sharing rule mean live work, not not-yet-closed#1323
os-trump merged 1 commit into
mainfrom
claude/issue-1145-triage-live-work-predicate

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes #1145

Option A as ruled on 2026-08-25: the unassigned_triage view and the
case_unassigned_triage_sharing rule both stop keying "no longer live work" on
is_closed and start keying it on status.

The premise, re-measured on origin/main @ d863d54

Verbatim, and it holds:

where measured
src/views/case.view.ts:191 { field: 'is_closed', operator: 'equals', value: false } on unassigned_triage
src/sharing/case.sharing.ts:94 P`record.owner_id == null && record.is_closed == false`
src/objects/case.hook.ts:268 input.is_closed = effStatus === 'closed' — never flips on resolved
src/objects/_case-assignment.ts:300, :445 status: { $nin: ['resolved', 'closed'] }
src/flows/case-sla-monitor.flow.ts:43 status: { $nin: ['resolved', 'closed'] }
src/objects/case.object.ts:420 'resolved': ['closed', 'in_progress'] — reopening is a declared transition

Two 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.

⚠️ The sharing half is a TIGHTENING of access — stated, not slipped through

This PR takes a permission away. case_unassigned_triage_sharing grants every
holder of the Service Agent position edit on unowned cases so they can pull
one out of the queue. Keyed on the derived flag, it was also granting every agent
edit on every resolved ownerless case, permanently — a grant nobody
intended 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:

  • Resolving an unowned case out of triage still works. Record access is
    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 spelling
    out that blocking the resolve itself would be the wrong fix.
  • What changes is what happens next: the row leaves the grant instead of
    staying in it. The same test now reconciles the rule after that resolve and
    asserts the share count is zero.
  • No real flow is made unreachable. Reopening stays a declared transition and
    stays performable by everyone who can reach the row (an admin via
    viewAllRecords; the owner via own-scope; a manager or director on a critical
    case). resolved simply joins the bucket closed was already in.
  • ⚠️ One seam consequence worth a reviewer's eye, deliberately not edited
    here.
    case_self_claim's guard 4 excludes closed only, and its header says
    that 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
    resolved was never a claimable target. What is now stale is one sentence of
    prose in src/objects/_case-assignment.ts, which is outside this PR's file
    surface. Reported rather than edited.

The guard: one predicate, pinned BY NAME

test/live-work-predicate-parity.test.ts holds five consumers to
CLOSED_CASE_STATUSES:

unassigned_triage · case_unassigned_triage_sharing · case_auto_assign ·
case_escalation_reassign · case_sla_monitor

Each 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 (parseFilterAST for the view rule, compileCelToFilter for the CEL
condition — 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)

leg mutation observed
A unassigned_triage back on is_closed 3 red, naming that consumer
B case_sla_monitor narrowed to ['closed'] 1 red, naming that consumer
C case_auto_assign renamed so the roster lookup misses 2 red — the by-name property, not a silent skip
D the sharing tightening reverted 8 red across both drivers, including both new behavioural pins

Leg 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 462087a changed to 3ecc731).

case_workflow — measured, and deliberately NOT changed

The ruling said "and case_workflow if it is the same disease". Measured, it is
not.

KanbanConfigSchema carries only groupByField and a card columns list — no
swimlane roster — so the board's columns come from the status field's own
options. 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 $nin is expressible in a view filter[], from evidence
gathered on hooks. It is — with a per-surface spelling. $nin itself is the
lowered AST form and is not authorable in a view filter; the view-layer spelling
is operator: 'not_in', a canonical member of VIEW_FILTER_OPERATORS whose
value is a list, and parseFilterAST(['status', 'not_in', [...]]) lowers to
exactly { status: { $nin: [...] } }. Nothing here touches the open question on
view 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 [...]) also
compiles, but it lowers to a top-level $not wrapping an $in, a
combination absent from the operator matrix at the head of
test/sharing-seeding.test.ts and on the one operator that file records a driver
regression for. The chain lowers to a plain conjunction of $ne. An
untranslatable 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 — the unassigned_triage filter (its label is
    untouched)
  • src/sharing/case.sharing.ts — the triage rule's condition. The two
    critical-escalation rules at :15 and :32 are untouched
  • test/live-work-predicate-parity.test.ts — new guard
  • test/unassigned-case-triage-reach.test.ts — a resolved ownerless fixture, the
    behavioural 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 old
    predicate verbatim and had to move with it
  • .changeset/triage-tab-is-live-work-not-unclosed.md

Gates

pnpm verify green on c0b6ac0, 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

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

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 26, 2026 7:13am

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces labels Aug 26, 2026
@os-trump
os-trump marked this pull request as ready for review August 26, 2026 07:18
@os-trump
os-trump added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit a76b69f Aug 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A resolved unowned case sits in Unassigned — triage forever: the tab filters is_closed, which only flips on closed

1 participant