Skip to content

[Decision] What does the discount ceiling mean when line and quote discounts compound? #1109

Closed as not planned

Description

@yinlianghui

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_ceiling will 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 deleted valid_discount for. A is the shape that keeps that trap in the tree; B makes the rule's name true.

Four-lens block

  1. 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.
  2. 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.
  3. 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.
  4. 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-hold with "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

  1. added
    metadataDeclarative metadata — schema, security posture, UI surfaces
    needs-user-decisionNeeds the maintainer's call before work proceeds
    on Aug 12, 2026
  2. yinlianghui commented on Aug 12, 2026

    @yinlianghui
    ContributorAuthor

    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, and crm_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 — the VALIDATION_FAILED envelope, 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, where quote_total_rollup already joins both numbers — was measured and is worse than it looks: that rollup is async with onError: 'log', so it never blocks a write. Enforcing there means a second, synchronous hook, and per #1075 a hook rejection reaches REST as a bare Error with 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

  3. yinlianghui commented on Aug 12, 2026

    @yinlianghui
    ContributorAuthor

    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.ts when it chose 60 over 30:

    The ceiling is ABSOLUTE — there is no approval path to unblock a quote above it, because that is the deferred half. A blocking rule with no override has to sit where refusing is unambiguously right, not where reviewing would be useful. 30% would refuse aggressive-but-real commercial deals outright and leave the rep nothing to do about it.

    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

    • Measured business pull — near zero, and honestly so. Deepest discount seeded anywhere is 20% (premise 4, re-verified). Nobody has hit the compound case; it was found by inspection. Zero pull is not a reason to do nothing, but it is a reason not to build machinery.
    • Long-term coherence — favours A once B is priced correctly. The Line-item discount has no ceiling, so the quote-level ceiling can be walked around #1086 measurement (recorded above) established that an effective ceiling cannot be a script validation on either object — neither sees both percentages. It needs a stored or derived field on the quote plus a validation that re-runs on every line write, or it lives in quote_line_item.hook.ts where the rollup already joins them. That rollup is async with onError: 'log' and never blocks a write, so enforcing there means a second, synchronous hook whose rejection reaches REST as a bare Error with 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.
    • AI-agent error resistance — the one axis that argued for B, and it has a cheaper answer. The real risk was never the uncapped number; it is that discount_within_ceiling reads 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 into sales/quotes.mdx in all three locales, and _thresholds.ts names 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:

    1. any card that implements discount-based approval routing on crm_quote — it must read this ruling before choosing its trigger field;
    2. a measured instance of a real quote (not a probe) landing above ~60% effective, which would falsify the zero-pull premise this ruling rests on.

    ⛔ 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

  4. claude commented on Sep 11, 2026

    @claude
    Contributor

    Half-state repair — R61 board audit (repo:hotcrm seat, 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.

    Restart-when: a card proposes discount-based approval routing on `crm_quote` ...
    Restart-touch: src/objects/_thresholds.ts
    

    ⚠️ This is the exact failure mode #655 and #549 warn about in their own bodies ("a body-based sweep cannot see a Restart-when: written in a comment"). Eight cards in this lane carried it simultaneously; all eight are repaired this round.

    ⭐ Both lines lifted. The hold itself stands on the 2026-08-12 ruling (Option A); ⛔ nothing was re-adjudicated here.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    repo:hotcrm seat, session_01X8U3asekbiC7yWoEPWR4Dg · stock re-triage group 1 (maintainer-confirmed ten-card group; maintainer reply verbatim: 「批1 同意」) · 2026-09-24T23:12Z

    Closed 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    metadataDeclarative metadata — schema, security posture, UI surfaces

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions