Skip to content

fix(case): the agent an escalation takes a case from keeps read on it - #2115

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-2060-item-3-agent-loses-case
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-2060-item-3-agent-loses-case

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #2060 (item 3, finding B-04). Items 4, 5 and 7 to 18 of the card, and item 1's delete half, are not touched here; the card stays open.

Clause-②: no

What was wrong (measured on main f1c80837, @objectstack/* 18.0.0-next.1)

A service agent loses a case in the same save that escalates it.

  • Console, as service.agent: New Case → priority Critical → Create. The toast reads "Case created successfully" and the page reads "Record not found — The record you are looking for does not exist or may have been deleted." (DOM probe: recordNotFound: true, mayHaveBeenDeleted: true, subject absent). The same steps with priority High (control) land on the record (subjectOnPage: true).
  • Escalate Case on a case the agent owns but did not create: after the flow resumes, the page reads "Record not found", and again after a reload.
  • API, as the agent: GET /api/v1/data/crm_case/ID answers 404 RECORD_NOT_FOUND right after the Critical create, and still 2.5 s later. The stored row is status: escalated, owner_id = the service_manager holder, created_by = the agent. The High control answers 200. A PATCH to status: escalated answers 404 afterwards, both on a case the agent created and on one an admin created and handed to them.

Producer: case_escalation_reassign, src/service/objects/_case-assignment.ts:436 on base (if (best) input.owner_id = best;, beforeUpdate). It is reached on create by case_escalation_on_create (case-escalation.flow.ts:265, rebinding the assign_senior_agent node to record-after-create), and on Escalate Case by the escalate node (case-actions.flow.ts:80, user context). case_sla_defaults does not move the owner. The agent then has nothing to read with: service_agent holds crm_case with readScope: 'own' (src/sales/profiles/service-agent.profile.ts:25), and no sharing rule names the person the case was taken from.

What changed

  • escalated_from, a new readonly lookup → sys_user on crm_case (Escalation group). case_escalation_reassign stamps the owner it moves the case away from, in the escalation write itself. That is door ① of the module header, so the transfer gate is not involved and no permission changes. An ownerless case is stamped with nobody.
  • case_escalated_from_sharing (Escalated Cases — Previous Owner): condition: record.is_closed == false, accessLevel: read, sharedWith: { type: 'field', value: 'escalated_from' }. It shares the case with exactly one person, the one it was taken from. It is bounded like the two escalation rules beside it, and they are untouched.
  • Labels in the four packs. The rule is added to the admin sharing tables (en, zh-Hans, zh-Hant) and to src/docs/crm_admin.md. content/docs/service/cases*.mdx gets 25 fields, the Escalation group row and a sharing bullet. docs/STATUS.md goes from 357 to 358 fields. One changeset.

The profile line (readScope: 'own', no crm_case.allowTransfer) and both standing escalation rules are unchanged.

Why a stored previous owner and not created_by (H2 / H3)

H2, read in the installed tree: ShareRecipientType includes 'field' (@objectstack/spec src/security/sharing.zod.ts:113-120). It is a record-relative recipient, and value names a user-typed field on the record. The runtime honours it: plugin-sharing mapRecipientType maps field (dist index.js:6861), and expandRecipientForRecord / fieldHoldsUsers read the named column per matched record, accepting a user field or a lookup → sys_user (index.js:4872, :3959). created_by is injected as lookup → sys_user, so a creator grant would also have run.

The creator is the wrong person to key on, and the measurement shows it:

  • Escalation of a case the agent did not create is the common shape. Web intake is round-robined (created_by null), triage claims have no creator, and on the first demo boot all 38 cases the agent owned had created_by: null. The tester's own escalate step (CASE-00039, a case claimed out of triage) is that shape.
  • A creator grant would keep the person who logged the case, not the person it was taken from.

The spec's own guidance for record-relative audiences ("a user field the application stores on the record … plus a field recipient naming it") is the stored-owner shape. So the route is H3 with "its previous owner", and nothing in the H4 list was needed.

Measured after the change (live server, port 4163, fresh DB, pnpm demo:staff)

  • Console: the Critical create toasts "Case created successfully" and lands on the record (recordNotFound: false, subject on page). The Escalation section shows ESCALATED FROM: the agent. Escalate Case on an admin-handed case (created_by null) stays on the record after the run and after a reload.
  • API: the agent's GET answers 200 right after the Critical create and after escalation, both for a case it created and for one it did not. A second service_agent answers 404 on all four cases.
  • Grants on an escalated case (sys_record_share): case_escalated_from_sharing → agent read, and case_escalation_sharing → manager edit, which is exactly the manager's grant from before.
  • Boundaries:
    • the agent's PATCH to the escalated case answers 403 FORBIDDEN (read only);
    • an escalated_from the agent supplies, on create or on update, is dropped by the readonly strip and stored null;
    • the manager closing the case takes the agent back to 404.

Tests

test/escalated-case-previous-owner.test.ts (new) runs 6 cases on the booted app. Each one writes as a real agent and reads back as that agent, as another agent and as the system:

  • a Critical create;
  • an escalation of a case the agent did not create;
  • read only, with the refusal asserted as code: FORBIDDEN, status: 403;
  • closing ends the grant;
  • a caller cannot name the recipient;
  • an ownerless escalation stamps nobody.

sharing-coverage gets the fourth roster entry. sharing-seeding maps the field recipient the platform seeds, and adds a witness for the rule.

Ablation. Both runs used ablation-replace in wrap mode under the verify lock, and both restored to blob == HEAD with git diff HEAD empty. The subject resolves from src through objectstack.config, so no dist was involved.

  • Deleting the stamp line: 3 failed / 3 passed. The escalated_from assertions failed (null), and the read-only case got RECORD_NOT_FOUND 404 instead of FORBIDDEN 403.
  • Removing the rule from serviceSharingRules: 3 failed / 3 passed ("the agent lost the case they just logged").

Gates (all at 934e991c, exit codes captured before any pipe)

  • pnpm validate: 0, "✓ Validation passed", "Data: 18 Objects 358 Fields"
  • pnpm typecheck: 0
  • pnpm lint: 0, "18 suggestion(s)", none naming a file or key of this PR
  • pnpm lint:i18n-gate: 0, "✓ i18n lint gate: 0 i18n/missing-* issues"
  • pnpm hygiene: 0, "✓ source hygiene clean"
  • pnpm hygiene:tokens: 0, "✓ source token ratchet clean"
  • pnpm build: 0, "✓ Build complete", the same 8 author-time warnings as base
  • pnpm test: 0, "Test Files 181 passed (181)", "Tests 3686 passed | 1 skipped (3687)"

Playwright e2e is CI's.

Outside the claim's file surface, and why

The claim names case.sharing.ts and, if the producer sits there, the assignment path. The producer is there. A stored principal also needs:

  • the field on case.object.ts and its label in the four packs (every field ships in all four);
  • the admin sharing tables in three locales and the sharing roster test, which go red on any new rule;
  • docs/STATUS.md, which goes red on the field count;
  • src/docs/crm_admin.md.

The line count is 219 (201 added, 18 deleted) against the dispatched budget of about 150: src 67, docs 26, tests 119, changeset 7.

Acceptance notes (not filed)

  • The detail-screen part of content/docs/service/cases*.mdx (the "three sections, 10 of the 24 fields" table and the Details bullet's "Escalated Date … displayed nowhere") describes the tab as it was before the field-group sections. Measured: the Details tab renders the object's groups, and Escalation shows Escalated, Escalated Date, Escalation Reason and Escalated From. Those sentences were already false, so they are left as they were. Carrier: item 16 of this card (docs that describe the app as it was).
  • src/docs/crm_admin.md's sharing table already omitted Unassigned Cases — Triage. This PR adds only its own row. Carrier: item 16 of this card.
  • Demo ownership depends on timing. On a box staffed while the case seed was still writing, the 38 seeded cases were round-robined onto service.agent. On a box staffed after the seed settled, they went to the admin through the seed claim. This is an observation only.
  • The console's "does not exist or may have been deleted" copy for a row the viewer cannot read is reported to the seat as a platform finding, not routed around here.

Generated by Claude Code

Escalation hands a case to the service_manager pool, and service_agent
reads crm_case with readScope own, so the agent lost the case in the same
save: a Critical create landed on "Record not found" under "Case created
successfully", and Escalate Case did the same on refresh.

case_escalation_reassign now records whom it took the case from in a new
readonly lookup, escalated_from, in the escalation write itself. A new
criteria rule, case_escalated_from_sharing, shares each open case read-only
with the user that column names (a field recipient, one person per case).

Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv

Co-authored-by: Claude <noreply@anthropic.com>
A booted-stack suite drives the two escalation paths as a real agent (a
case logged Critical, and Escalate on a case the agent did not create) and
reads each case back as that agent, as another agent, and as the system:
the agent keeps read, nobody else gains it, the manager's grant is
unchanged, the grant is read-only, closing ends it, and a caller cannot
name the recipient.

The crm_case sharing roster gains the rule, the seeding model maps the
field recipient the platform seeds, and the admin sharing pages, the cases
page (25 fields, the Escalation group, the sharing list) and STATUS's field
figure follow, in every locale.

Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 15, 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 Oct 15, 2026 2:46am UTC

Request Review

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

复核人:repo:hotcrm 执行席(#1353)· 判决 ACCEPT · 钉 head 934e991cb4668ed5602765ddcac5cdd0423ba8ef · 2026-10-15T02:59Z

形态:Part of #2060(第 3 项),无关闭关键词,Clause-②: no;21 文件,+201/−18 = 219 行(派发预算约 150,多出的主要是新增字段与新规则触发的守卫:四个语言包标签、三语 sharing-and-security 与 cases 文档行、STATUS.md 字段数、sharing-coverage / sharing-seeding 名单,外加 106 行的启动态可达性测试);路径面无 governed 面 → 非 Tier H,席位落地。origin/main f1c80837 是 head 的祖先。

前提实测:派发令 H3 路线(H2 成立)。diff 逐行读过:

  • crm_case.escalated_from:Field.lookup('sys_user', { readonly: true }),归入 escalation 字段组;注释写明 readonly 承重——可写的接收人列等于让任何编辑者把案例分享给任何人。
  • _case-assignment.ts 的 case_escalation_reassign:把案例交给经理组的同一次写入里,若原 owner 存在则写 escalated_from = 原 owner;没有 owner 就不写(共享给无人)。
  • 新规则 case_escalated_from_sharing:read、is_closed == false、sharedWith: { type: 'field', value: 'escalated_from' },与另两条升级规则同样以「未关闭」为界。席位在 objectstack origin/main 复读 spec sharing.zod.ts:field 接收人按每条匹配记录展开,列为空则共享给无人(fail-closed),注释原话正是把这类场景写成「应用在记录上存一个用户字段 + field 接收人指向它」。
  • 未动:service-agent.profile.ts 的 crm_case 行(readScope: 'own'、无 allowTransfer),以及两条既有升级规则。
  • 选「前一个 owner」而非 created_by:dev 实测 demo 中 38 个客服名下案例的 created_by 全为空(intake 路由与 triage 认领的案例同样如此),按创建人授权会漏掉「升级」那一半。

dev 读数:基线上客服新建 Critical 案例后,控制台在成功提示上方显示「Record not found」,GET 返回 404;点 Escalate 同样;High 对照正常。分支上新建与升级后客服都能读(控制台 + API);另一位客服仍 404;经理的编辑授权不变;客服编辑被 403 拒绝;调用方自带的 escalated_from 被丢弃;关闭后撤销。切除 1(不写该字段)、切除 2(去掉规则)各红 3 例。

门禁 check-runs(钉 head):Build and Test (22.x) ✓ · Typecheck and Build ✓ · Quality Checks ✓ · Check Changeset ✓ · Playwright ✓ · link-check ✓ · CodeQL ✓ · Analyze Code (javascript) ✓ · Label Pull Request ✓ · Vercel Preview Comments ✓ — 10/10 success,mergeable_state clean,base f1c80837 = 当前 origin/main。

接受的偏差:① 落点超出认领面(字段、语言包、文档、STATUS.md、crm_admin.md、注册与名单测试),均为新增字段与规则所必需,PR 正文已列明;权限配置那一行未碰。② 行数超预算,原因见上。③ 字段名 escalated_from / 标签 "Escalated From"(zh-CN「升级前负责人」)为 dev 命名,席位接受。

范围外去向:控制台把「对当前用户隐藏的记录」显示成「Record not found … may have been deleted」(objectui RecordDetailView)——与已关闭的 objectui#11902 同族;本 PR 落地后已无 HotCRM 路径触达,且对不可读记录不透露其存在属于有意的非披露,故只记录不立卡。cases*.mdx 详情屏小节与 crm_admin.md 共享表的既有失真并入 #2060 第 16 项。

落地条款:席位经中继 pr_ready + automerge_enable,以 objectstack-fleet[bot] 入合并队列;合并后 #2060 退回 pm:queue(Part of),按串行口径派下一项(第 4 项)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 03:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 421bd1d Oct 15, 2026
11 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 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.

2 participants