Skip to content

[Decision] crm_product.tax_rate: enforcing it is measurably wrong, so the remaining option is removal — which needs a ruling #1198

Description

@os-steve

Split out of #1193 so the decision survives that card closing. #1193's deliverable was the object-aware scan that found this field; the field's own verdict is this issue.

The ruling of 2026-08-17 —「逐个 enforce-or-remove(推荐)」— authorised per-field adjudication for the ten rows enumerated in #1182. This field was not one of them — it was invisible to that card's scan — so the existing ruling does not reach it.

What the field is

crm_product.tax_rate ("Default Tax Rate %", pricing group, defaultValue: 0). Read by nothing. pnpm scan:fields --sites crm_product.tax_rate on 62560210:

crm_product.tax_rate — 4 site(s)
  carrier   translations[0].en.objects.crm_product.fields
  carrier   translations[0].zh-CN.objects.crm_product.fields
  carrier   translations[0].ja-JP.objects.crm_product.fields
  carrier   translations[0].es-ES.objects.crm_product.fields

Four locale labels and nothing else — no view column, no form field, no formula, no hook, no flow. It is not on the product form either, so no admin can set it in the UI today; only the API can.

Enforce was the obvious answer, and it is wrong. Measured, not argued.

"Enforce" meant createLineItemPriceFill stamping tax_rate from the product the way it already stamps list_price. Measured on the real engine (memory driver, shipped stack, seeds skipped) with a product at tax_rate: 10, list_price: 1000, one line at quantity: 2:

line subtotal line total_price quote subtotal quote total_price
today (line rate 0) 2000 2000 2000 2000
with the stamp (line rate 10) 2000 2200 2000 2000

The quote total is 200 lower than the sum of its own line totals, because the two halves of this app model tax on different calibres and neither reads the other:

  • crm_quote_line_item.total_price is a formula, quantity × unit_price × (1 − discount/100) × (1 + tax_rate/100) — a per-line percentage;
  • quote_total_rollup recomputes the quote from raw quantity/unit_price/discount and never reads the line's tax_rate; the quote's total is subtotal − discount_amount + tax + shipping_handling, where crm_quote.tax is a manual amount typed on the quote.

So a product rate would produce a line item that disagrees with the quote it sits on, and a rep who also fills in crm_quote.tax — which is the only field the rollup reads — taxes the customer twice. src/data/revenue.seed.ts already refuses to seed a line rate for exactly this reason: "Seeding a rate here would put a second, contradictory tax number on the record."

Two more things enforcement would have to solve, recorded so they are not rediscovered:

  1. The price-fill hook is shared. createLineItemPriceFill serves both crm_quote_line_item and crm_opportunity_line_item, and the latter declares no tax_rate at all. A conditional is writable — ctx.object is on the hook context, so the sandbox's body-only rule is not the obstacle — but it is a branch that exists only to keep one object out.
  2. crm_product.is_taxable was removed in refactor(crm): enforce-or-remove every decorative field, one verdict each #1195, so "is this taxable" no longer exists as a concept. Only a rate does, and rate 0 has to carry the meaning "not taxed". That is fine arithmetically and it does mean a stamped 0 is indistinguishable from an unset one.

What removal would cost, for whoever rules

The third option, which is a design change rather than an adjudication

Make the product rate drive the quote-level figure — quote_total_rollup computing tax as Σ(line subtotal × rate) instead of reading the manual crm_quote.tax. That makes crm_product.tax_rate live and the numbers consistent, but it changes the meaning of an existing published field on a different object (crm_quote.tax stops being an input), and the seeds carry explicit tax amounts that would become derived. That is a product decision, not an enforce-or-remove verdict, which is why it is listed rather than done.

Recommendation: remove. Under the startup-focus principle it is a declared surface with no pull — no consumer, no UI affordance to set it, no seeded value, and its one plausible consumer is a second tax model this app deliberately does not have. Keeping it costs four locale rows and a docs sentence per release and invites exactly the misreading the page carried for months. But it is a published field, so the deletion is yours, not a sweep's.

Filed unassigned; no work started. Measurement reproducible from 62560210.

Activity

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

    @os-zhuang
    Contributor

    Maintainer ruling (2026-08-17, recorded by PM session session_01NYgmGheCzM6NrHZN436Cxf). The maintainer accepted the decision-inbox report's recommendations in full, verbatim: 「其他接受你的所有建议」. For this card:

    Ruled: remove crm_product.tax_rate.

    • Basis, as measured: enforcing it is provably wrong (the app's two tax models — per-line percentage formula vs quote-level manual amount — never read each other; a stamped rate yields quote totals 200 lower than the sum of line totals and double-taxes any rep who also fills crm_quote.tax); the field has zero consumers, zero UI affordance, zero seeded values, and the docs already promise nothing for it.
    • Removal scope per the card's own costing: the field, four locale rows, the docs row in three locales, the api_reference.md field list. Published-field deletion authorized explicitly by this ruling; no in-repo data is affected (all 13 catalog products at default 0).
    • The third option (product-rate-driven quote tax) is explicitly NOT taken — a real tax model is a future product-design card if ever wanted, not part of this verdict; nothing in this removal forecloses it.

    Label flipped needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionNeeds the maintainer's call before work proceeds
    on Aug 17, 2026
  4. huangyiirene commented on Aug 31, 2026

    @huangyiirene
    Collaborator

    既裁恢复:2026-08-17 的删除裁决仍然有效 —— 8/22 的重升级系未读裁决记录所致,本卡退出决策箱(不占新裁决)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · hotcrm 决裁批 #18 盘点项

    按「重挂前先查裁决」纪律核验

    1. 评论 5315744424(2026-08-17)是一条完整的裁决记录:维护者逐字引文(「其他接受你的所有建议」)+ 明确范围(字段/四语言行/三语言文档行/api_reference 列表)+ 「Published-field deletion authorized explicitly by this ruling」 + 同笔转 pm:queue;
    2. 评论 5381231651(2026-08-22)把标签退回 needs-user-decision,理由是「删除已发布字段是人工地板」—— 但该评论通篇未提及、未质疑上一条裁决记录:地板问题在 8/17 已由维护者亲自跨过,重升级是未读全记录的程序失误(本车道已知失败模式),不构成对裁决的推翻;
    3. 8/22 评论补充的唯一新事实(「外部客户是否写过该字段仓内不可知」)在 8/17 裁决中已被权衡并明确授权,不构成新证据。

    执行

    裁决原样恢复:删除 crm_product.tax_rate,范围照 8/17 记录;第三选项(产品税率驱动报价税额)照旧明确不采、不被本删除关闭。needs-user-decision → pm:queue(同笔)。⚠️ 透明记录:若维护者今日想推翻 8/17 裁决,一行即改;本评论只恢复既裁,不产生新裁。


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionNeeds the maintainer's call before work proceeds
    on Aug 31, 2026
  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 3, 2026
  7. self-assigned this
    on Sep 3, 2026
  8. os-sales commented on Sep 3, 2026

    @os-sales
    Collaborator

    Claim: repo:hotcrm PM seat — dispatching to an os-dev agent now (round R32).


    Generated by Claude Code

  9. os-sales commented on Sep 3, 2026

    @os-sales
    Collaborator

    os-dev-report

    {
      "issue": 1198,
      "status": "blocked",
      "branch": "claude/issue-1198-product-tax-rate-removal",
      "pr": null,
      "premise_still_valid": true,
      "summary": "The runtime premise holds exactly as the card states: nothing computes with crm_product.tax_rate. But the deletion is NOT a pure removal, and it is not executable inside this claim's declared file surface. test/field-consumer-scan.test.ts pins the field as the canonical fixture of the #1193 object-aware-resolution guard, and I MEASURED the breakage by ablation rather than predicting it: removing the field and its four locale rows turns 4 test cases red (15 passed, 4 failed). The ruling's zero-consumers basis is sound but structurally blind here - scan-field-consumers.ts walks only the registered metadata stack (roots objects/views/pages/apps/translations/data/mappings), never test/, so a test fixture cannot appear in the ledger the ruling was read from. Repairing it is authorship, not a mechanical edit, and it collides with a card fenced behind this very claim: the guard needs a shared field name carrying BOTH a live and an inert verdict, there are exactly three on this tree (tax_rate, line_number, description), and #1199 proposes removing line_number and description. All 15 inert rows on the tree are under adjudication (1 here + 14 on #1199), so if both cards land as proposed, ZERO inert fields remain and the live-vs-inert fixture class is extinguished. I stopped before touching test/ and left the tree byte-identical. I also falsified three of the ruling's scope items (feature-inventory row, api_reference row, revenue.seed value): all three carry nothing to delete, while the doc surface is 4x larger than 'three doc rows'. Branch is pushed and empty; no commits.",
      "blocked_on": "PM decision, two parts. (1) May this claim's declared file surface be extended to test/field-consumer-scan.test.ts? The deletion cannot leave pnpm test green without editing it, and that file is #1193's artifact, outside the surface the claim comment declared exclusive. (2) Which replacement fixture may the #1193 guard use, given #1199 targets the obvious candidates? Recommendation and durable option in open_questions. Everything else is measured and staged - a follow-up round is pure execution.",
      "tax_rate_hit_list": {
        "method": "git grep -n over 872 tracked files. Positive control: list_price returns 125 hits. Negative control: zzz_definitely_absent_token returns 0. So a zero result means absence, not a broken pattern. Total: 44 hits (41 outside CHANGELOG.md, 3 inside it).",
        "would_remove_5_files_13_lines": [
          "src/objects/product.object.ts:154-162 - the '// Tax & billing' comment plus the tax_rate field block (the field itself)",
          "src/translations/en.ts:891 - tax_rate: { label: 'Default Tax Rate %' } (inside the crm_product block opening at 862)",
          "src/translations/es-ES.ts:917 - label 'Tasa de Impuesto por Defecto %' (crm_product block opens 890)",
          "src/translations/ja-JP.ts:881 - label the default-tax-rate wording (crm_product block opens 854)",
          "src/translations/zh-CN.ts:928 - label the default-tax-rate wording (crm_product block opens 898)"
        ],
        "would_remove_docs_12_sites_not_3": [
          "content/docs/revenue/products.mdx:47 + zh-Hans:47 + zh-Hant:47 - the 'Default Tax Rate %' table row (the 3 rows the ruling counted)",
          "content/docs/revenue/products.mdx:51 + zh-Hans:51 + zh-Hant:51 - a whole PROSE PARAGRAPH about the product rate not being applied; dies with the field",
          "content/docs/revenue/products.mdx:3 + zh-Hans:3 + zh-Hant:3 - frontmatter description ends 'SKUs and tax rates'",
          "content/docs/revenue/products.mdx:17 + zh-Hans:17 + zh-Hant:17 - the Pricing field-group summary row lists 'tax rate'"
        ],
        "left_standing_and_why": [
          "src/objects/quote_line_item.object.ts:142,159 - crm_quote_line_item declares its OWN live tax_rate, read by the total_price formula. Different object. NOT this card.",
          "src/objects/quote_line_item.object.ts:57,60 - comments. Line 60 says 'crm_product keeps tax_rate there too' - a cross-reference that goes STALE on deletion. One clause, in another object's file. Reported, not edited.",
          "src/data/revenue.seed.ts:223 - a COMMENT only, and it is about crm_quote_line_item.tax_rate, inside the quoteLineItems seed block. Seeds no product value. Must not be touched.",
          "src/translations/{en,es-ES,ja-JP,zh-CN}.ts:{1308,1338,1283,1268} - the OTHER four tax_rate locale rows, belonging to crm_quote_line_item. Naive grep counts 8 rows; only 4 are the product's.",
          "test/undeclared-key-probe.test.ts:41,83,256 - uses tax_rate as a key declared on crm_quote_line_item and absent from crm_opportunity_line_item. crm_product is not involved. Unaffected by the deletion.",
          "test/line-item-conventions.test.ts:128 - evaluates the quote line item's own formula. Unaffected.",
          "scripts/scan-field-consumers.ts:16,17,194,489 - narrative comments recording the #1193 history. Accurate history, not a reference to the live declaration.",
          "content/docs/revenue/products.mdx:95 (+ both Chinese faces) - 'Tax is not filled in: enter it on the quote' is about QUOTE-level tax and stays true after the deletion.",
          "CHANGELOG.md - 3 hits, historical release notes, never retro-edited.",
          "test/field-consumer-scan.test.ts - 17 hits. THE BLOCKER. See test_fixture_blocker."
        ]
      },
      "test_fixture_blocker": {
        "measured_by": "ablation, not prediction",
        "failing_cases_4_of_19": [
          "'crm_quote_line_item.tax_rate is live and crm_product.tax_rate is not' - AssertionError: expected false to be true (fieldsByObject.get('crm_product').has('tax_rate'))",
          "'the inert one has locale rows and nothing else' - AssertionError: expected [] to include 'translations'",
          "'a DECLARED but genuinely inert field is still reported, still exit 0' - AssertionError: expected false to be true; the --sites spawn would now exit NON-ZERO (the #1255 refusal path)",
          "'names the objects that do declare the field, when any do' - expected the near-miss line to contain \"'tax_rate' is declared on crm_product, crm_quote_line_item\""
        ],
        "vitest_summary": "Tests  4 failed | 15 passed (19)",
        "why_the_repair_is_not_mechanical": "The first describe block exists to prove the resolver can tell two same-named fields apart - a failure mode the file's own header calls invisible ('a false negative cannot appear in the scan's own output'). crm_product.tax_rate IS that fixture, narrated throughout the file's prose as '#1193's headline row' and 'the control'. Re-pointing it picks a new fixture, rewrites the explanatory comments, and re-specifies the --sites control - authorship in another card's guard.",
        "cross_card_collision": "Shared field names carrying both a live and an inert verdict, measured from pnpm scan:fields --json: exactly three - tax_rate, line_number (crm_opportunity_line_item live / crm_quote_line_item inert), description. #1199 (pm:queue, fenced behind this claim) proposes removing crm_quote_line_item.line_number AND the three inert description rows. So the obvious replacement fixture is on the next card's chopping block; choosing it would silently pre-empt #1199's adjudication.",
        "timeline_dated_via_rest_not_the_shallow_clone": "The local clone is shallow (59 commits, floor 2026-08-31) and would have mis-dated this file; dated through the REST commits endpoint instead. 5d46177f 2026-08-17T05:46:24Z created the scan and the fixture, 6h14m BEFORE the ruling comment at 2026-08-17T12:00:39Z. ac02bf15 2026-08-24T02:45:30Z (#1266) then DEEPENED the dependency, adding the --sites control that pins crm_product.tax_rate by name with exit 0 and the literal output string - seven days AFTER the ruling."
      },
      "zone2_measurements": {
        "1_runtime_consumers": "HOLDS at runtime, FALSIFIED as a pure removal. No hook, flow, view, or formula reads the product's rate - the only formula reading a tax_rate is crm_quote_line_item.total_price reading its own. But 4 test cases depend on the declaration. Note the scan the ruling relied on cannot see test/: CARRIER_ROOTS/DISPLAY_ROOTS and the walk cover the registered metadata stack only.",
        "2_counts": "Locale rows: 4 for crm_product (CORRECT), out of 8 tax_rate locale rows total - the other 4 are crm_quote_line_item's. Doc table rows: 3 (CORRECT), but the doc surface is 12 sites across the 3 faces (row + prose paragraph + frontmatter description + Pricing group summary). No fifth locale pack, no fourth doc face.",
        "3_validate": "HOLDS. Ablated tree: '✓ Validation passed (1718ms)', exit 0, 54 warnings. Base tree: '✓ Validation passed (1566ms)', exit 0, 54 warnings. Warning sets identical; none mentions tax_rate or crm_product. POSITIVE CONTROL that validate really re-read the mutated tree: 'Data: 18 Objects  334 Fields' (base) vs 'Data: 18 Objects  333 Fields' (ablated) - exactly one field fewer.",
        "4_locale_parallelism": "HOLDS. After removing the four rows, all four packs carry 21 crm_product field keys and the key sets are IDENTICAL (symmetric difference empty against en). pnpm lint:i18n-gate exit 0: '✓ i18n lint gate: 0 i18n/missing-* issues (59 total lint issue(s) reported, unaffected by this gate)'.",
        "5_revenue_seed": "FALSIFIED, usefully. src/data/revenue.seed.ts seeds NO product tax_rate. Its single tax_rate occurrence is line 223, a COMMENT inside the quoteLineItems seed block explaining why the QUOTE LINE ITEM's rate is left at 0: 'Seeding a rate here would put a second, contradictory tax number on the record.' The file is out of surface and must not be edited."
      },
      "api_reference_reported_not_edited": "docs/developers/api_reference.md carries NO tax_rate row and no crm_product field list - zero occurrences of 'tax_rate', and zero of 'tax' in any spelling. The file is 1305 bytes and now says the field lists are 'deliberately not restated here', citing the 2026-08-31 ruling item 5; #1488 removed the fifteen hand-copied 'Key fields:' lists. So the ruling's scope item 'the crm_product field list in api_reference.md' was already satisfied by another card, and the #1494 fence has nothing to protect on this issue. NOT EDITED - I did not open it for writing.",
      "verify_outcome": "pnpm verify NOT run to completion: there is no implementation to verify - the tree is unmodified at origin/main f0a05613. Components exercised individually during the ablation, all on the mutated tree: pnpm validate exit 0, pnpm lint:i18n-gate exit 0, vitest test/field-consumer-scan.test.ts exit 1 (4 failed | 15 passed). Exit codes captured by redirecting to a file FIRST and reading the status before any pipe, so no head/tail could rewrite them.",
      "tests": "ABLATION (the only measurement run; no implementation exists). No rebuild step applies and none is claimed: the subject resolves through SOURCE, not dist - the test imports '../scripts/scan-field-consumers' directly and the --sites cases spawn tsx on scripts/scan-field-consumers.ts, while pnpm validate loads objectstack.config.ts through tsx. There is no exports/dist hop for a stale artifact to hide in. MUTATION CONFIRMED ON DISK, not by the editor's exit code: after deleting the field block and the four product locale rows, grep -c 'tax_rate' src/objects/product.object.ts = 0, grep -c 'Default Tax Rate %' = 0 in both product.object.ts and en.ts, and the SURVIVOR probes held - quote_line_item.object.ts still 4 hits, each locale pack down from 2 tax_rate rows to exactly 1. git diff --stat: 5 files changed, 13 deletions(-). RESTORE LEG PROVEN, not assumed: restore was 'git checkout HEAD -- ABSOLUTE_PATH' per file (HEAD named explicitly so a polluted index cannot hand back the mutation), driven by a trap on EXIT INT TERM with REPO_ROOT resolved before mutating. Verified by state, not exit code - all five git hash-object values match their HEAD blobs (product.object.ts 2d220399, en.ts c20d1368, es-ES.ts 681136a6, ja-JP.ts cdd60763, zh-CN.ts f59ac2fe), 'git diff HEAD' empty, 'git status --porcelain' empty, and the field is back (product.object.ts 1 hit, en.ts 2 hits). RESULTS: validate exit 0 with 333 vs 334 fields (control proving the re-read); lint:i18n-gate exit 0 with 0 missing-key issues; field-consumer-scan.test.ts exit 1, 'Tests  4 failed | 15 passed (19)'.",
      "mcp_calls": "2 - one issue_write (filed #1536) and one add_issue_comment (this report). Every GitHub READ went through the unauthenticated REST channel; note the /search/ endpoints are closed to this session ('sessions are bound to their configured repositories'), so issue dedup used the repo-scoped issues listing plus a local grep.",
      "open_questions": [
        {
          "question": "May this claim's declared file surface be extended to test/field-consumer-scan.test.ts, so the deletion can land green?",
          "options": [
            "A - Extend the surface to that one file in this claim, repair the fixture, land the deletion in one PR.",
            "B - Keep the surface as declared; land the deletion and the fixture repair as a paired change owned by whoever owns #1193's guard.",
            "C - Sequence #1198 behind #1199 so the fixture is re-pointed once, after the full inert ledger is adjudicated, instead of twice."
          ],
          "recommendation": "C, then A. Long-term soundness dominates: all 15 inert rows are under adjudication right now (1 here + 14 on #1199), so re-pointing the fixture today means re-pointing it again when #1199 lands - and #1199 targets both obvious replacements. Deciding the whole ledger first makes it one edit with the final field population known. If the deletion must land now, A with the durable fixture in the next question - but A alone, chosen without #1199 in view, is what produces the second churn."
        },
        {
          "question": "Which replacement fixture should the #1193 discrimination guard use, given every live-vs-inert shared pair is on a chopping block?",
          "options": [
            "A - crm_quote_line_item.line_number (live/inert twin pair, structurally identical to tax_rate). FRAGILE: #1199 proposes removing it.",
            "B - A live-vs-DISPLAY-ONLY shared pair, e.g. description with crm_account live and crm_product display-only. Both survive both cards.",
            "C - Keep crm_product.tax_rate solely as a test fixture and do not delete it."
          ],
          "recommendation": "B. Measured: after both cards land as proposed, zero inert fields remain, so every live-vs-inert pair is extinguished and A cannot be durable. Seven shared names keep a live-vs-display-only divergence through both cards (billing_address, description, do_not_call, lead_source, notes, title, website), so B still guards the real thing - that the resolver answers per object rather than per token. Two consequences the seat should decide with open eyes: the guard's boundary changes from live-vs-inert to live-vs-display-only, and the --sites control case, which by its own comment needs 'a DECLARED but genuinely inert field' returning exit 0, has no inert field left to pin and must be re-specified. C is rejected on the startup-focus axis and on the four-axis reading the ruling already made: a regression fixture is not a business consumer, and letting a test fixture veto a maintainer ruling inverts the hierarchy. The fixture cost is real but it is payable."
        }
      ],
      "out_of_scope_findings": [
        "filed as #1536: docs/feature-inventory.md QUO-010 (line 119) credits crm_product with 计费类型/计量单位/库存 - billing_type, unit_of_measure and any inventory field are all absent from the object's 14 declared fields - and QUO-012 (line 122) names a low-stock view that src/views/product.view.ts:11-13 itself records as removed. Three stale claims in two adjacent rows. Dedup: 0 matches across 83 open issues.",
        "NOT filed, reported for routing: src/objects/quote_line_item.object.ts:60 says 'crm_product keeps tax_rate there too', which becomes false the moment this deletion lands. One comment clause in a file this claim may only touch if it references the field. Whoever executes the deletion should fix it in the same PR - flagging it so it is not missed rather than filing a card for one clause.",
        "NOT filed, observation for the seat: the 2026-08-17 ruling's removal scope named an api_reference.md field list that #1488 had already deleted, a feature-inventory row that never existed, and undercounted the doc surface 3 sites vs 12. Nothing to fix in the repo - but if scope statements in decision cards are reused as dispatch surfaces, they are worth re-measuring at dispatch time rather than trusting."
      ]
    }

    Generated by Claude Code

  10. added and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 3, 2026
  11. removed their assignment
    on Sep 3, 2026
  12. os-sales commented on Sep 3, 2026

    @os-sales
    Collaborator

    Seat disposition — claim released, card blocked on #1543.

    The R32 dispatch came back with no PR and the tree byte-identical. That is the right outcome and the seat is recording it as a delivered result, not a failed run: the deletion cannot leave pnpm test green without editing test/field-consumer-scan.test.ts, which is #1193's artifact and was outside the surface this claim declared exclusive.

    ⛔ The deletion ruling is not reopened. The blocker is what happens to #1193's guard afterwards — see #1543.

    The seat verified the load-bearing half of the report independently rather than accepting it: test/field-consumer-scan.test.ts carries 19 tax_rate occurrences; :262 pins crm_product.tax_rate — 4 site(s) as a literal output string and :304 pins the near-miss line by name; and scripts/scan-field-consumers.ts:123/144 confirm the scan's roots are translations/data/mappings + views/pages/apps — test/ is not among them, which is why a test fixture could never have appeared in the ledger the ruling was read from.

    State changes:

    Carried forward to whoever executes this once #1543 rules — three scope items in the 2026-08-17 ruling are stale (the feature-inventory.md row never existed; the api_reference.md list was already removed by #1488, so the #1494 fence has nothing to protect here; revenue.seed.ts seeds no product rate and must not be touched), the doc surface is 12 sites, not 3, and src/objects/quote_line_item.object.ts:60 carries a cross-reference that goes false on deletion and should be fixed in the same PR.

    Separately filed from this run: #1536 (feature-inventory.md QUO-010/QUO-012 credit crm_product with three fields it does not declare, and name a view src/views/product.view.ts records as removed).


    Generated by Claude Code

  13. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    Pointer (director seat, summon #15, 2026-09-05T11:3xZ): #1543 is ruled F by the maintainer — the field-consumer scanner and its guard are retired from this repo outright (comment 5552508432 on that card), so the fixture collision this card was blocked on disappears rather than being re-pointed. This card stays pm:blocked on #1543 until the retirement PR is MERGED (deleting crm_product.tax_rate before the guard is gone turns four of its assertions red); it is released in the same stroke afterwards and dispatched under its existing 2026-08-17 ruling, carrying the four stale scope items #1543 recorded (feature-inventory row absent, api_reference list already removed by #1488, the seed file's tax_rate is a comment only, the doc surface is 12 sites across 3 faces) plus the quote_line_item.object.ts:60 comment clause.


    Generated by Claude Code

  14. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed on Sep 5, 2026
  15. self-assigned this
    on Sep 5, 2026
  16. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round — director seat summon #15; the #1543 blocker is gone (PR #1580 MERGED 2026-09-05T14:58:05Z, hotcrm main 021db549 no longer carries scripts/scan-field-consumers.ts or test/field-consumer-scan.test.ts); dispatched as the release step the F ruling planned (5552508432 item 3), under the maintainer's 2026-09-05 direct-dispatch instruction on #1543 (「你现在就派发处理」); the repo:hotcrm seat #1353 is vacant
    Session: session_01TezFG8ZMrNH6n5VTNpPpdH
    Branch: claude/issue-1198-product-tax-rate-removal-2 (the R32 branch claude/issue-1198-product-tax-rate-removal is an empty probe at f0a05613; a fresh branch from today's main avoids a stale base)
    Worktree: hotcrm-issue-1198
    Domain: repo:hotcrm
    Ruling executed: 2026-08-17 remove crm_product.tax_rate (5315744424, maintainer verbatim 「其他接受你的所有建议」; restored 5480898648 after the 08-22 re-escalation was found to have been made without reading the record). The third option (product rate driving quote tax) is NOT taken and NOT foreclosed.
    File surface (measured on main 021db549, 15:00Z): src/objects/product.object.ts (the tax_rate block at ~:154-162 with its // Tax & billing comment) · src/translations/{en,es-ES,ja-JP,zh-CN}.ts — the crm_product block's tax_rate row only (en:891, es-ES:1021, ja-JP:985, zh-CN:1051); ⛔ the four crm_quote_line_item rows (en:1308, es-ES:1506, ja-JP:1451, zh-CN:1436) stay · content/docs/revenue/products.mdx + .zh-Hans.mdx + .zh-Hant.mdx — 12 sites across the 3 faces (table row, the prose paragraph about the unapplied rate, the frontmatter description's "tax rates", the Pricing field-group summary row); ⛔ the "Tax is not filled in: enter it on the quote" sentence is about quote-level tax and stays · src/objects/quote_line_item.object.ts:60 — the one comment clause "crm_product keeps tax_rate there too" · one changeset. ⛔ Untouched: src/data/revenue.seed.ts (its only tax_rate is a comment about the line item), docs/developers/api_reference.md (#1488 already removed the field lists; the #1494 fence protects nothing here), docs/feature-inventory.md (no such row; #1536 is a separate card), test/undeclared-key-probe.test.ts and test/line-item-conventions.test.ts (about the line item's own tax_rate), governed surfaces (stop on breach; explain in the report)
    Container & model: S metadata deletion with a 12-site doc surface, handled as M; mode:subagent; model: opus — --tier (objectstack dispatch-gates) has no hotcrm path mandate; default tier because the docs faces carry two locale conventions (#1329 / #1368) a deletion must not disturb
    Clause-②: no (an app's published field is removed under an explicit maintainer ruling; no platform contract changes; the app-level changeset states the removal)
    Serial constraints cleared: this claim holds the src/translations/** fence — #1262 · #1311 · #1511 · #1199 are fenced for its duration (all four unclaimed at 15:00Z; open hotcrm PRs are two dependabot bumps); #1199 additionally stays pm:queue and undispatched pending a maintainer ruling on its 14 published-field rows; none otherwise.


    Generated by Claude Code

  17. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    Dev claim — os-dev subagent, mode:subagent, dispatched by the PM claim above (5552666138).

    • Session: session_01TezFG8ZMrNH6n5VTNpPpdH
    • Branch: claude/issue-1198-product-tax-rate-removal-2 (pushed empty at 021db549 as the landing marker; the R32 branch claude/issue-1198-product-tax-rate-removal is untouched)
    • Worktree: /home/user/hotcrm-issue-1198, base 021db549 — the commit that merged chore(test): retire the field-consumer scanner and its guard — detection belongs to the platform (#1543) #1580 and retired scripts/scan-field-consumers.ts + test/field-consumer-scan.test.ts, i.e. the blocker R32 hit is gone from the tree I am working on.
    • Ruling executed: 2026-08-17 remove crm_product.tax_rate (5315744424, restored by 5480898648). The third option (product rate driving quote-level tax) is NOT taken and NOT foreclosed; it gets one sentence in the changeset and nothing else.
    • File surface (exclusive for this claim): src/objects/product.object.ts · src/translations/{en,es-ES,ja-JP,zh-CN}.ts (the crm_product block's tax_rate row ONLY — I will print the enclosing object key before each deletion) · content/docs/revenue/products.mdx + .zh-Hans.mdx + .zh-Hant.mdx · src/objects/quote_line_item.object.ts (the one stale comment clause at :60) · one .changeset/*.md.
    • Fenced out, will not open for writing: src/data/revenue.seed.ts, docs/developers/api_reference.md, docs/feature-inventory.md, test/undeclared-key-probe.test.ts, test/line-item-conventions.test.ts, CHANGELOG.md, existing changesets, and the governed surfaces (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**, root skills/**).
    • Serial fence honoured: the PM claim holds src/translations/** for this card's duration; I touch only the four crm_product tax_rate rows there.

    Re-measuring the whole surface on 021db549 myself before editing; anything that differs from the dispatch's 15:00Z measurement goes in the report.


    Generated by Claude Code

  18. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 1198,
      "status": "done",
      "branch": "claude/issue-1198-product-tax-rate-removal-2",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/1588",
      "premise_still_valid": true,
      "summary": "The 2026-08-17 deletion ruling is executed in full and the tree is green. crm_product.tax_rate is gone: the field block plus its '// Tax & billing' header in src/objects/product.object.ts, one row in each of the four locale packs, and the 12 doc sites across the three product-page faces. The R32 blocker is genuinely gone - main 021db549 carries neither scripts/scan-field-consumers.ts nor test/field-consumer-scan.test.ts (retired by #1580), so the fixture collision that stopped the last round no longer exists and no test needed repairing. Every carried-forward correction from R32 re-measured TRUE on today's main: the api_reference field list is absent (#1488 already removed it), docs/feature-inventory.md carries no such row, revenue.seed.ts:223 is a comment about the LINE ITEM's rate, and the doc surface really is 12 sites, not 3. One surface item the dispatch did not name turned up and is included: docs/STATUS.md:22 transcribes the validator summary and states the field total, so 334 becomes 333 - that is pin test/docs-declared-versions.test.ts (#1011) doing its job, and I proved it by ablation rather than assuming. Deviation: I also fixed a SECOND stale comment cross-reference the deletion makes false, in src/objects/opportunity_line_item.object.ts:63, which is outside the dispatched surface - declared below.",
      "gates": [
        { "command": "pnpm validate",       "exit": 0, "verdict": "✓ Validation passed (1473ms) · Data: 18 Objects  333 Fields" },
        { "command": "pnpm typecheck",      "exit": 0, "verdict": "tsc --noEmit, no diagnostics" },
        { "command": "pnpm lint",           "exit": 0, "verdict": "90 warning(s), 12 suggestion(s) — no errors" },
        { "command": "pnpm lint:i18n-gate", "exit": 0, "verdict": "✓ i18n lint gate: 0 i18n/missing-* issues (102 total lint issues, unaffected by this gate)" },
        { "command": "pnpm hygiene",        "exit": 0, "verdict": "✓ source hygiene clean" },
        { "command": "pnpm hygiene:tokens", "exit": 0, "verdict": "✓ source token ratchet clean — business semantics ~84,018 (ceiling ~85,000); interaction ~37,429 (ceiling ~40,000); authored total ~135,789 (ceiling ~140,000). NO ceiling raised; the deletion lowers the count." },
        { "command": "pnpm build",          "exit": 0, "verdict": "Artifact: dist/objectstack.json (2011.4 KB)" },
        { "command": "pnpm test",           "exit": 0, "verdict": "Test Files 159 passed (159) · Tests 3380 passed, 1 skipped (3381)" }
      ],
      "gate_method": "Each command run SEPARATELY in the foreground, output redirected to a file FIRST and the exit code read before any pipe (cmd greater-than log 2 greater-than and-1; EXIT=dollar-question), so no head/tail could rewrite a status. Verdict lines quoted are the gates' own printed lines, never a bare exit code. This repo has no scripts/pm/os-verify-lock.sh - that is an objectstack script; the chain here is package.json's own, which pnpm verify concatenates.",
      "pm_mechanism_assumptions": {
        "1_validate_one_field_fewer": "HOLDS, and it is the positive control that validate re-read the tree. Base 021db549 unmodified: exit 0, 'Data: 18 Objects  334 Fields'. Mutated tree: exit 0, 'Data: 18 Objects  333 Fields'. Today's baseline equals R32's, so the field total was 334 on both days.",
        "2_docs_pins": "ONE pin went red, for exactly the authorised reason, and it is not one of the three the dispatch guessed at. docs-metadata-counts / docs-object-coverage / docs-dashboard-tiles all stayed green: they match prose spellings (objects, flows, dashboards, datasets, actions, positions) and none of them counts FIELDS. The pin that carries the field total is test/docs-declared-versions.test.ts (#1011), which parses the validator transcript fenced in docs/STATUS.md and compares every figure against the registered stack. The figure it protects: 'Data: 18 Objects  334 Fields' at docs/STATUS.md:22, updated to 333. ABLATION PROOF that this is the pin working rather than me guessing: with the figure left at 334 the suite fails - 'AssertionError: docs/STATUS.md transcribes figures that are not the current ones', Tests 1 failed | 18 passed, exit 1; at 333 it is 19 passed. Restore verified BY STATE, not by an exit code: git hash-object docs/STATUS.md = 38ddb8f52170a1a45185300d910a5a8b32782cf9 = the HEAD blob, git diff HEAD empty, git status --porcelain empty. docs/STATUS.md:22 is the only site in docs/ or content/ stating 334.",
        "3_i18n_gate": "HOLDS. pnpm lint:i18n-gate exit 0, 0 missing-key issues. Measured directly as well: after the deletion all four packs carry IDENTICAL crm_product field key sets, symmetric difference against en empty, tax_rate absent from all four. My count calibre is 13 DIRECT field keys per pack (option sub-keys and _views not counted); R32's '21' counted on a different calibre, so the number differs while the parity claim - the load-bearing half - is the same.",
        "4_no_runtime_consumers": "HOLDS. git grep -n tax_rate over the tracked tree, with controls (positive: list_price hits 30 files; negative: a nonsense token returns exit 1), classified every one of the 22 pre-edit hits. Nothing under src/views, src/pages, src/apps, src/flows, src/hooks, src/data or src/mappings references the product's rate - those directories return ZERO tax_rate hits of any kind except the one comment in src/data/revenue.seed.ts:223, which is about the LINE ITEM. Post-edit survivors, all correct: quote_line_item.object.ts (its own live field at :141 and the total_price formula reading it at :158, plus a fieldGroup comment), the four crm_quote_line_item locale rows, revenue.seed.ts:223's comment, test/undeclared-key-probe.test.ts + test/line-item-conventions.test.ts (the line item's field), CHANGELOG.md history and one existing changeset."
      },
      "deviations": [
        "IN-SCOPE ADDITION (not in the dispatched surface, required by a pin): docs/STATUS.md:22, 334 → 333 Fields. Authorised by PM mechanism assumption 2 - a pin went red because the documented figure changed, so the documented figure moves. Pin and figure named above.",
        "IN-PLACE FIX (declared): src/objects/opportunity_line_item.object.ts:63 said 'crm_quote and crm_product both keep every money field — tax included — in pricing'. The deletion makes the crm_product half false. Re-attributed to crm_quote's tax, where it is true. Four conditions met: same defect class as the quote_line_item.object.ts:60 clause the dispatch already authorises, mechanical and comment-only, no other claim holds the file (the only open hotcrm PRs are two dependabot bumps), and no new verification surface. Reported here and named in the PR body rather than filed as a card for one clause.",
        "DOC PROSE, not a pure deletion: the paragraph at :51 on each face existed only to explain that the inert rate is not applied. Deleting it outright would drop a still-true business concept, so it is replaced by ONE sentence per face - tax is a quote-level figure, the line item has its own rate, the catalog stores nothing about tax. The three faces keep their own locale conventions; the zh-Hant page's English console navigation (#1368) and the '#95 Tax is not filled in: enter it on the quote' tip are untouched.",
        "TITLE: the dispatch's '!' is dropped and the changeset is minor, not major. Convention read from the repo, not chosen: the comparable enforce-or-remove sweep that deleted NINE inert published fields shipped under '### Minor Changes' (540e488 in CHANGELOG.md), user-visible removals like the knowledge Review Queue tab are minor, and the only major entry in the current changelog is a behaviour-breaking data-model change (the owner-field consolidation)."
      ],
      "files_changed": [
        "src/objects/product.object.ts — the tax_rate field block and its '// Tax & billing' header (10 lines); the pricing field group survives on list_price/cost",
        "src/translations/en.ts:891 · es-ES.ts:1021 · ja-JP.ts:985 · zh-CN.ts:1051 — one crm_product row each. Enclosing object key PRINTED before every deletion; the four crm_quote_line_item rows (en:1308 es-ES:1506 ja-JP:1451 zh-CN:1436) are untouched and each pack went 2 tax_rate hits → 1",
        "content/docs/revenue/products.mdx · products.zh-Hans.mdx · products.zh-Hant.mdx — 4 sites each: frontmatter description, Pricing field-group summary row, the 'Default Tax Rate %' table row, the prose paragraph",
        "src/objects/quote_line_item.object.ts — the ':60' clause 'crm_product keeps tax_rate there too' only",
        "src/objects/opportunity_line_item.object.ts — the ':63' clause only (declared deviation)",
        "docs/STATUS.md — the validator transcript's field total (declared deviation)",
        ".changeset/remove-product-default-tax-rate.md — new, minor"
      ],
      "untouched_as_instructed": "src/data/revenue.seed.ts · docs/developers/api_reference.md · docs/feature-inventory.md · test/undeclared-key-probe.test.ts · test/line-item-conventions.test.ts · CHANGELOG.md · existing .changeset/*.md · AGENTS.md · CLAUDE.md · .claude/** · docs/adr/** · root skills/**. None was opened for writing.",
      "line_budget": "N/A — no skills/** file is touched, so the published-skill line ratchet does not apply. The repo's own token ratchet is the applicable budget and it is green with no ceiling raised: business semantics ~84,018 of ~85,000, authored total ~135,789 of ~140,000.",
      "tests": "REVERSE VERIFICATION, both legs run. BEFORE (unmodified 021db549): pnpm validate exit 0 'Data: 18 Objects  334 Fields'; pnpm test exit 0 'Test Files 159 passed (159) · Tests 3380 passed | 1 skipped (3381)'. AFTER: pnpm validate exit 0 'Data: 18 Objects  333 Fields' — exactly one field fewer, the control proving validate re-read the tree; pnpm test exit 0, the SAME 159 files and 3380 passed / 1 skipped, with the one documented figure updated. No test was adjusted and no assertion was weakened. ABLATION on the pin (documented above): mutation confirmed ON DISK before drawing any conclusion — '333 Fields' 1 → 0 occurrences and '334 Fields' 1 occurrence — then vitest test/docs-declared-versions.test.ts exit 1, 'Tests 1 failed | 18 passed'; the restore leg was 'git checkout HEAD -- ABSOLUTE_PATH' driven by a trap on EXIT INT TERM with REPO_ROOT resolved before the mutation, and it is proven by blob-hash equality with HEAD plus an empty git diff HEAD, not by a return code. No rebuild step is claimed or needed: the subject resolves through source (objectstack.config.ts loaded via tsx; the pin reads docs/STATUS.md off disk), there is no dist hop a stale artifact could hide in. Control-character self-scan over every changed file: clean.",
      "mcp_calls": "8 — issue_read get, issue_read get_comments, add_issue_comment (claim), list_pull_requests (checking for a competing claim on opportunity_line_item.object.ts), create_pull_request, pull_request_read (body read-back), add_issue_comment (this report), and the read-back of this comment. Every repo read was local git in the worktree.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NONE FILED this round, and none suppressed. The one drift I would otherwise have filed — docs/feature-inventory.md QUO-010/QUO-012 crediting crm_product with fields it does not declare — is already #1536 from the R32 run; I re-confirmed the file carries no tax_rate row of any kind, so this deletion neither touches it nor adds to it. The opportunity_line_item.object.ts:63 clause that R32 would have routed is fixed in this PR instead of filed, per the in-place-fix deviation above."
      ]
    }

    Generated by Claude Code

  19. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    Review — ACCEPT (director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T15:4xZ) · PR #1588, head b9290b17

    Checked on GitHub against references/review-checklist.md, not on the report:

    item reading
    PR shape draft ✓ · base main (021db549) ✓ · first line Fixes #1198 ✓ — correct: the ruling is executed in full; body scanned, no closing keyword near another open card
    Scope (get_files, 12 files) the claimed surface exactly — product.object.ts (−10, the field block + its header), four crm_product locale rows (the four crm_quote_line_item rows untouched, verified in each patch), three doc faces × 4 sites, quote_line_item.object.ts:60 clause, one changeset — plus two declared additions: docs/STATUS.md 334→333 (the figure test/docs-declared-versions.test.ts protects; the dispatch authorised exactly this; ablation proven) and opportunity_line_item.object.ts:63 (same comment-cross-reference class as the authorised :60 clause, comment-only, no competing claim, declared in the PR body). Both meet the in-place-fix conditions; the claim's file surface was not amended before the push — recorded as a nit, not a REWORK
    Ruling fidelity third option not implemented; only its one changeset sentence ✓; revenue.seed.ts, api_reference.md, feature-inventory.md, the line-item tests untouched ✓
    Docs zh-Hans / zh-Hant faces lose the same four sites as en; the :95 quote-tax tip stays; no unrelated "fixes" on the pages ✓
    Changeset 'hotcrm': minor with user-facing text; the ! dropped on the measured precedent (540e488, nine inert fields under Minor Changes) — the seat accepts the repo's own convention ✓; Check Changeset green
    Evidence real commands and outputs; validate 334→333 as the positive control; one pin red for the authorised reason and only that, proven by ablation with blob-hash restore; i18n gate 0; token ratchet lower, no ceiling moved ✓
    CI on b9290b17 at 15:4xZ read in the same stroke as this comment — landing waits for every check to read success

    Governed-surface path test: none of the twelve paths is governed ⇒ ordinary landing (ready → auto-merge squash). Implemented-by: branch claude/issue-1198-product-tax-rate-removal-2 (mode:subagent) · Reviewed-by: this session (same session — a PM review of a subagent's diff; Clause-②: no, no independent contract review owed).

    On MERGED: this card closes by the PR; #1199 stays pm:queue awaiting its own maintainer ruling (comment 5552667389) and is now free of the src/translations/** fence.


    Generated by Claude Code

  20. removed their assignment
    on Sep 5, 2026
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