Skip to content

docs(administration): list all five objects that have a state machine (#896) - #919

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-896-state-machines-list
Aug 6, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-896-state-machines-list

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #896

content/docs/administration/state-machines 的开篇清单只列了 Leads 和 Opportunities 两个对象,随后又把 case / contract / campaign / quote 四个点名为「有状态字段但只做更简单的状态处理,而不是完整状态机」。四个里有三个说反了。

一、stale-premise 复核:issue 的事实面成立

在 fresh origin/main(7df29789,平台 17.0.0-rc.3)上重跑 grep -rn "type: 'state_machine'" src/objects/,5 处命中逐条重定位(行号较 issue 各 +1,内容一致):

对象 规则名 位置 页面原本的说法
crm_lead lead_status_progression src/objects/lead.object.ts:512 有状态机 ✅
crm_opportunity opportunity_stage_progression src/objects/opportunity.object.ts:413 有状态机 ✅
crm_case case_status_progression src/objects/case.object.ts:346 ❌ 「没有完整状态机」
crm_contract contract_status_progression src/objects/contract.object.ts:259 ❌ 同上
crm_quote quote_status_progression src/objects/quote.object.ts:263 ❌ 同上

campaign 是四个里唯一说对的。issue 的核心事实成立。

二、severity 实测(⚠️ 这一步改掉了措辞)

按裁定不照抄 issue 的「会被校验规则拦下」。写了一次性探针(跑完即删),对 5 条规则逐条:真实 ObjectQL + InMemoryDriver,用各对象真实的 object schema 建记录,停在合法状态,然后尝试一次转换表之外的跳转,并同时做一次表内跳转作对照。

引擎侧机制先看清(@objectstack/objectql/dist/core.mjs:2433-2443):evaluateRule 产生 violation 后,只有 severity === 'error' 才 push 进 errors 数组、最终 throw new ValidationError(errors);其余 severity 走 logger.warn,不进 errors。checkStateMachine 在 mode === 'insert' 时若规则未声明 initialStates 直接 return null —— 而 grep -rn "initialStates" src/objects/ 零命中,故新建路径完全不检查。

探针实测结果(5/5 一致,无分档):

规则 声明 severity 实测非法跳转 引擎行为 落库后的值
lead_status_progression warning qualified 到 contacted 未阻断,记日志 contacted
opportunity_stage_progression warning prospecting 到 negotiation 未阻断,记日志 negotiation
case_status_progression warning resolved 到 waiting_customer 未阻断,记日志 waiting_customer
contract_status_progression warning draft 到 activated 未阻断,记日志 activated
quote_status_progression warning draft 到 accepted 未阻断,记日志 accepted

探针原始输出节选:

crm_case.status — rule 'case_status_progression'
  declared severity : warning
  transitions[resolved] : [closed, in_progress]
  probe: resolved -> waiting_customer  (outside the table? true)
  stored before     : resolved
  ILLEGAL verdict   : SAVED (not blocked)
  value after       : waiting_customer
  engine log lines  :
      WARN Validation rule 'case_status_progression' (warning): Invalid status transition
  CONTROL resolved -> in_progress (in table? true) : saved, value=in_progress

crm_contract.status — rule 'contract_status_progression'
  declared severity : warning
  transitions[draft] : [in_approval, terminated]
  probe: draft -> activated  (outside the table? true)
  ILLEGAL verdict   : SAVED (not blocked)
  value after       : activated
  engine log lines  :
      WARN Validation rule 'contract_status_progression' (warning): Invalid contract status transition
  CONTROL draft -> in_approval (in table? true) : saved, value=in_approval

crm_quote.status — rule 'quote_status_progression'
  declared severity : warning
  transitions[draft] : [in_review, expired]
  probe: draft -> accepted  (outside the table? true)
  ILLEGAL verdict   : SAVED (not blocked)
  value after       : accepted
  engine log lines  :
      WARN Validation rule 'quote_status_progression' (warning): Invalid quote status transition
  CONTROL draft -> in_review (in table? true) : saved, value=in_review

lead / opportunity 与 case / contract / quote 没有分档 —— 五条同为 warning、同为「记日志后照样保存」。所以页面按实况写「声明出来的路线是建议,不是闸门」,沿用 #832 / PR #874 已在 contracts 页落地的口径(advice, not a gate / 保存照样通过 / 儲存照樣通過),而不是 issue 里那句「会被校验规则拦下」。另附一句写实:新建路径不受检查。

(对照探针第一版:opportunity 用 prospecting 到 closed_won 时确实抛了异常,但抛的是 win_reason 的 requiredWhen,不是状态机 —— 换成 prospecting 到 negotiation 隔离掉后即 SAVED。这类串扰不算状态机拦截。)

三、:12-17 三语改法

清单补 case / contract / quote 三条,路线按各自 transitions 表逐条边核对后写(例如 case 的 resolved 只能从 in_progress / escalated 进入,所以没有写成 Waiting on Customer 直达 Resolved 的链)。随后一段讲清「五者同为对象上一条具名 state_machine 验证规则,作用于生命周期字段(商机是 stage),7.7 顶层 stateMachines 键移除后统一迁到这里」。

:17 的取舍:改写,不删句。 删掉的话,「campaign 有状态字段却没有状态机」这个有意为之的事实会连带消失,而 test/status-state-machines.test.ts 的注释恰好点出:「deliberately absent」和「forgotten」在元数据里长得一模一样,只有一种应该保持原状 —— 这正是值得留在页面上的信息。所以改写为只讲 campaign(页面原句里唯一说对的那个),并补上同类的其余几个。这里也没有照抄「只讲 campaign」的字面:实测 status 字段无状态机的不止 campaign,还有 campaign member / event / knowledge article / task,所以句子点名 campaign 之后以「同理」带过其余,而不逐一枚举成一份会腐烂的清单。

三语用词:

四、守卫

test/status-state-machines.test.ts 原本只有「元数据 to 元数据」一个方向:从编译后的 stack 推出必须被治理的对象集。#896 这类缺陷正好在它够不到的另一个方向 —— 源码长出了状态机,页面没跟上,而 os validate / pnpm lint 从不打开 content/docs。

新增 block 从同一份 GOVERNED / UNGOVERNED ledger 推导期望,读三语页面首个 ## 小节的列表项:每个被治理的对象必须出现在清单里;每个状态是描述性的对象(campaign / task)不得出现在清单里。只读列表项、不读散文 —— 因为讲「campaign 为什么没有状态机」的那一段合法地点了 campaign 的名,整节做子串搜索会把它误判成清单条目。另有一条 precondition 断言清单非空,防止改成表格或散文后 UNGOVERNED 那条空转变绿。

反向验证(方向为预测的 RED):把三个 mdx 还原到 origin/main 后单跑该文件,新断言在三个 locale 全红,且点名的恰好是 crm_case / crm_contract / crm_quote:

AssertionError: state-machines.mdx declares a state machine for these objects but does not list them:
  crm_case (Cases)
  crm_quote (Quotes)
  crm_contract (Contracts)
AssertionError: state-machines.zh-Hans.mdx ... crm_case (案例) / crm_quote (报价) / crm_contract (合同)
AssertionError: state-machines.zh-Hant.mdx ... crm_case (案例) / crm_quote (報價) / crm_contract (合約)

DOC_LABEL 是手写的、刻意不取自 src/translations/ —— 文档与语言包是两个各自会漂移的面(#837 就是 crm_case 一个对象在三处三种叫法),拿语言包去钉页面会让这个守卫因为与「清单是否完整」无关的原因变红。另有一条断言保证 ledger 里每个对象都在 DOC_LABEL 里有词条,避免新对象因为缺词条而悄悄跳过检查。

五、验证

全部在 flock -w 7200 /tmp/os-heavy-verify.lock 内串行执行,NODE_OPTIONS=--max-old-space-size=4096:

门 退出码 关键行
pnpm validate 0 ✓ Validation passed (2119ms)
pnpm typecheck 0 tsc --noEmit,无输出
pnpm lint 0 13 warning(s), 14 suggestion(s),与 main 同(均为既有 approval / naming 提示)
pnpm build 0 ✓ Build complete (1523ms)
pnpm hygiene 0 ✓ source hygiene clean(含 content / .changeset 的控制字节扫描)
pnpm test -- --maxWorkers=2 0 Test Files 66 passed (66);Tests 1597 passed, 1 skipped (1598)

单跑本次改动的守卫文件:Test Files 1 passed (1) / Tests 39 passed (39)。

push 前另做控制字节自扫(grep -naP 覆盖 [\x00-\x08\x0b\x0c\x0e-\x1f],超出 check:nul-bytes 的扫描面):四个改动文件 + changeset 均零命中。未起 dev server;探针为一次性文件,跑完已删,仓库无残留。

六、范围与边界

  • 只动 :12-17 这一节 ×3 + 一个测试 + changeset。src/** 零改动,content/docs/releases/ 未触碰,@objectstack/* 版本未动。
  • service/cases.mdx:78 只引用未改 —— 但它与实测冲突(见越界发现)。
  • 本页 :14-15 两条既有列表项保持原样:它们用的状态词(Working / Disqualified / Closed Without Decision)在 src/ 里查无此值,但同一套词贯穿本页的 ASCII 图、转换表、Copilot 小节,只改列表项会让页面与 5 行外的自家图自相矛盾 —— 作为越界发现另行归档,不在本 PR 顺手改。

…#896)

`content/docs/administration/state-machines` rostered two objects — Leads and
Opportunities — and then named case, contract, campaign and quote as objects
that "have status fields but use simpler status handling rather than full state
machines, because their transitions are less strict". Three of the four were
wrong: `crm_case`, `crm_contract` and `crm_quote` each carry a named
`state_machine` validation rule with a complete transition table, added by
#575 B4, and the roster never moved when they landed. Campaign was the one the
sentence got right.

All three locales now list five objects with their real routes, read off the
transition tables edge by edge, and state the mechanism once: every machine is
a named `state_machine` validation rule on the object over its lifecycle field
— the same mechanism for all five, not a lighter one for the newer three.

Severity was measured before the wording was chosen, and it changed it. All
five rules are `severity: 'warning'`, and the engine only pushes an `error` into
the thrown `ValidationError`; a warning is logged and the write proceeds. Driven
on a real ObjectQL over the in-memory driver, one object at a time, every
illegal transition SAVED and left the illegal value stored:

  lead        qualified -> contacted           saved, status=contacted
  opportunity prospecting -> negotiation       saved, stage=negotiation
  case        resolved -> waiting_customer     saved, status=waiting_customer
  contract    draft -> activated               saved, status=activated
  quote       draft -> accepted                saved, status=accepted

So the page says the declared route is advice, not a gate — matching the
wording `contract_status_progression` already has on the contracts page — rather
than the issue's "the validation rule stops you". No machine declares
`initialStates`, so a record created directly in a late state is not checked at
all; that is recorded too.

`test/status-state-machines.test.ts` gains the direction it was missing. It
already derived the governed set from the compiled stack; it now reads the
bullet roster off all three pages and fails when a governed object is absent
from it, or when a deliberately-descriptive status (campaign, task) appears in
it. Bullets only — the paragraph explaining why campaigns have no machine is
free to name them. Verified in reverse: reverting the three pages turns the new
assertions red naming exactly crm_case, crm_contract and crm_quote in each
locale.

Documentation and one test; nothing under src/ changed.

Refs #575 #832 #837
@vercel

vercel Bot commented Aug 6, 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 6, 2026 8:06am

Request Review

@github-actions github-actions Bot added the ci/cd CI plumbing and the verification pipeline label Aug 6, 2026
@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 08:47
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 6014b2c Aug 6, 2026
10 checks passed
This was referenced Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline

Projects

None yet

2 participants