fix(spec): judge react-block registry inputs against the full node contract - #13202
Merged
Conversation
…ntract `check:react-blocks-declaration-parity` compared a registry-declared input against `ComponentPropsMap[type]` alone — the PER-BLOCK half of what a page node may carry. The other half is declared once on `PageComponentSchema` and applies to every component, so a node-level key read as an invented input and the complaint was false rather than merely noisy. The node-level half is now DERIVED from `PageComponentSchema`'s own key shape (its `.transform()` makes it a ZodPipe, so the authorable shape is `_def.in`), with `retiredKey()` tombstones excluded so the widening cannot go vacuous.
…de-contract-parity
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-trump
marked this pull request as ready for review
August 29, 2026 11:10
os-trump
enabled auto-merge
August 29, 2026 11:10
Contributor
This was referenced Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #13192
check:react-blocks-declaration-parityjudged a registry-declared input againstComponentPropsMap[type]alone — the per-block half of what a page node may carry.The other half is declared once, on
PageComponentSchema, and applies to everycomponent whatever its block. So a node-level key read to this gate as an invented
input, and the complaint was false rather than merely inconvenient.
This is the inverse of the usual gate defect: not "should be red and isn't" but
"shouldn't be red and is about to be". objectui PR #6767 has landed — 17 renderers
across 12 packages now declare
dataSourceat theElementDataSourceGatewrappingseam — so the next read of objectui's manifest would have reported a false parity
failure on every one of them.
Re-measured on this ref, before editing
The card's two readings reproduce verbatim:
The implementer's
.pipe()note also holds, and it matters:PageComponentSchemaendsin
.transform(normalizeVisibleWhen), so it is aZodPipeandz.toJSONSchemaof thepipe itself yields no properties at all. The authorable key shape is the pipe's
input side,
_def.in.The fix
The node-level half of the accepted set is now derived from
PageComponentSchema'sown key shape, never listed. The accepted set per block is
node-level keys ∪ this block's props ∪ overlay.The literal this replaced —
['aria', 'type', 'id', 'className', 'style']— namedfive of the twelve keys the shape actually accepts, which is the substance of the
sweep below.
Sweep: the other node-level keys (the filer's first NOT-MEASURED item)
The card checked
classNameand left the rest ofPageComponentSchema's node-levelkeys unswept. Swept here. The shape carries 13 keys; 12 are accepted and 1
is a tombstone:
So
dataSourcewas not alone. Seven accepted node-level keys sat outside the oldliteral —
dataSource,events,label,properties,responsiveStyles,visibility,visibleWhen— each one registry declaration away from the same falsefailure. A fix handling only
dataSourceandclassNamewould have left the defectstanding for the next one.
responsiveis the discrimination that keeps the widening honest. It is in theshape but accepts nothing, so a registry still declaring it as an input is a real
finding and stays red. Sweeping the whole shape in without that test is exactly how a
widening turns a gate vacuous.
Reproduce-first
Same manifest (
object-griddeclaringobjectName, filter, dataSource, className;list-viewdeclaringobjectName, dataSource), same flags(
--baseline BASELINE_FILE --strict), the pre-fix script fromorigin/mainvs this one.The gate wraps a react-block tag in angle brackets; those are stripped in the quotes below
because this body's sanitizer eats short tag-shaped fragments, backticks included (#12886):
The acceptance is named in the report, not silently dropped — a widening nobody can
see in a CI log cannot be audited:
and every run now prints the derived set it used:
Calibration pins
Run against
object-gridwithdataSourceaccepted in the same run, so the acceptand the refusal cannot be confused for one lenient mode:
dataSourceclassNameviewNamenew registry-only input(s) not in baseline: viewNameresponsivenew registry-only input(s) not in baseline: responsivezzzInventedRegistryInputnew registry-only input(s) not in baseline: zzzInventedRegistryInputobjectNamegets no behavioural leg, and the reason is a measurement rather than anomission: it is accepted on every block for a legitimate per-block reason — a
prop of all six
object-*schemas and an overlay prop of all three react blocks. Thediscrimination that can actually go wrong is it being swept in as node-level, and
that is pinned directly on the derived set (
expect(derived).not.toContain('objectName')).Ablation — the pins are load-bearing
Both legs mutated on disk, proven by anchor count and blob hash, and proven still
mutated at the end of each run; both restored and the restore proven by
git diff HEADbeing empty. Nodist/is involved — the gate is executed from sourceby
tsx, so there is no build leg to prove.&& !NODE_CONTRACT_KEYS.has(p)from bothregistryOnlycomputations(anchors 2 → 0, blob
850d5a6e→3700c81a): 6 of the 7 new tests fail. Thesurvivor is the derived-set printing test, which pins a different thing.
.filter((k) => !acceptsNothing(props[k]))(anchor 1 → 0, blob
850d5a6e→efae4195): exactly 2 fail — theresponsivepin and the derived-set print — while the other five stay green. That is the
vacuous-widening shape, and it is caught.
An earlier attempt at leg 1 was a no-op: the
trap … EXITfrom the mutating shellfired when that shell exited, so the following call measured the restored tree and read
25/25 green. Caught by the "still mutated?" assertion, not by the exit code. Both legs
above were re-run mutation-and-measurement inside one shell.
Fences
three named keys plus a retired one still go red.
dataSourcecopied into any block'sComponentPropsMap.skills/objectstack-ui/contracts/react-blocks.contract.jsonis untouched; thediff is two files, both under
packages/spec/scripts/.react-declaration-parity.baseline.jsonis unchanged and needs noregeneration: the ratchet flags only new registry-only inputs, and this change can
only ever produce fewer. Its three
_acceptedReasonsentries (ObjectForm.initialData,.mobile,.navigateOnSuccess) are not node-level keys, so none of them goes stale.Verification
Union re-run after the final commit, at
9d3195219.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-fed paths; change set
2 path(s) vs merge base 33184fd29, three-dot). Familyidentical before and after the merge.
check:nul-bytes,check:published-files,check:cross-package-test-inputs,check:test-source-alias,check:page-declaration-shape,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:comment-mask-adoption,check:keyed-text-bounds,check:merge-driver,check:pm-governed-merges,check:undeclared-dep-imports,check:slot-lookup,check:type-source-resolution,check:objectql-double-limit,check:logger-receiver-detach,check:plugin-teardown-shape,check:ci-filter-parity,check:shard-attestation, and spec'scheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docs.(
build-schemas-check-mode.test.ts,check-generated-ledger.test.ts):Test Files 3 passed (3) · Tests 96 passed (96)— measured atd7202dbf7, the commitbefore the merge. Declared narrowing: two attempts to re-run all three on the merged
head hit
os-verify-lockqueue timeouts (exit 99, 540s each, holderissue-13135/batch8.shthenbatch9.sh), ~18 min with no acquisition, so the merged-headre-run was narrowed to the 7 new calibration tests —
Tests 7 passed | 18 skipped (25),exit 0, counts read from vitest's own output. What that narrowing excludes is bounded and
checked, not assumed: the merge's entire delta inside
packages/specis five filesunder
scripts/liveness/**, and none of the three suites referencesliveness(grep: 0hits), while
check:livenessitself was re-run green on the merged head above. CI runs allthree suites in full regardless.
pnpm --filter @objectstack/spec run typecheck— all three programs green, verdict linecheck:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json. Coverage of the edit proven rather than assumed:tsc -p tsconfig.scripts.json --listFilesreaches both edited files (1 hit each),while
tsconfig.test.jsonreaches neither — so the scripts program is what measuredthis diff.
pnpm --filter '@objectstack/spec^...' buildmatched no projects. That zero-match isreal, not a mistyped filter:
@objectstack/specdeclares no workspace dependencies,so there is nothing to build ahead of its tests.
Two readings are NOT MEASURED, each on the gate's own say-so rather than mine:
check:test-completeness(exit 3,PREREQUISITE NOT MET— it grades a savedturbo run testlog and none exists locally; its own text names the dispatch-gatesfamily and says "the local reading for this gate is NOT MEASURED … it is not a red"),
and
check:dev-prereqs(exit 1, "The workspace is not built — 1 unmet precondition, nota list of problems", 67/67 packages). The same unbuilt-workspace precondition parks
check:dual-build-cjs-loadsandcheck:type-check-debt --re-measure; CI builds firstand runs all four.
The one thing this PR could not verify is the objectui manifest itself: NOT MEASURED.
No
sdui.manifest.jsonexists in this repo and none can be produced here — its onlyproducer drives a browser against a built objectui console (#4690's standing constraint,
which this gate's own header documents). The false failure is therefore reproduced and
cured against synthetic manifests carrying the same shape objectui #6767 emits, not
against objectui's real dump.
Changeset
None, deliberately.
packages/spec'sfilesarray is["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]—
scriptsis absent, so neither edited file ships in the@objectstack/spectarballand this PR releases nothing. Precedent measured from git history rather than recalled:
of the last 400 commits on
main, every commit whose entire diff sits underpackages/*/scripts/carried zero.changeset/files — #13183 and #13081 (both2026-08-29), #12973, #12854, #12630 and #12549. The
Check Changesetjob has exactly twoexemptions and no path exemption, so
skip-changesetis the mechanism; the label isapplied to this PR.
Generated by Claude Code