diff --git a/.changeset/my-open-cases-means-open.md b/.changeset/my-open-cases-means-open.md new file mode 100644 index 00000000..135097d9 --- /dev/null +++ b/.changeset/my-open-cases-means-open.md @@ -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. diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index 93006283..2e2ac02d 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -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: diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index 8bdcf87c..7bb2bd2e 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -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* 的工单 | 本节原先列的七个名字里,有六个根本不是视图: diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index 233f1f07..734a2319 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -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* 的工單 | 本節原先列的七個名字裡,有六個根本不是檢視: diff --git a/src/sharing/case.sharing.ts b/src/sharing/case.sharing.ts index 931fbb47..1dab795a 100644 --- a/src/sharing/case.sharing.ts +++ b/src/sharing/case.sharing.ts @@ -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', @@ -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', diff --git a/src/views/case.view.ts b/src/views/case.view.ts index 99be626d..ea3d1aee 100644 --- a/src/views/case.view.ts +++ b/src/views/case.view.ts @@ -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' }, diff --git a/test/case-create-form-narrowing.test.ts b/test/case-create-form-narrowing.test.ts index 3ba9ae9d..905a5510 100644 --- a/test/case-create-form-narrowing.test.ts +++ b/test/case-create-form-narrowing.test.ts @@ -138,7 +138,13 @@ const LIFECYCLE_MAINTAINED: Record = { 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'], }, }; diff --git a/test/live-work-predicate-parity.test.ts b/test/live-work-predicate-parity.test.ts index a836120f..76c97a76 100644 --- a/test/live-work-predicate-parity.test.ts +++ b/test/live-work-predicate-parity.test.ts @@ -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 @@ -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', @@ -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)`); } @@ -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 }[] = [ { @@ -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.', }, ];