feat(service): SLA sweep gives an ownerless breached case an owner, then alerts them - #1534
Merged
os-sales merged 1 commit intoSep 3, 2026
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-sales
marked this pull request as ready for review
September 3, 2026 09:32
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1405
Implements the maintainer ruling of 2026-09-03 (option C): an ownerless breached case is assigned an owner through the existing
service_managerleast-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@d9fad90turned up something the dispatch could not have known:_case-assignment.tsdid not need to be made "callable from the flow". The sweep was already calling it.flag_breachwritesstatus: 'escalated', and that is the escalation transitioncase_escalation_reassign(beforeUpdate, priority 250) fires on — it stamps the least-loaded holder of theservice_managerposition onto the payload of the update already in flight.So the flow was never missing a write. It was missing a read.
currentCaseis the loop itemquery_breachedbound beforeflag_breachran, so the owner the sweep had just produced was invisible to it,{currentCase.owner_id}addressed the pre-write state, and thecheck_ownergate skipped the alert on exactly the cases nobody was accountable for.Measured through the real flow, the real
AutomationEngineand the app's realcrm_casehook chain, before any change: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:reload_case— aget_recordwith nolimit, so it callsfindOneand binds the single row. It re-bindscurrentCaseitself, which is why both branches converge on the one existingnotify_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.b2andb3are an exact partition of the cases wherecurrentCaseis bound.has(vars.currentCase)deliberately leads both, so an unbound iterator matches neither and the iteration simply ends — routing it intoreload_casewould leave{currentCase.id}resolving to nothing, andget_recordrefuses the step, which is the fault mode this flow was repaired to remove.CASE_HAS_OWNER) and used onb2andb5; 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 verifygreen at the final commit9e6429f—✓ 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 whethersys_user_positionholds aservice_manager). These are not "reverted the whole fix" ablations — each mutation is targeted, and the other branch staying green is what proves it:reload_casebinds a fresh variable instead of re-bindingcurrentCase, so notify keeps reading the snapshotb5becomes an unconditional edge — the ruling's forbidden shapeNode '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 greenEvery mutation was confirmed on disk before the run was read — blob hash moved (
26819fftofae7137/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 theHEADblob plus an emptygit diff HEAD, under a trap with an absolute repo root. No rebuild leg applies: this repo's tests importsrc/directly and there is nodistconsumption path.Also in here
test/helpers/flow-harness.ts— a hook now gets actx.apiover the run's own store, and hooks run inpriorityorder. Without the api every ownership writer in_case-assignment.tsreturns 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 takeswhereand onlywhere, the same linetest/helpers/hook-harness.tsdraws, because the kernel'sfindOneanswers an unknownfilterkey 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 thequery_breachednode's ownselectedinstead of the run-level figure (which sums everyget_record, so the per-case re-read now contributes to it: 5 + 3 = 8), and its gate lookup selects by edge id becausecheck_ownernow 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 thatcase_sla_monitorreaches this hook and depends on its transition predicate (narrowing it, or moving the assignment to anafterUpdatewrite, 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_manageris unstaffed on a fresh install, but the demo org now staffs it.content/docs/service/sla-and-escalation.mdxand 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⚠️ 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.
case_escalationfamily hole) is out of scope by the ruling and stays its own card —src/flows/case-escalation.flow.tsis untouched by this branch.The already-escalated ownerless case is a stated boundary:
case_escalation_reassignfires on the transition, so a case already sitting inescalatedis 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