fix(spec): the defaultValue literal gate prescribes the key rename, not a missing-member type error - #16409
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…s issue over a positional read Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f646729a597bbefdac91b9eadf8ab89e01230b22 && git checkout f646729a597bbefdac91b9eadf8ab89e01230b22
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf45e62cef121b50926eb837fa27e2f9956eb89c 414cc30592804c922a1911a2a4b3689a0eebdd20 && git checkout -B drift-repro bf45e62cef121b50926eb837fa27e2f9956eb89c && git merge --no-ff 414cc30592804c922a1911a2a4b3689a0eebdd20
node scripts/docs-audit/affected-docs.mjs --json bf45e62cef121b50926eb837fa27e2f9956eb89c
|
…e-preference Brings the branch current with main (dff0bdd) and re-fires CI. main's changes since the merge base touch no file under packages/spec, so this merge carries no content decision for this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…eral-rename-preference Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
|
os-dev-report Branch refresh only — no content change.
No PR body, label, draft-state or auto-merge change was made, and the Generated by Claude Code |
Fixes #16077
checkLiteralDefaultValue— the shared core of theFieldSchema.defaultValuegate (#7127) and theActionParamSchema.defaultValuegate (#6970) — read a value-contract rejection positionally,result.error.issues[0]. zod reports per-member issues before the object-levelunrecognized_keysone, so when an author RENAMED a key the message naming the rename sorted last and was discarded, and the author was handed a missing-member type error about a member they never wrote.The gate now prefers the
unrecognized_keysissue when the rejection carries one — the same shape #15490 landed on the stored-value path, implemented separately here becausepackages/specis upstream ofobjectqland cannot import that helper.Reproduced on current
mainbefore touching anythingThe card's three reproductions were measured at
c463d03e0. Re-measured onorigin/mainatde75e407e5rather than inherited — all three still hold, byte-identical to the card:detailbefore{type:'location'}, {latitude:1, longitude:2}Invalid input: expected number, received undefined`latitude` -> `lat`,`longitude` -> `lng`{type:'address'}, {street:5, postal_code:'98101'}Invalid input: expected string, received number`postal_code` -> `postalCode`{type:'address'}, {postal_code:'98101'}The third row is the point: which diagnosis an author got depended on whether some unrelated member happened to also be wrong. That asymmetry is what this removes.
Prerequisite 1 — the 16-class sweep, re-run ON THIS FUNCTION
#15490's sweep was taken on the
record-validator.tspath; that it carries over here was an inference. Measured here instead, over every classvalueSchemaFor(def, 'stored')covers, at BOTH arities ({type}and{type, multiple: true}), probing each with object literals carrying undeclared keys:unrecognized_keys?STRING_VALUE_TYPEStextNUMERIC_VALUE_TYPESnumberBOOLEAN_VALUE_TYPESbooleanCALENDAR_DATE_TYPESdateINSTANT_TYPESdatetimeCLOCK_TIME_TYPEStimeselectREFERENCE_VALUE_TYPESlookupFILE_REFERENCE_TYPESfilelocationlocationaddressaddresscompositecompositerecordrecordrepeaterrepeatervectorvectorz.unknown())json2 of 16 —
locationandaddress, the only two backed by astrictObject. The preference is a no-op for the other fourteen, measured on this function rather than carried over. A pin holding that no-op ships with the change.Prerequisite 2 — callers and pins on the current
detailtextCallers of
checkLiteralDefaultValue, fromgrep -rn "checkLiteralDefaultValue"over the whole tree excludingnode_modulesanddist:packages/spec/src/ui/action.zod.ts:500— composesverdict.detailinto the action-param refusal.packages/spec/src/ui/action.zod.ts:510— arity retry; reads.okonly, never.detail.packages/spec/src/data/field.zod.ts:2070— composesverdict.detailinto the field refusal.packages/spec/src/data/default-value-shape.test.ts— the unit test.packages/spec. Checkedobjectuiat51a402f: 0 hits.Assertions pinning the current text: none, and the searches that returned that zero were lit first.
default-value-shape.test.ts:98pinsdetailfor adatetime(toContain('ISO-8601 instant')) — class 5, whereunrecognized_keysis unreachable, so unmoved.field-default-value.test.tsalready carried anaddressand alocationrejection row, but both are the LUCKY half: their other members are well-typed, so nothing sorted ahead and the rename surfaced by accident. Both still pass.grep -rn -F "ISO-8601 instant"over*.ts/*.tsxreturns 16 hits includingdefault-value-shape.test.ts:98— the search reaches the test tree and can return a hit.grep -rn -F "Unrecognized key(s) on this address value"over*.ts,*.tsx,*.md,*.mdx,*.json,*.snapreturns 1 hit — the search reaches those file types.grep -rln "LocationField"returns 17 files there.One trap worth recording: the first pass searched for the composed phrase
cannot satisfy this field's own stored value contractand got zero — because the producer splits it across template-literal concatenation lines, so it exists on no single line. Zero from a search whose control had not been lit is not a reading; the shorter single-line-safe phrases above are.Clause 2 — I read it as NO, same as the dispatch
The mechanical floor in
references/contract-review.mdis a new exported symbol, or a new key on a published payload. This diff has neither:actionableValueIssueMessageis module-local, not exported.LiteralDefaultValueVerdictkeeps exactly{ ok, detail? }— no new key, no type change.check:api-surfaceexit 0 andcheck:export-originsexit 0 ("5277 exports across 17 entry points resolve exactly as recorded"), withgit statusclean after the build, so neither ledger moved.What moves is which of several ALREADY-reachable messages
detailcarries. The declared meaning — "the 'why' a refusal carries verbatim" — does not move; the implementation is brought into line with it. That is the triage seat's own 声明 ≠ 交付 framing. No verdict moves either: exactly the same defaults are accepted and refused.The one doc edit is the incidental half of the same TSDoc line, "First issue message", which described the mechanism that was wrong. The load-bearing half is kept verbatim.
Tests
Six new pins, each asserting BOTH halves — the prescription is present AND the half that was shown instead is gone. Without the second half a pin cannot see a regression back to the positional read.
default-value-shape.test.ts— the core: missing-member case (location), wrong-typed-member case (address), the asymmetry-is-gone pin holding the lucky and unlucky cases equal, and the no-op pin for a class that cannot emitunrecognized_keys.field-default-value.test.ts— two rows on the existing case table, which grew anotContainscolumn.action-param-default-value.test.ts— one pin, because that consumer composesdetailinto its own message: a core fix that never reached the composed text would be invisible to a core-only pin.Ablation. From the committed implementation, the call site was mutated back to the positional read, proven on disk (anchor counts 1 to 0 for the new spelling and 0 to 1 for the old; blob hash differs from the
HEADblob), the three files re-run, then restored viagit checkout HEAD -- pathand the restore proven by blob-hash equality plus an emptygit diff HEAD. Result: exactly the 6 new pins fail, the other 93 in those files pass. Direction: turned red, as predicted. No build was needed — the pins import the subject by relative path within its own package, so vitest readssrc/, notdist/.Verification
Every verdict below is the gate's own printed line with its exit code captured before any pipe.
pnpm --filter @objectstack/spec test— exit 0.Test Files 481 passed | 1 skipped (482),Tests 13107 passed | 1 skipped (13108).pnpm --filter @objectstack/spec typecheck— exit 0, all three legs (tsc --noEmit,check:scripts-typecheck,check:test-typecheck). The test layer is MEASURED, not assumed:tsc -p tsconfig.test.json --listFilesputs all four changed files in a 2270-file program, and the debt ledger held at54 file(s) / 261 error(s) / 145 pinned signature(s)unchanged.scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackrather than from the dispatch list: 73 families. 71 ran green.pnpm lint(eslint . --no-inline-config, the whole repo — no narrowing) — exit 0 at395a57c3d.pnpm check:nul-bytesexit 0.NOT MEASURED locally — 2 of 73, handed to CI, reconciled with
dispatch-gates.mjs --ranwhich named exactly these two:pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET, needs all 79 packages built.pnpm check:type-check-debt— exit 3,PREREQUISITE NOT MET, needs the full workspace closure built.Both print "This is NOT a pass and NOT a finding: nothing was measured." A whole-workspace build is the CI farm's run, not a foreground one. Reported as unmeasured, not as green.
Six gates first returned exit 1 or 3 purely because
packages/specwas unbuilt (check:api-surface,check:dual-source-exports,check:browser-reachable-entries,check:entry-nameability,check:exported-any,check:doc-formula-expressions). Those arePREREQUISITE NOT MET, not findings; afterturbo run build --filter=@objectstack/spec --filter=@objectstack/formula --filter=@objectstack/lintall six re-ran green, and those greens are what the 71 counts.Scope
The stored-value scan (
record-validator.ts, #15490) is deliberately untouched — a different surface with a different audience, as the card and the triage seat both drew it. No adjacent defects were found to file.⛔ Draft on purpose. Not for ready, not for the queue — the PM's call after review.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Generated by Claude Code