Skip to content

fix(service): title the escalation follow-up task with the case number, not the record id - #1227

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-1208-escalation-task-subject
Aug 22, 2026
Merged

fix(service): title the escalation follow-up task with the case number, not the record id#1227
os-sam merged 3 commits into
mainfrom
claude/issue-1208-escalation-task-subject

Conversation

@os-sam

@os-sam os-sam commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1208

Description

Escalating a case creates an urgent follow-up task for the account owner. That
task was subjected with the case's primary key:

subject: `Escalated case ${caseId ?? ''} needs attention`.trim(),

On a demo org with the seeded escalations, All Tasks therefore opened on
nine urgent rows, all due tomorrow, differing only in a 16-character opaque
string — and a string that appears nowhere else in the product: case pages, list
views, breadcrumbs and crm_case.display_title all name a case CASE-00039.

The task now reads Escalated: CASE-00039 · Login SSO failure after password reset — identifier first, so the Subject column (which visibly truncates) still
tells the rows apart, then the case subject for human context. The id is not
dropped: it stays in related_to_case, where a relationship belongs.

What was measured, not assumed

  • Both halves are already in hand. Against a real ObjectQL 17.1.0 engine, an
    afterUpdate pre-image is the whole stored row (driver.findOne with no
    projection). Observed keys included case_number: "CASE-00001" and subject.
    case_number is an engine-issued autonumber (CASE-{00000}) generated at
    insert, so it is never on an update payload but always on the pre-image —
    escalation performs no extra read. subject still prefers input, since
    an update may be renaming the case in the same write.
  • The 255 cap is load-bearing, not cosmetic. crm_task.subject declares
    maxLength: 255 and the engine enforces it — measured: Subject must be ≤ 255 characters (got 256). crm_case.subject allows the same 255, so an uncapped
    Escalated: + number + separator + subject is 279 characters and the
    insert is rejected. This hook is async: true + onError: 'log', so that
    rejection would surface nowhere: the escalation task would simply never exist
    — strictly worse than the bug being fixed. The composed title is capped at 255
    with the tail trimmed (), which is what keeps the identifier intact.
  • Empty subject is unreachable through the write path (crm_case.subject is
    required + notNull; insert with '' is rejected Subject is required),
    but the composition is still total: a blank half drops the separator rather
    than dangling it, and both blank falls back to a fixed string.

The repo mechanism this lands on

Composition is inline in the handler. Hook bodies ship body-only through
QuickJS; a shared helper would make extractHookBody throw, which the CLI build
catches and silently answers by bundling the closure — with no gate going red.
The build line confirms the property held: Skipping legacy runtime bundle (all 39 callables are body-only).

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring
  • Performance improvement
  • CI/CD update

Related Issues

Fixes #1208
Related to #1226 (sibling occurrences in lead.hook.ts and
opportunity.hook.ts, filed for triage — out of scope here)

Changes Made

  • src/objects/case.hook.ts — the escalation branch of
    case_status_side_effects composes the task subject from case_number and
    subject off the pre-image, capped at 255 with the tail trimmed. Nothing else
    in the file changed; the insert's other fields, owner_id resolution and the
    related_to_* set are untouched.
  • test/escalation-task-subject.test.ts — new pin, 9 tests. Runs the lowered
    body through real QuickJS (runHookBody), and drives the cap against a real
    ObjectQL on the shipped crm_task metadata.
  • .changeset/escalation-task-titled-by-case-number.md.

A downstream effect worth naming: src/flows/task-urgent-alert.flow.ts notifies
on "{record.subject}", so the urgent-task alert inherits the readable title.

Testing

All eight gates of pnpm verify, run under the shared verification lock at
370bacc (the final commit on this branch):

gate exit verdict line it printed
validate 0 ✓ Validation passed (1436ms)
typecheck 0 tsc --noEmit, no diagnostics
lint 0 81 warning(s), 10 suggestion(s) — all pre-existing
lint:i18n-gate 0 ✓ i18n lint gate: 0 i18n/missing-* issues
hygiene 0 ✓ source hygiene clean
hygiene:tokens 0 ✓ source token ratchet clean
build 0 ✓ Build complete · Skipping legacy runtime bundle (all 39 callables are body-only)
test 0 Test Files 116 passed (116) · Tests 2736 passed, 1 skipped

CI's own first step was run too: node scripts/check-stackblitz-lock.mjs → exit
0, package-lock.json is in sync with package.json (v3).

Token ratchet (shrink-only; no ceiling touched):

scope before after delta ceiling
business semantics ~81,791 ~81,904 +113 85,000
interaction layer ~38,831 ~38,831 0 42,000
authored total ~134,472 ~134,585 +113 140,000

Reverse verification. With the fix reverted (git checkout origin/main -- src/objects/case.hook.ts) and the pin left in place, 7 of 9 tests go red,
asserting against the real old string:

AssertionError: expected 'Escalated case PZPqP6vSaQQF-FHb needs…' not to contain 'PZPqP6vSaQQF-FHb'
Tests  7 failed | 2 passed (9)

The two that stay green are the two that must: is the length the shipped metadata declares reads crm_task metadata, and rejects the uncapped composition measures the engine — neither depends on the hook. An earlier draft
of the pin had 5 red, because two assertions passed for the wrong reason —
nine raw ids are nine distinct strings, and a short subject never exercises
the cap. Both were rewritten (commit 220a85b) before this run.

  • Unit tests pass (pnpm test)
  • Linting passes (pnpm lint)
  • Build succeeds (pnpm build)
  • Manual testing completed — not browser-driven; the behaviour is pinned out
    of the lowered body and against a real engine instead
  • New tests added (if applicable)

Checklist

  • I have added a changeset
  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation — no doc states the
    old subject; grep over content/, docs/, e2e/ and src/ found no
    other copy of the string
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Additional Notes

i18n. The task subject is a hook-generated runtime string, and this repo has
no mechanism for translating those: lint:i18n-gate covers the i18n/missing-*
family, which is metadata-driven (object/field/view/page labels), and every
hook-generated string in src/objects/*.hook.ts is hardcoded English today. No
locale rows were added and no new mechanism was invented — reported here rather
than built, per the brief.

Sibling occurrences are filed, not fixed. #1226 covers
src/objects/lead.hook.ts:301 and src/objects/opportunity.hook.ts:240, which
carry the same raw-id shape. Untouched here.

Generated by Claude Code


Generated by Claude Code

os-sam and others added 3 commits August 22, 2026 14:21
The escalation branch of `case_status_side_effects` subjected its follow-up
task `Escalated case ${caseId} needs attention` — the primary key. A demo org
with nine seeded escalations therefore opened All Tasks on nine urgent rows
that differ only in a 16-character opaque id, which matches nothing the reader
has seen: every case surface in this app names a case CASE-00039.

Title it `Escalated: CASE-00039 · Login SSO failure after password reset`
instead — identifier first, so a truncating column still discriminates. Both
parts are read off the afterUpdate pre-image, which is the whole stored row, so
no extra read. The id stays in `related_to_case`, where a relationship belongs.

Capped at 255: `crm_task.subject` declares maxLength 255 and the engine
enforces it, while `crm_case.subject` allows the same 255 — an uncapped
composition is 279 characters and the insert is rejected, silently, because
this hook is async + onError: log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzS6NUXo5A5KEwj1KjvPHk
Two assertions passed against the pre-fix hook: nine raw ids are nine distinct
strings, and a short subject never exercises the 255 cap. Assert the case
number is present and the id absent, and pin the composed length at the cap.

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

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 22, 2026 2:33pm

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.

Every escalation task is titled with a raw record id — the agent's task list is 9 rows of Escalated case PZPqP6vSaQQF-FHb needs attention

1 participant