Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/my-open-cases-means-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'hotcrm': patch
---

**My Open Cases** now lists cases whose status is neither *Resolved* nor
*Closed*. It used to list every case that was not *Closed*, so an agent's
"open" queue carried their finished work as well as their live work.

The tab filtered on `is_closed`, which the case hook derives as
`status === 'closed'` — it never flips on *Resolved*. Measured on the seeded
demo population: of 38 cases, the old predicate returned 30 and 7 of those 30
were resolved; the new one returns 23. Nothing else changes — no case is added
to the tab, and the seven that leave it are reachable in *All Cases*, on the
*Service Workflow* board where *Resolved* is a column of its own, and by
searching. A resolved case is finished work awaiting closure, and the
mainstream reading of "open" in a service queue excludes it: Zendesk's open set
omits Solved, and ServiceNow's out-of-the-box *Open* filter is
`state not in (Resolved, Closed, Cancelled)`.

The two sharing rules that hand a service manager `edit` and a service director
`read` on critical cases are **unchanged**, deliberately. They also stand
through the whole `resolved → closed` window, and there that reach is the
feature: `resolved` means "the agent believes this is fixed", and reviewing it —
quality sampling, the call-back, a reopen when the fix did not hold — is
precisely what a manager does in that window. Both grants are narrow (critical
only) and bounded (closing the case ends them). The reasoning now sits beside
each rule in the source, and both stay pinned on the boundary roster of
`test/live-work-predicate-parity.test.ts`, so a later "consistency" pass that
tries to align them turns that guard red instead of quietly revoking access.
2 changes: 1 addition & 1 deletion content/docs/service/cases.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Nothing else claims a case, and each exclusion is deliberate:
| **Escalated Cases** | `is_escalated` is true |
| **Unassigned — triage** | Open cases with no owner at all, urgent first then soonest due — where a web-to-case submission lands when nobody holds the Service Agent position |
| **⏰ SLA at Risk** | Open **High and Critical** cases, soonest due date first |
| **My Open Cases** | Your cases that are not closed |
| **My Open Cases** | Your cases whose status is neither *Resolved* nor *Closed* |

Six names this section used to list are not views at all:

Expand Down
2 changes: 1 addition & 1 deletion content/docs/service/cases.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案
| **Escalated Cases** | `is_escalated` 为真 |
| **Unassigned — triage** | 完全没有负责人的未结工单,先按紧急程度、再按最近的截止日期——没有人担任服务坐席时,Web 提交的工单就落在这里 |
| **⏰ SLA at Risk** | 未结的 **High 与 Critical** 工单,截止日期最近的排在前面 |
| **My Open Cases** | 你名下尚未关闭的工单 |
| **My Open Cases** | 你名下状态既不是 *Resolved* 也不是 *Closed* 的工单 |

本节原先列的七个名字里,有六个根本不是视图:

Expand Down
2 changes: 1 addition & 1 deletion content/docs/service/cases.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方
| **Escalated Cases** | `is_escalated` 為真 |
| **Unassigned — triage** | 完全沒有負責人的未結工單,先按緊急程度、再按最近的截止日期——沒有人擔任服務座席時,Web 提交的工單就落在這裡 |
| **⏰ SLA at Risk** | 未結的 **High 與 Critical** 工單,截止日期最近的排在前面 |
| **My Open Cases** | 你名下尚未關閉的工單 |
| **My Open Cases** | 你名下狀態既不是 *Resolved* 也不是 *Closed* 的工單 |

本節原先列的七個名字裡,有六個根本不是檢視:

Expand Down
56 changes: 56 additions & 0 deletions src/sharing/case.sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ import { P } from '@objectstack/spec';
* Share escalated/critical cases with service managers.
* ADR-0090 D3: `role_and_subordinates` is gone (positions are flat); the
* grant now targets the manager position itself.
*
* ### ⚠️ `is_closed == false` is DELIBERATE here — ruled 2026-08-31, do NOT align it
*
* `is_closed` is derived by `case_sla_defaults` as `effStatus === 'closed'` and
* never flips on `resolved`, so this grant stands for the WHOLE
* `resolved → closed` window: a manager keeps `edit` on a critical case that an
* agent has already resolved, until somebody closes it.
*
* That standing access is the point, not an oversight. `resolved` means "the
* agent believes this is fixed", not "this case is over", and the work that
* belongs in the gap is the manager's: quality-sampling the resolution, calling
* the customer back, reopening it when the fix did not hold. Taking the grant
* away at `resolved` would revoke access exactly when that review starts. It is
* also how mainstream service clouds shape the same window — a Zendesk
* satisfaction rating arrives after Solved, ServiceNow surveys on resolution
* and auto-closes on a timer.
*
* Two properties keep this from being the accumulating-permission hazard that
* #1145 was about: it is NARROW (`critical` only — not every case a manager
* could ask for) and it is BOUNDED (closing the case ends it, and closing is an
* ordinary agent gesture, not an admin one).
*
* ⛔ Do not rewrite this as `status not_in ['resolved', 'closed']` for symmetry
* with `case_unassigned_triage_sharing` below. Same spelling, different
* question: that rule asks "is this work waiting for a human", this one asks
* "may a manager still reach this case". `test/live-work-predicate-parity.test.ts`
* holds the difference on its boundary roster, so the alignment turns it red
* rather than landing silently.
*
* ### What would make this the WRONG shape
*
* The keep rests on a workflow nobody has yet measured a real user performing,
* so it is a judgement with named conditions rather than a fact. Any of these
* turning true makes it a card to re-decide, not a tidy-up to perform:
*
* - cases pile up in `resolved` because nothing closes them, so "bounded"
* stops being true in practice and the grant is effectively permanent;
* - the post-resolution review moves to a surface that needs no record access
* (a survey object, a report), leaving this grant with no consumer;
* - the criteria widens past `critical`, at which point "narrow" is gone too;
* - somebody measures that no manager ever opens a resolved case — the one
* piece of evidence #1328 could not obtain and explicitly declined to
* assume either way.
*/
export const CaseEscalationSharingRule = {
name: 'case_escalation_sharing',
Expand All @@ -23,6 +66,19 @@ export const CaseEscalationSharingRule = {
* Positions are FLAT (ADR-0090 D3), so the director rung needs its own grant —
* without it a service director cannot open the critical case their manager is
* being paged about. Read-only: the manager on the rule above handles it.
*
* ### ⚠️ `is_closed == false` is DELIBERATE here too — ruled 2026-08-31
*
* Read the note on `CaseEscalationSharingRule` above in full; it is the same
* decision and the same conditions for revisiting it. The short form: the flag
* never flips on `resolved`, so a director keeps `read` on a resolved critical
* case until it is closed, and that `resolved → closed` window is precisely
* when a director looks — an escalation is reviewed after it is handled, not
* while it is still burning. `read`, not `edit`, is the whole difference from
* the rule above: the director watches the window, the manager works it.
*
* ⛔ Do not move this onto `status not_in ['resolved', 'closed']`. Pinned on the
* boundary roster of `test/live-work-predicate-parity.test.ts`, with the reason.
*/
export const CaseDirectorSharingRule = {
name: 'case_director_sharing',
Expand Down
13 changes: 12 additions & 1 deletion src/views/case.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,20 @@ export const CaseViews = defineView({
label: 'My Open Cases',
data: { provider: 'object', object: 'crm_case' },
columns: ['case_number', 'subject', 'crm_account', 'priority', 'status', 'sla_due_date'],
// ⚠️ `status not_in CLOSED_CASE_STATUSES`, NOT `is_closed == false`: the
// flag is derived as `effStatus === 'closed'` and never flips on
// `resolved`, so the flag spelling listed every RESOLVED case under a tab
// labelled "My Open Cases" — measured on the seeded demo population, the
// flag returned 30 of 38 cases and 7 of those 30 were resolved. A resolved
// case is finished work awaiting closure, not an open queue, and the
// mainstream service-cloud reading of "open" excludes it (Zendesk's open
// set omits Solved; ServiceNow's OOTB Open filter is
// `state not in (Resolved, Closed, Cancelled)`). Ruled 2026-08-31; pinned by
// `test/live-work-predicate-parity.test.ts` (#1145), whose consumer roster
// this view joined in the same change.
filter: [
{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' },
{ field: 'is_closed', operator: 'equals', value: false },
{ field: 'status', operator: 'not_in', value: ['resolved', 'closed'] },
],
sort: [
{ field: 'priority_rank', order: 'desc' },
Expand Down
8 changes: 7 additions & 1 deletion test/case-create-form-narrowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ const LIFECYCLE_MAINTAINED: Record<string, { why: string; keeps: string[] }> = {
closed_date: { why: 'readonly on the object; stamped at close', keeps: ['case_timeline.endDateField'] },
is_closed: {
why: 'readonly on the object; derived from `status` on every write',
keeps: ['case_workflow.filter', 'my_open_cases.filter'],
// `my_open_cases.filter` was on this line until #1328 moved that view onto
// `status not_in ['resolved', 'closed']`: the flag is derived as
// `status === 'closed'` and never flips on `resolved`, so it could not
// express the tab's own label. `case_workflow` keeps the flag — that kanban
// is the lifecycle itself and `resolved` is a real swimlane on it. Both
// facts are pinned in `test/live-work-predicate-parity.test.ts`.
keeps: ['case_workflow.filter'],
},
};

Expand Down
76 changes: 53 additions & 23 deletions test/live-work-predicate-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ import { CLOSED_CASE_STATUSES } from '../src/objects/_case-assignment';
* and its name moved from the boundary roster into the consumer roster in the
* same change. `test/sla-at-risk-live-work.test.ts` is its behavioural half.
*
* ### The three that were left, answered one by one (#1328)
*
* #1145 left three same-shape consumers undecided and forbade widening to them.
* They were ruled 2026-08-31, and NOT the same way — which is the point:
*
* - `my_open_cases` JOINED the roster above. A tab labelled "My Open Cases"
* that lists resolved cases is a label saying one thing and a filter doing
* another; measured on the seeded demo population the flag returned 30 of
* 38 cases and 7 of those 30 were resolved.
* - `case_escalation_sharing` and `case_director_sharing` STAYED below, and
* stayed on `is_closed == false` on purpose. `resolved → closed` is the
* review window, and standing manager/director reach INSIDE that window is
* the workflow those grants exist for. Their entries carry the reasoning.
*
* ⛔ So "these three have the same shape" is not an argument for giving them the
* same predicate. Each entry below says which answer it got and why.
*
* ### ⚠️ BY NAME, never by count
*
* The roster below is a list of NAMES, and every name must resolve. A guard
Expand Down Expand Up @@ -211,6 +228,11 @@ const LIVE_WORK_CONSUMERS: { name: string; surface: string; excluded: () => stri
surface: 'view filter[] — src/views/case.view.ts',
excluded: () => excludedValues(loweredViewFilter('sla_at_risk'), 'status'),
},
{
name: 'my_open_cases',
surface: 'view filter[] — src/views/case.view.ts',
excluded: () => excludedValues(loweredViewFilter('my_open_cases'), 'status'),
},
{
name: 'case_unassigned_triage_sharing',
surface: 'sharing condition (CEL) — src/sharing/case.sharing.ts',
Expand Down Expand Up @@ -287,7 +309,7 @@ describe('the "no longer live work" predicate is ONE set, and the roster is by n
// above would also pass a consumer that excluded the right statuses AND
// additionally narrowed on the flag.
const offenders: string[] = [];
for (const name of ['unassigned_triage', 'sla_at_risk']) {
for (const name of ['unassigned_triage', 'sla_at_risk', 'my_open_cases']) {
const fields = narrowedFields(loweredViewFilter(name));
if (fields.includes('is_closed')) offenders.push(`${name} (view filter)`);
}
Expand All @@ -307,22 +329,23 @@ describe('the "no longer live work" predicate is ONE set, and the roster is by n
* The consumers that deliberately do NOT belong to the set above — pinned by
* name too, so the boundary is visible rather than inferred from an absence.
*
* ⚠️ These entries record a MEASUREMENT, not a decision anyone is free to make
* here. `case_workflow` was measured and ruled a different concept (below).
* `my_open_cases`, `case_escalation_sharing` and `case_director_sharing` were
* measured, reported, and left exactly as they are: widening #1145's ruling to
* them was explicitly out of scope, so what is pinned is their CURRENT shape —
* if one of them changes, that change becomes visible here instead of arriving
* as a silent widening.
* ⚠️ Every entry here is a RULED KEEP, not an unexamined leftover, and it is
* not a decision anyone is free to re-make in passing. `case_workflow` was
* measured and ruled a different concept; the two sharing rules were ruled a
* deliberate standing grant on 2026-08-31 (#1328). What is pinned is that they
* still key on `is_closed` and still exclude no status, so a later "tidy-up"
* onto the status predicate turns red here instead of landing silently.
*
* ⚠️ `sla_at_risk` was on this roster and has LEFT it (#1325). It is the one
* entry that turned out not to be a judgement call: `case_sla_monitor` owns SLA
* and had already answered the same question the other way, so the view was
* contradicting the automation rather than expressing a different concept. It
* is now in `LIVE_WORK_CONSUMERS` above. `my_open_cases` and the two sharing
* rules are NOT that case — they are open on decision card #1328, and moving
* one of them here on the strength of #1325 is exactly the silent widening this
* roster exists to make visible.
* ⚠️ TWO entries have LEFT this roster, and reading how is the fastest way to
* see what the boundary is for. `sla_at_risk` left in #1325: `case_sla_monitor`
* owns SLA and had already answered the same question the other way, so the
* view was contradicting the automation rather than expressing a second
* concept. `my_open_cases` left in #1328: its label promises "open" and its
* filter delivered "not closed", which is a user-visible inconsistency rather
* than a deliberate reach. Neither departure licenses the next one — the two
* rules below were examined in that SAME ruling and kept, so moving one of
* them up on the strength of the other two leaving is exactly the silent
* widening this roster exists to make visible.
*/
const NOT_LIVE_WORK: { name: string; surface: 'view' | 'sharing'; why: string }[] = [
{
Expand All @@ -336,20 +359,27 @@ const NOT_LIVE_WORK: { name: string; surface: 'view' | 'sharing'; why: string }[
'renders and make the resolve gesture\'s destination a hole. The triage tab\'s contract ' +
'is "work waiting for a human"; this board\'s contract is the lifecycle itself.',
},
{
name: 'my_open_cases',
surface: 'view',
why: 'Same shape, outside #1145\'s ruling. Measured and reported; not decided here.',
},
{
name: 'case_escalation_sharing',
surface: 'sharing',
why: 'Critical-escalation sharing, not triage. Ruled out of scope by #1145.',
why:
'The post-resolution REVIEW WINDOW, kept deliberately (ruled 2026-08-31). `resolved` ' +
'is the state in which a critical case gets quality-checked, called back on, or ' +
'reopened, and the manager holding `edit` is who does that — so the reach this rule ' +
'has over a resolved case is the feature, not the #1145 defect wearing another ' +
'spelling. It is narrow (critical only) and it is BOUNDED: `is_closed` flips at ' +
'`closed`, which ends the grant. Moving it onto the status predicate would revoke ' +
'access inside the window it exists for. The reasoning is beside the rule itself in ' +
'`src/sharing/case.sharing.ts`; changing it needs the same standing to be re-argued.',
},
{
name: 'case_director_sharing',
surface: 'sharing',
why: 'Critical-escalation sharing, not triage. Ruled out of scope by #1145.',
why:
'The same review window one rung up, kept deliberately (ruled 2026-08-31), and ' +
'`read` rather than `edit` — the director watches the window, the manager works it. ' +
'Same boundary as the rule above: narrow (critical only), bounded (ends at `closed`). ' +
'See `src/sharing/case.sharing.ts` for the reasoning beside the rule.',
},
];

Expand Down
Loading