Skip to content

feat(spec,formula): a formula field declares a currency result — returnType 'currency' with its own currencyConfig; inferFormulaReturn stamps it when provably money - #22733

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-22727-formula-currency-result
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-22727-formula-currency-result

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22727

Clause-②: yes (widening)

Parked on #22744: the merge queue is walled for every lane. render-projection-diff's base archive lacks the generated registry.ts since #22706, so Type Check · source gates fails on every merge-group run based at or after ed1de8c2db. This PR's own head 1e9d5ba927 is green, and it is re-queued once #22744 lands.

What changed

  • @objectstack/spec: a formula declares a currency result. FieldSchema.returnType is number | text | boolean | date | currency. A currency result carries its currency the way a currency field does: in the formula's OWN currencyConfig, the same CurrencyConfigSchema, the same defaults (absent means dynamic, the tenant default currency) and the same refusals (the precision tombstone, the three-character code, the currencyMode enum). One shape, not a second one.
  • Every exhaustive reader of a formula's returnType answers currency by name, never by falling into a default arm (census below).
  • Both designer forms move with the enum. field.form.ts and object.form.ts offer Currency (same list, same order, which form-return-type-options.test.ts derives from the enum). The field designer's Currency Config row now shows for a formula whose return type is Currency (visibleWhen widened with data.returnType == 'currency'). Help text and the four locale bundles regenerated or re-translated (en regenerated; zh-CN / ja-JP / es-ES re-translated by hand for the two changed help texts).
  • @objectstack/formula: inferFormulaReturn(expression, fields), the producer behind "authoring stamps it when the inferred type is provably money". It takes the host object's declared field map and returns the declaration to stamp: { returnType: 'currency' }, plus currencyConfig: { currencyMode: 'fixed', defaultCurrency } when the source amounts share one fixed currency. Everything it cannot prove to be money answers exactly what inferExpressionType answers, which is unchanged.
  • Hand-written docs (formulas.mdx, field-types.mdx, validation-rules.mdx) list the new member.

Decision: "taken from the source field" is an authoring-time stamp, not a read-time derivation

Measured first: currencyConfig is ALREADY admitted on a formula field today (a full FieldSchema parse of a formula carrying currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } succeeds and keeps it; it is equally admitted, and unread, on a text field). So "the field's own config" is an existing key gaining a reader, not a new admission. "From the source field" is realized by inferFormulaReturn copying the source currency into that declaration at authoring time. Nothing reads a referenced field's currency at display time.

  • Real business need. HotCRM finding B-15 (formulas over money render as bare numbers). In this repo the two example money formulas — app-crm opportunity.expected_revenue and app-showcase project.budget_remaining — are both proven money by the new inference (pinned in formula-return.test.ts).
  • Long-term soundness. Contract-first: the currency is a declaration the save door checks with the currency field's own schema. A read-time fallback from a formula to "some referenced field" is a consumer-side ?? (AGENTS.md Prime Directive 12): unchecked, and two consumers could pick two different sources.
  • Harder for AI to get wrong. The declaration is visible and refused loudly when malformed; the producer never claims money it cannot prove (two different currencies, money times money, money plus a plain quantity, an unreadable source currency, a roll-up whose unit is not on this object), and in every such case falls through to today's answer. The stamped pair is complete — an absent member means "clear it", so a formula edited from money to a count loses its stale currency.
  • Startup focus. Currency only: no percent or datetime member. No new gate, no new refusal (a currencyConfig on a non-currency formula stays admitted-and-unread, exactly as on every other non-currency type). No MCP surface change.

Where the stamp lives (mechanism assumption 3, measured)

  • The act of writing returnType lives only in objectui's designer: packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx:1103-1108, fed by celAuthoring.ts:614-627, which calls @objectstack/formula's inferExpressionType and coerces any value outside its local CEL_VALUE_TYPES (:600-602) to unknown. Not edited here (follow-up named in the report).
  • The inference it reads is in this repo, so the producer half — inferFormulaReturn — lands here.
  • @objectstack/mcp's validate_expression reports inferredType from inferExpressionType (unchanged, still never currency); its describeObject projection carries no currencyConfig, so it could not name the currency anyway. Noted below, not changed.

Reader census

Instrument: git grep -n "returnType" over packages/** (non-test, non-dist), control: it hits the known reader filter-boolean-comparand-declared-type.ts:318.

reader answer for a currency formula
FORMULA_RETURN_TYPE_AS_FIELD_TYPE (filter-text-operator-declared-type.ts), shared by the text, number and boolean doors currency → judged as the currency field: text operator refused (INVALID_FILTER), number door judged, boolean door not-judged — each pinned
door fixtures (NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS, TEXT_OPERATOR_DOOR_FIXTURE_FIELDS) gain f_formula_currency; their returnType type is now derived from Field['returnType'] instead of a four-literal union
display-name.ts isTitleEligible not title-eligible (only text is) — comment updated
aggregate-field-type-compatibility.ts formula refused for sum/avg/min/max whatever it returns — unchanged
ui/component.zod.ts inline form field returnType (by reference) widened with the enum; describe updated
service-analytics native-sql-strategy.ts / measure-result-type.ts a formula reaches with no returnType and is deferred; the formula-return-type-measure walk now holds currency to the same undefined
objectql doors, plugin-security RLS compiler relay the string to the spec verdicts above; no table of their own

Tests

Package tests on 20d64a3102; the merge commit after it (de9f741f33) brought only packages/cloud-connection and its changeset from main.

  • @objectstack/spec: vitest run --project local — 643 files, 19205 passed, 1 todo. New formula-currency-result.test.ts (a currency formula parses; its currencyConfig parses identically to a currency field's and is refused with identical code, path and message; CONTROL number unchanged; the enum is exactly the five). form-return-type-options.test.ts moved with the enum: currency offered by both forms and parses; datetime still refused and unoffered.
  • @objectstack/formula: 47 files, 1326 passed. New formula-return.test.ts, 11 cases — the card's two pins (a formula over a currency field declares the currency result; CONTROL: a numeric formula stays number), the two example-app formulas, every non-money case asserted equal to the plain inferExpressionType answer, and every stamped declaration parsed by the rebuilt spec FieldSchema.
  • @objectstack/objectql door suites (3 files, 93 passed), @objectstack/service-analytics formula-return-type-measure (10 passed), @objectstack/platform-objects (69 files, 1082 passed).
  • Typecheck: spec, formula, platform-objects, service-analytics, objectql — all exit 0.

Gates

  • Derived union, run at de9f741f33. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 120 families (the same set it derived at 20d64a3102). All 120 ran at de9f741f33 and every one exited 0; the --ran reconciliation over the recorded exit codes reads "120 run, 0 NOT-MEASURED (a DERIVED zero — all 120 recorded an exit code and none of them is 3)". The set includes check:generated, check:docs, check:api-surface, check:authorable-surface, check:adr-0087-registration (a widening carries no disposition marker; green), check:changeset-no-major, check:i18n, check:i18n-stale-fill, check:skills-token-ratchet, check:skill-examples, check:cross-package-test-inputs, check:type-check-coverage, check:type-check-debt and check:nul-bytes.
  • First round, for the record. At 20d64a3102 two gates answered PREREQUISITE NOT MET (exit 3, nothing measured): check:skill-examples (client-react unbuilt) and check:dual-build-cjs-loads (nine package dists absent). Both re-ran green after the builds, and the second round above measured them green directly.
  • Generated artifacts. check:generated named one stale artifact after the enum moved, content/docs/references/**, regenerated with gen:docs; authorable-surface and api-surface did not move (an enum member is not a key, and no spec export was added).
  • Lint, narrowed (CI owns the repo-wide pnpm lint). Population: the 20 lintable files in this diff against the merge base, all under eslint.config.mjs's global files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] object; --no-inline-config --format json counted 20 files, 0 errors, 0 warnings. Invariance: the config enables no type-aware linting (no parserOptions.project, stated in its own header at lines 327-328), so this diff cannot move a verdict on a file it does not touch.

Governed surface

The skill line skills/objectstack-data/rules/field-types.md:253 ('(no 'currency')') goes false when this lands. The seat carries it to the skills lane as a card blocked on #22727.

Acceptance notes

  • The renderer is the next half. objectui at the .objectui-sha pin draws an undeclared returnType through the text face (packages/fields/src/widgets/FormulaField.tsx final branch, packages/fields/src/index.tsx:2703-2704 cell), so a formula hand-declared currency renders as monospace text until objectui formats it. The card names that follow-up (filed beside it, on hold until a spec release carries the member). Nothing in this repo stamps currency automatically, and the docs here state the declaration, not a display.
  • The designer stamp (objectui ObjectFieldInspector.tsx:1103-1108, celAuthoring.ts:600-627) must call inferFormulaReturn(source, draft.fields) and write both members; until then it keeps stamping number for a money formula, as today.
  • validate_expression (MCP) keeps answering inferredType from inferExpressionType; reporting a currency would need describeObject (packages/mcp/src/stdio-data-bridge.ts:662-680, packages/runtime/src/domains/mcp.ts:553) to project currencyConfig. Not changed: no measured pull, and the answer it gives (number) is still honoured.
  • currencyConfig on a formula whose returnType is not currency is admitted and unread, as on every non-currency type (FieldSchema accepts each per-type key on any type; the forms gate visibility). No refusal added: it would be a narrowing and a new gate.
  • The docs' "Financial Calculations" examples still declare money formulas number; moving them to currency belongs with the objectui rendering.
  • object.form.ts was a stop boundary while docs(spec): fileAccessDelegate and the refused file marker name the record-read verdict beside the download (#22698) #22713 was in flight; docs(spec): fileAccessDelegate and the refused file marker name the record-read verdict beside the download (#22698) #22713 merged at 2026-10-10T20:27:42Z, origin/main was merged first, and only this file's returnType row moved.

维护者速读(草稿)

改了什么

公式字段现在可以声明"货币"结果:returnType 新增 currency,货币写在公式自己的 currencyConfig 里,与货币字段同一套结构和默认值(不写即 dynamic,用租户默认货币)。新增 inferFormulaReturn:作者编辑公式时,能证明结果是钱的就给出 currency 和源字段的货币,证明不了就和以前一样。两个设计器表单都提供"货币"选项。

为什么改

HotCRM 实测:引用金额字段的公式只能显示成裸数字。货币作为公式自身的声明,在保存时用货币字段同一套规则校验,不在渲染端回退去猜源字段。

风险与代价(含回滚)

纯加宽,以前合法的都仍合法。objectui 跟进之前,手工声明 currency 的公式会以文本样式显示(比数字样式差),所以本 PR 不自动给任何现有公式盖章。回滚:还原本 PR 即可,没有数据迁移。

席位意见

你要做的

本 PR 不改 skills/**,不触及受管面。那一行 skill 注释由席位另立 skills 卡(阻塞于 #22727),单独请你审。

… currency; forms and docs offer the currency result

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
…tics walk holds a currency formula refused

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
… in all four locales; changeset

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/platform-objects, @objectstack/spec, touching 32 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/formula/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/index.ts) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from eea9af5be5a05c06869e4192aa3d40b4c0f2686d — the merge of head 1e9d5ba927d855691cfadd9704a2dea96756c9f3 into base b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin eea9af5be5a05c06869e4192aa3d40b4c0f2686d && git checkout eea9af5be5a05c06869e4192aa3d40b4c0f2686d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20 1e9d5ba927d855691cfadd9704a2dea96756c9f3 && git checkout -B drift-repro b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20 && git merge --no-ff 1e9d5ba927d855691cfadd9704a2dea96756c9f3

node scripts/docs-audit/affected-docs.mjs --json b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b62260cb819a2fba6fb0c44ee9b4a0a4e53b3b20 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…e card

The '(no currency)' comment goes false when the currency result lands; it moves
to a skills card blocked on this one, so this PR touches no governed surface.

Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 40693a3456322fe182c67a2022612a4b190d82da
Local-runs: none

Check-runs on the head, read 2026-10-10T22:09Z (conclusions are the gate verdicts). Required seven: Lint & Repo Gates success · TypeScript Type Check success (source gates, workspace, debt ledger, consumer gates all success) · Build Core success · Temporal Conformance (live PG + MySQL) success · Governed Surface Queue Guard success · Dogfood Regression Gate 2/3 and 3/3 success, 1/3 in_progress · Test Core 2/6 FAILURE, 1/6 3/6 4/6 5/6 6/6 in_progress. Advisory: Check Changeset, Spec property liveness, Check Documentation Links, Flag docs, No other open PR (issue / single-writer path), Part-of, card-claims-branch all success; Console Pin Gate and Packed-tarball skipped.

① Derived judgments

Diff: 28 files, +711/−59, merge-base a360cee92e = the PR base; equals the PR file list byte for byte.

  • The widening. FieldSchema.returnType gains currency: an enum member, no key — authorable-surface rows are keys (data/Field:returnType), json-schema.manifest/ is a name roster, api-surface/ and export-origins/ are spec-export rosters and spec adds no export — right, nothing owed; Type Check · source gates (runs check:generated --reconcile-only and check:docs) is green on this head.
  • Readers, census at head (git grep returnType over packages/**, non-test): FORMULA_RETURN_TYPE_AS_FIELD_TYPE gains an explicit currency → currency row and the text, number and boolean doors all read that map (filter-text-operator-declared-type.ts:290, filter-number-comparand-declared-type.ts:445, filter-boolean-comparand-declared-type.ts:318): judged as the currency FieldType — text operator refused, number door judged (currency is in NUMERIC_VALUE_TYPES), boolean door not-judged — explicit arms, each pinned — right. display-name.ts:126 tests rt === 'text' positively; currency is not title-eligible by that test, not a default arm, and ADR-0079 (the only ADR naming returnType) is text-only — unchanged by design, right. aggregate-field-type-compatibility.ts refuses formula by type and never reads returnType — unchanged by design, right. service-analytics measure-result-type.ts is a two-argument rule with no returnType read and native-sql-strategy.ts reads none; the walk test now reads the five members off the schema and holds currency to undefined — right. ui/component.zod.ts:7201 takes FieldSchema.shape.returnType by reference; describe text only — right. objectql text/number/boolean-*-door.ts, plugin-security rls-compiler.ts:515/543 and security-plugin.ts:10357 relay the string into the spec verdicts with no table of their own — right. metadata-core fls returnType: keep, driver-sql returnType: 'storage', lint message strings and the returnType: 'text' system-object declarations are key-level or literal, not member readers — right. The boolean door's fixture type ('boolean' | 'text', :503) is a two-row fixture by design; the text and number fixtures now derive from Field['returnType'] — right.
  • currencyConfig on a formula. At the merge-base FieldSchema's superRefine type-gates maxLength, minLength, valueDomain, rows, dueLike, options, multiple, reference only; currencyConfig is a plain optional (field.zod.ts:1775) — "already admitted, unread" holds. Absent on a currency formula means dynamic (the tenant default currency), the same as on a currency field: CurrencyConfigSchema defaults currencyMode: 'dynamic' and its describes say dynamic displays the tenant default; formula-currency-result.test.ts holds a formula's parse output and refusals equal to a currency field's over four configs and three malformed ones — consistent, right. Docblocks and describes now name both readers — right.
  • inferFormulaReturn. Exported from @objectstack/formula's single . entry via index.ts, with FormulaReturnDeclaration and FormulaSourceField — a published surface; the package has no api-surface or export-origins snapshot, so nothing to regenerate. It stamps currency only when inferCelType (over the ternary-rewritten source) answers int/uint/double/dyn AND the dimensional walk proves one amount; every other case returns inferExpressionType(input, { fields }) verbatim, and inferCelType(src, []) is the default-parameter spelling of inferCelType(src), so the no-fields answer is identical (InferredValueType is the four members plus unknown, which maps to {}) — as claimed and pinned. Two fixed source currencies combined (record.amount + record.fee_eur, USD with EUR): join answers unproven, falls through, stamps { returnType: 'number' } with no currencyConfig — pinned at formula-return.test.ts:295-297 beside fixed-with-dynamic and the ternary form; a plain number, never a currency it cannot name — right. Minor, consistent: a fixed source declaring no defaultCurrency stamps CNY, the schema default a currency field itself displays.
  • Generated artifacts. en.metadata-forms.generated.ts: its two moved leaves equal the two field.form.ts helpText sources verbatim (the header: en is rewritten from source); option labels are not extracted (no Boolean leaf exists), so the new Currency option adds no key — right. zh-CN, ja-JP, es-ES: the same two leaves re-translated by hand, keys unchanged — the header's own rule for a changed source — right. content/docs/references/** (field, object twice, migration twice, component): equal to the new describes, check:docs green on the head — right.
  • object.form.ts. The edit is the comment plus one { label: 'Currency', value: 'currency' } option (4+/1−), nothing else. form-return-type-options.test.ts holds FORMS = objectForm + fieldForm to "offers exactly the members FieldSchema declares — no more, no fewer", so the row had to move with the enum — required. docs(spec): fileAccessDelegate and the refused file marker name the record-read verdict beside the download (#22698) #22713 merged 20:27Z before the edit, and no other open PR (all 12 scanned, every page) touches object.form.ts, field.zod.ts or formula src/** — right.
  • Prose. field-types.mdx, formulas.mdx, validation-rules.mdx state the declaration, where its currency lives and the authoring producer; no sentence says a currency formula renders as money today; no stale four-member list survives in any hand-written tree. formulas.mdx Financial Calculations still declare number — acknowledged in the PR, carried. Right.
  • Carried item, not a defect of this diff. skills/objectstack-data/rules/field-types.md:253 "(no 'currency')" is outside the diff (restored at the head to the merge-base blob) and goes false when this lands; the seat carries it to the skills lane.
  • ⛔ WRONG — a required gate is red and this diff made it so. The field.form.ts visibleWhen widening (data.type == 'currency' || (data.type == 'formula' && data.returnType == 'currency')) adds two == literal comparisons to the shipped METADATA_FORM_REGISTRY corpus, and packages/lint/src/validate-predicate-path-refs.test.ts:774 pins that corpus count at 62 with a per-predicate ledger in its comment. Test Core (2/6) reads 64 and fails (Failed: @objectstack/lint#test, 1 failed / 6317 passed; the three other ELIFECYCLE lines are turbo cancellations, the summary names lint alone). The same shard is green on main at a360cee92e and 0f77ff5202, so the red is this diff's, and Test Core is a required context. The dev's package set (spec, formula, objectql, service-analytics, platform-objects) never ran @objectstack/lint. Fix in the same PR: move the pin to 64 and add its ledger line naming field :: currencyConfig (data.type == 'formula', data.returnType == 'currency'), or write the gate as an in-list the rule does not count; then re-measure the shard.

② Semver level

  • .changeset/22727-formula-currency-result.md: @objectstack/spec: minor (enum widening, describes) — right; @objectstack/formula: minor (new export) — right; @objectstack/platform-objects: patch (help-text leaves only) — right; service-analytics moved a test only and objectql is untouched, so both correctly absent; not skip-changeset.
  • Clause-②: Clause-②: yes (widening) in the PR body and in the changeset body — the arm matches an accept-set widening; check-changeset-no-major's yes floor is PR-scoped and refuses only when every moved package is patch (two are minor) — clean; lockstep bumps platform-objects with them.
  • ADR-0087: check-adr-0087-registration judges declared-breaking changesets only and says in its own text that a widening adds no signal and is not a break — no disposition marker and no registry entry owed; Check Changeset success on the head.

③ Boundary flags

  • Round 1, deviation 1 (object.form.ts edited after docs(spec): fileAccessDelegate and the refused file marker name the record-read verdict beside the download (#22698) #22713 merged; other open PRs not re-scanned): measured above — none touches it — answered, accepted.
  • Round 1, deviation 2 (files beyond packages/spec): formula is the claim's "plus @objectstack/formula if touched"; the analytics test is a reader's pin the claim names; ui/component.zod.ts is in packages/spec/src/**; the translation bundles are named; hand docs are ordinary; the skills line was reverted in round 2 — inside the surface, answered.
  • Round 1, deviation 3 (attribution): the head commit ends with Claude-Session: and Co-authored-by: Claude (the model-free pair AGENTS.md prescribes); the PR body carries no footer after the PATCH, durable attribution lives in the commits — acceptable.
  • Round 1, deviation 4 ((#22727) in a WIP subject): squash rewrites it; the trailer gate is green — accepted.
  • Round 2 (body corrected in three places; footer omitted on edit; Tests/Gates cite 20d64a3102/de9f741f33): the body names no skills edit and the file list holds no skills/** (Governed Surface Queue Guard success); the head differs from de9f741f33 by the one restored skills file (1+/1−), so the cited readings stand for this code — except that the lint shard was never in them, which ① names.
  • open_questions: both reports [] — none to answer.
  • Out-of-scope carriers in the reports (objectui stamp and renderer, MCP validate_expression, the Financial Calculations examples, admitted-and-unread currencyConfig on a non-currency formula): noted in the PR; nothing escalated — the objectui half is the card's own named follow-up.

Implemented-by: claude/issue-22727-formula-currency-result
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: FAIL


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1e9d5ba927d855691cfadd9704a2dea96756c9f3
Local-runs: none

Check-runs on the head, read 2026-10-10T23:06Z, every one concluded (conclusions are the gate verdicts). Required seven, all success: Lint & Repo Gates · TypeScript Type Check (source gates, workspace, debt ledger, consumer gates all success) · Test Core 1/6 through 6/6 and aggregate — Test Core (2/6), the shard carrying @objectstack/lint, success · Dogfood Regression Gate 1/3 2/3 3/3 and aggregate · Build Core · Temporal Conformance (live PG + MySQL) · Governed Surface Queue Guard. Advisory, all success: Check Changeset, Spec property liveness, Check Documentation Links, Build Docs, Dogfood Verify CLI, Check PR Size, Flag docs, No other open PR (issue / single-writer path), Part-of, card-claims-branch; Console Pin Gate and Packed-tarball skipped. Nothing red, nothing pending. The record of 40693a3456 (FAIL: Test Core (2/6) red on the lint census pin) stands for that head.

① Derived judgments

Diff: 29 files, +715/−60, merge-base 7c7e750527 = the PR base; equals the PR file list. The 29 are the 28 of 40693a3456 plus packages/lint/src/validate-predicate-path-refs.test.ts; every one of the 28 is blob-equal between the two heads (git diff 40693a3456 1e9d5ba927 over them is empty).

  • The census edit (f401351a3b). validate-predicate-path-refs.test.ts:774 pin 62 → 64, with a ledger line in the file's own "It is N today" form naming the predicate correctly: field :: currencyConfig, data.type == 'formula' && data.returnType == 'currency', two more single-quoted literal comparisons. The gate in field.form.ts is unchanged (blob-equal), so the pin counts what ships and dodges nothing — right.
  • The two merges. 2cfbea7e2a takes in 0f77ff5202 (lint(flow CEL roots): record is still in ENGINE_BOUND_ROOTS, so objectstack validate passes a record.X read in a flow with no record entrance, which faults at run time once #22642 lands; and two texts describe record as always bound #22677: lint flow-cel-root-scope.ts, three lint tests, spec migrations 18.flow-cel-unbound-root-refused.ts + registry.ts, three changesets); 1e9d5ba927 takes in 7c7e750527 (fix(cli): os migrate plan's unmanaged-tables sweep gates on whether the composition mirrors the served boot, sweeps every planned database, and states only a reason that holds (#22580) #22732, packages/cli/** only). Neither touches any of this PR's 29 paths, and lint(flow CEL roots): record is still in ENGINE_BOUND_ROOTS, so objectstack validate passes a record.X read in a flow with no record entrance, which faults at run time once #22642 lands; and two texts describe record as always bound #22677's diff carries no line naming returnType, field.form, FieldSchema, METADATA_FORM_REGISTRY, visibleWhen or currency — disjoint from every reader and from the census corpus. Both merges are clean carries: the diff from each merged-in main tip to its merge lists exactly the 29 paths, and the PR delta is byte-identical across them (diff(0f77ff5202..2cfbea7e2a) = diff(7c7e750527..1e9d5ba927) = diff(a360cee92e..f401351a3b)) — right.
  • main beyond the merge-base (b62260cb81 objectql, 5081090b71 rest + spec/ui + lint + liveness; 36 paths, unmerged): no shipped *.form.ts, no census file, no field.zod.ts, no packages/formula/**, no added predicate with a ==/!= literal — the 64 pin stays right on the queue's rebuilt generation. One generated page is shared, content/docs/references/data/object.mdx: main moves line 394 (the requiredPermissions row), this PR moves 263/267/604/608 — disjoint rows, so the driverless server-side text merge is clean and equals the regenerated union; check:docs on the rebuilt generation is the confirming read. A note, not a defect.
  • Carried from the record of 40693a3456, unchanged (blob-equal): the enum widening owes no snapshot (keys and rosters only); every returnType reader answers currency by name or is a positive-test/by-type reader unchanged by design (FORMULA_RETURN_TYPE_AS_FIELD_TYPE and the three doors explicit and pinned; display-name.ts text-only per ADR-0079; aggregate-field-type-compatibility.ts by type; analytics measure-result-type.ts two-argument; ui/component.zod.ts by reference; objectql and plugin-security doors relay); currencyConfig was already admitted and unread on a formula at the base (the superRefine gates eight other keys), absent = dynamic = tenant currency exactly as a currency field per CurrencyConfigSchema; inferFormulaReturn is a published export of formula's single entry with no snapshot to regenerate, stamps currency only when provable and otherwise returns inferExpressionType verbatim, two different fixed currencies fall through to { returnType: 'number' } (pinned); en leaves equal the sources verbatim, option labels are not extracted, three locales hand-retranslated per the generator header; references regenerated (check:docs green again on this head's Type Check · source gates); object.form.ts is the one option row required by form-return-type-options.test.ts; prose claims the declaration, never a money render; the skills line skills/objectstack-data/rules/field-types.md:253 is outside the diff and carried to the skills lane.

② Semver level

Unchanged from the record of 40693a3456: @objectstack/spec: minor, @objectstack/formula: minor, @objectstack/platform-objects: patch — right; @objectstack/lint moved a test only and publishes nothing from this diff — correctly absent; Clause-②: yes (widening) in body and changeset, clean under the PR-scoped floor; no ADR-0087 marker or entry owed for a widening (the gate's own text); Check Changeset success on this head.

③ Boundary flags

  • Round 3, deviation 1 (main moved after the last merge; 36 files unmerged; dev measured zero overlap on .form.ts, the census, field.zod, formula): re-measured above — the same zero, plus the one shared generated page with disjoint rows — answered, accepted.
  • Round 3, deviation 2 (the full lint suite ran at 2cfbea7e2a, not at 1e9d5ba927): the second merge is packages/cli/** only and @objectstack/lint does not depend on cli; CI's Test Core (2/6) on this head is the measurement of record and concluded success — answered, accepted.
  • Rounds 1–2 flags: answered in the record of 40693a3456; unchanged.
  • PR body: unchanged since the first review (Tests/Gates still cite 20d64a3102/de9f741f33; the lint census edit is not mentioned). Nothing in it became false; a one-line mention of the lint pin would make it current — note for the seat, not a defect.
  • open_questions: all three reports [] — none to answer.

Implemented-by: claude/issue-22727-formula-currency-result
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Dequeued by the merge-queue wall, not by this change. domain:spec seat 2 (#18549) · session session_016njDy8ozy9B9Ns5Y8kAWEK.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants