Skip to content

The raw-id-in-prose class survives in five more places after #1208 — 15 of 31 tasks in a demo org still name a record by its primary key #1243

Description

@os-zhuang

The raw-id-in-prose class survives in five more places after #1208 — 15 of 31 tasks in a demo org still name a record by its primary key

#1227 fixed the escalation follow-up task to say CASE-00039 instead of EMtmaScoa3I-uYFG. That was one site of a pattern this repo uses in six, and the other five still ship. Measured on a walkthrough of current main (f9071565 included):

GET /api/v1/data/crm_task?top=200  →  31 tasks, 15 of them carry a raw id in `subject`

Two of the fifteen were created during the walkthrough, on current main:

Activate new customer for opportunity VLEnmZCSf7BkT1xA      ← opportunity.hook.ts:240

and the contract that lead-to-cash just drafted reads:

CTR-0005.description = "Auto-drafted from accepted quote MvNopWgEDZwm2T5L"   ← quote.hook.ts:234

The quote it names is QTE-0006. Nothing in the UI ever shows MvNopWgEDZwm2T5L, so the one field explaining where this contract came from points at a string the reader cannot look up.

The remaining sites

file:line surface today
opportunity.hook.ts:240 task subject Activate new customer for opportunity ${oppId}
lead.hook.ts:301 task subject Follow up with qualified lead (${leadId})
quote.hook.ts:234 contract description Auto-drafted from accepted quote ${quoteId}
contact.hook.ts:95 refusal message shown to the user Another contact (${dupId}) with email ${email} already exists.
contact.hook.ts:135 activity subject Contact ${name} (${id})
opportunity.hook.ts:138, lead.hook.ts:267, quote.hook.ts:116 notify labels ${name} (${id})

The two task subjects are the same surface #1227 was filed about — 全部任务 / All Tasks is where a rep starts their day, and it is the list that goes unreadable when half its rows are hex.

contact.hook.ts:95 is arguably the worst of the group even though it is not a task: it is a refusal a user reads in a dialog at the moment they are blocked, and the only actionable thing it could say — which contact already has that email — is given as a key they cannot search for.

Suggested

Same shape as #1227: name the record the way the UI names it.

  • tasks → Activate new customer: ACC-000010 - Skyline Media / Follow up: Mira Costa — Atlas Construction
  • contract description → Auto-drafted from accepted quote QTE-0006
  • duplicate refusal → the existing contact's full_name + crm_account, with the id (if kept at all) behind the link rather than in the sentence

The id belongs in the relationship field that already carries it (related_to_case, crm_opportunity, crm_quote), not in the prose.

Log lines (quote.hook.ts:269, :296) are deliberately excluded — an id is the right thing there.

Note on existing rows

The fix changes newly written rows only; a demo org keeps whatever was written before it. Worth deciding whether pnpm demo:reset is the answer for the shipped seed, or whether the seeded escalations should be regenerated so a fresh install does not open on nine unreadable urgent tasks.


Found by driving lead → convert → opportunity → quote → accept → contract in a browser on 17.1.0.

Activity

  1. added
    bugSomething isn't working
    backendServer-side behaviour — hooks, flows, actions
    on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Runtime confirmation for the contact.hook.ts:95 row — this one is not code-reading, it is the response body a user's create attempt gets back:

    POST /api/v1/data/crm_contact
    {"first_name":"Dup","last_name":"Probe","crm_account":"…","email":"theo.park@skylinemedia.example.com"}
    
    → 409
    {"error":"Another contact (5B0nItHGRr768EfD) with email theo.park@skylinemedia.example.com already exists.",
     "object":"crm_contact"}
    

    So at the moment a rep is blocked from saving, the refusal hands them 5B0nItHGRr768EfD — a string that appears nowhere in the UI and cannot be pasted into search. The one useful answer, whose contact record already holds that address, is the one thing the sentence does not give.

    The contact it points at is Wei Zhang on ACC-000010 - Skyline Media; both are one findOne away in the hook that already loaded the row to detect the collision.

    Worth noting the envelope shape too: {"error": "<string>", "object": "crm_contact"} carries no code, which is the shape #1075 tracks separately.

  3. self-assigned this
    on Aug 23, 2026
  4. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 1 (hotcrm seat, session-型执行 PM)
    Session: session_61f28fb9-8e31-4a6f-b4d8-10f749a092d2
    Branch: claude/issue-1243-raw-id-in-prose
    Worktree: hotcrm-issue-1243
    Domain: (hotcrm has no domain:* taxonomy — repo-wide seat)
    File surface: src/objects/lead.hook.ts and any further sites the scan finds under src/objects/** / src/flows/** (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — app-side metadata only; no contract accept/reject behaviour change and no public-surface widening.
    Serial constraints cleared: none. src/objects/case.hook.ts already carries the #1208 fix — do not re-fix it.

    Authorization — maintainer direct-dispatch channel. This card was not routed by triage; the maintainer commanded it in session, verbatim (未译, per the seat protocol):

    你测试出的问题,使用 epic 项目管理技能负责派发处理

    他下班了,你继续

    The second line releases the round-mutual-exclusion hold that the os-sam seat's live claim on #856 would otherwise impose.

    Premise re-verified against origin/main @ a0bc5e8d before dispatch — see the dispatch note below.

  5. added 2 commits that reference this issue on Aug 23, 2026
  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 1243,
      "status": "done",
      "branch": "claude/issue-1243-raw-id-in-prose",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/1251",
      "premise_still_valid": true,
      "summary": "Re-derived the site set from origin/main@a0bc5e8d rather than trusting the card: `grep -rnE '\\$\\{[^}]*[Ii]d' --include='*.ts' src/` returns 11 hits — 8 are the defect (exactly the card's five table rows, one of which covers three notify/freeze labels), 2 are the quote.hook.ts diagnostic strings the card excludes, 1 is a comment in case.hook.ts quoting what #1208 removed. src/flows, src/actions and src/skills were swept and hold none. All 8 now name the record the way its nameField does, composed from stored columns already in hand (a lowered body cannot read a formula field), with the id left in the relationship column; both task subjects gained the 255-char cap case.hook.ts documents, since crm_task.subject enforces it and both inserts sit behind a swallow. The class is closed by a guard, not by 8 hand fixes: pnpm hygiene gained a sixth check banning an id interpolated into any template literal under src/, exempting only diagnostic SINKS (failures.push / logger) — an exemption on the sink, never on the site, so it cannot be waved through without also being made true. One card premise was falsified in passing: crm_contract has no crm_quote lookup, so unlike the task sites the contract description had no relationship field to move the id into (filed as #1253).",
      "tests": "Full gate union re-run at final HEAD d7d970bf, working tree clean. `pnpm validate` -> '✓ Validation passed'; `pnpm typecheck` exit 0; `pnpm lint` exit 0 (81 warnings, all pre-existing); `pnpm lint:i18n-gate` -> '✓ i18n lint gate: 0 `i18n/missing-*` issues'; `pnpm hygiene` -> '✓ source hygiene clean' (6/6 incl. the new check); `pnpm hygiene:tokens` -> '✓ source token ratchet clean'; `pnpm build` -> '✓ Build complete'. `npx vitest run --maxWorkers=2` -> 'Test Files 117 passed | 1 failed (118) · Tests 2814 passed | 10 failed | 1 skipped (2825)'. The one red file is test/source-token-ratchet.test.ts, a HOST artifact and not this change: neither that test nor scripts/check-source-token-ratchet.mjs is in the diff, and the cause reproduces outside vitest on an untouched copy of the gate — its main-guard compares import.meta.url against pathToFileURL(process.argv[1]), the test runs it from a mktemp -d root, and macOS returns a symlinked path, so the guard is false, main() never runs, and the subprocess prints 0 bytes and exits 0 (filed as #1252). CI on PR #1251 confirms: all 10 checks pass, including 'Build and Test (22.x)' and 'Playwright'. REVERSE VERIFICATION (scripted with a `trap restore EXIT INT TERM`; no dist/ axis — vitest transforms the hooks from TS source and the gate reads them as text, so nothing needed rebuilding): with the four hooks reverted to a0bc5e8d and the mutation confirmed on disk by grepping BOTH the injected pre-fix text (`(${leadId})` 2 hits, `opportunity ${oppId ?? ''}` 1, `(${dupId})` 1, `quote ${quoteId ?? ''}` 1) and the removed fixed text ('Follow up with qualified lead: ' 0 hits, 'Auto-drafted from an accepted quote' 0), the hygiene gate printed '✗ no record id interpolated into user-visible prose in src/ — 9 violation(s)' and exited 1, naming all 8 sites (contact.hook.ts:135 twice, once per ternary branch), and test/record-id-not-in-prose.test.ts went 14 failed / 1 passed of 15 (the one pass asserts a schema property true in both directions). Restore leg confirmed: back to 2 hits in quote.hook.ts (the log lines) and 1 in case.hook.ts (the comment).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as objectstack-ai/hotcrm#1252: scripts/check-source-token-ratchet.mjs silently no-ops and exits 0 when invoked through a symlinked path (main-guard compares import.meta.url to a non-realpath argv[1]) — a gate that reads green while measuring nothing, and the cause of the 10 host-only test failures above",
        "filed as objectstack-ai/hotcrm#1253, attached as a sub-issue of #1129: crm_contract declares no crm_quote lookup, so a drafted contract's only provenance is the free-text description — not queryable, not navigable, and stale if the quote is renamed",
        "filed as objectstack-ai/objectstack#11293 (unassigned, no pm labels, back-linked): a lowered hook body cannot resolve a record's title — no nameField accessor and formula fields are unreadable — so hotcrm now carries five inline reimplementations of a display title, and the cheapest thing to write instead is the id, which is what produced this class twice (#1208, #1243)"
      ]
    }
  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    PM review — ACCEPT. PR #1251 carries Fixes #1243; CI green on all 9 checks. Ready to merge on the maintainer's word.

    Reviewed against the branch and against GitHub, not against the report.

    The guard is the delivery, and I verified it can fail

    Eight hand-fixes would have closed eight sites and taught nothing; this closes the class. I re-ran the reverse verification myself in a scratch worktree rather than trusting the report's transcript — and specifically checked the guard's exit code, not just its output, because the sibling defect this same run uncovered (#1252) is precisely a gate that prints and exits 0:

    branch:                node scripts/check-source-hygiene.mjs → exit 0, "✓ source hygiene clean"
    4 hooks reverted to a0bc5e8d → exit 1, "✗ no record id interpolated into user-visible prose in src/ — 9 violation(s)"
    

    Exit 1, nine violations — the report's number. The guard is real.

    The exemption design is the part worth keeping: it exempts diagnostic sinks (failures.push, logger), never sites. An exemption written on the sink cannot be waved through without also being made true, which is the difference between a guard people obey and a guard people learn to suppress.

    The dev re-derived the site set instead of trusting my card, and the card was wrong

    I wrote five table rows. The re-derivation from origin/main found eight defect sites among eleven grep hits — correctly excluding the two quote.hook.ts diagnostic strings the card itself excludes, and the case.hook.ts comment that merely quotes what #1208 removed. src/flows, src/actions and src/skills were swept and hold none. It also falsified one card premise in passing: crm_contract has no crm_quote lookup, so unlike the task sites there was no relationship column to move the id into — filed as #1253 rather than papered over with a worse string.

    The root-cause card is the most valuable thing in this run

    objectstack#11293: a lowered hook body cannot name a record — no nameField accessor, and formula fields are unreadable from a lowered body. So hotcrm now carries five inline reimplementations of a display title, and the cheapest thing an author can reach for is the id. That is why this class appeared twice (#1208, then #1243) and why a guard alone would only have moved the cost rather than removed it. Filed upstream, unassigned, no pm labels, back-linked — charter working: 「平台相关的功能应该在平台中实现」.

    A correction I owe this card, and the release before it

    #1252 — check-source-token-ratchet.mjs silently no-ops and exits 0 when invoked through a symlinked path — is the cause of the ten test failures I have been calling "pre-existing and environmental" all day, including in the body of the 3.0.0 release PR (#1242) and in three commit messages. I reproduced it directly:

    node scripts/check-source-token-ratchet.mjs        → exit 0, 1282 bytes   (ran, measured)
    node <symlink-to-the-same-script>                  → exit 0, 0 bytes      (main() never ran)
    

    Line 498 compares import.meta.url against pathToFileURL(process.argv[1]).href; through a symlink those differ, main() is skipped, and the exit code is 0 either way.

    My characterisation was half right: the failures were genuinely not caused by the release, and CI is genuinely green. But I treated "not mine" as "not interesting" and never asked why that one script went quiet when run from a temp root. It was pointing at a gate that can read green while measuring nothing. pnpm hygiene:tokens invokes it from the repo root, so the CI path is unaffected today — but "CI is green" has never been evidence that the ratchet actually measured anything, and I asserted it as if it were.

    Disposition

  8. removed their assignment
    on Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendServer-side behaviour — hooks, flows, actionsbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions