Skip to content

fix(spec)!: a fixed currency config needs its code — currencyConfig.defaultCurrency loses its CNY default - #27218

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-rd9t22
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-rd9t22

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread
Fixes #27157

Ruling-ref: 6464202770
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/** teaches defaultCurrency in two places (skills/objectstack-data/rules/field-types.md lines 55 and 174-187, skills/objectstack-ui/rules/dashboards.md lines 303-304), and both already teach it only as an explicit code under currencyMode: 'fixed'; no skills/** text teaches the 'CNY' default (git grep -n CNY -- skills → 0 hits). Not Tier H.

维护者速读

  • 改了什么:currencyConfig.defaultCurrency 去掉 'CNY' 默认值;currencyMode: 'fixed' 不写币种代码时在解析处拒收(货币字段和返回 currency 的公式字段同一规则),提示「写 ISO 4217 代码,或改用 dynamic(组织默认币种)」;dynamic 配置解析后不再带 defaultCurrency。
  • 为什么改:裁决 X([Decision] Currency, all of it: resume multi-currency (#8345) and in what shape, and one rule for an amount whose currency is not known (Studio's USD, the template's USD, fixed's CNY, the example's USD) #27119 Q2):币种未知的金额取组织默认币种,任何地方都不猜。之前不写代码的 fixed 字段静默变成人民币字段,dynamic 配置也每次被物化出一个没人读的 CNY。
  • 风险与回滚:收窄(BREAKING,minor)。仓库调查 11 条 fixed 配置、0 条缺代码。已存的 sys_metadata 行按原样保存,所以以 { currencyMode: 'fixed' } 存下的行没有代码,转换不补(补 CNY 正是裁决禁止的):它照常加载注册,启动计为 invalid 并打印 [metadata_spec_invalid],下次保存 422 拒收直到写上代码;显示不变(之前就落到组织默认币种)。dynamic 行上的 CNY 由 ADR-0087 转换 currency-config-dynamic-default-currency-removed 在回读时剥掉,显示不变。回滚即 revert 本 PR。
  • 先看:packages/spec/src/data/field.zod.ts(CurrencyConfigSchema 与 refuseFixedCurrencyWithoutCode)、packages/spec/src/conversions/registry.ts(新转换)、packages/formula/src/formula-return.ts 的 currencyOf。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/currency-config-fixed-code-required.test.ts src/conversions/currency-config-dynamic-default-currency-removed.test.ts,预期 2 个文件 12 个用例全绿:fixed 无代码在 defaultCurrency 处拒收,dynamic 输出无代码,已存 dynamic 行剥掉 CNY、带代码的 fixed 行保留,已存的无代码 fixed 行不被转换且仍被拒收。
  • 需要你做的:无,席位落地。

What changes

  • Spec (@objectstack/spec, minor, BREAKING). CurrencyConfigSchema.defaultCurrency is .optional() instead of .default('CNY'), and the schema gains one superRefine: currencyMode: 'fixed' with no defaultCurrency answers one custom issue at defaultCurrency (currencyConfig.defaultCurrency on a field), first sentence "currencyConfig.currencyMode: 'fixed' needs defaultCurrency, the field's one currency: give its ISO 4217 code (e.g. defaultCurrency: 'USD'), or use currencyMode: 'dynamic' to display amounts in the tenant default currency (the localization.currency setting)." FieldSchema.currencyConfig is this one schema for a currency field and for a formula with returnType: 'currency' (spec: a formula field cannot declare a currency result — returnType is number | text | boolean | date, so a formula over money renders as a bare number #22727), so both take the same refusal. A dynamic (or empty) config parses to { currencyMode: 'dynamic' }; an authored code under dynamic stays legal and unread. .describe(), the field form's help text, and the hand-written docs tables (content/docs/data-modeling/field-types.mdx, validation-rules.mdx) no longer say "Defaults to CNY".
  • ADR-0087: registered. D2 conversion currency-config-dynamic-default-currency-removed (toMajor 18, retiredFromLoadPath, retiredAfter: '18.2.0', order 78): strips defaultCurrency from a config whose mode is dynamic or absent, on objects and object extensions; a fixed config is never touched, and no code is written into one. D3 semantic entry currency-config-fixed-default-currency-required (step 18, conversionIds = the D2 id) and a STEP18_RATIONALE fragment. DEFAULT_CHANGES_BY_MAJOR[18] declares data/CurrencyConfig:defaultCurrency: "CNY" → (none). The currency-config-precision-removed fixture's dynamic row drops its baked CNY so the two fixtures stay disjoint.
  • Stored rows and built artifacts, measured. A built artifact holds the parsed stack, so its fixed configs carry the code the old parse filled in and keep it. A sys_metadata object row is stored as authored (saveMetaItem persists the payload verbatim), so a row saved as { currencyMode: 'fixed' } (Studio's Fixed mode with the code left blank, or a /meta write) has no code, and the conversion writes none in. Measured on this head with a probe through loadMetaFromDb and saveMetaItem:
    • Load: { loaded: 1, errors: 0, invalid: 1 }; the row registers unchanged and boot prints [metadata_spec_invalid] stored object/crm_invoice fails the current spec schema even after conversion (fields.amount.currencyConfig.defaultCurrency: …the prescription…). A dynamic CNY row in the same probe: invalid: 0, registered as { currencyMode: 'dynamic' }.
    • Save: the next save of that object answers 422 INVALID_METADATA, one custom issue at fields.amount.currencyConfig.defaultCurrency, until a code is written.
    • Display: unchanged. The registry already held the row as stored, and resolveFieldCurrency reads a fixed code only when one is present, so the field showed the tenant default before and still does (its test "fixed with no code: falls through to the tenant default" pins it).
    • Formula: a formula over that field is read as an unproven currency instead of fixed CNY (the currencyOf row below).
    • The new conversion test pins the row: no notice, no change, and ObjectSchema.safeParse refuses it at fields.amount.currencyConfig.defaultCurrency with the prescription.
  • Generated. dropped-refinements.baseline.json gains data/CurrencyConfig plus the currencyConfig site in the 12 schemas that embed it (totals 252 schemas / 900 sites); authorable-defaults/data.json loses the CNY row; content/docs/references/data/field.mdx regenerated. Liveness field.json currencyConfig row: a dated note, verdict unchanged (live).
  • Readers, each measured.
    • ui/packages/i18n/src/currency.ts#resolveFieldCurrency (the display rule at :34-36 and :60): unchanged. It reads the code only under fixed, so a dynamic config without CNY displays the same. Its test "parsed-empty" now asserts the spec's empty-config output carries no code (toBeUndefined()) and still resolves to the tenant USD; the docblock says the CNY now lives only in rows not yet stripped, and that a stored fixed row saved without a code still loads with none and shows the tenant default.
    • packages/formula/src/formula-return.ts#currencyOf: the one reader that changes. It parses through CurrencyConfigSchema, so a fixed config without a code now fails to parse and reads as an unproven currency (no returnType inferred) instead of fixed CNY. New row fixed_no_code pins it; the defaultCurrency === undefined guard only narrows the type.
    • packages/services/service-analytics/src/plugin.ts (the field-currency relay, :1447): reads the raw config's code only under fixed; unchanged.
    • packages/types/src/share-link-resolve-answer.ts (:120): copies defaultCurrency only when it is a string, so a dynamic field's projection loses the CNY key; the console reads it only under fixed. No display changes.
    • Every other defaultCurrency read under ui/ goes through resolveFieldCurrency or reads the legacy flat key.
  • Survey (metadata in the repository). examples/**, packages/platform-objects/**, packages/qa/**, seeds and fixtures (CHANGELOGs excluded), 1,034 files: 11 currencyMode: 'fixed' configs, 0 without a code. The same detector over the whole tree flags the CHANGELOG prose that names fixed without a code, so it is not blind. A deployed database's sys_metadata rows cannot be surveyed from here; any fixed row without a code there behaves as measured above.

Tests

  • packages/spec/src/data/currency-config-fixed-code-required.test.ts (new): fixed without a code refused at defaultCurrency with the prescription; the same on a currency field and on a formula returning currency (currencyConfig.defaultCurrency); fixed with a code passes unchanged; dynamic output carries no code; parse(parse(x)) equals parse(x) for every accepted shape, on the config and on FieldSchema.
  • packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts (new): registered for 18, retired from the load path, linked from the D3 entry; a stored object row loses the code on dynamic configs (currency field and formula), keeps both fixed ones by reference, parses to itself, replays idempotently; the authoring load path does not run it; a stored { currencyMode: 'fixed' } row with no code gets no notice and no change, and the current schema refuses it at fields.amount.currencyConfig.defaultCurrency with the prescription.
  • Updated: field.test.ts, currency-precision-iso4217.test.ts, formula-currency-result.test.ts (the fixed-without-code row moves to the refused list), retired-key-removal-version.pin.test.ts (JOIN row), packages/formula/src/formula-return.test.ts, ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts.

Acceptance notes

  • The refusal no longer cites ADR-0049 (the narrowing is ruling-driven); it names @objectstack/spec 18 because retired-key-removal-version.pin.test.ts requires every entry retired after the newest stable to name the release it derives.
  • An authored defaultCurrency under dynamic is not refused (the order asked for no such narrowing); the conversion strips it only from stored rows and artifacts.
  • ui/packages/i18n/src/currency.ts changes a comment only, so no @object-ui/i18n changeset line.
  • The formula reader stops inferring a fixed CNY currency for a source field the schema now refuses; that field is itself refused at publish.

Verification

All readings on base main 6866e2e7c on a shared container; the lock's VERDICT lines are quoted. Round 1: code head 5f876858f, head c172c878e (a changeset wording commit only: the survey count). Round 2 head a7683e16f (contract review 6467521422): wording at the stored-row sites, the ADR-0049 citation dropped from the refusal, one new conversion test; readings below marked (r2) were taken on it.

Round 2 (a7683e1)
  • Stored-row probe through loadMetaFromDb / saveMetaItem (deleted after use), as quoted under Stored rows and built artifacts, measured.
  • pnpm --filter @objectstack/spec gen:migration-registry && build, then the conversions, migrations, field, currency and retirement-pin tests → Test Files 31 passed (31), Tests 1274 passed. VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec check:generated → current. Spec vitest --project local → Test Files 742 passed (742), Tests 22244 passed | 1 todo (one more than round 1: the new pin). typecheck → exit 0. ui vitest packages/i18n/ → Test Files 89 passed (89), Tests 1373 passed | 13 skipped. VERDICT command-exit 0.
  • Gates re-run on a7683e16f: the 34 --commands families that read the changeset, the spec package, lint or i18n (including check-adr-0087-registration --base origin/main, the changeset families, check:nul-bytes) → 34/34 exit 0, VERDICT command-exit 0. The --commands list is identical (113 families). The other 79 read nothing this commit touches (formula, platform-objects and the dogfood battery included: no source there changed) and keep their round 1 readings.
Measure first (origin/main 6866e2e)
  • CurrencyConfigSchema.safeParse({ currencyMode: 'fixed' }) → {"currencyMode":"fixed","defaultCurrency":"CNY"}.
  • { currencyMode: 'dynamic' } → {"currencyMode":"dynamic","defaultCurrency":"CNY"}; {} → the same.
  • A formula field with returnType: 'currency' and { currencyMode: 'fixed' } → {"currencyMode":"fixed","defaultCurrency":"CNY"}.
Tests
  • pnpm --filter @objectstack/spec vitest --project local (after pnpm --filter @objectstack/spec build, VERDICT command-exit 0) → Test Files 742 passed (742), Tests 22243 passed | 1 todo. pnpm --filter @objectstack/spec typecheck → exit 0 (tsc, scripts-typecheck, test-typecheck OK).
  • pnpm --filter @objectstack/formula test → Test Files 56 passed (56), Tests 1484 passed; typecheck → exit 0.
  • @objectstack/platform-objects vitest (after its closure build) → Test Files 96 passed (96), Tests 1361 passed. VERDICT command-exit 0.
  • ui vitest packages/i18n/ (from the ui root) → Test Files 89 passed (89), Tests 1373 passed | 13 skipped. VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec check:generated --fix → 14 artifacts current after gen:docs (re-check green).
  • Dogfood battery, full (pnpm --filter @objectstack/dogfood test, closure built, head 5f876858f) → Test Files 348 passed | 3 skipped (351), Tests 2804 passed | 20 skipped (2824). VERDICT command-exit 0 · held the lock 1733s.
Gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 113 derived families; the list is identical on c172c878e.
  • 110 exited 0 on the code head's tree. Three were re-run: pnpm --dir ui exec vitest run scripts/ (first run: one 15 s timeout in check-vi-mock-inherit.test.ts while the dogfood held the box; that file alone then 83/83, and the whole gate exit 0 on c172c878e), spec check:skill-examples and check:dual-build-cjs-loads (exit 3, built output missing; exit 0 after building client-react, studio and the other named packages).
  • The changeset-reading families re-ran on c172c878e, all exit 0: check-adr-0087-registration (→ registered currency-config-fixed-default-currency-required, currency-config-dynamic-default-currency-removed (new here: both)), check-changeset-no-major, check-empty-changeset, check-closing-keyword-parity, check:changeset-gate-self-tests, check:pm-changeset-deadline-census.
  • --ran → 113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED.
  • node scripts/check-nul-bytes.mjs → exit 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh

…oses its CNY default

currencyConfig.defaultCurrency was .default('CNY'): a fixed config with no
code became a yuan field, and every dynamic config's parse output carried a
CNY no reader takes. The default goes; a superRefine refuses fixed without a
code with the prescription (give a code, or use dynamic), on a currency field
and on a formula returning currency alike.

ADR-0087: the D2 conversion currency-config-dynamic-default-currency-removed
strips the baked code from dynamic stored rows and keeps fixed ones; the D3
entry currency-config-fixed-default-currency-required carries the judgment.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh
Declares the defaultCurrency default change (CNY -> none) under major 18,
the dropped-refinement sites the new superRefine adds, and the regenerated
authorable defaults and reference docs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh
The fixed-without-code refusal names the release it ships in, joined in the
retirement-release pin for the new D2 entry; the dropped-refinements header
totals follow the 13 new sites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/platform-objects, @objectstack/spec, touching 18 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json, packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/field.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via defaultCurrency (literal, a string literal in apply; a string literal in refuseFixedCurrencyWithoutCode; a string literal in sections))
  • content/docs/data-modeling/field-types.mdx (via defaultCurrency (literal, a string literal in apply; a string literal in refuseFixedCurrencyWithoutCode; a string literal in sections))
  • content/docs/data-modeling/fields.mdx (via defaultCurrency (literal, a string literal in apply; a string literal in refuseFixedCurrencyWithoutCode; a string literal in sections))
  • content/docs/data-modeling/object-extensions.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/data-modeling/validation-rules.mdx (via defaultCurrency (literal, a string literal in apply; a string literal in refuseFixedCurrencyWithoutCode; a string literal in sections))
  • content/docs/deployment/install-modes.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/getting-started/common-patterns.mdx (via defaultCurrency (literal, a string literal in apply; a string literal in refuseFixedCurrencyWithoutCode; a string literal in sections))
  • content/docs/getting-started/quick-start.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/protocol/kernel/i18n-standard.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/protocol/objectql/schema.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/protocol/objectql/types.mdx (via defaultCurrency (literal, a string literal in apply; a string literal in refuseFixedCurrencyWithoutCode; a string literal in sections))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object currencyConfigDynamicDefaultCurrencyRemoved), retiredFromLoadPath (symbol, a field of const object currencyConfigDynamicDefaultCurrencyRemoved), objectExtensions (literal, a string literal in apply))
  • content/docs/releases/v18/18-0.mdx (via objectExtensions (literal, a string literal in apply))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json, packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/field.json, …) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 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 — 149 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 cdb949a288e1bd3350f09527c5f2169eb4752c23 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 142e1d1b1bbc587f8b51baccf2a4f743d5ab5da5 — the merge of head a7683e16f04b033a608a5bb8fdfe8cb03e3619ec into base cdb949a288e1bd3350f09527c5f2169eb4752c23, 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 142e1d1b1bbc587f8b51baccf2a4f743d5ab5da5 && git checkout 142e1d1b1bbc587f8b51baccf2a4f743d5ab5da5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cdb949a288e1bd3350f09527c5f2169eb4752c23 a7683e16f04b033a608a5bb8fdfe8cb03e3619ec && git checkout -B drift-repro cdb949a288e1bd3350f09527c5f2169eb4752c23 && git merge --no-ff a7683e16f04b033a608a5bb8fdfe8cb03e3619ec

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

⚠️ 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 cdb949a288e1bd3350f09527c5f2169eb4752c23 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c172c878ec3164e8bd361fcdb5cc4039336eda7c
Local-runs: none
Scope: packages/spec/src/data/field.zod.ts, packages/spec/src/data/field.form.ts, packages/spec/src/data/field.test.ts, packages/spec/src/data/currency-config-fixed-code-required.test.ts, packages/spec/src/data/currency-precision-iso4217.test.ts, packages/spec/src/data/formula-currency-result.test.ts, packages/spec/src/conversions/registry.ts, packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts, packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts, packages/spec/src/migrations/registry.ts.template, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, packages/spec/scripts/lib/default-changes.ts, packages/spec/authorable-defaults/data.json, packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/field.json, packages/formula/src/formula-return.ts, packages/formula/src/formula-return.test.ts, packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts, ui/packages/i18n/src/currency.ts, ui/packages/i18n/src/tests/resolveFieldCurrency.test.ts + packages/metadata-protocol/src/protocol.ts (saveMetaItem persistence rule, loadMetaFromDb hydration), packages/metadata-protocol/src/metadata-diagnostics.ts, packages/objectql/src/registry.ts (registerObject), packages/spec/src/conversions/stored.ts, packages/spec/src/shared/refinement-projection.ts, packages/metadata-core/src/artifact-forward-conversion.ts, packages/services/service-analytics/src/plugin.ts, packages/services/service-analytics/src/analytics-service.ts, packages/types/src/share-link-resolve-answer.ts, ui/packages/plugin-grid/src/useColumnSummary.ts, ui/packages/plugin-dashboard/src/recordFields.tsx, ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts, ui/packages/fields/src/index.tsx, ui/apps/console/src/pages/shared-record-shape.ts, docs/adr/0087-metadata-protocol-upgrade-contract.md, skills/objectstack-data/rules/field-types.md, skills/objectstack-ui/rules/dashboards.md, examples/app-showcase/src/data/objects/.object.ts, ui/examples/schema-catalog/src/schemas/fields-currency/.json

Inputs read (2026-10-28 11:01Z to 11:10Z): card #27157 body and both comments (claim 6465165193, os-dev-report 6467252308); ruling 6464202770 on #27119 (Q2 = X, G3); the FieldSchema docblock principle #9689 at field.zod.ts on origin/main 91a4036a6a; ADR-0087 on main (D2, D3, the 2026-07-31 stored-row addendum, the 2026-08-13 disposition vocabulary, the 2026-09-13 artifact window with its 2026-09-30 and 2026-10-13 amendments, the 2026-09-13 level half); PR #27218 body, 27-path file list, the one PR comment (docs-drift 6467265809), no review comments, the net diff against main at c172c878ec; the head's check-runs at 11:02Z, 11:08Z and 11:10Z. Base read as origin/main 91a4036a6a (the PR body measured on 6866e2e7c; the readings below agree on every cited line).

① Derived judgments

The refusal. CurrencyConfigSchema (field.zod.ts, head) drops .default('CNY') for .optional() and adds one .superRefine(refuseFixedCurrencyWithoutCode) inside the lazySchema lambda, so the check travels with the schema everywhere it is embedded. { currencyMode: 'fixed' } answers one custom issue at defaultCurrency whose first sentence is the prescription (ISO 4217 code, or dynamic for the tenant default); the message carries no tracker number. fixed with a code round-trips byte-identical; {} and { currencyMode: 'dynamic' } parse to { currencyMode: 'dynamic' } with no defaultCurrency key; parse(parse(x)) is pinned on the config and on FieldSchema for every accepted shape (currency-config-fixed-code-required.test.ts). Authoring doors: objectstack build and publish parse through ObjectSchema / the marketplace manifest.objects.element.fields shapes (the 13 new currencyConfig sites in dropped-refinements.baseline.json are exactly those embeddings, 252 schemas / 900 sites); the Studio field editor and every /meta write go through saveMetaItem, whose resolveOverlaySchema('object') is ObjectSchema.safeParse (422 with the issue). The .describe() reads "required under fixed"; the field form's help text and its four locale bundles say "Required in fixed mode; nothing fills one in"; authorable-defaults/data.json loses the CNY row. One door is not reached, as for every refinement on main: a raw programmatic registry.registerObject(def) runs only the field-type door (registry.ts registerObject, objectFieldTypeRefusal) and no ObjectSchema parse; pre-existing posture, not this PR's.

The formula shape (#22727). FieldSchema.currencyConfig is CurrencyConfigSchema.optional() once (field.zod.ts currencyConfig: row), read for a currency field and for returnType: 'currency'; the test pins the same custom issue at currencyConfig.defaultCurrency on both, and formula-currency-result.test.ts moves { currencyMode: 'fixed' } from the accepted list to the malformed list. One refine, no copy.

ADR-0087 conversions. D2 currency-config-dynamic-default-currency-removed: toMajor: 18, retiredFromLoadPath: true, retiredAfter: '18.2.0' (the current stable label of @objectstack/spec, which is what MetadataConversion.retiredAfter is defined as), order 78 (unique; the step's previous maximum is 77, and currency-config-precision-removed at 41 runs first, which the re-pinned currency-precision-iso4217.test.ts depends on). It walks objects and objectExtensions fields, strips defaultCurrency only where currencyMode is absent or 'dynamic', and leaves a fixed config by reference; fixture pair with expectedNotices: 3; applyConversionsToStoredItem wraps a stored object row as { objects: [row] } so the stored seam reaches it, pinned. D3 currency-config-fixed-default-currency-required links the D2 id; STEP18_RATIONALE order 112; DEFAULT_CHANGES_BY_MAJOR[18] declares data/CurrencyConfig:defaultCurrency: "CNY" → (none). It is a key strip of an unread key, not a default-flip reinterpretation, so it owes no DEFAULT_FLIPS_NOT_REPLAYED_HERE row at the artifact door; built artifacts hold the parsed stack (artifact-forward-conversion.ts docblock), so an artifact's fixed config carries its baked code and its dynamic CNY is stripped inside the window (floor <= 18.2.0).

A stored fixed row without a code CAN exist, and the PR declares it cannot. saveMetaItem stores every type but view as the original payload verbatim with two grafts (protocol.ts, the resolveOverlaySchema docblock: "Every other type stores its original payload verbatim"; ADR-0005 appendix (c)). So a sys_metadata object row authored through Studio's composite (mode Fixed, code left blank) or a /meta write of { currencyMode: 'fixed' } was accepted by the old schema and persisted WITHOUT a code; no parse baked CNY into it. The PR says the opposite in five places: the changeset body ("Every earlier parse baked the code in, so no stored fixed row lacks one"), the D3 reason ("no stored fixed row lacks one", projected into the upgrade guide), the DEFAULT_CHANGES_BY_MAJOR[18] reason, the registry docblock ("A fixed row is left alone: its code, authored or baked... satisfies the new refinement"), and ui/packages/i18n/src/currency.ts ("A fixed config now always carries its code"). What load actually does with such a row at this head: convertStoredItem leaves it (mode is fixed), computeMetadataDiagnostics fails it at fields.*.currencyConfig.defaultCurrency, loadMetaFromDb counts it invalid, prints [metadata_spec_invalid], and registers the raw body with _diagnostics; a re-save in Studio is refused 422 with the prescription until the code is written. Display does not change for that row: the registry held the raw body before too, so resolveFieldCurrency (currency.ts:60, || chain) already fell through to the tenant default, and the analytics relay (plugin.ts:1447) relayed undefined; what changes is @objectstack/formula's currencyOf for a formula over that field (fixed CNY → unproven, which the PR declares generally). This is consistent with ruling X (nothing is converted to 'CNY', nothing guesses), but the disposition the changeset and D3 entry ship is a false factual claim about data at rest, in the CHANGELOG text an upgrading agent greps and in the generated upgrade guide, and the real disposition (registered with diagnostics, refused on re-save, formula inference flips) is undeclared and unpinned.

Readers of defaultCurrency on main, each measured. ui/packages/i18n/src/currency.ts:60 reads the code only under currencyMode === 'fixed' (display rule unchanged; its test now pins the empty config carries no code and still resolves to the tenant USD); every ui field, grid, dashboard and detail face reaches it through resolveFieldCurrency or passes currencyConfig through untouched (useColumnSummary.ts:659, recordFields.tsx buildFieldMeta, buildDefaultPageSchema.ts:805, fields/src/index.tsx:835, objectGridGenerateColumns.tsx, RecordDetailPanel.tsx, RelatedList.tsx, fieldEnrichment.ts); shared-record-shape.ts:122 is a type mirror. packages/services/service-analytics/src/plugin.ts:1447 relays the code only under fixed; analytics-service.ts:3167 reads that relay. packages/types/src/share-link-resolve-answer.ts:120 copies the code when it is a string, so a dynamic projection loses a CNY key the console reads only under fixed. packages/formula/src/formula-return.ts currencyOf is the one changed reader, pinned by fixed_no_code. plugin-email has no currencyConfig reader (its currency is the template argument, G2 of the ruling); no lint rule reads the key; packages/spec/src/api/analytics.zod.ts, contracts/analytics-service.ts, percent-scale.ts are prose. No reader starts formatting in another currency or throws; the one that saw 'CNY' and now sees undefined on a dynamic config (share-link projection) was already mode-gated at its consumer.

Survey. git grep over origin/main for every spelling of currencyMode = fixed (TS, JSON, YAML, the mode: alias) outside tests, CHANGELOGs and prose: examples 6 (account.object.ts:65, field-zoo.object.ts:60, project.object.ts:71/77/88, semantic-zoo.object.ts:62), ui/examples/schema-catalog 2 (euro-currency.json, usd-currency.json, code on the next line), conversions/registry.ts precision fixture 4; packages/platform-objects/** and packages/qa/** declare no currency field with a config (field-zoo.matrix.ts:105 is a value check). Every hit carries a code: 0 fixed without a code, agreeing with the PR's 0. Field.currency() (field.zod.ts:3137) spreads the input and sets no mode, so no factory produces a bare fixed.

Declarations. Changeset 27157-currency-config-fixed-code-required.md: @objectstack/spec minor, @objectstack/formula minor, @objectstack/platform-objects minor — Metadata-protocol: narrowed takes at least minor on each package the diff moves, pre-GA minor with the BREAKING banner per ADR-0087's 2026-09-13 level half; Clause-②: no (narrowing) and Metadata-protocol: narrowed on both body and changeset; marker adr-0087: registered naming both ids, both new in the diff (grammar per the 2026-08-13 addendum; Check Changeset success at 11:01:08Z). FROM → TO and the one-line fix present. Liveness field.json currencyConfig row: dated NARROWED note, verdict LIVE, reader re-cited. Skills-docs: none holds: git grep CNY -- skills ui/skills .claude/skills is 0 hits; skills/objectstack-data/rules/field-types.md:174-187 and skills/objectstack-ui/rules/dashboards.md:301 teach defaultCurrency only as an explicit code under fixed. Fixes #27157 present. The two hand-written docs tables and the generated references/data/field.mdx drop "Defaults to CNY"; remaining CNY mentions under content/docs are unrelated (an email currency_code example, a list of codes). ui/packages/i18n/src/currency.ts is a comment-only change and carries no @object-ui/i18n changeset line; the changeset gates accepted that.

Tests. No test deleted. Three re-pins move the bare fixed case from accepted to refused (field.test.ts "should accept both currency modes", the factory test at field.test.ts:1814, formula-currency-result.test.ts malformed list); field.test.ts byte-exact empty-config output and currency-precision-iso4217.test.ts rows re-pinned to no CNY. New pins cover every claim in the brief's refusal and formula sections and the D2 entry's registration, retirement, D3 link, stored-row strip-and-keep, re-parse, idempotence and non-run on the authoring path. The one unpinned case is the stored fixed row WITHOUT a code above.

JSON Schema projection. The rule is value-conditional ("required when currencyMode is 'fixed'"), which refinement-projection.ts names as the if/then shape that stays dropped (the dependent-required arm is presence-only), so the 13 new ledger sites are the sanctioned handling. The published json-schema/** and the generated form therefore accept { currencyMode: 'fixed' } that the runtime refuses; a discriminated union over currencyMode would project exactly, and is a follow-up, not a block.

② Semver level

minor on @objectstack/spec, @objectstack/formula and @objectstack/platform-objects, each with the **BREAKING** banner and the registered ADR-0087 disposition — correct for a Metadata-protocol: narrowed change under the pre-GA level rule (ADR-0087, 2026-09-13 amendment: a metadata-facing retirement or break ships minor; an npm major is a planned act). The refusal names @objectstack/spec 18, which retired-key-removal-version.pin.test.ts derives from retiredAfter: '18.2.0' being the newest published stable (label 18.2.0), so the sentence and the pin agree. No major changeset is introduced.

③ Boundary flags

  • FAIL — the stored-row disposition rests on a false premise and the real disposition is undeclared. saveMetaItem persists object rows verbatim (packages/metadata-protocol/src/protocol.ts, resolveOverlaySchema docblock), so a fixed row with no defaultCurrency can exist in sys_metadata; the changeset body, the D3 reason (18.currency-config-fixed-default-currency-required.ts), default-changes.ts, the registry docblock and ui/packages/i18n/src/currency.ts all state that every stored fixed row carries a code. Fix before landing: reword those five to the measured behaviour — such a row is registered unchanged with _diagnostics invalid and the [metadata_spec_invalid] boot line, its display is unchanged (the registry already held the raw body and every reader is mode-gated), a re-save is refused with the prescription, and @objectstack/formula reads a formula over it as unproven instead of fixed CNY — and pin it in currency-config-dynamic-default-currency-removed.test.ts (a stored row with { currencyMode: 'fixed' } is left by the conversion and refused by ObjectSchema.safeParse at fields.*.currencyConfig.defaultCurrency). The code itself needs no change under ruling X; converting such a row to 'CNY' would be the guess the ruling forbids.
  • Dev flag "An authored defaultCurrency under dynamic is not refused": accepted; the order asked for no such narrowing, the conversion strips it only at rest, and the test pins it is returned as written.
  • Dev flag "The refusal names @objectstack/spec 18": accepted, derived as in ② above. The same sentence cites "(ADR-0049)" for a ruling-driven narrowing (6464202770, principle FieldSchema accepts deleteBehavior: 'set_null' on a master_detail, and the engine silently resolves it to cascade #9689); not an enforce-or-remove retirement — a prose nit, fix with the FAIL item or leave.
  • Dev flag "ui/packages/i18n/src/currency.ts changes a comment only, so no @object-ui/i18n changeset line": accepted; the gates accepted it and nothing published moves.
  • Dev flag "The formula reader stops inferring a fixed CNY currency for a source field the schema now refuses; that field is itself refused at publish": accepted for authored sources; for a stored row it is the FAIL item's undeclared half.
  • The dev-reported gate run (113/113, 0 NOT-MEASURED; dogfood 351 files, 2,804 passed on 5f876858f) is the dev's own reading and is not re-run here. Check-runs on c172c878ec, read at 11:02Z, 11:08Z, 11:10Z and 11:13Z (62 runs at the last reading): completed success 36, among them the required contexts Lint & Repo Gates, Build Core, Governed Surface Queue Guard, Lint UI and Build & E2E UI, plus Check Changeset (11:01:08Z), Spec property liveness, PR-body guards, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Plan Test Core shards, Build UI Deps, Build Docs, every Lint · lane and Type Check · source gates, · debt ledger, · consumer gates, · workspace; skipped 7 (Dogfood Regression Gate shards, UI Published Artifacts, Dogfood Verify CLI, Temporal Conformance, 18.x upgrade harness, Schedule gate, Packed-tarball smoke); in_progress 19 at 11:13Z — the required TypeScript Type Check, Test Core (shards 1, 3, 4, 5, 6 still running; 2, 7, 8 success) and Type Check UI, and Test UI shards 1-4, 6-8, 10-12, 15, 16. No failure read at any of the four readings. An in_progress gate is a reading, not a pass; the verdict here does not rest on them.

Implemented-by: claude/project-thread-rd9t22
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: FAIL

…#27157)

Contract review round 2. A sys_metadata object row is kept as authored, so a
row saved as { currencyMode: 'fixed' } has no code; the conversion writes
none in. It loads and registers unchanged, is counted invalid with a
[metadata_spec_invalid] line, and its next save is refused until a code is
written; its display (the tenant default) does not change. The changeset,
the D3 entry, the default-changes row, the conversion docblock, the field
docblock, the upgrade-guide fragment and the console currency comment now
say so, and the conversion test pins the row. The refusal drops its
ADR-0049 citation (the narrowing is ruling-driven).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a7683e16f04b033a608a5bb8fdfe8cb03e3619ec
Local-runs: none
Scope: packages/spec/src/data/field.zod.ts, packages/spec/src/data/field.form.ts, packages/spec/src/data/field.test.ts, packages/spec/src/data/currency-config-fixed-code-required.test.ts, packages/spec/src/data/currency-precision-iso4217.test.ts, packages/spec/src/data/formula-currency-result.test.ts, packages/spec/src/conversions/registry.ts, packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts, packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts, packages/spec/src/migrations/registry.ts.template, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, packages/spec/scripts/lib/default-changes.ts, packages/spec/authorable-defaults/data.json, packages/spec/dropped-refinements.baseline.json, packages/spec/liveness/field.json, packages/formula/src/formula-return.ts, packages/formula/src/formula-return.test.ts, packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts, ui/packages/i18n/src/currency.ts, ui/packages/i18n/src/tests/resolveFieldCurrency.test.ts + packages/metadata-protocol/src/protocol.ts (saveMetaItem persistence rule and 422 envelope, loadMetaFromDb hydration), packages/metadata-protocol/src/metadata-diagnostics.ts, packages/objectql/src/registry.ts (registerObject), packages/spec/src/conversions/stored.ts, packages/metadata-core/src/artifact-forward-conversion.ts, ui/packages/i18n/src/currency.ts (resolveFieldCurrency), packages/formula/src/formula-return.ts (currencyOf), docs/adr/0087-metadata-protocol-upgrade-contract.md

Round 2 on the round-1 FAIL (record 6467521422 on c172c878ec). Inputs read 11:39Z onward: the compare c172c878ec...a7683e16f0 (one commit, 8 files, +102 / −40), the PR body as rewritten by the seat at this head, the eight changed files at a7683e16f0 in full at the changed regions, the round-1 cited code on origin/main 91a4036a6a, and the head's check-runs polled every two minutes from 11:40Z to completion. Everything judged PASS-worthy in round 1 is unchanged on this head (the one commit touches no schema logic, no conversion apply, no test that round 1 relied on); only the round-1 FAIL item and its flags are re-judged here.

① Derived judgments

Every sentence about stored rows is now true in code. The eight sites say one thing, and each clause has a reader on main:

  • "A built artifact holds the parsed stack, so its fixed configs carry the code the old parse filled in and keep it" — artifact-forward-conversion.ts docblock ("os build writes the parsed stack"); the D2 apply skips every config whose currencyMode is set and not 'dynamic'.
  • "A sys_metadata object row is stored as authored (saveMetaItem persists the payload verbatim), so a row saved as { currencyMode: 'fixed' } ... has no code" — protocol.ts resolveOverlaySchema docblock: every type but view stores its original payload verbatim with two grafts (filter operator, form groups→sections), neither touching currencyConfig.
  • "nothing converts one in" — the D2 apply at head: if (!isDict(config) || (config.currencyMode !== undefined && config.currencyMode !== 'dynamic')) { next[name] = def; continue; }; no entry writes defaultCurrency.
  • "Such a row still loads and is registered unchanged; boot counts it invalid and prints [metadata_spec_invalid] stored object/NAME fails the current spec schema even after conversion (fields.FIELD.currencyConfig.defaultCurrency: …)" — loadMetaFromDb: convertStoredItem then computeMetadataDiagnostics; on !verdict.valid it increments invalid, prints exactly [Protocol] [metadata_spec_invalid] stored ${type}/${name} fails the current spec schema even after conversion (${first.path}: ${first.message}) (the field-type door does not fire for type: 'currency'), and registers { ...data, _provenance: 'org' } — the raw converted body.
  • "the next save of that object is refused (422 INVALID_METADATA at fields.FIELD.currencyConfig.defaultCurrency) until the code is written" — saveMetaItem → resolveOverlaySchema('object') → ObjectSchema.safeParse → the INVALID_METADATA envelope (protocol.ts:4017, (err as any).code = 'INVALID_METADATA') with the issue the new test pins at that path.
  • "Its display does not change: the registry already held the row as stored, and the console reads a fixed code only when there is one" — registerObject stores the body it is handed (no ObjectSchema parse on that door); resolveFieldCurrency (currency.ts:60) reads config.defaultCurrency only when currencyMode === 'fixed' and ||-falls to the tenant default when it is undefined, before and after.
  • "@objectstack/formula now reads a formula over that field as an unproven currency instead of fixed CNY" — currencyOf parses the raw registered config through CurrencyConfigSchema; the old schema materialised CNY, the new one refuses, null → UNPROVEN.
    No site at this head still says "no stored fixed row lacks one", "every earlier parse baked the code in, so…" or "a fixed config now always carries its code" (grep over the eight head files: 0 hits); the PR body's "Stored rows and built artifacts, measured" section says the same as the changeset and quotes the probe's { loaded: 1, errors: 0, invalid: 1 }. The registry docblock's "a stored row copied from a parsed body" clause (a dynamic row may carry a code written or copied) is true as well: the strip is keyed on mode, not on provenance.

The pin asserts what it claims. New test "leaves a stored fixed row with no code unconverted; the current schema refuses it with the prescription": a stored object row with amount: { type: 'currency', currencyConfig: { currencyMode: 'fixed' } } through applyConversionsToStoredItem('object', …) — the FULL chain, retired entries included — emits no notice from this entry and deep-equals its structuredClone before (so no entry of the chain rewrote the row), the config is still { currencyMode: 'fixed' }, and ObjectSchema.safeParse(item) fails with exactly one issue ['fields.amount.currencyConfig.defaultCurrency', 'custom'], whose message matches the prescription regex and does not contain ADR-0049. What it does not pin — the boot counter, the [metadata_spec_invalid] line and the 422 — lives in @objectstack/metadata-protocol, outside @objectstack/spec's test reach; the dev's probe reading (loaded 1, invalid 1; 422 at the same path) is quoted in the PR body and agrees with the code read above. Acceptable: the spec-side pin is the contract the conversion owns.

The acceptanceCriteria tells operators a correct remedy. "For stored sys_metadata object rows, look for the [metadata_spec_invalid] boot line at fields.FIELD.currencyConfig.defaultCurrency: such a row displayed the tenant default, so switch it to dynamic or write the code it should carry, and save it." Correct: the boot line names object and field; the read carries _diagnostics so Studio badges the row; the save door accepts the corrected body (either currencyMode: 'dynamic' or a code) and refuses anything else with the same prescription. "Displayed the tenant default" is the console's reading (resolveFieldCurrency); the only consumer that read it as CNY was the formula inference, which now reads it as unproven — stated two sentences earlier in the reason. One remedy the text does not name, correctly: os migrate meta --stored would report the row refused rather than fix it (the pass re-saves through saveMetaItem), so pointing operators at the save is right.

The refusal. CURRENCY_CONFIG_FIXED_WITHOUT_CODE drops "(ADR-0049)" and keeps "Since @objectstack/spec 18 nothing fills a code in; it used to be 'CNY'", which retired-key-removal-version.pin.test.ts derives from retiredAfter: '18.2.0' = newest published stable. Tracker-number-free.

The rest of the diff. default-changes.ts reason, STEP18_RATIONALE fragment, field.zod.ts schema docblock, the registry entry's summary ("a fixed config is never touched") and fixture comment, and the currency.ts comment all restate the same behaviour; the conversion test's storedObject() docblock no longer claims every stored row carries a code. No apply, schema, fixture data or expectedNotices changed, so round 1's judgments on the refusal, the formula shape, the D2/D3 registration, readers, survey, declarations and tests carry over unchanged.

② Semver level

Unchanged from round 1: minor on @objectstack/spec, @objectstack/formula, @objectstack/platform-objects with the BREAKING banner and the registered disposition naming both ids — correct for Metadata-protocol: narrowed under ADR-0087's pre-GA level half. The round-2 commit is prose, one test and a two-word refusal edit; it moves no level.

③ Boundary flags

  • Round-1 FAIL item: closed. All five sites named in 6467521422, plus three the dev found (field.zod.ts docblock, STEP18_RATIONALE, the registry fixture comment), now state the measured behaviour, and the stored fixed-no-code row is pinned at the conversion seam.
  • Round-1 prose nit (ADR-0049 in the refusal): closed.
  • Dev flags carried from round 1 (an authored code under dynamic not refused; the refusal naming @objectstack/spec 18; no @object-ui/i18n changeset line for a comment-only change): accepted as before.
  • Non-blocking, unchanged: the rule is value-conditional and stays a dropped refinement (13 ledger sites), so the published JSON Schema and the generated form accept { currencyMode: 'fixed' } that the runtime refuses — a discriminated union over currencyMode would project exactly; follow-up, not a block. A raw programmatic registry.registerObject(def) is not refused (pre-existing posture for every refinement).
  • One observation, not a defect: a formula field stamped at authoring over a bare-fixed source before this change carries its own currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'CNY' } (the stamp copied the inferred CNY), so that stored formula field keeps displaying CNY and is not refused; only a re-inference changes its stamp. Consistent with "a fixed config with a code keeps it".
  • Gates on a7683e16f0, read to completion (polled 11:40Z to 11:59Z; 68 check-runs, all completed at 11:59:14Z): the eight required contexts are all success — Lint & Repo Gates 11:47:43Z, TypeScript Type Check 11:54:47Z, Test Core 11:57:24Z (shards 1-8 all success), Build Core 11:47:37Z, Governed Surface Queue Guard 11:36:56Z, Lint UI 11:47:18Z, Type Check UI 11:53:33Z, Build & E2E UI 11:47:14Z; also success: Check Changeset (11:40:37Z, on the reworded changeset), Spec property liveness, PR-body guards, Check PR Size, Check Documentation Links, Flag docs, Build UI Deps, Build Docs, every Lint · and Type Check · lane, Test UI shards 1-2 and 4-16; skipped 9 (Dogfood shards, UI Published Artifacts, Dogfood Verify CLI, Temporal Conformance, 18.x upgrade harness, Schedule gate, Packed-tarball smoke, and the re-run Auto Label / Check PR Size pair). Two failures, both advisory: Test UI (3/16) and the Test UI aggregate it feeds. The one failing assertion (check-run annotation; the raw log host is proxy-denied from this seat) is ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx:100 — "drops an app the /me/apps answer does not list": seen.mock.lastCall held a 3-entry list including showcase_app after the Probe had already rendered the narrowed setup,crm, i.e. a race between the two hook instances' effects. This PR touches nothing under app-shell; the test landed on main at 08:04Z (85c40fdf2e); the same shard was success on the previous head c172c878ec at 11:15:58Z and on main pushes 48bffe389e (11:41:20Z), 2d05cd4758 (11:42:44Z and 11:57:44Z) and 2594f5b2c8 (11:50:35Z) — the minutes this run was red. A flaky signature, not a defect of this diff; Test UI is outside the eight required contexts and rides through. The flake owes its own card (Prime Directive chore: version packages #10, ruling 6181973363 class); this seat is read-only and files none.
  • Dev-reported round-2 readings (spec 742/742 files, 22,244 tests; typecheck; check:generated; ui i18n 89/89; 34/34 affected gate families) are the dev's own and were not re-run.

Implemented-by: claude/project-thread-rd9t22
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 (席位终稿)

  • 改了什么:currencyConfig.defaultCurrency 去掉 'CNY' 默认值。currencyMode: 'fixed' 不写币种代码时在解析处拒收,货币字段和返回 currency 的公式字段同一规则,并附处方「写 ISO 4217 代码,或改用 dynamic(组织默认币种)」。dynamic 配置解析后不再带 defaultCurrency。
  • 为什么改:裁决 X([Decision] Currency, all of it: resume multi-currency (#8345) and in what shape, and one rule for an amount whose currency is not known (Studio's USD, the template's USD, fixed's CNY, the example's USD) #27119 Q2,6464202770)规定币种未知时取组织默认币种,任何地方都不猜。以前不写代码的 fixed 字段会静默变成人民币字段。
  • 风险与回滚:收窄(BREAKING,minor,ADR-0087 处置已登记)。仓库内 11 条 fixed 配置,缺代码的 0 条。已存的无代码 fixed 行照常加载,启动计为 invalid 并打印 [metadata_spec_invalid],下次保存返回 422,直到写上代码;显示不变。dynamic 行上的 CNY 在回读时由转换剥掉。回滚 = revert 本 PR。
  • 先看:packages/spec/src/data/field.zod.ts 的 CurrencyConfigSchema 与 refuseFixedCurrencyWithoutCode,以及 packages/spec/src/conversions/registry.ts 的转换条目。
  • 两分钟验证:{ currencyMode: 'fixed' } 解析被拒;{ currencyMode: 'dynamic' } 解析结果不含 defaultCurrency。
  • 需要你做的:无,席位落地。

席位意见:接受。契约复核第一轮 FAIL(6467521422),原因是「已存 fixed 行都带代码」这一前提不成立。第二轮 PASS(6468532881):八处文字改为实测行为,并在转换层钉住了无代码的 fixed 存量行。

席位核对 · ACCEPT · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T12:30Z

Head: a7683e16f04b033a608a5bb8fdfe8cb03e3619ec · card #27157 · claim 6465165193 · ruling 6464202770

  1. 形态: a draft PR to main carrying Fixes #27157, 27 paths (+575 / −44), all inside the claim's surface. It ships minor changesets with the BREAKING banner and the registered ADR-0087 disposition on @objectstack/spec, @objectstack/formula and @objectstack/platform-objects. check-governed-merges --pr 27218 reports NOT governed, so the ordinary queue landing applies. The PR body's Skills-docs: none line holds: skills/** already teaches defaultCurrency only as an explicit code under fixed, with 0 CNY hits. The assignee is os-support-ai.

  2. 前提实测: the card's premise holds on main. .default('CNY') sat on defaultCurrency, so every parse materialised it, dynamic configs included. The round 1 review found one false premise in the PR's own prose: that every stored fixed row carries a code. saveMetaItem stores object rows verbatim, so a code-less fixed row can exist in sys_metadata. Round 2 confirmed all eight sites now state the measured behaviour, each with a reader on main:

    • such a row loads and registers unchanged;
    • boot counts it invalid with the [metadata_spec_invalid] line;
    • the next save returns 422 INVALID_METADATA at fields.FIELD.currencyConfig.defaultCurrency;
    • its display is unchanged, because resolveFieldCurrency falls back to the tenant default when there is no code;
    • @objectstack/formula reads a formula over it as unproven instead of fixed CNY.

    The conversion seam pins that row: it is left unconverted and refused by the current schema. Converting it to 'CNY' would be the guess the ruling forbids.

  3. 门禁 check-runs (on this head): all nine required contexts in scripts/check-required-contexts.mjs, Test UI included. The first run had one red: Test UI (3/16) at ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx:100. That is the known main-red flake carded as main red: useLauncherApps-26838.test.tsx intermittently sees a third app (Showcase) where it expects two; main push red at 08:30Z and 10:21Z #27216 (p0, dispatched). This PR touches nothing under app-shell, and the same shard was green on the previous head and on the main pushes of those minutes. The seat re-ran the failed jobs of ci.yml run 40681282495 (attempt 2), with no test changed, skipped or retried in code. Read at 2026-10-28T12:23Z: 68 runs, 59 success, 9 skipped, none red or pending; Test UI (3/16) and Test UI both success. All nine required contexts are green.

  4. 接受的偏差:

    • (a) An authored defaultCurrency under dynamic is not refused. The order asked for no such narrowing; the conversion strips it only at rest, and a test pins that it is returned as written.
    • (b) The refusal names @objectstack/spec 18, derived by retired-key-removal-version.pin.test.ts from retiredAfter: '18.2.0'.
    • (c) ui/packages/i18n/src/currency.ts changes a comment only, so there is no @object-ui/i18n changeset line.
    • (d) A formula field stamped before this change over a bare-fixed source carries its own { currencyMode: 'fixed', defaultCurrency: 'CNY' } stamp, and keeps displaying CNY until it is re-inferred. This is consistent with "a fixed config with a code keeps it".
  5. 范围外去向: one non-blocking note, and no new card. The rule is value-conditional, so it stays a dropped refinement: the published JSON Schema and the generated form accept a bare { currencyMode: 'fixed' } that the runtime refuses. A discriminated union over currencyMode would project it exactly. spec(field): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1, epic #8345) #27184 (currencyMode: 'record' + currencyField) reshapes this same enum, so that is where the union belongs. The round 2 review's note that Test UI sits outside the required set is out of date: the set has nine contexts, and this record reads it that way.

  6. 落地条款: the seat checked the prose faces against the diff on this head: the PR body (the seat's round 2 rewrite, read back), the changesets, the D3 reason, default-changes.ts and the registry docblock. The seat readies the PR and arms auto-merge in this act, follows it to MERGED, and lands spec(field): fixed-mode currencyConfig with no defaultCurrency materialises 'CNY' — require the code, drop the default (narrowing; #27119 Q2 X, G3) #27157.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 28, 2026 13:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 0cd578f Oct 28, 2026
106 of 108 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-rd9t22 branch October 28, 2026 13:22
os-bill pushed a commit that referenced this pull request Oct 28, 2026
…ication notes

PR #27218 (0cd578f) landed #27157 on 2026-10-28: defaultCurrency has no
default and a fixed config without a code is refused. The readings stay
dated at cdec747; a one-line note records the landing.

Claude-Session: https://claude.ai/code/session_01Utp6CbTZrFqRYNgjSaSbN5
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(field): fixed-mode currencyConfig with no defaultCurrency materialises 'CNY' — require the code, drop the default (narrowing; #27119 Q2 X, G3)

2 participants