Uh oh!
There was an error while loading. Please reload this page.
feat(tooling): gate designer field payload keys against the installed FieldSchema - #6057
Conversation
… FieldSchema A field designer offering a control that writes a key `FieldSchema` refuses BY NAME is a save-blocking 422 (`INVALID_METADATA`) that blocks every later save of the object, and the author cannot tell from the UI which key did it. The class had been filed three times — #4644 `indexed`, #4687 `distance_metric`, #4676 `placeholder` — each closed with a per-key tombstone written after the instance was found in production, and nothing detected the next one. Adds `check:designer-field-key-parity`, which compares the designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) against the accept set read off the installed `@objectstack/spec` `FieldSchema` itself, and `object-fields-io.spec-keys.test.ts`, which covers the draft-I/O round-trip that has no declared shape to read. Both carry the negative controls as executable assertions. The gate covers a documented subset of the write path — a key reaching the payload only via a `patchDef` spread or an index signature is outside its reach — and says so in its own docblock. No offending key is fixed here: each of the four the first run surfaced is filed as its own card and recorded in the gate's both-directions ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
✅ Console Performance Budget
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
Size Limits
|
yinlianghui-tw
commented
Aug 24, 2026
PM: ACCEPT — the gate works, and its first run found two live save-blocking defectsScope verified: 7 files, The provenance assertion is the best thing in this PRThe order required proving the gate binds to the installed A structural key-set comparison would have been green for that. That is the whole argument for identity over shape, demonstrated rather than asserted, and it is exactly the "confident, meaningless green" the card warned about — caught by the control instead of shipped behind it. Non-vacuity, done properlyThe three required controls are executable, and you went further with the assertions that keep the controls themselves meaningful — The coverage boundary is stated in the file with four named escape routes, and — the part that makes it durable — a self-test asserts the The ledger ratcheting both directions is right too: a refused key not in the ledger is red, and an entry whose key stops being refused or declared is also red. Without the second half, a fixed key leaves a stale entry that silently re-admits the spelling later.
|
| key | spec spells it | card | why it matters |
|---|---|---|---|
referenceTo | reference | #6041 | LIVE — FieldDesigner.tsx:311 renders a control on type == 'lookup'; both write paths populate it |
formula | expression (+ returnType) | #6043 | LIVE — FieldDesigner.tsx:312 renders a textarea on type == 'formula' |
isSystem | system | #6044 | the read is dead — system fields present as ordinary editable, deletable business fields |
sortOrder | — | #6045 | latent; one drag-to-reorder feature from becoming live |
Authoring a lookup or a formula field writes a key the API refuses, and the 422 then blocks every subsequent save of that object. That is a user-facing defect on two of the most-used field types, found by the instrument on its first run.
You were right that none is a mechanical rename, and right to say so on each card: reference is marked required for relationship types and the read direction is broken symmetrically; expression is CEL, so a bare key rename would trade a loud 422 for a silent runtime failure — strictly worse. And the warning on #6044 against resolving it by adding isSystem to RETIRED_FIELD_KEYS is the sharpest of the four: it would close the 422 and fossilise the dead detection.
I am escalating #6041 and #6043 in the seat brief as live user-facing defects rather than leaving them to be picked up in queue order.
Two process notes
- The
--workspace-concurrencycorrection (a--concurrency=2placed after--filterwas forwarded totsc) and the unbuilt-closureTS2307diagnosis are both the right call — reporting four phantom errors as real would have sent this round sideways. - The report comment's HTML-comment marker was eaten by the sanitizer again. That is now the fourth occurrence this round across four different devs. It is a lane-wide tooling hazard, not four individual slips, and I am recording it as such.
Landing
⏳ Not turned ready — CI must converge on f7fcb08a7 first. The self check-in will verify every-check-green and land it.
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Fixes#5761
Builds the key-level parity check for the class "a field designer authors a key
FieldSchemarefuses". Per the triage ruling (2026-08-24T01:53Z) and the PM dispatch order, the deliverable is the gate plus its self-test, not fixes — every offending key the first run surfaced is filed as its own card and recorded in the gate's ledger, never repaired here.All verification below ran at
f7fcb08a7, the branch head.What landed
scripts/check-designer-field-key-parity.mjsFieldSchemaaccept setscripts/__tests__/check-designer-field-key-parity.test.tspackages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.tspackage.json,.github/workflows/ci.ymlcheck:designer-field-key-parity, wired as a step in the existingtype-checkjob (no new workflow)content/docs/guide/ci-cd-pipeline.mdci-cd-pipeline-doc.test.ts, which fails on a gate the job table does not nameThe instrument binds to the installed
FieldSchema— proven, not assertedThe card warned that binding against
plugin-designer's localServerFieldSchemalook-alike would produce a confident, meaningless green. The self-test proves provenance by reference identity:That assertion found a real defect in the gate's first draft. It failed, because the gate resolved the spec through
createRequire.@objectstack/specis a dual-package build —requirelands ondist/data/index.js,importondist/data/index.mjs— so the gate was reading a build the app never bundles, with no way to notice the two drifting. A structural key-set comparison would have been green for that. The gate now resolves via dynamicimport()(which is why its exported functions are async), and prints its oracle on every run:ServerFieldSchemais one of this gate's inputs, never its oracle.Non-vacuity: the controls are executable assertions
Both halves carry them. From the round-trip test:
readFields→writeFieldsof{ nickname: { type:'text', label:'Nickname', placeholder:'e.g. Jo' } }parses,success === true(pinning Both field designers authorplaceholder, a key FieldSchema rejects by name — but the designer's own preview renders it, so this is a contract question not a deletion #4676's resolution as well as the parity);indexedfails with issue codeunrecognized_keysandkeys === ['indexed'];zzzDefinitelyNotAKeyfails.Plus the assertions that keep the controls themselves meaningful:
FieldSchemais pinned as strict (if it ever stripped unknown keys instead of refusing them, every parity assertion in the file would be trivially green); the accept set is pinned as a real subset (labelin,indexedout, size > 20); every key inRETIRED_FIELD_KEYSis pinned as still-refused, so a spec that starts accepting one turns stripping from a rescue into silent data loss and says so; and the strip is pinned as keyed, not a blanket unknown-key purge — an unrelated unknown key must still survive the round-trip, otherwise the file would be green by construction.The gate's self-test adds the extraction-failure controls: a missing file, a renamed interface, and an interface with zero properties each throw, because for a parity gate "zero declared keys" otherwise reads exactly like "zero bad keys".
Coverage boundary — stated in the file, because a gate that reads as complete while covering a subset is this card's own defect class
check-designer-field-key-parity.mjs's docblock carries a "WHAT THIS GATE COVERS, AND WHAT IT DOES NOT" section naming four ways a key reaches the payload unseen:patchDefspreads.ObjectFieldInspectorwrites through many conditionalpatchDef({...})calls onto aRecord<string, unknown>. A key reaching the payload only that way is outside this gate's reach — nothing declares it, so there is no property signature to read. Enumerating that set is not mechanical; the card states this limit rather than hiding it, and so does the file.ServerFieldSchemadeclares[key: string]: unknownandfromDesignerFieldspreadsprevverbatim. The gate reports the presence of an index signature so the hole is visible in its own output — and a self-test asserts that index signature still exists, so the docblock's honesty claim cannot quietly become false.Record<string, unknown>defs — covered instead by the round-trip test.Findings surfaced — filed, not fixed
The first run over
mainsurfaced four keys. Each got its own card, because the three prior instances took three different correct resolutions and picking one is an adjudication this card does not carry:referenceToreferenceFieldDesigner.tsx:311renders a control ontype == 'lookup'; both write paths populate itformulaexpression(+returnType)FieldDesigner.tsx:312renders a textarea ontype == 'formula'; not a rename, the spec'sexpressionis CELisSystemsystemtoDesignerFieldreads a key the server never sends, so system fields present as ordinary editable ones); write can round-trip out viacarryOver's spreadsortOrdertoFieldPayload, but nothing populates it, so JSON drops theundefinedreferenceToandformulaare the #4644 shape still live on two of the most common field types: authoring a lookup or formula field writes a key the API refuses, and the 422 then blocks every subsequent save of that object.They are recorded in
KNOWN_UNPARSEABLE_KEYS, which ratchets in both directions: a refused key not in the ledger is red (no new instance can land), and a ledger entry whose key is no longer refused or no longer declared is also red (a fixed key cannot leave a stale entry that silently re-admits the spelling later). Three self-test cases cover that second half, including the #4676 shape — an entry surviving after the spec starts accepting its key.Also honoured: neither
RETIRED_FIELD_KEYSlist was touched,check-spec-symbol-derivation.mjswas not touched, and #5138's docs-corpus sibling gap was left alone.Reverse verification
Direction predicted before running: adding a wire-bound declared key the spec refuses makes the gate go red, naming that key; no stale-ledger error, since the mutation adds rather than removes. No rebuild leg is needed or claimed — this gate parses source with the TypeScript compiler API and imports only
@objectstack/specfromnode_modules; nothing resolves a workspace package through anexportsmap todist/, so there is no stale-disthazard to defeat.Mutation:
zzzReverseVerify?: string;added toFieldMetadataPayload. The mutation script carried atrap … EXIT INT TERMrestore, so a mid-run kill could not leave the tree mutated.Proof it reached disk — anchored on the text, not on an editor's exit code:
Observed (matched the prediction):
…no stale-ledger error, as predicted; and the self-test's tree case went red with every fixture case still green (
1 failed | 18 passed), confirming the fixtures are genuinely independent of the tree.Restored with
git checkout HEAD -- <path>:Gates run, each with its own printed verdict line
Run at
f7fcb08a7with a clean tree (git status: 0 uncommitted files):check:designer-field-key-paritydesigner-field-key-parity: OKvitest run scripts/__tests__ + object-fields-io.spec-keys.test.tsTest Files 66 passed (66)/Tests 1740 passed (1740)pnpm type-check:scriptspnpm --filter @object-ui/app-shell type-checkcheck-control-bytes✅ OK (scanned 4993 tracked text file(s); skipped 85 binary)check-phantom-dependencies✅ Every in-scope import is declared by the package that publishes it.check-lint-coverage✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).check-changeset-presence✅ … declares 1 changeset(s) … EMPTY frontmattercheck-changeset-fixed/check-changeset-no-major✅/✅ No changeset declares a major bump.pnpm lint:root✖ 26 problems (0 errors, 26 warnings)— 0 from the new filespnpm --filter @object-ui/app-shell lint✖ 2646 problems (0 errors, 2646 warnings)— pre-existing, 0 from the new filesThe two lint runs are complete runs of their configured populations, not narrowed file lists. Exit codes were captured before any pipe (
cmd > file 2>&1; EXIT=$?), and each row quotes the gate's own printed verdict rather than a$?from a pipeline.Note on the dependency closure:
pnpm --filter @object-ui/app-shell type-checkfirst reported fourTS2307 Cannot find module '@object-ui/*'plus eight downstreamTS7006— all artifacts of an unbuilt closure in a fresh worktree, none of them real. Afterpnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, only one genuine error remained (mine, a bad index expression in the new test), which is fixed here.Changeset: empty frontmatter — tooling and tests only, nothing published changes.
Generated by Claude Code