Repository navigation
spec 17.0.0 GA made the element props schemas strict — two spec-parity tests still encode rc.6 strip-mode as their control #4910
Description
Found while re-pointing PR #4639 (the @objectstack/spec GA pin bump, #4636). Filed
unassigned; not fixed on that branch, because the pin-bump card's file surface is the
package.json range lines plus the lockfile, and this is a real GA behaviour change rather
than a mirror of those lines.
What changed at GA
@objectstack/spec@17.0.0 flipped the element:* props schemas from strip mode to
strict. An undeclared key used to parse green and simply vanish from data; it now
fails with unrecognized_keys. The spec's own diagnostic states the intent:
Unrecognized key(s) on this `element:record_picker`: `notASpecKey`. Until #4001 batch A an
undeclared prop was dropped in silence: the props schema stripped it and
`PageComponent.properties` is an open bag, so the key reached objectui's renderer, was not
read there, and the author got a success receipt for configuration that did nothing.
Measured directly against the installed GA package:
ElementRecordPickerPropsSchema.safeParse({ object: 'account', notASpecKey: 1 })
-> success: false, issues[0].code === 'unrecognized_keys'
This is the change working as designed. The problem is only that two of our tests pin the
old behaviour as their deliberate contrast probe.
The two failing tests
Both fail on the same assertion shape — the contrast half of an otherwise-passing test.
Their subject claims (the key is declared, published, and survives the parse) all pass.
| file | failing assertion |
|---|---|
packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts:101 |
expect(undeclared.success).toBe(true) |
packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts:83 |
expect(undeclared.success).toBe(true) |
Each sits under a comment that states the now-falsified premise verbatim, e.g.
// This props schema is a strip-mode `z.object`, so an UNDECLARED key parses green too and
// is simply absent from `data` afterwards; asserting `success` alone would prove nothing.
The decision this needs
The probe exists to keep the surrounding key-reachability claim non-vacuous — it shows what
an unpublished key looks like, so "the key survives the parse" means something. Under
strict mode that contrast has to be re-expressed. The candidate shapes:
- A — assert the rejection. Replace
success === truewith an assertion that the
undeclared key now yieldssuccess === falseandissues[0].code === 'unrecognized_keys'.
Keeps the contrast (declared key survives / undeclared key is refused by name), and upgrades
it: the probe now pins the stronger GA guarantee rather than the silent-strip behaviour the
spec deliberately retired. - B — drop the probe. Smallest diff, but it removes the non-vacuity guard the test's own
comments say is load-bearing, and the remaining assertions would then pass against a schema
that had stopped declaring anything.
A looks clearly right, but it is a test-contract change on the repo's key-reachability
convention (declared-key reachability is asserted by key survival, per the comments in both
files), so it wants a deliberate call rather than a drive-by edit — and the same convention
is spelled out in other parity tests that do not currently fail.
Scope note
A repo-wide scan for this exact spelling finds only these two files:
grep -rn "undeclared.success\|notASpecKey" --include=*.ts --include=*.tsx packages apps examples scripts
-> packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts:82,83,84
packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts:99,101,102
Worth widening once during the fix to any parity test asserting a green safeParse on a
props schema carrying an extra key, since the strict flip is schema-wide and not specific to
these two blocks.
Reproduce
Only after the GA pin resolves — on main (still ^17.0.0-rc.6) both tests pass.
git checkout claude/issue-4636-spec-pin-ga # PR #4639, at 47c7cc3
pnpm exec vitest run --maxWorkers=2 packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts \
packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts
Blocked-by: #4636 (the failure is only reachable once the GA pin lands).
Generated by Claude Code
Activity
os-steve commentedon Aug 17, 2026
Claim: PM loop round 3
Session:
session_01DbRmJD3iPhXjKr6vhd4QkvBranch:
claude/issue-4910-strict-mode-parity-probesWorktree:
objectui-issue-4910Domain:
repo:objectuiFile surface:
packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts+packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts+ any additional parity test the ruled widening scan finds asserting a greensafeParsewith an undeclared key (tests only; ⛔ no production code) (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opusSerial constraints cleared: in-flight checked this round — #4814 / PR #4915 (fields, plugin-grid, app-shell, docs, catalog — in review), #4886 (sibling seat, app-shell/views/metadata-admin), #4003 / PR #4892 (examples/schema-catalog), #4884 / PR #4888 (plugin-gantt), #4636 / PR #4639 (package.json ranges + lockfile, parked) — none touches
packages/componentstests. Read-coupling declared: PR #4639 asserts this card's output (its red set includes these two tests); landing here on rc.6 main is dormant-safe by design and #4639 re-verifies after.Generated by Claude Code
test(components): re-express the two element:* parity contrast probes…
os-steve commentedon Aug 17, 2026
{ "issue": 4910, "status": "done", "branch": "claude/issue-4910-strict-mode-parity-probes", "pr": "https://github.com/objectstack-ai/objectui/pull/4920", "premise_still_valid": true, "summary": "Implemented the adjudicated option A expressed pin-aware. Both contrast probes in packages/components/src/__tests__/{text-input,record-picker}-inputs-spec-parity.test.ts now read the installed spec's refusal mode BEHAVIOURALLY (parse a payload carrying a key no spec declares; never a version string) and assert the same verdict under either mode: under GA an envelope refusal — 'unrecognized_keys' among the issue codes with the refused keys naming notASpecKey, plus the declared key explicitly NOT among them — and under rc.6 the silent drop, key absent from data. Two deliberate strengthenings beyond a straight port: the undeclared key is now carried alongside the declared one so either arm is attributable to notASpecKey rather than to the declared key going bad, and the load-bearing comments in both files now state the two-mode contract instead of the falsified strip-mode premise. The card's measurements all reproduced on the branch: rc.6 strips (success=true, keys=[inputType,required,disabled] / [object]), GA refuses with unrecognized_keys naming notASpecKey, and the only red assertions were the two the card names at record-picker:101 and text-input:83.", "tests": "All readings at final commit 846ec5dd8, working tree clean; heavy runs under flock -E 99 -w 540 /tmp/os-heavy-verify.lock; repo-root path-filter vitest only. GA readings use PR #4660's npm-pack overlay re-derived — npm pack @objectstack/spec@17.0.0 unpacked over the pnpm STORE directory all 34 @objectstack/spec symlinks already resolve to, so all 34 arm at once with the ai/zod peer links mirrored by construction (they sit one level up in the same store entry — PR #4901's peer-link repair step); rc.6 restored afterwards and the installed version + raw refusal mode printed before every run. BOTH PINS GREEN on the two files: rc.6 'Test Files 2 passed (2) / Tests 15 passed (15)', GA 'Test Files 2 passed (2) / Tests 15 passed (15)'. @object-ui/components full suite on rc.6: 'Test Files 145 passed (145) / Tests 1311 passed (1311)'. WIDENING SCAN, empirical half — all 220 test files importing @objectstack/spec, under the GA overlay: before the branch 'Test Files 3 failed | 217 passed (220) / Tests 3 failed | 3396 passed | 1 skipped (3400)' (the two named files + FilterConditionField.operators.test.ts); at HEAD 'Test Files 1 failed | 219 passed (220)', the remainder being #4911 which is unrelated to the strip flip. Static half — 4 spelling families over packages/apps/examples/scripts (strip-mode prose; notASpecKey-style probe identifiers; unrecognized_keys; and the high-signal one, safeParse payloads with an 'as never/any/unknown' type escape, single-line and multiline -Pz): the cast spelling's only repo-wide hits are the two files this PR changes, which doubles as the positive control that the scan reaches them. REVERSE VERIFICATION, direction predicted first, one ablation per arm, run from the committed state — Ablation A broke only the strict arm (text-input), predicted GA red / rc.6 green; Ablation B broke only the strip arm (record-picker), predicted rc.6 red / GA green. Observed exactly and symmetrically: GA gave 'x text-input ... AssertionError: expected [ notASpecKey ] to include __ablation_A_no_such_key__ / Test Files 1 failed | 1 passed (2)' with record-picker green, and rc.6 gave 'x record-picker ... AssertionError: expected { status: open } to deeply equal { status: __ablation_B__ } / Test Files 1 failed | 1 passed (2)' with text-input green. That is the which-arm-ran-under-which-pin evidence: the strict arm judges under GA and the strip arm under rc.6, each dormant on the other pin, neither vacuous; ablation A's message is also the positive control that the strict arm truly receives the named key. Both restored with git checkout from the branch, git status clean (byte-identical), re-run green on both pins. Gates: type-check @object-ui/components PASS on BOTH pins (script name echoed, so not the zero-match silent pass; dependency closure built first with pnpm --workspace-concurrency=2 --filter '@object-ui/components^...' build); eslint --quiet on the 2 changed files PASS (0 errors); check:control-bytes PASS (4398 files) plus a manual control-byte grep over the 3 changed files (no hits); check-changeset-presence PASS and it names the exemption explicitly — '2 source file(s) of 1 released package(s) changed ... Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the explicit exemption'; check-changeset-fixed PASS; check-changeset-no-major PASS; check:self-import PASS.", "open_questions": [], "out_of_scope_findings": [ "already filed as #4911 (searched first, not re-filed): GA's FieldOperatorsSchema adds $like / $ilike which the filter builder cannot author — surfaced by the widening scan as the third GA failure ('expected [ $like, $ilike ] to deeply equal []' in packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts). A different GA change, not the strip flip; no undeclared-key parse involved. Not addressed in PR #4920 and remains open.", "filed as #4918: two plugin-detail spec-parity tests (recordRelatedListInputs ~line 100, recordDetailsInputs ~line 278) still narrate rc.6 strip mode as present-tense fact in PROSE ONLY. Both parse payloads carrying only declared keys, so both are green under the GA overlay and no gate can catch the stale comment. Outside this card's claimed surface, which is bounded to tests that ASSERT a green safeParse with an undeclared key, so filed unassigned rather than fixed. Note recordDetailsInputs disagrees with itself: its section-key block at ~234 is already pin-aware and asserts unrecognized_keys, forty lines above the block that still says strip." ] }Notes for the PM, outside the machine-read block:
assignee and no pre-existing label.
skip-changesetlabel was applied or created — confirmed phantom in this repo perA
skip-changesetlabel exists on this repo, but no workflow reads it — the repo's own test records that neither the workflow nor the label was ever real #4912; the tests-only declaration is the empty-frontmatter changeset(
.changeset/strict-mode-parity-probes-4910.md), precedent PRs test(app-shell): pin relation-field header-action predicates on the console record page #4641 / test(app-shell): pin the built-in header Edit under a session-user relation predicate (objectstack#8499) #4666 / test(parity): adapt the spec parity gates to 17.0.0 GA (ruled items 1-5); fork the four-block exemption #4671.is draft and its gates are
in_progressat the time of this report.spec.rc6.bakgone,installed version re-confirmed
17.0.0-rc.6); worktree removed without--forceon aclean tree.
Generated by Claude Code
Generated by Claude Code
os-steve commentedon Aug 17, 2026
ACCEPT — PR #4920 (reviewer of record: PM session
session_01DbRmJD3iPhXjKr6vhd4Qkv; readings taken on GitHub at head846ec5dd8, not from the report).main, first lineFixes #4910— correct: the ruled scope (two probes + widening scan) completes with this merge.skip-changesetlabel.specRefusesUnknownTopLevelKeys, module-level, never a version string), the strict arm asserts the refusal as an ENVELOPE (code + the refused key by name, and the declared keys explicitly NOT refused — the assertion a bare "it failed" would fake), the strip arm keeps the silent-drop contrast verbatim. Carrying the declared key alongside the probe key is a genuine strengthening: it makes either arm attributable. Load-bearing comments now state the two-mode contract and cite the sibling precedent.$like/$ilike— the same shape as the closed$icontainsgap (#4023) #4911's separate change) is the half a spelling-based scan cannot fake; the staticsafeParse(... as never)spelling doubling as its own positive control is exactly the absence-claim discipline this lane requires. Hit list: the two named files, no third.['notASpecKey'].Ready-flip + auto-merge after the six gate jobs report
successat this head. Downstream: with this merged, PR #4639's red set reduces to #4911 alone.Generated by Claude Code
test(components): re-express the two element:* parity contrast probes…