You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Ruling (maintainer, 2026-08-11, PM chat, verbatim: 「接受你的全部建议」): Option B — add requiredWhen on crm_quote.crm_contact from presented onward. 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.
Claim: 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 (the crm_contact field block only), quote tests, content/docs/sales/quotes.mdx if 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 from quote.object.ts.
Premise re-verified on origin/main before dispatch: crm_quote.crm_contact is optional at quote.object.ts:81 and still carries the comment "Recipient is nailed down by the time a quote is presented" with no mechanism behind it; crm_contract.crm_contact is required: true + storage.notNull at contract.object.ts:66-67. In-repo requiredWhen precedent for the dev to follow: src/objects/lead.object.ts:372,378 (ADR-0113, P\...`` predicate form).
{
"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."
]
}
ACCEPT — 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 the has() 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) and rejected (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-field code = 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 to accepted — 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_contact block so that card inherits no conflict.
Activity
huangyiirene commentedon Aug 11, 2026
Ruling (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
huangyiirene commentedon Aug 11, 2026
Claim: PM loop round 3 (hotcrm whole-repo seat)
Session:
session_01NM6o28jmBgsyTRQHutn7LCBranch:
claude/issue-1017-quote-contact-requiredwhenWorktree: 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 updatingContainer & model: S (contract-touching, not mechanical),
mode:cloud(dedicated session),model: claude-opus-5Serial constraints cleared: #599's ceiling validation is held for a later round (same file); in-flight #1066 and same-batch #595 are both disjoint from
quote.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
test(quote): pin what the write-time gate does NOT reach (#1017)
huangyiirene commentedon Aug 11, 2026
{ "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 commentedon Aug 11, 2026
ACCEPT — 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:
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.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.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.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
test(i18n): extend the Chinese term guard to status names and field l…
test: run the sandboxed bodies through the verify handle's real doors