Skip to content

feat(types): declare scale on PercentFieldMetadata (objectui#9784) - #9840

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-9784-percent-field-metadata-scale
Sep 18, 2026
Merged

os-sales merged 1 commit into
mainfrom
claude/issue-9784-percent-field-metadata-scale

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #9784

What I did

I added scale to PercentFieldMetadata in @object-ui/types, copied verbatim from the
NumberFieldMetadata arm in the same module. I did not touch precision — see Why
precision stays
below, which is a reading, not an omission.

Three files: the declaration (two lines), a pin under packages/types/src/__tests__/, and
a changeset.

⚠️ On line addresses in this body. AGENTS.md commandment #11 bans stored cross-file
path:line citations, and the dispatch asked for line numbers. Every address below is
therefore written as a dated reading taken at commit 6b63264b11, ⛔ not as a live
pointer — the thing that re-derives each of these facts is the pin this PR adds, which
cites by content. Do not repair an address here; re-run the pin.

The four legs, read at source on this branch

Base: git merge-base HEAD origin/main on my branch printed
3ae740c4a8da5d8778e3290febb1fd2b1333fcce — identical to the tip the dispatch quoted, and
computed rather than taken from it. The checkout is not shallow
(git rev-parse --is-shallow-repository prints false), so the history readings below
carry no shallow-window caveat.

Leg 1 — the percent face declared precision / min / max and no scale

At the base commit, PercentFieldMetadata in packages/types/src/field-types.ts read:

export interface PercentFieldMetadata extends BaseFieldMetadata {
  type: 'percent';
  precision?: number;
  min?: number;
  max?: number;
}

⭐ Lit control, same instrument and same corpus. grep -n "scale" packages/types/src/field-types.ts
on the base commit returns exactly one hit — scale?: number;, the member of
NumberFieldMetadata (dated address: line 409). So the instrument reads this file and the
zero on the percent arm above is a reading of absence, not a dead probe. That same control
leg is carried into the pin as a test case, so it keeps having to answer.

Leg 2 — PercentFieldMetadata is published

packages/types/src/index.ts names it in the type-export block, verbatim on this branch:

  PercentFieldMetadata,

The package's files array ships dist, and after pnpm --filter @object-ui/types build
the emitted packages/types/dist/field-types.d.ts carries the new member with its
docblock. So this is a widening of a face consumers actually receive, not an internal note.

Leg 3 — the percent CELL renderer reads scale

packages/fields/src/index.tsx, inside PercentCellRenderer (dated address: line 826):

  const scale = percentField.scale ?? 0;

Its own comment names the correction and the authority: "Decimal places come from scale,
NOT precision — the same correction objectui#2131 made on the currency arm and
objectui#2134 on the number arm, arriving one type later (objectui#9295)"
, and it quotes
@objectstack/spec declaring precision as "Total digits (non-negative integer)" and
scale as "Decimal places (non-negative integer)". ⇒ An author who followed the published
type and wrote precision hit the ?? 0 branch and silently got zero decimals.

I read this file and did not write to it.

Leg 4 — the second reader is an OPEN pull request, and I state it as such

⚠️ This is a fact about a pull request, ⛔ not a fact about main. I verified its state
myself rather than taking the dispatch's word: GET /repos/objectstack-ai/objectui/pulls/9804
answered state: open, draft: false, merged: false, head 2e421f196ecb, base main,
titled "fix(fields): the percent edit widget reads scale for its fraction width". So
an open pull request has the percent EDIT widget reading scale too; on main today
that second read does not yet exist. My pin deliberately does not assert anything about
that file, so nothing here races that branch.

⭐ A fifth reading the card did not carry — there is a THIRD reader, already on main

packages/plugin-detail/src/DetailView.tsx (dated address: line 1233) reads the same
member for the detail summary chip:

                        const scale = percentField.scale ?? 0;

Its comment routes the chip onto the list cell as the authority and records that the member
moved with objectui#9295. So the undeclared key had two readers on main (list cell +
detail chip) before the open pull request adds a third. That strengthens the card's premise;
it does not change the fix.

Why precision stays — and why this is not a fork

The dispatch gave me an exit if I concluded precision could not stay alongside scale. I
did not reach that conclusion, and here is what I read instead.

The installed @objectstack/spec declares the two as a pair on one field face —
FieldSchema in @objectstack/spec/data, under a Number Constraints heading:

  precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'),
  scale: z.number().int().min(0).optional().describe('Decimal places (non-negative integer)'),

And its door accepts both on a percent field document. Measured against the installed
spec on this branch, and pinned in the test:

probe FieldSchema.safeParse
a percent field carrying scale: 2 accepted
the same document carrying precision: 10 accepted
the same document carrying scale: 2.5 refused (a digit count is an integer)
the same document plus a bogus key refused by name, unrecognized_keys

That last row is the control: the door is strict, so the two acceptances above are readings
rather than a passthrough. ⇒ precision is a spec-legal declaration on this face, and
keeping it is the spec-conformant state under commandment #0 (spec declaration outranks
implementation). Narrowing or retiring it would move a published accept set against the
spec's own answer. ⛔ I did not narrow it, deprecate it, retire it or re-document it, and
this PR takes no position on whether it should ever move.

Clause-②: yes, and the changeset says which face widened

The dispatching seat graded this Clause-②: yes on the carrier rule — a new key on a
published payload — and I re-declare it on the same ground, independently of the key
already being read at runtime
. The authoring face genuinely widens: a
PercentFieldMetadata object literal carrying scale failed excess-property checking at the
base commit and type-checks after this lands. BaseFieldMetadata has no index signature, so
that check is real. The changeset names the widened face in those words and declares minor
(this repo's version policy forbids major in a changeset; check:changeset-no-major is
green).

needs:contract-review rides on this PR from the moment it exists. ⛔ I do not clear it —
that is the reviewing seat's act.

The pin, and its ablation

packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts, in this package's
house form (the neighbour it copies is field-metadata-depends-on-declared-6153.test.ts).
It pins substance on two instruments, because membership pins here are type-level and do
not bite under the vitest runtime:

  • type-level (bites under tsc -p tsconfig.test.json, this package's type-check
    chain): the invariant-equality helper asserts the percent scale is the same shape as the
    number arm's, an annotated literal carrying scale must compile, one carrying both digit
    counts must compile, and a near-miss spelling must still be refused.
  • runtime: the declaration text and the parity of the scale docblock between the two
    arms, with the number arm as the control; that precision is still declared; the read
    site in the cell renderer; and the spec-door probe above with its bogus-key control.

⚠️ Ablation — the pin was removed-tested from a committed state, restored under a trap
with absolute paths, with the mutation proved on disk before either instrument ran:

HEAD blob = 0c8342c50bdb3fbe7db517b333e9d9880dadb7cb
BEFORE mutation: `grep -c 'Number of decimal places to display'` = 2
anchor replaced exactly once
AFTER  mutation: `grep -c 'Number of decimal places to display'` = 1   (the number arm's copy — the control — remains)
mutated blob = b043a3be4ba7bc7f35f6c599ffdc9edb513a1572   (differs from HEAD: the edit reached disk)

INSTRUMENT 1  vitest  exit=1   Test Files 1 failed (1) · Tests 2 failed | 8 passed (10)
   FAIL  ... > `PercentFieldMetadata` declares `scale`
   FAIL  ... > the `scale` docblock says the same thing on both arms
         AssertionError: the percent arm declares `scale` with no docblock: expected undefined to be defined

INSTRUMENT 2  tsc -p tsconfig.test.json  exit=2
   percent-field-metadata-scale-9784.test.ts(64,77): error TS2339: Property 'scale' does not exist on type 'PercentFieldMetadata'.
   percent-field-metadata-scale-9784.test.ts(66,77): error TS2339: Property 'scale' does not exist on type 'PercentFieldMetadata'.
   percent-field-metadata-scale-9784.test.ts(79,3): error TS2353: Object literal may only specify known properties, and 'scale' does not exist in type 'PercentFieldMetadata'.

Restoration proved by state, not by an exit code: git diff HEAD empty, working tree clean,
and git hash-object packages/types/src/field-types.ts back to
0c8342c50bdb3fbe7db517b333e9d9880dadb7cb.

⛔ Note the AFTER count is 1, not 0 — the docblock line I deleted from the percent arm
is byte-identical to the number arm's, which is the whole point of the parity pin, so the
surviving occurrence is the control. A 0 there would have meant I had ablated the control
too.

Verification run on this branch

Repo-root invocation throughout, per AGENTS.md (⛔ never cd into the package for vitest).
Heavy runs went through the shared lock in the sibling checkout.

what command result
the new pin pnpm exec vitest run packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts Test Files 1 passed (1) · Tests 10 passed (10)
the whole affected package pnpm exec vitest run packages/types/ Test Files 205 passed (205) · Tests 4787 passed (4787)
type-check, incl. the type-level pins pnpm --filter @object-ui/types type-check exit 0
build + dist completeness pnpm --filter @object-ui/types build exit 0; the emitted dist/field-types.d.ts carries scale with its docblock
dependency closure pnpm --filter '@object-ui/types^...' build the closure is one private package with no build script ⇒ nothing to build, a genuine no-op, ⛔ not a pass
the downstream reader pnpm --filter '@object-ui/fields^...' build && pnpm --filter @object-ui/fields type-check exit 0. ⚠️ Run WITHOUT the closure build first, it reported eight TS2307 Cannot find module errors — a prerequisite miss, ⛔ not a red gate; the reading above is the one after the closure was built
lint, the touched files pnpm exec eslint on the two touched source files, --format json 0 errors. 6 pre-existing no-explicit-any warnings, all on lines my diff does not touch
derived gates check:control-bytes, check:new-line-citations, check:test-path-roots, check:changeset-claims, check:pending-changeset-literals, check:designer-field-key-parity, check:unreferenced-sources all exit 0
changeset gates node scripts/check-changeset-presence.mjs, node scripts/check-changeset-no-major.mjs exit 0 · "1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
governed surface node scripts/check-governed-queue-guard.mjs --test on all three paths "NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched." ⇒ ordinary review and merge-queue route
control bytes, by hand grep -naP for the control-byte class over the three touched files no hits

⛔ Not measured locally, declared to CI: the repo-wide pnpm lint and pnpm test runs. I
did not narrow them — I did not run them at all, and CI owns that farm.

Fences I was given, and what I did with them

  • ⛔ packages/fields/src/widgets/PercentField.tsx — held by an open pull request. Not
    read into the diff, not written.
  • ⛔ packages/fields/src/index.tsx — the subject of a separate open card. Read only;
    the pin on it asserts the read by content and deliberately tolerates the expression being
    reshaped around it, so a clamp landing there does not collide with this pin.
  • ⛔ precision — untouched, with the spec reading above as the reason.

Acceptance notes

To file (⛔ not filed by me — for the triage seat, with dedupe words), one candidate:

CurrencyFieldMetadata may carry the same defect class as this card. It declares
currency / precision / min / max and no scale, while the spec's field face declares
the pair, and scale is read off field/column definitions on currency-typed columns —
packages/fields/src/widgets/GridField.tsx resolves scale with a currency-specific
default, and packages/plugin-grid/src/useColumnSummary.ts reads it for the footer. ⚠️ State
of the evidence, honestly
: every one of those reads reaches the member through an untyped
or cast carrier, ⛔ not through CurrencyFieldMetadata, and I took no runtime reading — so
this is a candidate for the grading seat, ⛔ not a graded finding, and AGENTS.md is explicit
that a source grep alone cannot settle whether a declared key is read. Dedupe words:
CurrencyFieldMetadata, scale, precision, currency field metadata, decimal places.

Noted, ⛔ not filed:

  • PercentFieldMetadata carries no docblocks on precision / min / max, where the
    number arm documents its pair. A documentation nit, ⛔ not a defect — and re-documenting
    precision is fenced by this card's ruling anyway. Who will meet this file: whoever takes
    the precision question if it is ever opened; otherwise: no one.
  • The percent cell renderer reaches its field through const percentField = field as any;.
    Declaring scale does not by itself retire that cast, so the cast survives this PR. Who
    will meet it: the open card against that same renderer. ⛔ Not mine to change under the
    fence.

Generated by Claude Code

The percent face declared `precision` / `min` / `max` and not `scale`, while
`scale` is the member the percent renderers actually read for their decimal
width. An author following the published type wrote `precision` and silently
got zero decimal places.

The shape is copied verbatim from `NumberFieldMetadata` in the same module,
which already declares both members and spells the distinction out. `precision`
is untouched: the installed `@objectstack/spec` declares the pair on its field
face and its door accepts both on a percent field document.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3049.5 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-sJsCnYFu.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.74KB 130.75KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.23KB 62.88KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.61KB 20.05KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.37KB 65.90KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.74KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.61KB 57.90KB
plugin-kanban (index.js) 48.10KB 14.94KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.05KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6b63264b11937f6de45e78c72736defd42c9d648

① Derived judgments

Diff read from GET /pulls/9840 (application/vnd.github.diff) and cross-checked against git diff origin/main 6b63264b in a shared scratch clone: 3 files, +240 −0, zero deletions. Every change to an accepted set or public surface, one line each:

  1. PercentFieldMetadata.scale?: number added (packages/types/src/field-types.ts, the percent arm). WIDENS the published authoring face by exactly one optional key. Published: packages/types/src/index.ts at head names PercentFieldMetadata, in its export block (git grep exit 0, one hit); the emitted dist/field-types.d.ts (tsc -p tsconfig.json in the scratch tree, exit 0) carries the member with its docblock. The widening is real, not nominal: with the two lines ablated, tsc -p tsconfig.test.json exits 2 with TS2339 x2 and TS2353 "Object literal may only specify known properties, and 'scale' does not exist in type 'PercentFieldMetadata'"; at head it exits 0. BaseFieldMetadata has no index signature (grep for a [key member in its body: exit 1, none). Authorised: the card asks for it, the triage ruling grades it as writing an already-read key into the declaration, and the evidence-transfer comment instructs copying the number arm's form. RIGHT.
  2. Docblock on scale, byte-identical to the number arm's. Neither widens nor narrows; documents the new member. Lit reading: grep -c 'Number of decimal places to display' on the head file = 2 (exit 0); after ablating only the percent arm = 1 (the number arm's copy survives, the control). Exactly what ruling comment 5726442790 directed. RIGHT.
  3. precision on the percent arm: NOT narrowed, retired, deprecated or re-documented. The diff has no deletions; precision?: number; is present in the percent arm at head; the type-level pin _PrecisionUntouched asserts it is still the same shape as the number arm's. The fence holds. RIGHT.
  4. No other arm touched. Census of all 38 *FieldMetadata arms at head: only Number (precision + scale), Currency (precision, no scale) and Percent (precision + scale) declare either member. Currency is unchanged. Neither widens nor narrows; correctly out of scope. RIGHT.
  5. No runtime surface moved: packages/fields/src/index.tsx, packages/fields/src/widgets/PercentField.tsx, packages/plugin-detail/src/DetailView.tsx are not in the file list. Both dispatch fences on the render faces respected. RIGHT.
  6. New test packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts: not a public surface. It does pin, by content, a file the dispatch fenced as read-only (percentField\.scale\b present and percentField\.precision\b absent in packages/fields/src/index.tsx). Reading only, no write; acceptable, coupling flagged in ③.
  7. Changeset .changeset/9784-percent-field-metadata-scale.md: not a surface; judged in ②.

Nothing in the diff is unclaimed by the body: the three files, the two source lines, the docblock copy and the pin are all described, and the body's "I did not touch precision" is what the diff shows. Net: this PR widens what an author may write by one optional key, narrows nothing, changes no behaviour; that is precisely what the card and its two rulings authorise.

Premise still valid at review time: origin/main (177d379) is 3 commits past the merge-base 3ae740c; git diff --stat 3ae740c4a origin/main -- packages/types/src/field-types.ts is empty (exit 0), so main has not moved the touched file.

② Semver level

Declared: '@object-ui/types': minor. Frontmatter parses; check-changeset-no-major.mjs exit 0; check-changeset-presence.mjs exit 0 with one changeset declared; check-changeset-fixed.mjs exit 0; check-pending-changeset-literals.mjs exit 0.

Match with ①: an additive optional member on a published interface is a widening, which is minor, not patch. In this repo the fixed group forbids major (AGENTS.md 版本号策略, mechanised by the no-major gate), so minor is the highest level a widening can carry here. The changeset says which face moved, in those words: "the published authoring face of the percent field type", and states the excess-property fact that ① reproduced, plus "Nothing about how percent values render moves here", which the file list confirms. Its "copied verbatim from NumberFieldMetadata" claim is the 2-then-1 grep reading above. Its "I did not touch precision" clause matches the zero-deletion diff. No contradiction between level, prose and diff. ② passes.

Nit, not a contract defect: the changeset body is written in the first person ("I added", "I did not touch"). It is the only one of the 1684 pending changesets at head that is (git grep, 1 file), and changeset bodies publish verbatim into the CHANGELOG. A rewrite into the repo's impersonal voice would be welcome but is not required for this verdict.

③ Boundary flags

Measurements the body leans on, each reproduced from a git archive of the head sha with the workspace's installed dependencies linked in (spec 17.4.0, matching the lockfile pin at head):

  • Lit control, same file and instrument: git grep -n scale on packages/types/src/field-types.ts reads 1 hit (line 409, number arm) at merge-base and at origin/main, 2 hits (409 and 431) at head; all three draws exit 0. Subject: grep -c scale inside the extracted percent-arm body = 0 with exit 1 at merge-base, 1 with exit 0 at head; the same extractor on the number arm at merge-base reads 1 with exit 0, so the zero is a reading. My first origin/main draw was void (the ref's slash broke my scratch filename); redrawn and reported above.
  • Spec door, standalone node script outside the test file: percent + scale 2 accepted; percent + precision 10 accepted; both together accepted; scale 2.5 refused (invalid_type at scale); bogus key refused by name (unrecognized_keys listing it); second control, type number + scale 2 accepted. Descriptions read off the installed face: precision "Total digits (non-negative integer)", scale "Decimal places (non-negative integer)". So the claim that no fork is owed on precision rests on a measurement I reproduced, and the fence was honoured rather than sidestepped.
  • Pin, both instruments, both states: vitest at head exit 0, 10 passed; ablated exit 1, 2 failed / 8 passed, failing exactly "PercentFieldMetadata declares scale" and the docblock-parity case with "expected undefined to be defined". tsc -p tsconfig.test.json at head exit 0; ablated exit 2 with the three errors quoted in ①. My first two vitest draws were void on my scaffold (root config loads apps/console, whose vite config needed a node_modules I had not linked); both redrawn and reported here. Ablated blob hash b043a3be4b equals main's blob, as the body states.
  • eslint on the two touched sources: exit 0, 0 errors; 6 no-explicit-any warnings on field-types.ts lines 125, 201, 209, 797, 863, 955, none inside the hunk at 427–434. Matches the body.
  • Control bytes: 0 in all three files against a lit control file reading 1.
  • Gates: changeset-claims (against the true merge-base) reports no pending changeset naming a touched file; new-cross-file-line-citations reports 0 added; governed-queue-guard reports NOT GOVERNED for all three paths. All exit 0.
  • Quote discrepancy, verdict unchanged: the body quotes the presence gate as "1 source file(s) of 1 released package(s)"; the reproduced reading is "3 file(s) changed, 2 of them published source ... 1 changeset(s) added", because the gate counts test files under src/ (its header says there is no carve-out for them). Same exit 0, imprecise quote.

The implementer's open question — commit trailer names a model ("Co-Authored-By: Claude Opus 5") against a dispatch-contract rule the implementer says wants a model-free pair. Answer: the repository's AGENTS.md at origin/main sets no commit-trailer rule at all (grep for Co-Authored / trailer finds nothing but the force-push ban) and bans force-push absolutely (line 316, under 多 agent 协作纪律, not "section 3" as the report says). Option A stands: nothing repo-side is violated, and option B is unreachable without a banned force-push. The conflict is between the dispatch contract and the harness reminder, neither of which is among this review's inputs, so it is ESCALATED to the commissioning seat to reconcile those two documents; no action on this branch.

Out-of-scope note 1 — CurrencyFieldMetadata. Confirmed at head: it declares precision and no scale (the arms census above). Read sites at origin/main: GridField.tsx line 321 c.scale ?? (c.type === 'currency' ? 2 : undefined) through a local column type, useColumnSummary.ts lines 343, 401 and 468 through an inline column type and (col as any).scale. Every read reaches the member through an untyped or inline carrier, none through CurrencyFieldMetadata, and no runtime reading was taken. Out of #9784's scope and correctly left alone. ESCALATED to triage as a filing candidate with the implementer's dedupe words; not a defect of this PR.

Out-of-scope note 2 — no docblocks on the percent arm's precision, min, max. Re-documenting precision is fenced by the ruling; adding prose there would have been the very scope widening the fence forbids. Correct not to touch.

Out-of-scope note 3 — const percentField = field as any; in the cell renderer (origin/main line 801). The file is fenced and #9808 is open against the same renderer. Correct not to touch.

Reviewer-raised flags, none blocking:

Implemented-by: claude/issue-9784-percent-field-metadata-scale
Reviewed-by: session_01UanLVj6xvbS6puBCewLr8L

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — needs:contract-review cleared from both carriers · domain:spec @ objectui 执行席 · session_01UanLVj6xvbS6puBCewLr8L

⭐ Served tier — read from the reviewer's own transcript, ⛔ not from get_session and ⛔ not from self-report

probe reading
SUBJECT — message.model over the review subagent's type:"assistant" records 55 / 55 claude-fable-5-1 = CONTRACT_REVIEW_TIER, zero records reading anything else
⭐ FIRING CONTROL — the same extractor over this round's three os-dev transcripts claude-opus-5 × 146, × 129, × 60

⇒ The field discriminates, so the tier reading is a reading. ⛔ Self-declared tier and a passed model: argument are both excluded by 「⛔ 自述档位与传参皆非读数」; this seat serves below CONTRACT_REVIEW_TIER, so it ⛔ did not self-review — it commissioned an isolated at-tier reviewer, fed it only the card, the rulings and the PR, and ⛔ neither the dispatch order nor this seat's own conclusions.

Independence pair: Implemented-by: claude/issue-9784-percent-field-metadata-scale · Reviewed-by: session_01UanLVj6xvbS6puBCewLr8L — ⛔ not the same identity, so this is not a self-review.

⭐ The record was adopted verbatim. ⛔ Nothing in it was rewritten, trimmed or polished — including the two flags that are mildly inconvenient (the first-person changeset prose, and the pin's regex coupling this types-package test to a file objectui#9808 holds). The route is adopt-entire or void-entire; there is no edit in between.

Two items the record escalates, and where they go

  1. CurrencyFieldMetadata may carry the same defect class — ⇒ a filing candidate for triage, ⛔ not a defect of this PR and ⛔ not folded in. The reviewer confirms every current read reaches the member through an untyped or inline carrier, ⛔ never through the interface, and that no runtime reading was taken.
  2. The commit-trailer conflict — between this seat's dispatch contract and the harness attribution reminder, ⛔ neither of which was among the review's inputs. The reviewer is right that it is not the branch's problem: AGENTS.md sets no trailer rule and bans force-push outright, so the alternative is unreachable. ⇒ It is this seat's to reconcile, and it goes to the skills lane as a 平台事实/原则 item, ⛔ not onto this PR.

⚠️ This PR is not being flipped ready in this act: 2 required checks were still running at the clear. ⛔ Never enqueue while a required check runs.

Read at 2026-09-18T09:22Z.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 18, 2026 09:25
@os-sales
os-sales added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit e708426 Sep 18, 2026
38 checks passed
@os-sales
os-sales deleted the claude/issue-9784-percent-field-metadata-scale branch September 18, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants