Repository navigation
crm_quote.crm_contact 要不要自 presented 起 requiredWhen——缺 contact 的报价永远起草不出合同,失败只出现在服务器日志里(源自 #714) #1017
Description
Activity
- addedmetadataDeclarative metadata — schema, security posture, UI surfacesDeclarative metadata — schema, security posture, UI surfacesneeds-user-decisionNeeds the maintainer's call before work proceedsNeeds the maintainer's call before work proceeds
on Aug 7, 2026 - removedneeds-user-decisionNeeds the maintainer's call before work proceedsNeeds the maintainer's call before work proceeds
on Aug 11, 2026 huangyiirene commented
on Aug 11, 2026 CollaboratorMore actionsRuling (maintainer, 2026-08-11, PM chat, verbatim: 「接受你的全部建议」): Option B — add
requiredWhenoncrm_quote.crm_contactfrompresentedonward. The schema comment "Recipient is nailed down by the time a quote is presented" finally gets a mechanism; the contract-drafting failure moves from an async server log to a synchronous, someone-is-watching moment (declared = enforced). Scope includes handling stock presented-without-contact quotes (enumerate and report them in the PR; the requiredWhen gate applies on write, so stock rows need a stated disposition, not silent breakage).Queued (S). Serial constraint: same schema file as #599's ceiling validation (
quote.object.ts) — this card dispatches first, #599 follows in a later round.
Generated by Claude Code
- addedpm:dispatchedDispatched to a dev agent by /pm-dispatchDispatched to a dev agent by /pm-dispatch
on Aug 11, 2026 huangyiirene commented
on Aug 11, 2026 CollaboratorMore actionsClaim: PM loop round 3 (hotcrm whole-repo seat)
Session:session_01NM6o28jmBgsyTRQHutn7LC
Branch:claude/issue-1017-quote-contact-requiredwhen
Worktree: dedicated cloud session (fresh clone)
Domain: hotcrm whole-repo seat
File surface:src/objects/quote.object.ts(thecrm_contactfield block only), quote tests,content/docs/sales/quotes.mdxif the口径 needs updating
Container & model: S (contract-touching, not mechanical),mode:cloud(dedicated session),model: claude-opus-5
Serial constraints cleared: #599's ceiling validation is held for a later round (same file); in-flight #1066 and same-batch #595 are both disjoint fromquote.object.ts.Premise re-verified on
origin/mainbefore dispatch:crm_quote.crm_contactis optional atquote.object.ts:81and still carries the comment "Recipient is nailed down by the time a quote is presented" with no mechanism behind it;crm_contract.crm_contactisrequired: true+storage.notNullatcontract.object.ts:66-67. In-reporequiredWhenprecedent for the dev to follow:src/objects/lead.object.ts:372,378(ADR-0113,P\...`` predicate form).
Generated by Claude Code
- added a commit that references this issue
on Aug 11, 2026 huangyiirene commented
on Aug 11, 2026 CollaboratorMore actions{ "issue": 1017, "status": "in_progress", "branch": "claude/issue-1017-quote-contact-requiredwhen", "pr": "https://github.com/objectstack-ai/hotcrm/pull/1068", "premise_still_valid": true, "summary": "Option B implemented as ruled. `crm_quote.crm_contact` now carries `requiredWhen: has(record.status) && (record.status == \"presented\" || record.status == \"accepted\")`, giving the schema comment 'Recipient is nailed down by the time a quote is presented' its first mechanism; the contract-drafting refusal moves from an async server log to a synchronous 400 VALIDATION_FAILED against the field. Gated states determined from the object's own `quote_status_progression` vocabulary, not guessed: `presented` + `accepted` only. `expired` is excluded because the nightly `quote_expiration` flow expires never-sent drafts (gating it would fail a scheduled system write on exactly the quotes that have no recipient); `rejected` is excluded because it is legal straight out of `in_review`, i.e. a quote killed before anyone saw it. Draft/in_review still need no contact, so `quote_generation` can still quote a contact-less opportunity. STOCK DATA: enumerated all 5 seeded quotes in src/data/revenue.seed.ts — count of gated-state-without-contact rows is 0 (accepted/Acme and presented/Globex both name a contact; the contact-less one is `rejected`, which is not gated). demo-bootstrap, quote-generation, scripts/** and e2e/** create no gated quotes. Disposition: nothing to migrate, and measured on both drivers (memory + real SQLite) a pre-existing presented-without-contact row is NOT bricked — it reads, ordinary edits land, and filling the contact in is an ordinary edit; the residue is that such a legacy row can still be walked to `accepted`, where it meets the pre-#1017 behaviour. Zero such rows ship here. All three behaviours are pinned as tests and written into the quotes doc's admin tips. Diff confined to the crm_contact field block in quote.object.ts so #599 inherits no conflict; package.json/lockfiles, case.hook.ts, service.seed.ts and e2e/** untouched.", "tests": "New test/quote-contact-required-when.test.ts (22 cases) drives real ObjectQL on BOTH driver shapes — InMemoryDriver (sparse rows, the shape that makes an unguarded predicate abort) and a real SQLite database (column-complete NULLs). Each refusal is asserted by substance: envelope code VALIDATION_FAILED (which @objectstack/runtime's validation-failure.ts answers with VALIDATION_FAILED_STATUS = 400 + fields[]; the test mirrors its discriminator since neither is exported), per-field code `required`, the message naming the field, AND the record not moving. Refused: draft→presented, draft→accepted, INSERT born presented, blanking the contact on a presented quote. Positive cases green: presenting AND accepting a quote WITH a contact, expiring a never-sent contact-less quote, rejecting one killed in review, unrelated edits, later edits to an already-presented quote. REVERSE VERIFICATION (run once, direction predicted first): deleting the single requiredWhen line flips 7 of 19 to red — all five enforcement pins report 'the write was ADMITTED — no refusal to inspect', the 12 that stay green are the positive/ungated/seed paths that should not change. One existing fixture needed the contact (test/cascade-guard-messages.test.ts drove a contact-less quote to accepted); fixed. Gates against main at c5bc586 (rc.5 — #1066's rc.6 bump had NOT landed at merge time; re-merge warranted if it lands first): pnpm validate 0, typecheck 0, lint 0 (116 warnings all pre-existing, none naming crm_contact), hygiene 0, build 0, test 81 files / 1896 passed / 1 skipped, test:coverage branches 84.82% vs threshold 78. Changeset added. CI not yet reported — PR opened as draft.", "open_questions": [], "out_of_scope_findings": [ "Filed hotcrm#1069 (new, unassigned, dedup-searched): `requiredWhen` is a TRANSITION gate, not an invariant — a record already in the required state is never re-validated. Measured on both drivers: a legacy presented-without-contact quote goes presented→accepted unrefused, while draft→presented is refused. Same hole applies to crm_opportunity.win_reason/loss_reason (#593) and crm_lead's duplicate lookups (ADR-0113): a deal already at closed_won with no win_reason keeps taking edits forever. Half of this is load-bearing (it is why adding a gate does not brick stock rows, which #1017's ruling asked for), so the card poses it as a product choice rather than asserting a platform bug." ] }
Generated by Claude Code
huangyiirene commented
on Aug 11, 2026 CollaboratorMore actionsACCEPT — PR #1068 reviewed and MERGED (
8a90449). Option B implemented as ruled; the sentence "Recipient is nailed down by the time a quote is presented" now has a mechanism.Review notes:
- The gate is
requiredWhen: has(record.status) && (status == "presented" || status == "accepted"), with thehas()guard carrying real load — the recorded trap (a predicate that fails to evaluate is skipped with a warning, leaving a rule that reads as enforced and requires nothing) is pinned by its own test. - Which states are gated was read off
quote_status_progression, not assumed:expired(the nightly sweep over never-sent drafts) andrejected(killed in internal review) are deliberately left open, and the test asserts their absence from the predicate. - Refusals are asserted by substance — envelope
code=VALIDATION_FAILED, HTTP 400, per-fieldcode=required, and the record did not move — driven through a real ObjectQL on both driver shapes (in-memory sparse records and a real SQLite database with column-complete NULLs). Reverse verification ran: deleting the one line flips exactly the five enforcement pins to "the write was ADMITTED", the positive paths stay green. - The ruling's stock-data sub-clause is answered by measurement, not assertion: all 5 seeded quotes carry a contact; a legacy
presented-without-contact row (built by inserting through an ungated schema and re-opening the store gated) stays readable and editable, is repaired by an ordinary edit, and the one residue — such a row can still be walked toaccepted— is stated in the PR, pinned as a test, and written into the doc's admin tips rather than left for a user to discover.
Serial note: #599's discount-ceiling validation lands in this same file next round; this diff stayed confined to the
crm_contactblock so that card inherits no conflict.
Generated by Claude Code
- The gate is
- added a commit that references this issue
on Sep 3, 2026 - added a commit that references this issue
on Oct 8, 2026
源自 #714 / PR #1013 的验收遗留(机械缺陷已修完:
false不再进 lookup、失败已变诚实、close-won 不再被连累)。悬而未决的是一个产品语义分叉,按纪律交维护者拍板。事实(已实测,证据在 PR #1013 正文末节)
crm_quote.crm_contact有意可选;crm_contract.crm_contact必填(required + notNull,缺键与 null 均报Primary Contact is required,warn-first / strict 两种 posture 一致);content/docs/sales/quotes.mdx已写给销售的口径是 "what the quote does not carry, acceptance cannot pass on",与现状一致。选项
crm_quote.crm_contact加自presented起的 requiredWhen:把失败前移到同步、可修复、有人在场的时刻;契合「declared = enforced」与 schema 注释里已写死的意图。代价:改变报价何时可被呈现,并触及存量数据(已 presented 而无 contact 的报价)。crm_contract.crm_contact为可选:CPQ 链恒成立,但削弱合同数据模型(法律文件不指名对手方),与 Contracts/Quotes 两页文档冲突,属典型 consumer-side 宽容。推荐
B(#714 的 dev 推荐,PM 同意):意图已在 schema 注释里写死、只是从未被执行;B 把错误搬到有人在场的时刻。但 B 改变呈现门槛并触及存量数据,需维护者拍板。拍板后一单可派。
Refs #714 · PR #1013(正文末节含三选项完整论证)