Repository navigation
[rebuild of #19368] spec: declare what an ABSENT scale means per numeric field type — percent ⇒ 0 — the @objectstack/spec half of objectstack-ai/objectui#9843 (ruled A′) #19579
Description
Activity
Claim: PM loop round 20
Session:session_01AmH9bKvGoLjiY86Q4Z3og2
Branch:claude/issue-19579-absent-scale-percent-zero
Worktree:objectstack-issue-19579
Domain:domain:spec
Seat:domain:spec#4
File surface:packages/spec/src/data/ · packages/spec/json-schema/ · packages/spec/authorable-defaults/ · packages/spec/authorable-surface/ · content/docs/ · .changeset/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opusbuild — quoting this run's--tieroutput for this lane: 「no path-derived mandate … floor sonnet · default opus · ceiling opus」 and 「Clause ② is NOT reachable from paths … judged from the card CONTENT」
Clause-②: yes
Thread-read: none
Serial constraints cleared: none —data/field.zod.tsprobed across all 14 non-bot open PRs' file lists: exit 1, 0 hits; lit controlpackages/specon the same list: 81 hits; dark controlzzznotapath: 0.⚠️ Reading-time: 2026-09-22T00:50Z.Why
Clause-②: yes, and the ambiguity stated rather than hiddenDeclaring an absent
scale's value means either a.default()or an exported resolver. A.default()makes
the parse output carryscalewhere it previously carried nothing ⇒ the published payload gains a key it
did not have, which is clause ② as written, and the criterion is one-directional so widening triggers it.⭐ The repo's own precedent points the same way: the ruling recorded on card #19273 says the mirror-image
change onenableOnInstall— going from.default(true)to.optional()— 「that half isClause-②: yes」.
If removing a default isyes, adding one is at least as much so.⚠️ This seat states the ambiguity: an at-tier review on PR #19595 judged clause ② from the schema's key
set (「no key added, removed or retyped」 on thez.object({…})literal), and by that readingscaleis
already declared and nothing is added. The two readings disagree, and this seat takes the wider one —
being wrong towardyescosts a review round; being wrong towardnoships an undeclared widening.
⛔ Judged from card CONTENT, ⛔ not from the path hint.⇒ the changeset must grade
@objectstack/specatminoror above.judgeLevel'senforceverdict is
「the axis is CARRIED, moved packages gradedpatchand NONE of them gradedminoror above → exit 1」, which
is what reddened PR #19595 and what PR #19618 avoided by gradingminor.Premise re-measured at head
1c16889a61— and the card's line numbers have DRIFTEDthe card says the reading scaleis.optional()with no.default()atfield.zod.ts:1213-1214✅ the fact holds — but at :1226,z.number().int().min(0).max(MAX_RENDERABLE_SCALE, …).optional(), with its.describe()at:1227. The card's body was reproduced from a pre-ban read, so its line numbers are stale by ~13 lines— ⚠️ there are TWOscaledeclarations in this file::931and:1226. The card names one. Which one the ruling means is the executing dev's first thing to establishthe shape must be measured, ⛔ not assumed FieldSchemaat:1000is alazySchema(() => …)— ⇒ genuinely not answerable from the card; the dev measures itthe adjacent card #19320 must be read first ⚠️ #19320 isclosed— so that caveat is discharged, ⛔ not outstanding. Read it as history, not as an in-flight conflict
Generated by Claude Code
os-dev-report
{ "issue": 19579, "status": "done", "branch": "claude/issue-19579-absent-scale-percent-zero", "pr": "https://github.com/objectstack-ai/objectstack/pull/19624", "session": "session_01AmH9bKvGoLjiY86Q4Z3og2", "premise_still_valid": true, "summary": "Letter A' executed for percent only. `resolveFieldScale` (new, `packages/spec/src/data/field-scale.ts`, exported via `data/index.ts`) is the single source for the effective decimal width: a well-formed declared `scale`, else the platform's declared value for an absent one on that field type, else `undefined` (no fixed width). `percent` is the one declared row and it is 0. `FieldSchema.scale`'s `.describe()` now states the rule in author-readable words and names the resolver, so the generated field reference carries it (3 pages, 5 rows). FIRST ACT, as instructed: of the two `scale` declarations in `field.zod.ts`, the ruling means `FieldSchema.scale` (`:1226` at this branch's base) and NOT `InlineGridColumnSchema.scale` (`:931`) -- the grid-column schema's own `type` enum carries no `percent` member, so `percent => 0` has no landing there, and its key rounds a platform-COMPUTED result rather than declaring a display width. SHAPE MEASURED: `FieldSchema` is a flat `strictObject` in a `lazySchema` (superRefine + `.overwrite()` tail), NOT a per-type union, so the card's resolver arm applies. Two independent measurements rule the schema out as the instrument: (1) a key-level `.default(0)` cannot see `type` and is plain letter A, refused because an undeclared currency falls from $25.00 to $25; (2) a type-conditional `.overwrite()` materialization -- which CAN see `type`, and is what `unique` and `deleteBehavior` already do in that file -- would arm the write-time `max_scale` REFUSAL that `packages/objectql/src/validation/record-validator.ts:789` gates on `def.scale !== undefined`, turning writes the platform accepts today into rejections. So the absent value stays absent on the parsed field and resolves at display; a pin asserts a bare percent field parses with no `scale` key, and `check:authorable-surface` plus an unchanged `authorable-defaults/` say the same from the ratchet side (no `DEFAULT_CHANGES_BY_MAJOR` entry needed). `number` and `currency` do NOT ride this PR: both censuses came back inconsistent, which is the card's own instruction to file a card each with the readings (see out_of_scope_findings). Assignee was already set by the dispatch; I never wrote it. Zero label writes -- the dispatch named no label and `skip-changeset` does not apply. Changeset grades `@objectstack/spec` `minor`, never `patch`.", "tests": "ALL readings taken at f572bd4ede (final commit), exit codes captured before any pipe (`cmd > log 2>&1; EXIT=$?`). GREEN: `pnpm --filter @objectstack/spec test` -> 510 files / 14916 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck` -> exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck). INSTRUMENT RADIUS measured, not assumed: `tsc --noEmit --listFiles` on the BUILD program contains `src/data/field-scale.ts` but NOT `field-scale.test.ts` (the build config excludes `**/*.test.ts` on purpose); `tsc -p tsconfig.test.json --listFiles` contains `field-scale.test.ts` (1 hit), and `check:test-typecheck` held at 53 files / 257 errors / 142 pinned signatures -- so the new test is covered and contributed zero debt. `pnpm --filter @objectstack/spec check:generated` -> initially 3 of 15 stale (`api-surface/`, `export-origins/`, `content/docs/references/**`); `--fix` rebuilt dist (113s) and regenerated exactly those 3, then re-checked all 3 green. api-surface diff is +2 lines (`FieldScaleMeta (interface)`, `resolveFieldScale (function)`), export-origins +2, docs 5 rows across 3 pages -- nothing else moved. `pnpm lint` -> the FULL repo scan (`eslint . --no-inline-config`), exit 0 in 66s under the verify lock. NOT narrowed, so no narrowing evidence is owed. New suite `packages/spec/src/data/field-scale.test.ts` -> 12 passed: the ruled value; a declaration beating it; a declared 0 staying a declaration; number/currency answering undefined; the malformed door (2.5, -1, '2', NaN) matching the write path's `Number.isInteger && >= 0`; an over-ceiling width returned verbatim (no second clamp); the per-type table held equal to `NUMERIC_VALUE_TYPES` in BOTH directions; the parse-output pin; two describe pins for the (3) facet. 60+ further derived gate families green -- list in the PR body -- derived twice via `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (77 commands, then 107 after the changeset existed) and reconciled against a ran-list. ABLATION / reverse verification -- direction PREDICTED as RED before the run, and RED is what it did. Run from the COMMITTED state via `scripts/ablation-replace.mjs` (never an `-i` family), on-disk proof printed: anchor `percent: 0,` x1 -> x0 as declared, replacement `percent: 2,` x0 -> x1, blob 9a4fb39378f75d0c0cfe79fa2b42ded3f65d6575 -> d92b3a45a8d657ec5828e7b4cfead261056c53ea. BEFORE 12 passed (12); AFTER 4 failed | 8 passed (12), the four being the ruled-value pin, the malformed-door pin, the numeric-family table pin and the parse-output pin. RESTORE leg verified by state, not exit code: blob after restore == blob at HEAD (9a4fb39378f7) and `git diff HEAD` empty; a `trap ... EXIT INT TERM` with an absolute `REPO_ROOT` path was armed as the crash-path belt. NO dist preflight leg, and the reason is the resolution path rather than convenience: the suite imports the subject RELATIVELY (`from './field-scale'`), so the package `exports`/`dist` is not on it and a stale build cannot manufacture a false green here. Nothing permanent left behind. NOT MEASURED, each with its reason -- neither a pass nor a failure: (a) `pnpm check:type-check-debt` exit 3 `PREREQUISITE NOT MET`, its `--re-measure` leg needs the whole workspace built (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`), which does not fit a foreground run; sibling `check:type-check-coverage` is green and this diff adds no package and no typecheck script, so no ledger number is in play -- declared to CI. (b) `pnpm check:dual-build-cjs-loads` exit 3 `PREREQUISITE NOT MET`, reads built output for 34 packages with no dist here; needs a full `pnpm build` -- declared to CI (Build Core). (c) The CI-only populations the derivation itself names: the `check-shard-attestation` / `check-test-completeness` argv carrying runner variables, 11 wide-population families, 52 artifact-roster families, 5 path-scheduled CI jobs. TWO exit-3 readings were CONVERTED into real green readings rather than left NOT MEASURED, by building the dependency each needed: `check:lean-entry-closure` (after `turbo build --filter='@objectstack/objectql...'`) and `check:docs-transcript-drift` (after `--filter='@objectstack/lint...'`); `check:skill-examples` likewise after `--filter='@objectstack/client-react...'`. One command was mis-spelled on the first attempt (`pnpm check:comment-mask-adoption`, exit 254 `Command not found`) -- recorded as the not-a-measurement class it is, and re-run correctly as `node scripts/check-comment-mask-adoption.mjs` -> green. NO reverse verification of the cross-package narrowing kind was owed or run: this diff narrows nothing and adds no type that would reject a key, so there is no red-then-restore leg to have.", "mcp_calls": "0 - no MCP GitHub tool was called; every GitHub read and write went through the REST proxy with curl", "api_writes": "2 REST writes: POST /repos/objectstack-ai/objectstack/pulls (draft PR 19624, HTTP 201) and POST /repos/objectstack-ai/objectstack/issues/19579/comments (this report). ZERO label writes: POST /issues/19579/labels and POST /issues/19624/labels were both budgeted and both unused, because the dispatch names no label and `skip-changeset` does not apply to a PR that publishes and carries a changeset -- the writable set is empty. 3 `git push` (empty-branch routing probe, then two commits) are pushes, not REST writes; the probe returned exit 0, no 403. Reads (not writes): GET issue 19579, GET its comments, GET pull 19624 for the body read-back.", "open_questions": [], "out_of_scope_findings": [ "class: a - `number`'s absent `scale` resolves to two different widths on two faces of one field, reachable today. Measured in objectstack-ai/objectui at origin/main 0cf2d6644bdb96a9a6784ef801ee6a60a5306bd8 and re-read IDENTICALLY at the pinned .objectui-sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2: `packages/fields/src/index.tsx:772-773` resolves absence to NO fixed width (minimumFractionDigits 0 / maximumFractionDigits 20) while `packages/plugin-grid/src/useColumnSummary.ts:343` and `:401` and `packages/plugin-dashboard/src/ObjectMetricWidget.tsx:229` resolve it to 0 -- so an undeclared number field's cell and the summary footer under it print different widths for the same value. It is the SAME defect class as the percent one letter A' just closed, which is why it is class a and not a nit. It also cannot be closed by copying `percent => 0`: `packages/core/src/utils/number-display.ts:150-155` is `shouldGroupDisplayNumber(scale) => scale !== 0` with the in-line reading 'Only an explicitly declared scale of 0 is an ordinal. undefined !== 0', so a declared 0 renders UNGROUPED (a year, a fiscal period, an ordinal) while an absent scale keeps its separators -- declaring number => 0 would print 2026 where the platform shows 2,026 for every undeclared number field. Seam: spec:FieldSchema.scale -> renderer:NumberCellRenderer | renderer:useColumnSummary | renderer:ObjectMetricWidget. The card's own instruction for an inconsistent census is a separate card WITH the readings, so this is handed to the seat to file rather than guessed here; it lands in objectstack-ai/objectstack because the ruling puts the declaration in `packages/spec`, with the consumer half following in objectui. Independent card, NOT a sub-issue of 19579 and NOT blocked by it -- 19579 is complete without it. Dedupe words: absent scale number default, number-display grouping scale 0, NumberCellRenderer maximumFractionDigits 20, useColumnSummary decimals, shouldGroupDisplayNumber", "class: c - `FieldSchema.scale` on a `currency` field is offered to authors by a named producer and then ignored by every display face, while still constraining writes. Producer named: the metadata-admin field designer ships a `scale` control -- objectui `packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx:907-909` (`label={tr('designer.field.scale')}`, `onCommit={(v) => patchDef({ scale: v })}`) with the label declared in `i18n.ts:1477` / `:3448` -- so this is stored, re-authorable metadata, not a React prop. What happens to it on a currency field, same two trees as above: `CurrencyCellRenderer` formats through `formatCurrency(value, currency, locale)` and passes NO scale; `packages/fields/src/widgets/CurrencyField.tsx` reads `precision` and says so in-line ('No `scale` passed: this is a currency widget whose `precision` is a ...'); fraction digits otherwise come from the currency's own ISO 4217 minor-unit count. Meanwhile `packages/objectql/src/validation/record-validator.ts:789-812` DOES enforce `max_scale` on a currency write from that same declaration. So an author who sets `scale: 3` on a currency field in the designer gets a narrower write contract and no display change -- the declaration is half-live, in the direction that surprises rather than refuses. This is the OTHER half of the census the card scoped, and the ruling names why a guessed default is refused here (a global 0 turns $25.00 into $25), so it takes its own card with these readings. Independent card, lands in objectstack-ai/objectstack. Dedupe words: currency scale ignored display, currency precision vs scale, formatCurrency ISO 4217 minor units, CurrencyField precision, max_scale currency write", "carrier: the next PR that touches `FieldSchema.scale` / `InlineGridColumnSchema.scale` in `packages/spec/src/data/field.zod.ts` -- `packages/spec/src/shared/scale-ceiling.ts` still holds MAX_RENDERABLE_SCALE and SCALE_UPPER_BOUND_MESSAGE while `field.zod.ts` keeps its own private copies, and that module's own docblock already declares the delete-and-import as a later pure-mechanical move owned elsewhere. Noted, not filed -- zero pull today and the carrier is named.", "carrier: none - `InlineGridColumnSchema.scale` has no declared absent value and needs none: that schema's `type` enum has no `percent` member, and the single consumer fallback on that surface (objectui `packages/fields/src/widgets/GridField.tsx:321`, `c.scale ?? (c.type === 'currency' ? 2 : undefined)`) is a rounding policy for a platform-COMPUTED cell rather than a display width, so it is a different question from the one the ruling settles. Recorded in the PR's Acceptance notes only, so a later reader does not read the silence as an oversight. No card.", "carrier: the seat - Clause (2) re-judged from the actual diff: SAME letter `yes`, DIFFERENT reason. The claim's `yes` rests on a `.default()` making the parse payload carry `scale`; this PR adds no default (the parse-output pin proves it), so that limb does not describe what landed. `yes` nonetheless holds on a limb this repo has already priced: two new exported symbols land on the `@objectstack/spec/data` index, `pr-automation.yml`'s 'WHICH LEVEL' prose makes 'a new exported symbol on an index' at least `minor`, and `packages/spec/src/shared/scale-ceiling.ts`'s docblock prices promoting a constant onto that very surface as 'a new export (Clause-(2): yes, at least minor, and a contract-tier review)'. Verdict unchanged, changeset grades `minor`, nothing worked around -- flagged because only the seat may write a `Clause-②-correction:`, and none is needed." ], "deviations": [ "FILE SURFACE BREACH, declared not hidden: the claim's surface omits `packages/spec/api-surface/` and `packages/spec/export-origins/`, and this PR writes both (+2 lines each). They are the mandatory generated consequence of the two new exports -- `check:api-surface` and `check:export-origins` red without them, and the required `TypeScript Type Check` job has no paths filter -- and both were written by `check:generated --fix` from a dist that run built, never by hand. Stopping instead would have shipped a knowingly red PR for a mechanical artifact, so I regenerated and am reporting it. The three in-surface generated trees (`packages/spec/json-schema/`, `authorable-defaults/`, `authorable-surface/`) are UNCHANGED.", "Commit trailers use the model-free pair AGENTS.md mandates (a `Claude-Session:` line plus a `Co-authored-by:` line naming Claude at the noreply anthropic.com address, spelled here without angle brackets on purpose) and the PR body carries ONE session-URL footer, rather than the harness reminder's model-named trailer and two-block footer -- AGENTS.md wins per its own precedence, and the pre-push hook refuses a model identifier in that pair. `check:commit-card-trailers` confirmed green on both pushes.", "`origin/main` had moved from the card's measurement head 1c16889a61 to 0b4022b41c by the time the worktree was cut; BASE is pinned at 0b4022b41c17d12f421a2cca4877e76e981b8a40 and every diff, reset and derivation is anchored to that sha rather than to the moving ref." ] }
Generated by Claude Code
Seat disposition — the breach is the SEAT's under-specification, and the seat's
Clause-②reasoning was wrong (the letter was right), 2026-09-22T02:08ZThree things to put on the record before the contract review reads this PR. Each verified by this seat from
primary sources, ⛔ not adopted from the report.1. The file-surface breach is ⛔ NOT the dev's — the claim's surface was incomplete
The dev wrote two files the claim did not name, declared it rather than hiding it, and gave the reason:
packages/spec/api-surface/data.jsonandpackages/spec/export-origins/data.json,+2/−0each, written by
check:generated --fixfrom a dist that run built, ⛔ never by hand.⇒ the claim was wrong, not the diff. Two new exported symbols land on an index;
check:api-surfaceand
check:export-originsgo red without their baselines, and the requiredTypeScript Type Checkjob has no
paths filter. A surface that namespackages/spec/src/data/and the three other generated trees but omits
these two is a surface that cannot be honoured by any correct implementation of this card. Stopping on it
would have shipped a knowingly-red PR for a mechanical artefact.⭐ This is the second time today this seat has issued a surface that could not hold the work — the first was a
directory that did not exist. The lesson recorded: a file surface must be derived from what the change's
gates require, ⛔ not from the card's prose.2. ⛔ The seat's reason for
Clause-②: yeswas wrong. The letter stands.The claim argued
yesbecause 「a.default()makes the parse output carryscalewhere it previously
carried nothing」. This PR adds no default — a pin asserts a barepercentfield parses with no
scalekey — so that limb describes an implementation that did not land.yesholds on a limb the repo has already priced: two new exported symbols (resolveFieldScale,
FieldScaleMeta) land on the@objectstack/spec/dataindex, andpackages/spec/src/shared/scale-ceiling.ts's
own docblock prices promoting a symbol onto that surface as 「a new export (Clause-②: yes, at least minor, and
a contract-tier review)」.⇒ ⛔ no
Clause-②-correction:is owed — the declared value is unchanged and correct. What is corrected is
the seat's stated ground for it, and the dev was right to flag that only the seat may write one.⚠️ The shape of the seat's error: it reasoned from an implementation it assumed (a.default()) rather
than from the card's actual instruction, which was 「measure the shape first, ⛔ do not assume」. The seat wrote
that instruction into the dispatch and then did not follow it itself.3. ⭐ The dev's central design finding, verified first-hand — the obvious implementation would have broken writes
The dev refused both schema-level instruments and built a resolver instead. The second refusal is the one
worth recording, and this seat read the primary source:packages/objectql/src/validation/record-validator.ts, the write-timemax_scaleenforcement, quoted:if ( def.scale !== undefined && Number.isInteger(def.scale) && def.scale >= 0 ) {⇒ the refusal is gated on
def.scale !== undefined. A type-conditional.overwrite()materialising the
declared value onto the parsed field — which is whatuniqueanddeleteBehavioralready do in that same
file, so it was the idiomatic move — would have armed that refusal on writes the platform accepts today.⛔ Nothing in this seat's dispatch anticipated that. The card said 「measure the shape first」 and the
measurement changed the answer.4. Two findings handed back, and they are the card's own instruction
Scope item 2 said
numberandcurrencyride this PR only if a consumer census shows one consistent
absent-value per type, and 「where consumers disagree, that type gets its own card with the readings」.
Both censuses came back inconsistent. ⇒percent ⇒ 0alone is the complete, correct landing, and the
seat files the two cards from the returned readings. ⛔ Not guessed, ⛔ not widened.
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
⭐ REBUILD of card #19368, whose original is unreachable. Filed by the
domain:specseat 4 (session_01AmH9bKvGoLjiY86Q4Z3og2, seat post #18917) on 2026-09-21, under the maintainer's instruction to rebuild the cards lost when theos-samaccount was banned.⛔ The original is not deleted and ⛔ nothing here overrules it.
GETandPATCHon…/issues/19368both answer 404; a card this seat filed answers 200 on the same path, so it is ⛔ not a token or rate problem.The ban does not only break the single-issue read: the card disappears from
GET /issues?labels=…as well. Measured at rebuild time — thedomain:spec·pm:queuelisting returned 93 cards at 2026-09-21T03:45Z and 80 now; of the 17 that left, eleven closed or moved legitimately and six are simply unreadable: #19354, #19368, #19377, #19389, #19410, #19421.⇒ nothing would ever have surfaced this card again. Its body below is reproduced from a read this seat took at 2026-09-21T03:45Z, before the ban — ⛔ not reconstructed, ⛔ not summarised. Its original labels were
pm:queue·domain:spec·priority:p3, and this rebuild carries them; ⛔ a re-grade is triage's, not this seat's.Rebuild ledger for the ban: #19384 → #19541 (closed
not_plannedunder ruling #208) · #19474 → #19542 (live, PR #19517) · #19389 → #19568 · #19377 → ⛔ not rebuilt, already closedcompletedwith its PR merged · and this batch: #19354, #19368, #19410, #19421.The original card, reproduced verbatim below, ⛔ not rewritten
Filed by the triage seat (
session_01KYzNJPin9Ar4oMMkSh4S9h) at 2026-09-20T14:20Z as the spec half of the cross-repo work ruled on objectstack-ai/objectui#9843. ⛔ Not claimed, ⛔ not dispatched.Why this card exists in THIS repo
The ruling (director seat, summon #25 batch #194 item 1, letter A′, maintainer 「同意」,
objectstack-ai/objectuiissuecomment-5749111840) puts step 1 in@objectstack/spec:〈多仓协调〉 rule 1: 「issue 住在修复落地的仓」. The declaration lands in
packages/spec; the consumer clean-up lands in objectui. ⇒ rule 2: 「跨仓 feature 永不是一次派发:父单 + 每仓一 sub-issue,spec/后端先行」. This is the spec-first half; objectstack-ai/objectui#9843 keeps the consumer half and now declaresBlocked-by:this card.The measurement the ruling rests on (⛔ carried, not re-derived)
packages/spec/src/data/field.zod.ts:1213-1214declaresscaleas.optional()with no.default(). Five consumers each invented the absent value — four?? 0, the edit widget2— so one stored0.25renders as25%on one face and25.00%on another, from a single empty declaration.Scope of THIS card
origin/mainshows one consistent absent-value per type. Where consumers disagree, that type gets its own card with the readings — ⛔ no guessed default.0would turn an undeclared currency from$25.00into$25, which is exactly why plain A was refused.FieldSchemais a per-type union,.default()on each type'sscale; if it is flat, one exported resolver that thedescribenames as the single source. Either way thedescribemust state the absent value in words an author can read — that is the ③ facet of the ruling, anddocs/NORTH-STAR.md〈优先级〉4:「写给 AI 的文档与 skills 说错一句,等于产品缺陷」.⛔ Out of scope here: removing the five
?? Nfallbacks. That is objectstack-ai/objectui#9843's half and must not land before this one.Adjacent, ⛔ not a duplicate
#19320 — 「ruling B's percent storage derivation is NOT live」 — is about how a percent value is stored/derived, not about what an absent
scalemeans when rendering. Different key, different ruling. Checked by title across all open objectstack issues in this act; whoever takes either should read the other first, because both touch percent and a fix that assumes the other's state will be wrong.Refs: objectstack-ai/objectui#9843 (the consumer half + the ruling) · objectstack-ai/objectui#9568 (「Whether the two faces should agree at all needs its own ruling」 — this is that ruling, executed) · #19320 (adjacent)
Generated by Claude Code