Skip to content

feat(service): SLA sweep gives an ownerless breached case an owner, then alerts them - #1534

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-1405-sla-sweep-assigns-ownerless-breach
Sep 3, 2026
Merged

feat(service): SLA sweep gives an ownerless breached case an owner, then alerts them#1534
os-sales merged 1 commit into
mainfrom
claude/issue-1405-sla-sweep-assigns-ownerless-breach

Conversation

@os-sales

@os-sales os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1405

Implements the maintainer ruling of 2026-09-03 (option C): an ownerless breached case is assigned an owner through the existing service_manager least-loaded assignment, and an empty pool is a graceful no-op.

The premise moved twice, and the second one changed the design

The card's headline harm — the whole scheduled run dying — was fixed by PR #1432 and cannot happen any more. What was left was the ruling's positive half. Re-deriving it on origin/main @ d9fad90 turned up something the dispatch could not have known:

_case-assignment.ts did not need to be made "callable from the flow". The sweep was already calling it. flag_breach writes status: 'escalated', and that is the escalation transition case_escalation_reassign (beforeUpdate, priority 250) fires on — it stamps the least-loaded holder of the service_manager position onto the payload of the update already in flight.

So the flow was never missing a write. It was missing a read. currentCase is the loop item query_breached bound before flag_breach ran, so the owner the sweep had just produced was invisible to it, {currentCase.owner_id} addressed the pre-write state, and the check_owner gate skipped the alert on exactly the cases nobody was accountable for.

Measured through the real flow, the real AutomationEngine and the app's real crm_case hook chain, before any change:

pool ownerless case, after the sweep alerted
staffed owned by a service manager nobody
empty unowned nobody

The assignment was landing. Only the notification was reading a stale row.

The change

check_owner's ownerless branch is no longer a dead end:

flag_breach ──▶ check_owner ──[b2: has owner]────────────────────▶ notify_team
                     └──────[b3: no owner]──▶ reload_case ──▶ check_assigned
                                                                   └──[b5: has owner]──▶ notify_team
  • reload_case — a get_record with no limit, so it calls findOne and binds the single row. It re-binds currentCase itself, which is why both branches converge on the one existing notify_team: the recipient, title, message and action URL stay authored once and cannot drift into two versions of the same alert.
  • check_assigned — the ruled empty-pool clause as a gate rather than a failure. No pool ⇒ the hook assigns nobody ⇒ the re-read case is still unowned ⇒ no edge matches ⇒ the iteration ends. The breach stays on the record and in the run summary's named gate, and the run completes.
  • b2 and b3 are an exact partition of the cases where currentCase is bound. has(vars.currentCase) deliberately leads both, so an unbound iterator matches neither and the iteration simply ends — routing it into reload_case would leave {currentCase.id} resolving to nothing, and get_record refuses the step, which is the fault mode this flow was repaired to remove.
  • The predicate is authored once (CASE_HAS_OWNER) and used on b2 and b5; its complement (CASE_HAS_NO_OWNER) is the opposite-polarity spelling the house rule prescribes for a partition.

⛔ No fallback recipient, no manager-chain dot-walk, no new notification target, no second implementation of least-loaded balancing.

An already-owned breach is deliberately unchanged and still alerts the agent it came from. The escalation hand-off does move an owned case to the manager pool — that is case_escalation_reassign's shipped behaviour, not this card's — so its alert now reaches an agent who no longer owns the case. That is a real defect, it predates this branch, and re-routing that alert is a product question: filed as #1535 rather than decided here.

Evidence

pnpm verify green at the final commit 9e6429f✓ Validation passed · ✓ i18n lint gate: 0 i18n/missing-* issues · ✓ source hygiene clean (incl. ✓ no raw control bytes in first-party files) · ✓ source token ratchet clean (business semantics ~83,423, ceiling ~85,000) · ✓ Build complete · Test Files 159 passed (159) · Tests 3342 passed | 1 skipped (3343).

A pin per ruled branch, each observed failing (test/flow-sla-ownerless-assignment.test.ts, 10 cases; both runs drive the real hook chain and differ only in whether sys_user_position holds a service_manager). These are not "reverted the whole fix" ablations — each mutation is targeted, and the other branch staying green is what proves it:

ablation mutation result
A — assignment branch reload_case binds a fresh variable instead of re-binding currentCase, so notify keeps reading the snapshot 1 red: alerts the owner the assignment produced. All 4 empty-pool cases stayed green
B — empty-pool branch b5 becomes an unconditional edge — the ruling's forbidden shape 3 red, with the card's original signature: Node 'notify_team' failed: notify: at least one recipient is required, the run dies, and a case queued behind the ownerless one is never flagged. All 6 staffed-pool cases stayed green

Every mutation was confirmed on disk before the run was read — blob hash moved (26819ff to fae7137 / b9d127d) with anchored counts on both the removed and the injected text going 1 to 0 and 0 to 1 — and every restore was confirmed by hash equality against the HEAD blob plus an empty git diff HEAD, under a trap with an absolute repo root. No rebuild leg applies: this repo's tests import src/ directly and there is no dist consumption path.

Also in here

  • test/helpers/flow-harness.ts — a hook now gets a ctx.api over the run's own store, and hooks run in priority order. Without the api every ownership writer in _case-assignment.ts returns on its second line (if (!api) return), so the hook "runs", nothing throws, and the write it exists to make silently does not happen — the pin would have been green over a body that never reached its own work. The api takes where and only where, the same line test/helpers/hook-harness.ts draws, because the kernel's findOne answers an unknown filter key with the object's first row.
  • test/flow-sla-ownerless-case.test.ts — PR fix(flows): keep the SLA sweep alive on an ownerless breached case #1432's pin, re-aimed where the node graph moved under it, not weakened. Its selection claim now reads the query_breached node's own selected instead of the run-level figure (which sums every get_record, so the per-case re-read now contributes to it: 5 + 3 = 8), and its gate lookup selects by edge id because check_owner now has two out-edges and the engine emits gate rows in completion order, which varies run to run.
  • src/objects/_case-assignment.ts — comments only, no code change. Records that case_sla_monitor reaches this hook and depends on its transition predicate (narrowing it, or moving the assignment to an afterUpdate write, silently returns the sweep to alerting nobody), and corrects one sentence that Both case-routing pools are unstaffed in the demo org, so neither intake round-robin nor escalation hand-off does anything there #1102 falsified: service_manager is unstaffed on a fresh install, but the demo org now staffs it.
  • Docscontent/docs/service/sla-and-escalation.mdx and its two Chinese pages gain a short paragraph on the unowned breach, and lose the same stale "and the demo org too" claim in all three.

Not in here

#1430 (the case_escalation family hole) is out of scope by the ruling and stays its own card — src/flows/case-escalation.flow.ts is untouched by this branch. ⚠️ Correction to an earlier draft of this body: #1430 is no longer open. It was completed on 2026-09-02T23:45Z, before the ruling was written; the scope fence is unaffected either way, and nothing here touches that family.

The already-escalated ownerless case is a stated boundary: case_escalation_reassign fires on the transition, so a case already sitting in escalated is not handed over by this sweep. It degrades exactly the way the ruled empty-pool branch does — unowned, alert skipped at the named gate, run survives.

🤖 Generated with Claude Code

https://claude.ai/code/session_019hUuCQStzXGMFSX4dzww5t

…hen alerts them

An unowned case that breached its SLA was flagged and escalated, and then
nobody was told: `notify_team` addresses `{currentCase.owner_id}` alone, so the
`check_owner` gate (PR #1432) skipped the alert for exactly the cases with
nobody accountable for them.

The assignment already existed and the sweep already reached it. `flag_breach`
writes `status: 'escalated'`, which IS the transition `case_escalation_reassign`
fires on, so the hook stamps the least-loaded `service_manager` onto the update
already in flight. What the flow lacked was the READ: `currentCase` is the loop
item bound before that write, so the owner it just produced was invisible.

So the ownerless branch of `check_owner` now leads to `reload_case`, a
`get_record` that re-binds `currentCase` from the stored row, and a second gate
`check_assigned` decides on the case as it now stands. Both branches converge on
the one existing notify node, so the alert is authored once.

An empty `service_manager` pool stays a graceful no-op: the hook assigns nobody,
the re-read case is still unowned, `check_assigned` skips the notification at a
named gate, and the run completes with the breach recorded. No fallback
recipient, no manager-chain dot-walk, no hard failure.

An already-owned breach is deliberately unchanged and still alerts its own
owner.

test/flow-sla-ownerless-assignment.test.ts pins both ruled branches against the
real flow, the real AutomationEngine and the app's real crm_case hook chain, the
two runs differing only in whether sys_user_position holds a service_manager.
test/helpers/flow-harness.ts now hands a hook `ctx.api` over the run's own store
and runs hooks in priority order — without it every ownership writer in
_case-assignment.ts stands down on its `if (!api) return` and the pin would have
certified a body that never reached its own work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hUuCQStzXGMFSX4dzww5t
@vercel

vercel Bot commented Sep 3, 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 Sep 3, 2026 9:21am UTC

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions 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.

case_sla_monitor dies on a breached case with no owner — the whole scheduled run fails, terminally

2 participants