Skip to content

feat(scripts): object-aware field consumer ledger, and the product docs it corrects (#1193, #1194) - #1202

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-1193-object-aware-field-scan
Aug 17, 2026
Merged

os-steve merged 3 commits into
mainfrom
claude/issue-1193-object-aware-field-scan

Conversation

@os-steve

@os-steve os-steve commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1193
Fixes #1194

crm_product.tax_rate is inert, and the sweep that produced #1182's row set could not have found it. This PR ships the scan that can, the complete list of what that scan surfaces, and the docs corrections for both cards. It does not touch tax_rate itself — see The verdict below.

1. The object-aware scan — pnpm scan:fields

scripts/scan-field-consumers.ts. The sweep it replaces called a field consumed when its name appeared in a src/**/*.ts file outside views/ and translations/. That is line-oriented and object-blind: tax_rate matches lines in quote_line_item.object.ts, so the product's field read as consumed and never reached a card, while is_taxable — two declarations away, inert for the same reason — did reach it because its name is unique.

The new scan resolves each hit to the object whose declaration encloses it, not the file it sits in. The whole consumer surface of this app is the registered stack, so it walks objectstack.config.ts and carries an object context down the tree (object / objectName / targetObject / data.object / list.data.object, dataset resolved through the dataset's own object, a flow's trigger objectName hoisted to its later nodes, a map keyed by object name). Text blobs — hook handlers, action bodies, CEL sources — get the same rule applied inside the blob, with api.object('crm_product') switching context mid-body. Tokens that resolve to no object are counted and reported, never dropped; a scan that silently discards what it cannot place is how the previous one granted immunity.

Consumption is bucketed rather than boolean, because a boolean would have contradicted #1182's own verdicts (it removed fields a view column displayed, and kept one a roll-up computed):

  • behaviour — formulas, summary operations, validation predicates, view filters/sort/grouping, flow nodes, hook and action bodies, dataset dimensions and measures, dashboard and report configs, sharing-rule conditions, skill instructions;
  • display — view columns, form and page fields, highlightFields, searchableFields, indexes;
  • carrier — locale bundles, seed values, import columns, automation writes, prose. What a removal must clean; never evidence of a reader.

live = has a behaviour site. display-only = drawn, nothing reads it. inert = neither.

Report-only, not a pnpm verify gate — the argument

The 2026-08-17 ruling was 「逐个 enforce-or-remove(推荐)」: a verdict per field. A gate that failed on any inert field would encode the blanket rule that ruling declined to make, and would go red the moment someone lands a field one PR before its consumer, which is ordinary in a metadata app. What this card needed fixed was not inertness — it was invisibility, and a ledger fixes that.

So the count is not gated; the scan's ability to see is. test/field-consumer-scan.test.ts pins that two objects sharing a field name still get different verdicts, that no site is ever attributed to an object that does not declare the field, and that #1182's enforced row still reads live. A gate on the number would have been cheaper and would have guarded the wrong thing.

Reverse verification of the scan, on the pre-removal tree

Run in a comparison worktree at c83aa744 (the commit before #1195 removed the fields), because that is the only tree where the question is answerable. Of the twelve fields #1182 deleted, two read inert, ten read display-only, none read live — and on that same tree:

crm_product.tax_rate         inert   read=0 display=0 carrier=0
crm_quote_line_item.tax_rate live    read=1 display=0 carrier=0

Two objects, one token, opposite verdicts, from one run. That discrimination is the whole card. Positive controls on the same run: crm_product.list_price read=23, crm_opportunity.amount read=56 — the scan is not calling everything inert.

The enforce half of #1182 tracks too: crm_account.parent_account reads display-only on the pre tree and live on this one, because child_account_revenue now names it as its relationshipField.

Ablation of the guard test: replacing the per-object credit with an object-blind one (credit every object declaring the token) turns exactly the two discrimination assertions red — expected 'live' to be 'inert' — while the seven population-level ones stay green. Predicted red, measured red, restored from the commit and re-run green.

2. What else was masked — the complete list

The card's most useful output. pnpm scan:fields on 62560210, 334 declared fields across 18 objects: live 276 · display-only 43 · inert 15.

Inert (15). "Masked" = the name is also declared on another object, so the old token grep read it as consumed and it could never have reached #1182.

Field Masked? Note
crm_product.tax_rate yes — crm_quote_line_item this card. 4 locale rows, nothing else
crm_campaign.description yes — 12 objects not on the campaign form; nine other objects do put description on one
crm_contract.description yes — 12 objects same shape
crm_quote_line_item.description yes — 12 objects the opportunity line item's twin IS read, by the billing hand-off payload
crm_quote_line_item.line_number yes — crm_opportunity_line_item same split: the opportunity twin is read, this one is not
crm_quote_line_item.subtotal yes — crm_quote a formula evaluated on every read and displayed nowhere; the rollup deliberately recomputes from raw fields
crm_contact.mailing_street no declared, plus an import-mapping column
crm_contact.mailing_city no same
crm_contact.mailing_state no same
crm_contact.mailing_postal_code no same
crm_contact.mailing_country no same
crm_account.logo no image field, on no form and no view
crm_article_feedback.comment no the comment a reader types is stored and never shown
crm_campaign_member.added_date no written by campaign-enrollment.flow and seeded, read by nothing, drawn nowhere
crm_forecast.seed_key no the seed externalId by deliberate design (#613) — listed for completeness, not a defect

Six masked rows, five of them new. Every one was immune to the old method by construction.

None of these is touched here. Each is a separate adjudication; they are recorded for the maintainer as #1199, with two clusters called out — the five crm_contact.mailing_* fields (inert and asked for on the shipped customer import template, which is worse than merely inert) and the three unshown description fields.

3. The verdict on crm_product.tax_rate — enforce is wrong, and removal is not mine

Enforce was mine to make and would have made a published promise true, so it was measured rather than assumed. Real engine, shipped stack, memory driver, 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 comes out 200 below the sum of its own line totals. The two halves model tax on different calibres and neither reads the other: the line's total_price formula applies a per-line percentage, while quote_total_rollup recomputes from raw quantity/unit_price/discount and adds crm_quote.tax, a manual amount. A rep who also fills that field taxes the customer twice. src/data/revenue.seed.ts already refuses to seed a line rate for this exact reason.

Also measured, and recorded so they are not rediscovered: the price-fill factory is shared with crm_opportunity_line_item, which declares no tax_rate at all — and the engine stores the undeclared key anyway rather than refusing it, which is a finding in its own right (#1200). ctx.object is on the hook context, so a conditional is writable and the QuickJS body-only rule is not the obstacle; the arithmetic is.

Since #1195 removed crm_product.is_taxable, "is this taxable" no longer exists as a concept — only a rate does, and rate 0 has to mean "not taxed". Arithmetically fine; it does mean a stamped 0 and an unset one are the same thing.

So enforce is wrong, removal is a maintainer decision, and the field is unchanged here. The verdict request is #1198, filed as its own card so that it stays open once this card is closed — with the measurement, the removal cost, and a third option (deriving crm_quote.tax from the product rate) that is a design change rather than an adjudication. #1198 is not addressed by this PR.

What is corrected is the page, which was false today under any future ruling: the pricing table now says the rate is stored and applied nowhere, a short paragraph explains that tax is a quote-level amount, and the sales-rep tip that claimed "pricing and tax are auto-filled" now says what is actually filled in. All three locales.

4. #1194, and a guard for the class

Verified against src/views/product.view.ts rather than taken from the finding: the object ships all_products ("All Products", grid, sorted by name, grouping on category) and product_catalog ("Product Catalog", gallery, image as cover), reachable from two switcher tabs. Active Products, By Category and By Family are not views; the category grouping is a setting on the one grid, and nothing groups by family. Rewritten in the table shape the corrected sibling pages already use, in all three locales.

The guard: coverage, not "no phantom names" — because the obvious rule was measured first

test/docs-view-rosters.test.ts. The obvious rule is that every bolded name in a roster section must resolve to a shipped view. It was written first and produces thirteen false positives across five of the ten English pages, because these sections legitimately bold things that are not views: column and filter names inside the same table, whole sentences (**Status is the filter you already have.**), tab labels where the view name is in a later column (sales/activities is written that way throughout) — and worst, the pages that are right: service/cases closes with "Six names this section used to list are not views at all" and then names them, so the corrected page fails hardest. Excusing thirteen cases needs an exemption map bigger than the roster it guards.

So the rule runs the other way: every view the stack ships for the object a page documents must be named in that page's roster. Zero false positives, derived from src/views/** rather than written down, and it caught this card's defect directly. Stated limitation, in the file: a roster that names every real view plus an invented one still passes. What can no longer happen is a roster written from imagination instead of from source, which is what all four instances of this defect have been.

Only the English face resolves names — most translated pages spell view labels in Chinese (revenue/contracts.zh-Hans says 全部合同) and there is no zh-Hant bundle in this app at all (supportedLocales is en / zh-CN / ja-JP / es-ES), so a traditional-Chinese label has nothing to resolve against. The faces are held to structure instead — same section, same number of roster entries — the same honest split #736 made for callouts, and it is exactly the failure mode #1194 was: one wrong roster copied into three faces.

It lives in its own file rather than in test/docs-drift.test.ts because that file is 97KB against this repo's 100KB source-file cap; appending there fails pnpm hygiene, measured.

Two pages the guard turned red, fixed in place

Same defect class, mechanically fixed against source, no other claim on either file, same gate family. Both named here rather than slipped in, and the claim's file surface was amended in the same round.

  • content/docs/marketing/campaigns*.mdx — the section named six views (Active Campaigns, Upcoming Campaigns, Completed This Quarter, Over Budget, By Type, Best ROI). src/views/campaign.view.ts ships four — All Campaigns, Campaign Schedule, Launch Calendar, Marketing Timeline — and not one of the six exists. Rewritten from the view file, three locales. ROI is called out explicitly as not being a grid column: it is on the record under Costs.
  • content/docs/service/cases*.mdx — the roster said crm_case ships "seven" views and listed seven; it ships eight. Unassigned — triage was missing from a page that states its own count. One row added and the count corrected, three locales.

Reverse verification of the guard

Predicted red before the content fix, green after. Measured by restoring the three English pages from origin/main on top of the guard:

× every view the app ships is named in its page’s roster
    content/docs/revenue/products.mdx    never names "All Products" / "Product Catalog"
    content/docs/marketing/campaigns.mdx never names "All Campaigns" / "Campaign Schedule"
                                          / "Launch Calendar" / "Marketing Timeline"
    content/docs/service/cases.mdx       never names "Unassigned — triage"
× every translated face carries the same roster, entry for entry
    products.zh-Hans / .zh-Hant: 2 entries, but products.mdx has 3   (…and campaigns, cases)

Seven missing views across three pages, and the parity half caught the half-fixed locale faces — which is precisely what it is there for. Restored from the commit; green after.

Gates

Run locally, all green, on the final commit 62560210 (the union was re-run on this HEAD after the last commit, including the full suite):

Gate Result
pnpm validate 0 (warnings pre-existing: form colSpan advisories)
pnpm typecheck 0 — scripts/**/*.ts and test/**/* are inside tsconfig.include
pnpm lint 0 (83 warnings, 10 suggestions — all pre-existing)
pnpm lint:i18n-gate 0 i18n/missing-* issues
pnpm hygiene clean, 304 files (the 100KB cap is why the new guard is its own file)
pnpm hygiene:tokens clean, unchanged: business semantics ~80,767 / interaction ~38,848 / total ~133,465 against ceilings 85,000 / 42,000 / 140,000. Before == after because this PR touches no src/ file — git diff origin/main -- src/ objectstack.config.ts is empty
pnpm build 0 — 18 Objects, 334 Fields, 14 Views
pnpm test 112 files, 2693 passed, 1 skipped, 0 failed
node scripts/check-stackblitz-lock.mjs in sync (v3) — the #1149 gap, run explicitly since pnpm verify does not

Left to CI: everything else in the lint/CI workflow beyond the list above, per #1149 — a green local verify is not the full CI set.

MDX validated structurally, not compiled. Per #1169 the Docs App workflow does not trigger on a docs-only change, so nothing will compile these nine files. Checked mechanically instead, all nine: frontmatter present and closed with a title, every table's column count consistent with a separator row, ** balanced on every line, and — the two that actually break an MDX build — no bare < before a letter and no bare { outside a code span. Section headings and line counts stay identical across the three faces of each page.

Not touched: crm_account.health_score (#1186), and crm_product.tax_rate itself (#1198 remains open).


Generated by Claude Code

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 17, 2026 5:40am

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes labels Aug 17, 2026
@os-steve
os-steve marked this pull request as ready for review August 17, 2026 05:46
@os-steve
os-steve added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 5d46177 Aug 17, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes

Projects

None yet

2 participants