Skip to content

fix(objectql): a currency field written as an object is refused with the bare-number lesson - #27258

Draft
objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/project-thread-u4sy2p
Draft

objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/project-thread-u4sy2p

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread
Fixes #27236

Ruling-ref: 6464202770
Claim: 6468731037
Clause-②: no
Metadata-protocol: none

The accept set does not move, only the message. Measured: the same four writes (an object, 'abc', 10, an object on a number field) answer the same verdict and the same wire code before and after; only the first one's message changes. No authorable key, schema, type or export changes.

维护者速读

  • 改了什么: 货币字段的值写成对象 { value, currency } 时,拒收码仍是 invalid_number,但提示改为说明:货币值是裸数字(ADR-0104),币种在字段的 currencyConfig 里声明,dynamic 模式下取组织默认币种。
  • 为什么改: spec(field): retire the @deprecated CurrencyValueSchema (still exported at field.zod.ts:581) — ADR-0104 D1 says deleted; prerequisite of the multi-currency epic #8345 #27182 退役了 { value, currency } 这个旧形状,AI 作者和老代码仍会这样写;以前只看到「必须是数字」,不知道该怎么改。
  • 风险与回滚: 只换提示文字,错误码、错误字段、接受与拒收的集合都不变;回滚 = revert 本 PR。
  • 先看: packages/objectql/src/validation/record-validator.ts(数值分支那一行 fail)、packages/spec/src/system/validation-message.ts(四种语言的新句子)。
  • 两分钟验证: pnpm --filter @objectstack/objectql exec vitest run src/validation/record-validator.test.ts -t 27236 → 7 个用例全绿。
  • 需要您做的: 无,席位落地。

What changes

Tests

  • packages/objectql/src/validation/record-validator.test.ts, a new [#27236] block, 7 cases:
    • { value: 10, currency: 'USD' } on a dynamic-mode and on a fixed-mode currency field, on insert and on update, is refused as exactly [{ field, code: 'invalid_number', message: LESSON, label }], the same keys as before.
    • The lesson is not the plain sentence, starts with "Amount is a currency amount: write it as a bare number", and names currencyConfig.
    • A bare number passes.
    • CONTROL: 'abc' on a currency field keeps the plain sentence.
    • CONTROL: [10] on a currency field keeps the plain sentence.
    • CONTROL: an object on a number field keeps the plain sentence, because the lesson is currency-only.
  • packages/spec/src/system/validation-message.test.ts, a new block: every locale names { value, currency }, currencyConfig, defaultCurrency, fixed, dynamic and ADR-0104, and no locale spells a three-letter currency code or a currency symbol. The existing completeness pins (every locale defines every key, every template names {{label}}) cover the new key.

Acceptance notes

  • os validate does not judge seed record values against their field type. A seed row giving a currency field { value: 10, currency: 'USD' } passes validation (exit 0) and is only refused later, when the seed is written through the record validator. This is a separate finding, reported to the seat and not changed here.

Verification

All readings below are on head 6d7035fe (branch off origin/main d745bb5e).

The refusal before and after (objectql validator, field def with no label, so the label is the API name)

Before, on origin/main d745bb5e:

currency  { value: 10, currency: 'USD' }  → invalid_number  "amount must be a number"
currency  (fixed, EUR) same object        → invalid_number  "amount must be a number"
currency  'abc'                           → invalid_number  "amount must be a number"
currency  10                              → PASS
number    { value: 10, currency: 'USD' }  → invalid_number  "amount must be a number"

After, on 6d7035fe:

currency  { value: 10, currency: 'USD' }  → invalid_number  "amount is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode's defaultCurrency, or under the dynamic mode (the default) the tenant default currency"
currency  'abc'                           → invalid_number  "amount must be a number"
currency  10                              → PASS
number    { value: 10, currency: 'USD' }  → invalid_number  "amount must be a number"

The error object's keys are field, code, message and label in every row, before and after.

Does os validate seed data reach valueSchemaFor('currency')? No

examples/app-todo, temporarily given budget: Field.currency({ label: 'Budget' }) and a seed row with budget: { value: 10, currency: 'USD' }, then node packages/cli/bin/run.js validate on origin/main d745bb5e (cli closure built). Result: ✓ Validation passed, exit 0. The only line naming the field is the field-no-consumers warning, which lists data[0].records[4].budget as a carrier site, so the seed row was read and its value was not judged. SeedRecordValueSchema (data/seed.zod.ts) is z.unknown() apart from the seed-file claim. The temporary edits were restored with git checkout HEAD -- and git diff HEAD was empty afterwards.

Ablation

With the fix committed, node scripts/ablation-replace.mjs replaced t === 'currency' && isPlainObjectValue(value) ? with false /* ablated-27236 */ ? (anchor 1 → 0, marker 0 → 1, blob f47f64b9 → 845a0720), then ran record-validator.test.ts: 2 failed | 131 passed, the two refuses { value, currency } … with invalid_number and the lesson cases. The restore confirmed blob equals HEAD and git diff HEAD empty. The objectql test imports the validator from src, so no rebuild was needed for that leg. A first attempt was a no-op and is not counted: its replacement text undefined already appeared in the file, and the tool refused it before running anything.

Package tests and typechecks (under os-verify-lock, each exit captured separately)
  • pnpm --filter @objectstack/spec test: exit 0, Test Files 743 passed (743), Tests 22265 passed, 1 todo.
  • pnpm --filter @objectstack/spec typecheck: exit 0.
  • pnpm --filter @objectstack/objectql test: exit 0, Test Files 477 passed (477), Tests 8906 passed (8906).
  • pnpm --filter @objectstack/objectql typecheck: exit 0.
  • Dogfood battery: not run (no ! in the title, and Clause-② has no (narrowing) arm).
Derived gates: 82 derived, 81 run with exit 0, 1 NOT MEASURED

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 82 families. Each was run serially and recorded as CMD :: exit N, and --ran reconciled: 82 accounted, 81 run, 1 NOT-MEASURED, 0 UNRUN.

  • pnpm check:dual-build-cjs-loads: exit 3, PREREQUISITE NOT MET (it needs a full-repo build; only the cli and objectql closures were built). NOT MEASURED; CI runs it.
  • node scripts/check-plugin-teardown-shape.mjs --self-test: exit 3 on the first run (the shallow clone lacked its pinned fixture commit), exit 0 after git fetch origin 621a4876.
  • Every other derived family, including check-adr-0087-registration --base origin/main, check-changeset-no-major, check-empty-changeset, check:doc-authoring, check:cross-package-test-inputs and the spec check:* set, exited 0.

Generated by Claude Code

…the bare-number lesson

The record validator's numeric arm refused `{ value, currency }` on a
currency field with the plain `invalid_number` sentence. It now picks the
spec catalog's `invalid_number_currency_object` sentence for a plain
object on a currency field: a currency value is a bare number, and its
currency is declared in `currencyConfig` or is the tenant default
(ADR-0104). The wire code and the accept set are unchanged.

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

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 6 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via invalid_number (literal, a string literal in validateOne))

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

  • content/docs/releases/v17/17-5.mdx (via invalid_number (literal, a string literal in validateOne))

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
  • 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 — 150 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 7858c1d16bb27ac9a5a6d3ae9db9d6dd7331d43d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7858c1d16bb27ac9a5a6d3ae9db9d6dd7331d43d

⚠️ 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 7858c1d16bb27ac9a5a6d3ae9db9d6dd7331d43d → 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: 6d7035fec0ae4ba11b21e63485280e6b5964092f
Local-runs: none
Scope: packages/spec/src/system/validation-message.ts, packages/objectql/src/validation/record-validator.ts + packages/spec/src/data/field.zod.ts (CurrencyConfigSchema), packages/spec/src/data/field-value.zod.ts (the numeric class), packages/spec/src/api/errors.zod.ts (FieldErrorCode), docs/adr/0104-field-runtime-value-shape-contract.md, packages/spec/src/data/seed.zod.ts (SeedRecordValueSchema), packages/cli/src/commands/validate.ts, packages/cli/src/utils/bundle-seed-files.ts, packages/core/src/utils/import-coerce.ts, packages/runtime/src/dispatcher-error-vocabulary.ts, ui/packages/plugin-grid/src/ImportWizard.tsx, .changeset/27236-currency-object-value-lesson.md, PR #27233's file list at c85e08be

Inputs read: card #27236 (body; triage 6468450177; claim 6468731037; os-dev-report 6469929253), ruling 6464202770 on #27119 (Q2 = X), card #27182 and PR #27233 (overlap and ordering only), PR #27258 body, file list and the net diff against main at the head (merge-base d745bb5e), and the head's check-runs read to completion. Nothing was built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong.

  1. Accept set: unchanged — right. The only behavioural hunk is the numeric arm of validateOne (isJudgedNumberType(t)), where fail('invalid_number') becomes fail('invalid_number', undefined, t === 'currency' && isPlainObjectValue(value) ? 'invalid_number_currency_object' : undefined). fail's third parameter is messageKey?: string, and buildFieldError renders args.messageKey ?? args.code; the wire code is the first argument and stays invalid_number. Every value refused before is refused now, every value accepted before (a number, a string the numeric grammar reads) takes the unchanged second branch. The FieldValidationError keys (field, code, message, label) come from buildFieldError, which the diff does not touch.
  2. The lesson fires only for a currency field given a plain object — right. t is def.type, so a number, percent, rating, slider or progress field keeps the plain sentence; a formula is outside isJudgedNumberType (COMPUTED_VALUE_TYPES). isPlainObjectValue refuses null, arrays and any non-Object.prototype/non-null prototype (Date, class instances), so a string, [10] and a Date keep the plain sentence. The 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 operator-object gate runs before the per-type branches and refuses { $in: [...] } as invalid_type/invalid_value_shape, so an operator object never reaches the lesson; { value, currency } carries no operator key and falls through to the numeric arm. The seven objectql pins cover dynamic and fixed mode, insert and update, and the three controls ('abc', [10], an object on number).
  3. No client contract moves — right. FieldErrorCode (packages/spec/src/api/errors.zod.ts, ADR-0114) is unchanged; invalid_number_currency_object is a catalog message key, the same code/messageKey split invalid_value_shape, required_cleared, invalid_option_picklist_unresolved and value_domain_* already use, and message keys never reach the wire. Every reader of invalid_number outside the two touched sources matches on code, not on text: packages/runtime/src/dispatcher-error-vocabulary.ts (three catalog rows, code only), packages/core/src/utils/import-coerce.ts (its own import_invalid_number sentence), ui/packages/plugin-grid/src/ImportWizard.tsx and its cellCheck tests (import codes), the rest import tests (toMatchObject({ code: 'invalid_number' })). The one text pin, operator-object-write-value.test.ts:172 'N must be a number', is a number field given an operator object: refused one gate earlier, and number is outside the lesson either way. REST ships FieldValidationError.message verbatim (validation-message.ts header); no REST or Console code parses it.
  4. Public surface: no export, type or schema change — right. BUILTIN_VALIDATION_MESSAGES is typed as a Record of string to a Record of string to string (an open string-keyed map, no literal key union), so a new key changes no .d.ts; isPlainObjectValue is module-private; no .describe(), no authorable key. The spec generated-artifact gates have nothing to regenerate, and Lint & Repo Gates and TypeScript Type Check agree (below).
  5. One owning constant — right. The sentence lives once per locale in the catalog; the validator passes only the key. The test's startsWith('Amount is a currency amount: write it as a bare number') is a pin on the rendered output, not a second copy the runtime reads.
  6. i18n — right, and no gate is owed. validation-message.ts carries four catalogs (en, zh-CN, ja-JP, es-ES); the new key joins all four, with {{label}} in each and single-brace { value, currency } that interpolateValidationMessage's {{name}} regex cannot touch. These catalogs are platform constants outside the extract-and-gate bundle pipeline by design (the file header), and none of scripts/check-i18n-*.mjs reads the file; the gate that holds the four complete is the spec's own every locale defines every message key pin, which runs in Test Core.
  7. True under ruling X and ADR-0104 — right in substance; one cite placement to tidy (S1). No locale spells a currency code or symbol (the spec pin checks a 3-capital pattern and $¥€£ after stripping ADR-0104), so the lesson names no default. "A bare number" is ADR-0104 D1. "The fixed mode's defaultCurrency" and "under the dynamic mode (the default) the tenant default currency" match CurrencyConfigSchema at the head (currencyMode default 'dynamic'; dynamic displays in the tenant default) and ruling 6464202770 Q2 = X. defaultCurrency still defaults to 'CNY' at the head (ruling G3 removes it on another card); the lesson does not depend on that default, so it stays true after G3 lands.
  8. Overlap and ordering with PR feat(spec)!: retire the never-consumed CurrencyValueSchema / CurrencyValue export (ADR-0104 D1) #27233 — right. The two file lists (5 against 26 at feat(spec)!: retire the never-consumed CurrencyValueSchema / CurrencyValue export (ADR-0104 D1) #27233 head c85e08be, still its head at review time) intersect in zero paths; this PR does not touch field.zod.ts, field-value.zod.ts or ADR-0104, all feat(spec)!: retire the never-consumed CurrencyValueSchema / CurrencyValue export (ADR-0104 D1) #27233 files. Leaving the valueSchemaFor('currency') arm alone is both what the serial constraint requires and what the measurement supports: currency is a member of the numeric class (field-value.zod.ts:65), and the record validator judges that class in its own arm without consulting valueSchemaFor, so a sentence there would never reach a record writer. "Lands after feat(spec)!: retire the never-consumed CurrencyValueSchema / CurrencyValue export (ADR-0104 D1) #27233" stands as the card wrote it.
  9. Out-of-scope finding (os validate passes a non-number currency seed value) — real as described. SeedRecordValueSchema is z.unknown() with a superRefine that judges only seed file references; validate.ts reaches seed rows only through collectSeedFiles (bundle-seed-files.ts, file references again); the lint seed rules are validateSeedReplaySafety and validateSeedStateMachine. No door judges a seed value against its field type before the record validator at seed load. The dev reported it and did not expand scope, as Prime Directive chore: version packages #10 asks; it is a card for the seat to file, not a change here.

② Semver level

  • Changeset .changeset/27236-currency-object-value-lesson.md: '@objectstack/spec': patch, '@objectstack/objectql': patch. Both are published (private unset, 18.2.0), and they are the only two packages whose src the diff moves. Level right: Clause-②: no with no arm and Metadata-protocol: none take patch.
  • Clause-②: no is true: the accept set is the same before and after (①.1). Metadata-protocol: none is true: nothing an author can write changes (①.4). Both declarations are line-initial in the PR body and the changeset.
  • The ADR-0087 marker not-required (no-migration-prescription) is present and its facts hold (no authorable key, export or stored shape moves). Not a breaking changeset, so no migration is owed; the body still states what to do (nothing).
  • Title carries no !; nothing here is a release act.

③ Boundary flags

  • Dev flag: check:dual-build-cjs-loads NOT MEASURED locally. CI measures it in ci.yml job build-core, context Build Core, step "Every published require entry point actually loads" (it needs a real dist/, which is why it lives there and not in the lint job). Conclusion on this head: job 122837326812 success, and its step 14 "Every published require entry point actually loads" ran to success (not skipped), so the gate was measured, not only hosted.

  • Required contexts on this head (REQUIRED_CONTEXTS in scripts/check-required-contexts.mjs on origin/main has ten rows, not nine: Test UI and Live E2E UI both joined it), every one read to completion:

    context conclusion
    Lint & Repo Gates success
    TypeScript Type Check success
    Test Core success (all eight shards success)
    Build Core success
    Governed Surface Queue Guard success
    Lint UI skipped — the job's if: reads the filter job's ui output, which is false on a diff with no ui/ path; a paths-filter skip by design, not a missing run
    Build & E2E UI success
    Type Check UI success
    Test UI success
    Live E2E UI success
  • All 49 check-runs on the head completed; every conclusion is success or skipped, none failed.

  • Dev flag: ablation. Read, not re-run: 2 failed / 131 passed with the t === 'currency' && isPlainObjectValue(value) condition ablated, restored blob equals HEAD. Consistent with the diff (only the two lesson pins depend on that condition).

  • open_questions: none in the os-dev-report. Nothing to answer or escalate.

  • Claim identity. Claim 6468731037 names branch claude/project-thread-u4sy2p, the PR's head ref; no later Claim: on the card. The dev's report comment is its record; it wrote no second claim.

  • Should-fix S1 (non-blocking, wording): in all four locales the parenthetical (ADR-0104) closes the clause "under the dynamic mode (the default) the tenant default currency". ADR-0104 decides the bare-number shape (D1) and says nothing about a tenant default; that half is ruling 6464202770 Q2 = X and the currencyMode describe in field.zod.ts. Move the cite next to the shape clause ("write it as a bare number (ADR-0104), ...") or leave the tenant-default clause uncited. The spec pin only asserts ADR-0104 is present, so it holds either way.

  • Observation (not the dev's): the brief's premise of nine required contexts is one short of the register; recorded above so the next review reads ten.

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

VERDICT: PASS


Generated by Claude Code

…mber clause only

ADR-0104 decides that a currency value is a bare number; the tenant
default comes from the currency-mode contract, not from the ADR. The cite
moves next to the bare-number clause in all four locales and in the
changeset's quoted sentence.

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

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 0dd08045be400cc0e17736de7cb884a180b0616e · 2026-10-28T14:59Z

Triage seat, under gap rule (b) (#27062): this head is over 60 minutes old with no record and no claim; a delta re-review from the lane's PASS on 6d7035fe. The review runs at CONTRACT_REVIEW_TIER. If the lane claims or posts a record on this head first, triage withdraws.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0dd08045be400cc0e17736de7cb884a180b0616e
Local-runs: none
Scope: packages/spec/src/system/validation-message.ts + packages/objectql/src/validation/record-validator.ts (fail, buildFieldError, the #5922 operator gate, the numeric arm at :1222), packages/spec/src/system/validation-message.test.ts (completeness pins), packages/spec/src/data/field.zod.ts (CurrencyConfigSchema :552–565), packages/spec/src/api/errors.zod.ts (FieldErrorCode), docs/adr/0104-field-runtime-value-shape-contract.md (D1), content/docs/api/error-catalog.mdx (:875), .changeset/27236-currency-object-value-lesson.md

Re-review on a moved head. Prior record: the lane's PASS on 6d7035fec0 (comment 6470497082, 13:37Z). Delta 6d7035fec0..0dd08045be is ONE non-merge commit, 0dd08045be "fix(spec): the currency-object sentence cites ADR-0104 on the bare-number clause only" (2 files, +5/−5: the four locale sentences in validation-message.ts and the changeset's quoted sentence). No merge in the delta, so no evil-merge recomputation applies; git merge-tree --write-tree origin/main prr-27258 → e5d84faa with no conflict, and git diff d745bb5e origin/main on the four touched sources is empty (main moved 31 commits, none on these files). diff net-old.diff net.diff shows exactly the delta commit's hunks and nothing else. Inputs: card #27236 (body; triage 6468450177; claim 6468731037; os-dev-report 6469929253), ruling 6464202770 on #27119 (read with gh api), PR body, file list, net diff at the head, pr-comments (drift check 6469927846, prior record 6470497082, triage claim 6472472268), and the 53 check-runs on the head, all completed. Nothing built, run or re-run.

① Derived judgments

  1. Accept set unchanged — right. The one behavioural hunk is record-validator.ts:1222: fail('invalid_number') → fail('invalid_number', undefined, t === 'currency' && isPlainObjectValue(value) ? 'invalid_number_currency_object' : undefined). fail's third parameter is messageKey?: string (:1019); buildFieldError renders args.messageKey ?? args.code (:477) and ships code from the first argument. Every value refused at that line before is refused there now with the same code; the accepted branch (number, or a string parseNumericString reads) is untouched. Error keys (field, code, message, label) come from buildFieldError, not touched.
  2. The lesson fires only for a currency field given a plain object — right. t is def.type; isPlainObjectValue refuses null, arrays and any prototype other than Object.prototype/null. The 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 operator gate (:1072–1090) runs first and refuses { $in: … } as invalid_type/invalid_value_shape; { value, currency } carries no operator key and reaches the numeric arm. isEmptyForRequired does not read an object as blank. The seven objectql pins cover dynamic/fixed × insert/update and three controls ('abc', [10], object on number); the shapeOnly (readonly) path takes the same arm, so a readonly currency field gets the same lesson.
  3. No client contract moves — right. FieldErrorCode (errors.zod.ts:252) is unchanged; invalid_number_currency_object is a catalog messageKey, the same code/messageKey split required_cleared, invalid_value_shape and value_domain_* use, and the validation-message.ts header says such variants never reach the wire. content/docs/api/error-catalog.mdx:875 lists invalid_number by code only, so the drift check's flag is advisory and the page stays true.
  4. Public surface — right. BUILTIN_VALIDATION_MESSAGES is Record<string, Record<string, string>>, so a new key changes no .d.ts; isPlainObjectValue is module-private; no authorable key, .describe() or export moves. TypeScript Type Check, Lint & Repo Gates and Spec property liveness are success.
  5. One owning constant — right. Four locale catalogs carry the sentence once each; the validator passes the key. A deployment override resolves under validation.field.invalid_number_currency_object through renderValidationMessage step 1, as the changeset claims.
  6. i18n — right. All four locales gain the key; each carries {{label}} and no {{field}}; the single-brace { value, currency } is outside interpolateValidationMessage's /\{\{(\w+)\}\}/ regex. The spec's every locale defines every message key pin (test :34) covers it in Test Core (success, 8/8 shards).
  7. True under ruling 6464202770 Q2 = X and ADR-0104 D1 — right. No locale spells a code or symbol (spec pin: [A-Z]{3} after stripping ADR-0104, and [$¥€£]). "fixed mode's defaultCurrency" and "dynamic mode (the default) the tenant default currency" match CurrencyConfigSchema (currencyMode default 'dynamic', describe text) at the head. The delta moves the (ADR-0104) cite onto the bare-number clause in all four locales, which is the clause the ADR decides; the tenant-default clause is now uncited, which is the prior S1 as written. Independent of ruling G3 (removing the 'CNY' default): the sentence names no default.
  8. Overlap with PR feat(spec)!: retire the never-consumed CurrencyValueSchema / CurrencyValue export (ADR-0104 D1) #27233 — right. This head's five paths touch none of field.zod.ts, field-value.zod.ts or ADR-0104. The valueSchemaFor('currency') arm is left alone; the card made it optional and conditional on os validate reaching it, and the dev measured that it does not (SeedRecordValueSchema is z.unknown()).
  9. Out-of-scope finding (os validate passes an object currency seed value) — real, correctly reported and not acted on here.

② Semver level

  • Changeset: '@objectstack/spec': patch, '@objectstack/objectql': patch — the two and only two published packages whose src moves. Level right: Clause-②: no (no arm) and Metadata-protocol: none take patch. Both lines line-initial in the PR body and changeset; Check Changeset is success (both runs).
  • Clause-②: no is true (①.1). Metadata-protocol: none is true (①.4). ADR-0087 marker not-required (no-migration-prescription) present, facts hold. The delta commit re-quotes the en sentence in the changeset and it matches the catalog byte for byte.
  • No ! in the title; nothing is a release act.

③ Boundary flags

  • Prior should-fix S1 (ADR-0104 cite placement) — resolved exactly. All four locales now cite (ADR-0104) on the bare-number clause and nowhere else: en "bare number (ADR-0104), such as 10", zh-CN "请直接写数字(ADR-0104),如 10", ja-JP "数値のみ(ADR-0104、例: 10)", es-ES "número sin más (ADR-0104), por ejemplo 10". Changeset quote updated to match. The spec pin still finds ADR-0104 in every locale. No prior must-fix existed.
  • Dev flag: check:dual-build-cjs-loads NOT MEASURED locally — Build Core on this head is success; it hosts that step.
  • Dev flag: ablation — reported 2 failed / 131 passed with the condition ablated at the prior head; the delta does not touch the condition, so it still holds.
  • Check-runs on 0dd08045be: 53 runs, all completed; every conclusion success or skipped, none failure, none pending. Required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core (+8 shards), Build Core, Governed Surface Queue Guard, Build & E2E UI, Type Check UI, Test UI, Live E2E UI all success; Lint UI skipped (paths-filter, no ui/ path in the diff). Also success: PR-body guards, Check Changeset, Spec property liveness, Dogfood Regression Gate, Flag docs affected by code changes.
  • open_questions: none in the os-dev-report. Nothing to escalate.
  • Rework note: the lane posted no separate rework comment; the delta commit message is the claim ("the cite moves next to the bare-number clause in all four locales and in the changeset's quoted sentence"), and the tree confirms it in all five places.
  • Claim identity: claim 6468731037 names branch claude/project-thread-u4sy2p, the PR head ref; no later claim. Triage's contract-review claim 6472472268 names this head.
  • Observation: PR is still draft: true with mergeable_state: clean; the brief's "lands after feat(spec)!: retire the never-consumed CurrencyValueSchema / CurrencyValue export (ADR-0104 D1) #27233" ordering is the card's, unchanged.

④ Design judgment

hits = packages/spec/src/system/validation-message.ts (the published-schema face; record-validator.ts is objectql and is cited, not a hit).

Judged against the ruling it implements (6464202770 Q2 = X), the ADR it cites (0104 D1) and its first consumer (record-validator.ts:1222 via buildFieldError):

  • Holds against the consumer. The key is consumed exactly once, as a messageKey under wire code invalid_number; renderValidationMessage resolves override → locale → en → label (key) and the key exists in all four locales, so step 4 is unreachable. The sentence interpolates only {{label}}, which buildFieldError always supplies; no {{value}} is used, so a large or odd object is never echoed.
  • Holds against the ruling. The sentence delegates the currency to currencyConfig or the tenant default and names none itself, which is X's chain (explicit → tenant default). It does not describe X's "no tenant default" leg (display without symbol), but that is a display rule, not a write rule, and the sentence makes no claim about it.
  • Holds against ADR-0104 D1. D1 is the value-shape contract; currency is in the numeric class and the field.zod.ts @deprecated note on CurrencyValueSchema says the same ("a currency field's value is a BARE NUMBER everywhere ... ADR-0104 D1"). The delta's cite placement puts the ADR on the clause it decides.
  • Attempted breaks: (a) Object.create(null) with value/currency — plain, lesson fires; (b) new Map() / class instance — plain sentence, correct since it is not the retired shape; (c) a plain object with no value key, e.g. { amount: 10 } — lesson fires; the wording "not an object such as { value, currency }" is still true and the remedy (a bare number) is right; (d) { $in: [10] } — refused earlier by the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 gate as invalid_type, never reaches the lesson; (e) a number/percent field with the object — plain sentence (CONTROL pinned); (f) formula/summary — outside isJudgedNumberType; (g) the spec pin's [A-Z]{3} guard — none of the four sentences carries another all-caps trigram, and the ja-JP ADR-0104、例: 10 strip leaves (、例: 10), which matches neither regex; (h) override path — validation.field.invalid_number_currency_object is derived from the key by validationMessageTranslationKey, no second spelling.
  • Findings: no must-fix. No should-fix: the prior S1 is closed, and the en sentence reads as one instruction with its cite on the deciding clause.

Implemented-by: claude/project-thread-u4sy2p
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

This branch has not been deployed

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

Projects

None yet

2 participants