feat(quote): a quote may not be presented without a recipient (#1017) - #1068
Merged
Merged
Conversation
`crm_quote.crm_contact` carried the sentence "Recipient is nailed down by the time a quote is presented" for the field's whole life with nothing enforcing it. `crm_contract.crm_contact` is `required` + `notNull`, so a quote accepted without a recipient could never draft its contract — since #714/#1013 that failure is honest and no longer swallows the close-won leg, but `quote_on_accepted` is `async` + `onError: 'log'`, so the accepting write still answers 200 and the only evidence is a server log. Per the maintainer's ruling on #1017 (Option B), the sentence now has a mechanism: `requiredWhen` on `presented`/`accepted`. The refusal moves to the synchronous, someone-is-watching moment, reported against the field with the quote still editable. Drafting is unchanged — `draft`/`in_review` still need no recipient, which is what lets `quote_generation` quote a contact-less opportunity. The two states reachable without the quote ever being sent are deliberately not gated: `expired`, written by the nightly `quote_expiration` sweep over never-sent drafts, and `rejected`, legal straight out of `in_review`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BksPWmRi2gbZjapgUVU4V
Measured, not assumed: the engine evaluates the requirement on the write that makes the predicate BECOME true, so a quote that was already `presented` without a contact when the rule landed is not bricked — it reads, ordinary edits land, and filling the contact in is an ordinary edit. The cost is that such a row can still be walked on to `accepted`, where it meets the pre-#1017 behaviour. This repo ships no such row, so the residue is empty here; it is pinned so the boundary is a recorded measurement rather than a later surprise, and stated for admins in the quotes doc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BksPWmRi2gbZjapgUVU4V
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
huangyiirene
marked this pull request as ready for review
August 11, 2026 06:30
This was referenced Aug 11, 2026
feat(case): SLA policy matrix per priority × account tier, single first-response writer (#595)
#1071
Merged
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1017
Description
Option B, per the maintainer's ruling on #1017.
crm_quote.crm_contacthas carried the sentence "Recipient is nailed down by the time a quote is presented" for the field's whole life with nothing enforcing it. That intent now has a mechanism: the field isrequiredWhenthe quote's status ispresentedoraccepted.Why it matters, restated from #714 / PR #1013:
crm_contract.crm_contactisrequired+notNull, so a quote accepted without a recipient could never draft its contract. Since #1013 that failure is honest and no longer swallows the close-won leg — butquote_on_acceptedisasync: true+onError: 'log', so the accepting write still answers 200 and the only evidence is a server log with no human in front of it. The gate moves the same refusal forward to the write that turns the quote outward:Option A (docs only) and Option C (relax
crm_contract.crm_contact) were declined in the ruling and are not revisited here.Which states are gated, and why not the other two
Read off
quote_status_progressionin the same file rather than guessed. The gate ispresented+accepted. The two states a quote can reach without ever being sent are deliberately left open:expiredquote_expirationflow sweeps onexpiration_datealone and expires never-sent drafts as readily as presented ones. Gating it would make a scheduled system write fail on exactly the quotes that have no recipient because nobody sent them — and itsrunAs: 'system'would not exempt it, since the gate applies on write, not on actor.rejectedin_review, i.e. a quote killed internally before anyone saw it. Demanding a contact to file that verdict asks for a recipient the quote never had.Neither passes anything on to a contract, so neither needs one. Drafting is unchanged:
draft/in_reviewstill need no recipient, which is what letsquote_generationquote a contact-less opportunity (the original reason the field is optional at all).Type of Change
Related Issues
Fixes #1017
Related to #714 · PR #1013
Changes Made
src/objects/quote.object.ts—crm_contactgainsrequiredWhen: has(record.status) && (record.status == "presented" || record.status == "accepted")and a fielddescription. The diff is confined to that field block, so Discount governance: ceiling validation + approval routing; one definition of "large deal" #599's discount-ceiling validation on this same file does not inherit a conflict.test/quote-contact-required-when.test.ts— new, 22 cases (see Testing).src/translations/{en,zh-CN,es-ES,ja-JP}.ts— the field now has adescription, andtest/i18n-references.test.tsrequires a matchinghelpstring in every locale bundle.content/docs/sales/quotes.mdx— the page's standing 口径 was "Put both on the quote before you mark it accepted — what the quote does not carry, acceptance cannot pass on", i.e. advice. The contact half is now enforced, so that paragraph is rewritten, a Built-in rules bullet is added, and an admin tip records the write-time boundary below. The related opportunity is still genuinely optional and is now called out as such.test/cascade-guard-messages.test.ts— one fixture drove a contact-less quote toacceptedto reach the freeze it measures; it now gets a contact. This is the only existing test the gate touched.Stock data — enumeration and disposition
The ruling's binding sub-clause: the gate applies on write, so existing
presented-without-contact quotes need a stated disposition.Enumerated (count: 0 gated rows without a contact). The only quote records this repo ships are the 5 in
src/data/revenue.seed.ts:crm_contactacceptedjohn.smith@acme.example.compresentedsarah.j@globex.example.comin_reviewdraftrejectedAlso swept, and none of them creates a quote:
src/flows/demo-bootstrap.flow.ts(backfillsowner_idonly),src/flows/quote-generation.flow.ts(createsdraft),scripts/**(backfill-owner-id.tswrites ownership only),e2e/**(namescrm_quotein an object list, creates none). Two test fixtures hold contact-less quotes:test/flow-scheduled.test.ts(stub flow harness, no engine validation — unaffected) andtest/cascade-guard-messages.test.ts(fixed, above).Disposition: nothing to migrate, and no stock row becomes un-editable. This is measured, not inferred, and the measurement changed what the doc says. A row that entered the gated state before the rule existed was built the way an upgrading deployment gets one — insert through an engine whose schema has no
requiredWhen, then re-open the same store with the shipped schema — on both drivers:The engine evaluates the requirement on the write that makes the predicate become true, not on every later write while it holds. So pre-existing rows are not bricked, and the repair is an ordinary edit in the same form that reports the error. The cost of that, stated rather than hidden: such a legacy row can still be walked on to
accepted, where it meets the pre-#1017 behaviour (contract cannot be drafted, reason in the log). That residue exists only for rows already gated when the rule arrived — a quote created under this schema cannot get there, becausedraft → presentedis refused. This repo ships no such row, so the residue is empty here. All three behaviours are pinned as tests, and the boundary is written into the doc's admin tips.Testing
pnpm test) — 81 files, 1896 passed, 1 skippedpnpm lint) — exit 0, 116 warnings / 14 suggestions, all pre-existing (form sections withoutname, line-item lookups); none namescrm_contactpnpm build)Gates run on this branch after
git fetch origin main && git merge origin/main(main atc5bc586; #1066's rc.5 → rc.6 bump had not landed at that point, so these numbers are against rc.5 — re-running is warranted if #1066 merges first):pnpm validate✅ ·pnpm typecheck✅ ·pnpm lint✅ ·pnpm hygiene✅ ·pnpm build✅ ·pnpm test✅ ·pnpm test:coverage✅ (branches 84.82%, threshold 78).test/quote-contact-required-when.test.ts(22 cases) does not check that a rule is declared — this repo has measured five metadata surfaces that accept a rule and then don't apply it, andrequiredWhenhas its own version of that (a predicate that fails to evaluate is skipped with a warning, leaving a rule that reads as enforced and requires nothing; hence thehas(record.status)guard, following thelead.object.tsprecedent under ADR-0113). So refusals are driven through a real ObjectQL on both driver shapes —InMemoryDriver, whose stored rows omit unwritten columns, and a real SQLite database, whose rows are column-complete with NULLs.Each refusal is asserted by its substance, not by "it threw": envelope
code=VALIDATION_FAILED(which@objectstack/runtime'svalidation-failure.tsanswers withVALIDATION_FAILED_STATUS = 400plus afields[]payload — the module exports neither, so the test mirrors its discriminator, which is what makes the assertion bite: a refusal raised in some other shape would stop being recognised and come back 500), per-fieldcode=required, message names the field, and the record did not move — a rule that reports a problem while the write lands anyway is the flow-condition failure mode (#633), not enforcement.Refused:
draft → presented;draft → accepted; an INSERT bornpresented; blanking the contact out of a presented quote. Green and staying green: presenting and accepting a quote that has a contact; expiring a never-sent contact-less quote (the nightly sweep); rejecting one killed in review; unrelated edits; a later edit to an already-presented quote.Reverse verification (run once, direction predicted first)
Deleting the single
requiredWhenline and re-running the file:All five enforcement pins flip to "the write was ADMITTED" — the pin fails for the right reason, in the predicted direction. The 12 that stayed green are the positive paths, the ungated states and the seed enumeration: platform behaviour did not change there, so they should be green on both sides. (Reverse run made before the three legacy-row cases were added, hence 19 rather than 22; those measure engine behaviour with the rule present.)
Checklist
Additional Notes
Draft: gates are green locally against main at
c5bc586, CI is still to report. Untouched by design, per the card:package.json/ lockfiles (#1066),src/objects/case.hook.ts,src/data/service.seed.ts(#595),e2e/**.Generated by Claude Code