Repository navigation
[Decision] What does the discount ceiling mean when line and quote discounts compound? #1109
Description
Restart-when: a card proposes discount-based approval routing on crm_quote (it must key on the effective discount, not the quote-level percentage), or a real quote — not a probe — is measured above ~60% effective discount
Restart-touch: src/objects/_thresholds.ts
Split out of #1086 by the PM at dispatch time. #1086 itself is dispatched with the mechanical half only (a per-line ceiling at QUOTE_DISCOUNT_CEILING, restoring #599's own stated acceptance criterion). This card carries the business-policy half, which has no answer in the code, the docs, or #599's ruling.
The question
crm_quote.discount and crm_quote_line_item.discount are two independent multipliers on the same total:
subtotal = Σ (quantity × unit_price × (1 − line_discount/100))
discount_amount = subtotal × quote.discount%
total_price = subtotal − discount_amount + tax + shipping_handling
QUOTE_DISCOUNT_CEILING = 60 is currently enforced on the quote-level field only. Once #1086 lands, it will also be enforced per line. Both at their ceiling still compounds: 60% off each line, then 60% off the remainder, is ~84% off list.
So: is "60%" a limit on each discount input, or on what the customer actually pays?
Premises — each with its re-check command
| # | premise | re-check |
|---|---|---|
| 1 | QUOTE_DISCOUNT_CEILING is 60 and is authored once |
git grep -n 'QUOTE_DISCOUNT_CEILING' origin/main -- src/ |
| 2 | The quote-level rule reads only record.discount |
git show origin/main:src/objects/quote.object.ts | grep -n 'discount_within_ceiling' -A6 |
| 3 | No stored field holds the effective discount | git grep -nE 'effective_discount|list_total' origin/main -- src/objects/ (expect zero) |
| 4 | The deepest discount seeded anywhere is 20% | git grep -nE 'discount: [0-9]+' origin/main -- src/data/ |
| 5 | Discount-triggered approval routing is still deferred | git grep -rn 'discount' origin/main -- src/flows/opportunity-approval.flow.ts (expect zero) |
Options
A — Per-input ceiling (status quo once #1086 lands). 60% is a cap on each discount field. Effective discount can reach ~84%.
Cost: the number the business cares about is unbounded; "we never discount more than 60%" stays false, just less false.
Benefit: purely local rules, expressible as type: 'script' validations on each object, no new stored state.
B — Effective-discount ceiling. Enforce 1 − total_price / list_total ≤ 60% on the quote.
Cost: the effective discount is not a stored field. It needs a formula field or a rollup so a validation can read it, and a line-level script validation's record scope is the line — so the check has to live on the quote or in the rollup hook, and must re-run when any line changes. Measurably more machinery.
Benefit: enforces the number a policy actually means, and cannot be walked around by splitting a discount across the two inputs.
C — Both: keep per-input ceilings, add an effective ceiling at a higher line (e.g. 70%).
Cost: two numbers to explain and keep in sync; more metadata surface.
Benefit: per-input rules stay cheap and catch the common error early with a precise message; the effective rule becomes the backstop that cannot be gamed.
Recommendation: B, implemented as the quote-level rule, with C as the fallback if the rollup timing turns out to be unreliable.
Reasoning along the three evaluation axes:
- Real business need (measured, not assumed). Premise 4: the deepest discount this app seeds anywhere is 20%, and Discount governance: ceiling validation + approval routing; one definition of "large deal" #599's own reasoning set the ceiling at 60 precisely so it "refuses nothing this app ships". So no shipped scenario needs >60% effective, and B costs no real capability. What B buys is that the guarantee is about the thing a CFO would ask about.
⚠️ Counter-evidence worth weighing: nobody has reported hitting this — it was found by inspection, not by a user. - Long-term soundness. A rules an implementation detail (which of two fields you typed into); B rules the business quantity. Adding fields later (a third discount input, a promotional credit) silently widens A's hole and leaves B's guarantee intact. B is the one that survives schema growth.
- AI-authoring error resistance. This is the axis that most favours B. An AI author reading
discount_within_ceilingwill cite it as "discounts are capped at 60%" — the metadata claims an enforcement it does not deliver, which is the exact failure class feat(quote): hard discount ceiling, and one definition of "large deal" (#599) #1088 deletedvalid_discountfor. A is the shape that keeps that trap in the tree; B makes the rule's name true.
Four-lens block
- Platform long-term coherence — B shrinks special-casing: one invariant over the business quantity replaces N per-input rules that grow with every new discount field. A and C both grow the rule count as the schema grows.
- Measured business pull — near zero today (premise 4: nothing ships above 20%; no user report). This is a correctness/honesty fix, not a demand-driven one, which is a legitimate reason to pick defer — see below.
- AI-agent error-resistance — strongly favours B: it is the only option under which the rule's name and the rule's effect agree, so an AI author or consumer citing it is not misled. A leaves a documented walk-around in shipped metadata.
- Startup scope discipline — the honest tension. B is real machinery (formula/rollup + a validation that re-runs on line writes) for a hole nobody has hit. Deferring this card with Line-item discount has no ceiling, so the quote-level ceiling can be walked around #1086's per-line ceiling as the shipped answer is a defensible outcome — it closes the 90%-line case the card was filed for and leaves only the compound case open. If you want that, answer "A for now" and this card becomes
pm:on-holdwith "revisit when discount-triggered approval routing lands" as the restart condition.
What is NOT being asked
Not asking whether the ceiling should be 60 — that was ruled in #599 and is not reopened here. Only what the 60 is measured against.
Refs #1086 · #599 / PR #1088 · src/objects/_thresholds.ts · #1069 (invariant vs transition-gate semantics)
Generated by Claude Code
Activity
yinlianghui commentedon Aug 12, 2026
Measurement from #1086 / PR #1112 — one option is now ruled OUT, before anyone reaches for it
The per-line half shipped, and implementing it produced a fact that narrows this decision. Recording it here so the maintainer is not choosing in the dark, and so the obvious-looking answer is not attempted first.
A script validation is evaluated only against the record being written. So
crm_quote_line_item's rules are not consulted when the quote row is updated, andcrm_quote's rules are not consulted when a line is written. Neither object's validations can see both percentages at once.⇒ "Add an effective-discount script validation to one of the two objects" cannot work. That is the shape a reader reaches for first, and it is not available. Pinned as a live test case in
test/quote-discount-ceiling.test.ts(RECORDS THE BOUNDARY: a rule on the line cannot gate a write to the quote), so it stays true rather than being re-discovered.This does not change the question, and it does not change the recommendation (B) — it removes a phantom option and puts a real cost on B.
Option B, re-priced with the measurement
B (store the effective discount on
crm_quote, validate it there) is still the only route that keeps both properties the two shipped ceilings already have — theVALIDATION_FAILEDenvelope, and invariant-not-transition-gate semantics. Its cost is now concrete: a rollup or formula field on the quote that is trustworthy enough for a blocking rule to sit on, and it must re-evaluate when any line changes.The alternative the dev raised — enforce in
quote_line_item.hook.ts, wherequote_total_rollupalready joins both numbers — was measured and is worse than it looks: that rollup is async withonError: 'log', so it never blocks a write. Enforcing there means a second, synchronous hook, and per #1075 a hook rejection reaches REST as a bareErrorwith no envelope. That trades the refusal shape this app just standardised for a silent one.Unchanged: the honest case for answering "A for now"
Nothing here strengthens the case for building B. Premise 4 still holds (deepest seeded discount 20%), nobody has hit the compound case, and the per-line ceiling that just shipped closes the 90%-line scenario the whole thread started from. Closing this as "per-line + per-quote is the policy" (option C) remains defensible and costs nothing — and is now more defensible than when this card was filed, because the cheap half is already in
main.What is no longer available is the middle path of assuming an effective ceiling is a small addition. It is not.
Refs #1086 / PR #1112 ·
src/objects/_thresholds.ts· #1075 (why the hook route loses the envelope)Generated by Claude Code
yinlianghui commentedon Aug 12, 2026
RULING: Option A — per-input ceilings are the whole enforced policy. ⛔ Do NOT build the effective-discount ceiling. →
pm:on-hold.Authority: the maintainer delegated this batch to the PM on 2026-08-12 — "决策箱你试下站在成熟 hotcrm 产品角度,长远稳定角度看看能不能帮我决策". Standard veto window.
Date: 2026-08-12 · State: decision made, deliberately deferred · Restart condition: at the bottom.
I filed this card recommending B. I am ruling A, and the thing that changed my mind is not new information about cost — it is the "mature product" frame the maintainer asked for.
The argument that flips it
A hard block with no override path is only defensible where refusing is unambiguously right. That is not my rule — it is #599's own reasoning, written into
_thresholds.tswhen it chose 60 over 30:An effective-discount hard ceiling is that same mistake at a different number. 60% effective is well inside the range where a real strategic deal lives — a multi-year enterprise agreement at 65% off list is a deal a mature CRM routes for review, not one it refuses with no recourse. Building B would refuse it and leave the rep nothing to do, which is precisely the failure #599 reasoned its way out of six weeks ago.
What a mature CRM actually does with compounded discount is route it for approval. That is the correct long-term answer, and it is not a third ceiling.
The three axes
quote_line_item.hook.tswhere the rollup already joins them. That rollup is async withonError: 'log'and never blocks a write, so enforcing there means a second, synchronous hook whose rejection reaches REST as a bareErrorwith no envelope (No refusal in this app carries an error envelope: every business rejection reaches REST as a bare Error with code and status undefined #1075). Every route to B degrades something this app just finished standardising.discount_within_ceilingreads as a guarantee it does not deliver, so an author cites "discounts are capped at 60%" and is wrong. That is an honesty problem, and honesty is cheap. It is already largely paid: PR feat(quotes): cap the per-line discount at the same 60% ceiling as the quote (#1086) #1112 shipped the "60% + 60% is not 60%" caveat intosales/quotes.mdxin all three locales, and_thresholds.tsnames this card as the open question. The metadata no longer claims what it cannot do.⇒ B buys enforcement of a number nobody has approached, at the cost of new machinery and a degraded refusal shape, to close a gap whose readability half is already closed.
What is accepted, stated plainly
A quote at 60% per line and 60% at quote level is ~84% below list, and nothing refuses it. That is now a decision, not an oversight. It is documented for users, documented in source, and bounded in practice by the fact that this app ships nothing deeper than 20%.
⏳ Restart condition
When discount-triggered approval routing lands — the half #599's ruling deferred — the effective discount is the number it should key on, not the quote-level percentage. That is where the compound case gets handled: by review, with an override path, which is what it always needed.
Two concrete triggers, either one reopens this:
crm_quote— it must read this ruling before choosing its trigger field;⛔ Until then: no effective-discount ceiling, no formula field for it, no rollup change. A card proposing one should be closed as duplicate-of-this unless it carries trigger 2.
Recorded so it is not re-derived
The measurement that killed the obvious implementation — a script validation is evaluated only against the record being written, so neither object's rules can see both percentages — is pinned as a live test in
test/quote-discount-ceiling.test.ts(RECORDS THE BOUNDARY: a rule on the line cannot gate a write to the quote). Whoever picks up the approval routing inherits that fact rather than rediscovering it.Generated by Claude Code
claude commentedon Sep 11, 2026
Half-state repair — R61 board audit (
repo:hotcrmseat,session_0132iDHq4FLW2zS9VPXqbf9f, 2026-09-11T01:4xZ)This card's state label was legal in substance but unreadable to the machine index: the condition existed only in a comment, so the body-based reverse index could not see it. The line below is now the first line of the body, lifted verbatim from
5380859648(os-sam, 2026-08-22) — ⛔ no wording was rewritten and no condition was re-decided.⭐ Both lines lifted. The hold itself stands on the 2026-08-12 ruling (Option A); ⛔ nothing was re-adjudicated here.
Generated by Claude Code
objectstack-fleet commentedon Sep 24, 2026
repo:hotcrmseat,session_01X8U3asekbiC7yWoEPWR4Dg· stock re-triage group 1 (maintainer-confirmed ten-card group; maintainer reply verbatim: 「批1 同意」) · 2026-09-24T23:12ZClosed not_planned — decided, nothing left to build
Ruled A on 2026-08-12: per-input ceilings are the whole enforced policy, ⛔ no effective-discount ceiling. That leaves no work on this card, and its
Restart-when:("a card proposes discount-based approval routing…") is not machine-checkable. The ruling stands in this thread; a future discount-routing card should cite it and key on the effective discount.Generated by Claude Code
feat(quotes): cap the per-line discount at the same 60% ceiling as th…