Skip to content

SLA policy matrix: per-priority × tier clocks, first-response stamping, escalation that reassigns #595

Description

@os-zhuang

Batch-2 (business completeness) item from the 2026-08-02 review.

Problems

  • The only SLA logic in the app is case.hook.ts:58: critical priority ⇒ sla_due_date = now + 4h, hardcoded. High/medium/low cases get no SLA due date at all, so case_sla_monitor can never fire for them.
  • crm_account.tier (strategic/enterprise/mid_market/smb) exists and is the obvious SLA driver — nothing reads it.
  • first_response_date is stamped only by log_call/log_meeting (global.actions.ts:315); a case answered any other way never records a first response.
  • Escalation does not reassign — case-escalation.flow.ts:62 documents that flow templates cannot dot-walk owner.manager, so an "escalated" case stays with the same overloaded agent and only notifies its owner.

Scope

  1. Replace the hardcoded rule with a priority × account-tier matrix (a single config map in the hook module, pinned by tests) that stamps sla_due_date for every priority; keep 4h-critical as the strategic-tier cell so behavior is a superset of today's.
  2. Stamp first_response_date on the first outbound touch of any kind (call, meeting, email send, first agent status transition) — not just calls/meetings.
  3. Escalation reassigns: round-robin to the service_manager position pool (the flat-position substitute for the missing manager chain — same technique as lead_auto_assign), in addition to notifying.
  4. Document the business-hours assumption explicitly: clocks run on calendar hours until the platform ships a business-hours calendar (known platform gap — a P1 raised Friday 5pm breaches Friday 9pm; do not hide this).

Acceptance

  • Every seeded open case has an sla_due_date consistent with the matrix.
  • case_sla_monitor fires on non-critical breaches in a runtime test.
  • An escalated case changes hands to a manager-pool member.

Activity

  1. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Ruling (maintainer, 2026-08-11, PM chat, verbatim: 「接受你的全部建议」): implement, minimal version. This is not capability expansion — sla_due_date, case_sla_monitor and crm_account.tier are all shipped surfaces, and measurement shows 3 of 4 priorities get no SLA clock at all (declared-but-unenforced). Scope of the minimal version: the priority × tier matrix (config constant pinned by a test) + first-response stamping on all real touchpoints + the calendar-hours assumption stated explicitly. The escalation-reassignment half is simplified or deferred (platform dot-walk limitation) — the dev states which in the PR; if deferred, it gets its own follow-up card, not silence.

    Queued (M). Serial constraint: this card and #596 both land in case.hook.ts — #595 dispatches first, #596 follows in a later round.


    Generated by Claude Code

  2. self-assigned this
    on Aug 11, 2026
  3. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 3 (hotcrm whole-repo seat)
    Session: session_01NM6o28jmBgsyTRQHutn7LC
    Branch: claude/issue-595-sla-policy-matrix
    Worktree: dedicated cloud session (fresh clone)
    Domain: hotcrm whole-repo seat
    File surface: src/objects/case.hook.ts, src/actions/global.actions.ts (first-response stamping), src/data/service.seed.ts (seed SLA values consistent with the matrix), case tests (test/case-first-response.test.ts + a new matrix pin), language packs if new strings appear
    Container & model: M, mode:cloud (dedicated session), model: claude-opus-5
    Serial constraints cleared: #596 is deliberately held for a later round (same case.hook.ts); in-flight #1066 (rc.6 upgrade) touches package.json/lockfiles/src/flows/forecast-snapshot.flow.ts/five unrelated test files — disjoint from this surface; #1017 dispatched in the same batch touches src/objects/quote.object.ts only — disjoint.

    Path correction found by the pre-dispatch premise check (the issue body's paths are stale, the facts are not): the SLA hook is src/objects/case.hook.ts:60, not src/hooks/case.hook.ts:58. Verified on origin/main: the critical-only 4h rule stands (priority === 'critical' && !input.sla_due_date), crm_account.tier is declared (account.object.ts:344, four options) and read by nothing outside src/views/account.view.ts, and the service_manager position exists (src/sharing/positions.ts:24).


    Generated by Claude Code

  4. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator
    {
      "issue": 595,
      "status": "done",
      "branch": "claude/issue-595-sla-policy-matrix",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/1071",
      "premise_still_valid": true,
      "summary": "Implemented the minimal version. (1) Priority x account-tier SLA matrix (16 cells, calendar hours) stamps sla_due_date for EVERY priority; the critical row is flat at 4h across all tiers so the change is a strict superset of the old critical-only rule (differentiating that row by tier would have LOOSENED the deadline on non-strategic critical cases, i.e. taken a clock away from work that already had one — the ruling's stated goal, 'superset', is only literally true this way; strategic/critical is 4h as instructed). Tier is resolved via ctx.api from crm_account.tier; unreadable/blank/unknown tier falls back to the smb column (the field's own default), because inventing a tighter deadline out of a permission error would manufacture breaches. Unknown priority still gets no clock. The lookup is skipped entirely when a row does not vary by tier, so the critical path is byte-for-byte the old behaviour. (2) first_response_date moved out of the log_call/log_meeting action body into event_activity_bubble (src/objects/event.hook.ts), which fires on any crm_event entering 'held' with related_to_case — one writer covering every outbound touchpoint including events entered directly, replacing the old 'CONVENTION: remember to stamp this too' comment. Status transitions remain deliberately excluded (documented, previously-rejected non-goal: an agent can move a case to in_progress and investigate for an hour while the customer hears nothing); booked-but-not-held meetings likewise. (3) Calendar-hours assumption stated in _case-sla.ts, in the hook body beside the numbers, on the sla_due_date field, in src/docs/crm_service.md and across the SLA/cases/setup/FAQ/glossary pages in all three locales (15 content files) — those pages carried detailed 'only Critical has a clock' claims this change makes false. Also switched the offset from setHours(getHours()+n) to elapsed milliseconds: local calendar arithmetic turns '+4 hours' into 3 or 5 across a DST transition, and a 168h clock crosses one twice a year. (4) Seeded case due dates now derived from the matrix + the seeded account's tier (daysAgo(n) + duration('Nh')) instead of hand-typed; is_sla_violated left alone deliberately — it is case_sla_monitor's field to write. Matrix lives in two copies by necessity (sandboxed hook bodies cannot import a module constant); the pin test drives the shipped handler so neither copy can move alone. ESCALATION REASSIGNMENT: DEFERRED, filed as #1070 — the blocker is not the dot-walk (a hook can do what the flow cannot) but three separate decisions: crm_case.allowTransfer is a permission-model widening, the write re-enters the record-change trigger surface that has looped in this exact file before, and an unstaffed service_manager pool needs a defined no-op plus demo staffing.",
      "tests": "All five local gates green, re-run against rc.6 after `git fetch origin main && git merge origin/main` pulled in #1066 (rc.5->rc.6) and #1068 and deps were reinstalled from the merged lockfile: `pnpm build` exit 0; `pnpm validate` exit 0; `pnpm lint` exit 0; `pnpm hygiene` exit 0; `pnpm typecheck` exit 0; `pnpm test` 82 files, 1939 passed / 1 skipped / 0 failed. New test/case-sla-matrix.test.ts (32 cases) pins all 16 cells by driving the shipped handler — PROVEN to fail on a cell change, twice: editing high x smb 8->12 in the hook only gave 2 failures ('high×smb: expected 8h, got 12.000h', 'denied read: expected 8h, got 12.000h'); editing it in BOTH copies gave 2 failures including 'constant cell high×smb: expected 12 to be 8' from the longhand expectation table (deliberately not derived from the constant under test); reverted, 32 passed. Acceptance criterion 2 written as a real runtime test, not reasoning: test/flow-scheduled.test.ts 'case_sla_monitor — non-critical breaches (#595)' winds the clock back to creation, runs the SHIPPED case_sla_defaults handler to stamp the deadline, then runs the SHIPPED case_sla_monitor flow through the real AutomationEngine, over high x strategic / high x enterprise / medium x mid_market / low x smb, asserting is_sla_violated, is_escalated, status escalated, a non-empty escalation_reason and exactly one owner notification — plus the negative case (a High case inside its window is untouched). The repo's own guard test/hook-write-shape.test.ts caught the new update() call site ('11 sites but this file exercises 10'); the added case runs the new hook code through the real QuickJS sandbox. test/seed-consistency.test.ts re-derives all 38 seeded due dates from the matrix. test/case-first-response.test.ts rewritten for the hook path, with a guard that fails if either action body re-grows its own stamp. test/hooks-runtime-service.test.ts's 'does not give a non-critical case an SLA' — the assertion that encoded the defect — now asserts the opposite.",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #1070: escalation still does not reassign — the deferred half of this card, with the transfer-grant / re-entrancy / empty-pool analysis and acceptance criteria"]
    }

    Generated by Claude Code

  5. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    ACCEPT — PR #1071 reviewed, CI 9/9 green, queued to land.

    The dev corrected the ruling, and the correction is right — recording it in the open. The ruling said "keep 4h-critical as the strategic-tier cell so behavior is a superset of today's". Differentiating the rest of the critical row by tier would have loosened the deadline on non-strategic critical cases — taking a clock away from work that already had one, which is the opposite of a superset. The shipped matrix holds the whole critical row flat at 4h, and src/objects/_case-sla.ts records why. Read the module comment, not the ruling, as the authority on that row.

    Verified rather than taken from the report:

    • Reverse verification of the matrix pin, run twice: editing high × smb 8→12 in the hook alone fails 2 cases; editing it in both copies still fails, via a longhand expectation table deliberately not derived from the constant under test. The two-copy duplication is a platform constraint (sandboxed hook bodies cannot import a module constant) and is mitigated the only way it can be — the pin drives the shipped handler, so neither copy can move alone.
    • Acceptance criterion 2 is a real runtime test, not reasoning: case_sla_monitor driven through the actual AutomationEngine over four non-critical priority×tier combinations, asserting the breach stamp, escalation, reason and exactly one notification, plus the negative case.
    • Escalation does not reassign: route an escalated case to the service_manager pool #1070 exists and carries the deferred escalation half with its three real blockers (the allowTransfer permission widening, afterUpdate re-entrancy on a file that has looped before, and the unstaffed-pool no-op) — the ruling's "if deferred, it gets its own card, not silence" is satisfied.

    File surface exceeded the claim, with reasons accepted: src/objects/event.hook.ts (first-response stamping moved out of the two action bodies into one writer on crm_event → held, which is what "all real touchpoints" required — and removes a "CONVENTION: remember to stamp this too" comment, i.e. a missing-producer shape), src/objects/case.object.ts, a new src/objects/_case-sla.ts, and 15 content files across three locales that carried "only Critical has a clock" claims this change makes false. Leaving those docs would have shipped a lie; the expansion is the right call, and it was declared rather than quietly taken.

    Two things worth keeping beyond this card: the DST fix (setHours(getHours()+n) → elapsed milliseconds — local calendar arithmetic turns "+4 hours" into 3 or 5 across a transition, and a 168h clock crosses two a year), and that the repo's own test/hook-write-shape.test.ts guard caught the new update() call site unprompted.

    Gates were re-run against rc.6 after merging main mid-flight (82 files, 1939 passed).


    Generated by Claude Code

  6. added
    priority:p1High: required for production / M2
    and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions