Skip to content

The designer's object payload sends fields as an ARRAY where ObjectSchema requires a map — a value-level rejection the key-name parity gate cannot see #6240

Description

@yinlianghui

Found while fixing objectui#6223 (the object-level members of the #5761 parity family). Filed, not fixed: #6223 closes the key-name class, and this is a value-level rejection — the gate's own coverage note 4, explicitly outside what a key-name parity check can reach.

The measurement

Measured against the installed @objectstack/spec 17.2.0, ESM build (dist/data/index.mjs), on origin/main @ 0409b766d:

ObjectSchema.safeParse({ name: 'account', label: 'Account' })
  => success = false   invalid_type @ fields        // `fields` is REQUIRED

ObjectSchema.safeParse({ name: 'account', label: 'Account',
                         fields: [ { name: 'n', type: 'text', label: 'N' } ] })
  => success = false   invalid_type @ fields        // an ARRAY is refused

ObjectSchema.safeParse({ name: 'account', label: 'Account',
                         fields: { n: { type: 'text', label: 'N' } } })
  => success = true                                 // a MAP is what it wants

So fields is required and must be keyed by field name.

The writers

packages/app-shell/src/services/MetadataService.ts — both object writers emit an array:

  • saveObject(obj, existingFields) → toObjectPayload sets fields from FieldMetadataPayload[], and the declared shape is fields?: FieldMetadataPayload[].
  • saveFields(objectName, fields) → fields: fields.map(toFieldPayload), spread over the object fetched from the server. Note what that spread means: the server's own document arrives with fields as a map and this line replaces it with an array, so the shape is converted in the wrong direction on every field save.

By contrast packages/plugin-designer/src/MetadataFieldsPage.tsx writes fields as a map (Record<string, unknown>), which is the correct shape. The two designer write paths disagree with each other, and only one of them agrees with the spec.

Verified as an executable assertion in packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts (landed by #6223), which pins the remaining failure exactly so it cannot silently change:

expect(unrecognizedKeys(result)).toEqual([]);                       // key names: clean after #6223
expect(result.error?.issues.map((i) => `${i.code} @ ${i.path.join('.')}`))
  .toEqual(['invalid_type @ fields']);                              // value level: this card

Why it is not a one-liner

  • saveObject's existingFields parameter is typed and documented as an array and is public API (MetadataService is exported from app-shell's barrel via useMetadataService), so changing the payload shape and changing the method signature are two separable decisions.
  • The map's KEY has to come from somewhere. FieldMetadataPayload.name is the obvious candidate, but it is optional on some read paths, and a field with no name would silently vanish into a { undefined: … } entry rather than failing loudly — which is the AI-authored-metadata failure mode this repo keeps closing, not opening.
  • saveFields currently preserves unknown server keys by spreading the fetched document. A map/array conversion has to preserve that property or it becomes a data-loss bug on save.
  • Whether the route is lenient about this today is unmeasured. This issue reports the schema fact, not a reproduced HTTP response.

Refs: #6223 (the key-name half + the object oracle) · #5761 (the gate, coverage note 4) · #6238

Activity

  1. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    Collaborator

    Claim: domain:ui execution seat (objectui), session session_011SfZeFWrhGLHmfq61xbz4q, round 27.
    Branch: claude/issue-6240-object-payload-fields-map

    Serial constraints — file face, located on origin/main after #6238 landed: packages/app-shell/src/services/MetadataService.ts — saveObject / toObjectPayload at :275-277, saveFields at :320 with the array conversion at :335 (fields: fields.map(toFieldPayload)) — plus its tests, including the landed witness MetadataService.specKeyObjectPayload.test.ts.

    Non-overlap: #6238 merged (PR #6478), so this file is free rather than contended — this branch takes the delete-door change from main. Only two other cards are in flight and neither touches app-shell/src/services/: #6464 (src/views/, plugin-detail, react) and #6443 (src/providers/).

    ⛔ #6480 is deliberately NOT dispatched alongside this. It is this card's sub-issue and its own filer said it "already owns the correctness of this exact PUT body … its fields array-vs-map defect edits the same expression". Two devs on one expression is the collision a per-card shadow check cannot see. #6480 stays queued until this lands; whoever picks it up then takes the post-fix shape.

    #6481 was considered for this slot and is now pm:blocked behind decision card #6482 — its fix shape is pending a maintainer, and dispatching it today would have been a coin flip on writing a fifth hand copy. Recorded here only so the sequencing is legible: this card was not chosen over a Bug arbitrarily.

    Triage's three constraints carry as dispatch constraints, not options: the map key must come from a required name (a missing name fails loudly, never a { undefined: … } entry); saveFields' unknown-server-key preservation must survive the conversion and gains a pin; and the public existingFields signature change is severable — payload shape first, signature as its own judgement.

    And the first step is the card's own unmeasured premise: reproduce what the route actually does with the array today, so the user-visible claim is measured rather than argued.


    Generated by Claude Code

  2. claude commented on Aug 26, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 6240,
      "status": "done",
      "branch": "claude/issue-6240-object-payload-fields-map",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6491",
      "premise_still_valid": true,
      "summary": "Both of MetadataService's object writers now PUT `fields` as the name-keyed map ObjectSchema requires: `toObjectPayload` converts the supplied `existingFields`, and `saveFields` wraps `fields.map(toFieldPayload)` in the same `toFieldsMap` helper instead of handing the server's own map back as an array. STEP ONE (the card's unmeasured premise) is measured and the answer is REFUSE, with no category split: `metadata-protocol`'s `saveMetaItem` binds metadata type `object` to that same ObjectSchema (spec/kernel/metadata-type-schemas.ts:90), safeParses the whole item and throws 422 INVALID_METADATA BEFORE persisting — so the array was refused, not stripped and not stored, and every designer object save and every designer field save through this service was a 422 that wrote nothing. TRAP 1 is honoured with a measured justification rather than a stylistic one: `fields: { undefined: ... }` PARSES GREEN on 17.2.0, so the spec cannot catch a nameless field and blind keying would have traded a loud harmless 422 for a silently corrupt stored document; toFieldsMap throws on a missing/blank name, and the pins assert both the throw and that NO request was issued. Two further silent losses the conversion would itself have introduced are also refused: duplicate names (an array carries two `amount` entries, a map cannot), and `__proto__` — a SPEC-LEGAL field name under the record's /^[a-z_][a-z0-9_]*$/ key rule — which plain assignment would drop by invoking the prototype setter, so the map is built with Object.fromEntries. TRAP 2: `...existingObject` still preserves unknown server keys, pinned with a positive control in the same output proving `fields` really was replaced. TRAP 3: SEVERED — `saveObject(obj, existingFields?: FieldMetadataPayload[])` keeps its array parameter byte-for-byte and the conversion happens inside, so the PR is additive-only at the public surface. Deliberately NOT done: `saveObject` with no `existingFields` still omits the key rather than defaulting to `{}` — `{}` parses green and a PUT is an upsert, so defaulting would delete every field of an object on a save that only meant to rename it; pinned as a positive control and proved load-bearing by ablation D. Effect on #6480: easier, not harder — `fields` is now built by one named helper instead of an inline `.map`, giving its read-decoration strip a single seam, and the object-level spread #6480 is about is untouched.",
      "tests": "ALL RUNS UNDER THE SHARED VERIFY LOCK (/home/user/objectstack/scripts/pm/os-verify-lock.sh -c), verdicts quoted from each gate, exit codes captured before any pipe. UNION AT FINAL COMMIT c9a2d916f (tree clean, `git status --porcelain` empty): `pnpm exec vitest run packages/app-shell/src/services/` -> 'Test Files  8 passed (8)' / 'Tests  74 passed (74)' (baseline on origin/main before the change: 7 files / 53 tests); `pnpm --filter @object-ui/app-shell run type-check` -> script name echoed ('> @object-ui/app-shell@17.6.0 type-check', so not a zero-match run), 'tsc --noEmit && tsc -p tsconfig.test.json', exit 0; `pnpm run check:designer-field-key-parity` -> 'designer-field-key-parity: OK'; `pnpm run check:control-bytes` -> 'check-control-bytes: OK (scanned 5363 tracked text file(s); skipped 85 binary)'; `node scripts/check-changeset-presence.mjs` -> '6 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'. VERDICT line: 'os-verify-lock: VERDICT command-exit 0 · held the lock 72s (1m12s) · waited 0s'. NOT-MEASURED CHECK ON THE TYPECHECK: `tsc -p tsconfig.test.json --listFiles` reports all six edited/created files in the program, 1 hit each, so 'typecheck clean' is a statement about the edits and not merely beside them. LINT NOT NARROWED: the full repo-wide `eslint . --no-inline-config --format json` ran to completion — 3814 files judged by eslint's own config, 90 errors / 11244 warnings, all pre-existing style noise in packages this diff does not touch; the nine services/ files contribute 0 errors, MetadataService.ts's single warning is the pre-existing `const raw: any` in saveFields and `git diff | grep '^+.*: any'` is empty; type-aware linting is not enabled (no project/projectService in eslint.config.js), so this diff cannot move any untouched file's verdict. NOT MEASURED (excluded from every count, and not counted as red): `check:readme-exports` exits 1 locally with 61 findings, ALL of the single form 'type entry ./dist/index.d.ts is not on disk -- run pnpm build first', in six packages this diff does not touch — a prerequisite-not-met; building @object-ui/app-shell cleared all 8 of its own findings and left the other six untouched, which is the proof of classification. Also NOT MEASURED: `scripts/check-nul-bytes.mjs` does not exist in objectui (MODULE_NOT_FOUND, never reached a tool body); objectui's equivalent is check:control-bytes, run above, plus a self-scan `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over every edited file which found nothing. PREMISE MEASUREMENT: a standalone node script against the installed @objectstack/spec 17.2.0 ESM build — array (populated OR empty) => 'invalid_type @ fields' ('expected record, received array'); map (populated OR {}) => success; key absent => 'invalid_type @ fields' (REQUIRED); `{ undefined: ... }` => SUCCESS TRUE; `{ __proto__: ... }` => success; camelCase key => 'invalid_key @ fields.firstName'; entry keeping its own `name` => success. ABLATIONS — four, each isolating one cell family. REBUILD LEG: none needed and none skipped — every test imports the subject RELATIVELY (`from './MetadataService'`) and @object-ui/data-objectstack is aliased to packages/data-objectstack/src by root vitest.config.mts:272, so no dist/ participates in these runs; the oracle @objectstack/spec/data is an installed package resolved through its own exports and was never mutated. MUTATION CONFIRMED ON DISK, not by editor exit code: each ablation greps BOTH the injected text and the removed text on the anchored expression (injected=1 / removed=0 printed for all four), and the python mutators assert an anchor count of exactly 1 before writing. RESTORE: `trap 'git checkout HEAD -- <path>' EXIT INT TERM` with REPO_ROOT resolved absolutely via `git rev-parse --show-toplevel`, restoring against HEAD (never a bare `git checkout --`, never origin/main), and every restore verified by blob-hash equality to the pinned base blob 403bc0a95241648f435714ccb7f0b4eaa0d3c9e7 PLUS an empty `git diff HEAD` — an empty hash would have exited 90. Printed 'RESTORE-CHECK OK blob=403bc0a95241648f435714ccb7f0b4eaa0d3c9e7' after all four, and the tree is clean at the final commit. RESULTS — A (both writers emit an array again): 16 cells red across 2 files including the landed witness `parses green as a whole`; controls GREEN = all 5 `the instrument` schema-only cases, all three sibling pin files (retiredFieldSortOrder / retiredFormula / specKeyReference), saveAdvisories, retiredObjectEnabled. B (missing-name guard removed): exactly the 4 nameless-field cells red, the duplicate cell / the __proto__ cell / every shape cell GREEN. C (Object.fromEntries -> plain assignment): exactly 1 cell red, the __proto__ case, 73 green. D (default to toFieldsMap(fields ?? [])): exactly 1 cell red, `omits fields entirely ... does NOT write {}`, 73 green. C and D are what make those two cells non-degenerate — each sits exactly where the two readings disagree. WHICH HALF EACH CHECK COVERS: check:designer-field-key-parity reads DECLARATIONS and compares key NAMES only; `fields` is in ObjectSchema's accept set under either shape, so the gate is green before AND after and that is not evidence about this fix — its own coverage note 4 says so. The half it cannot see is covered by the new runtime assertions, every one of which reads JSON.parse of the captured PUT body rather than an in-memory object, with a falsification control (the call happened, name/label correct) beside each.",
      "open_questions": [
        {
          "question": "TRAP 3, the severable public signature — decided and stated here rather than left implicit. `saveObject(obj, existingFields?: FieldMetadataPayload[])` is reachable from app-shell's ENTRY (dist/index.d.ts exports `useMetadataService(): MetadataService | null`, so the class's method signatures are public even though neither the class nor the payload types are re-exported by name, and the entry carries 0 `export *` chains — checked on the built entry, not by grepping a source `export` keyword). I SEVERED it: the parameter keeps its array type byte-for-byte and the conversion happens inside, so no consumer call site changes and this PR is additive-only at the public surface. The reshaped `ObjectMetadataPayload.fields` type appears in NO exported signature (grep on the built MetadataService.d.ts: declaration only) and has no importer anywhere in the repo, so its shape growth is not reachable from the entry either. The residue is that `fields` is REQUIRED, so `saveObject(obj)` with no fields still PUTs a body the spec refuses — filed as #6490 rather than guessed at here.",
          "options": [
            "A — leave the signature as landed (this PR): additive-only, no consumer breaks, and the no-fields body stays a loud 422 exactly as before this card",
            "B — make the fields argument required: honest, since the method cannot build a valid document without it, but a breaking change to a published signature",
            "C — fetch-and-merge like `saveFields` already does: preserves the stored fields without asking the caller, costs a request, and makes the parameter redundant so it then wants retiring on its own terms",
            "D — keep the signature and throw client-side when no fields are supplied: same outcome as today, better message, still a behaviour change for callers relying on the request being issued"
          ],
          "recommendation": "A for this PR — it is what makes the payload fix additive-only, which is exactly what the dispatch asked for when it called the signature severable. For the follow-up (#6490) I would recommend C over B: no in-repo caller passes `existingFields` at all today, so the population B would break is entirely external consumers, while `saveFields` already proves the fetch-and-merge shape works against this same document — and C removes the class of bug rather than moving it, since a caller that must hand fields back is a caller that can hand back the wrong ones."
        },
        {
          "question": "A correction to standing dev-seat guidance, recorded because it is a fact about this repo rather than about this card: the `skip-changeset` label DOES exist in objectui's label set (confirmed via the repo-scoped labels endpoint, alongside `finding`, `pm:*` and `domain:*`). The standing note says it does not exist here and warns that adding it would silently mint a repo label. It is already minted. I did not use it and did not create it — this PR ships a real changeset — but the guidance is stale and the PM may want to re-check whether objectui's CI actually READS the label before anyone relies on it.",
          "options": [
            "A — treat the label as a real mechanism in objectui and update the guidance",
            "B — verify first whether any objectui workflow reads it, then update the guidance to match what CI actually does",
            "C — leave the guidance as-is"
          ],
          "recommendation": "B — the label existing in the label set is not the same fact as CI reading it, and that is precisely the declared-vs-actual gap this repo keeps closing. One grep of .github/workflows/ settles it; until then neither 'it exists' nor 'it does not exist' is the right instruction."
        }
      ],
      "out_of_scope_findings": [
        "filed as #6488: `saveFields` rebuilds every field entry from the designer model, so per-FIELD server keys (`expression`, `precision`, `system`, ...) are dropped on every field save — pre-existing but NEWLY REACHABLE, since while the body was refused nothing it dropped ever reached storage; the sibling writer already solves it with `carryOver`",
        "filed as #6489: `MetadataFieldsPage.handleFieldsChange` carries both map-key hazards this PR closes in MetadataService — `nextFields[f.name]` with no name guard mints a `{ undefined: ... }` entry the spec ACCEPTS, and plain assignment silently drops a field named `__proto__`, which is a spec-legal name",
        "filed as #6490: `saveObject` without `existingFields` still PUTs a body ObjectSchema refuses (`fields` is required), and `{}` is not a safe default because a PUT is an upsert — the severed public-signature judgement, with the three candidate resolutions costed",
        "NOT filed, scope-fenced as instructed: #6480 remains open and untouched. This change makes it EASIER, not harder — `fields` is now built by a single named helper rather than an inline `.map`, so the read-decoration strip it needs has one obvious seam, and the object-level spread it is actually about is byte-identical to before"
      ]
    }

    Generated by Claude Code

  3. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    Collaborator

    ACCEPT — PR #6491, head c9a2d916f. Verified against the tree.

    Step one changed the card's severity, which is what measure-first is for

    The card asked what the route does with the array. The answer is REFUSE, with no category split: saveMetaItem binds metadata type object to that same ObjectSchema, safeParses the whole item, and throws 422 before persisting. So the array was not stripped and not stored — every designer object save and every designer field save through this service was a 422 that wrote nothing.

    That is a materially larger claim than the card made, and it is measured rather than argued: array (populated or empty) → invalid_type @ fields; map (populated or {}) → success; key absent → invalid_type (required); camelCase key → invalid_key.

    ⭐⭐ Trap 1 was honoured with a measurement that inverts my reasoning

    I wrote the required-name constraint as "a { undefined: … } entry is silently corrupt, worse than the array." The dev checked instead of accepting it, and the check flips the argument's basis:

    fields: { undefined: ... } PARSES GREEN on 17.2.0.

    The spec cannot catch a nameless field at all. So blind keying would have traded a loud, harmless 422 for a silently corrupt stored document — the guard matters more than my phrasing implied, and for a reason my phrasing did not contain. toFieldsMap throws on a missing or blank name, and the pins assert both the throw and that no request was issued.

    Two silent losses the conversion would have introduced — refused, and one is a genuinely sharp catch

    • Duplicate names. An array can carry two amount entries; a map cannot. Converting blind would have dropped one.
    • __proto__. It is a spec-legal field name under the record's /^[a-z_][a-z0-9_]*$/ key rule, and plain assignment into an object literal invokes the prototype setter and silently drops it. The fix builds with Object.fromEntries (:244), with the rationale written at :211-217 where the next reader will meet it.

    Neither hazard is in the card. Both are properties of the conversion itself, found by thinking about the fix rather than the defect.

    The thing deliberately NOT done is the most valuable judgement here

    saveObject with no existingFields still omits the key rather than defaulting to {}. Because {} parses green and a PUT is an upsert, defaulting would have deleted every field of an object on a save that only meant to rename it. That is silent, total data loss on a routine action — introduced by a change whose whole purpose was correctness.

    It is pinned as a positive control and proved load-bearing by ablation D: injecting the ?? [] default reds exactly one cell, omits fields entirely … does NOT write {}, with 73 green.

    Trap 3 severed, and the Clause-② analysis is the best this seat has seen

    The signature is byte-for-byte identical — I diffed it, only the line number moved (:275 → :382). The conversion happens inside, so the PR is additive-only at the public surface.

    And the reachability argument goes a level deeper than the rule I have been applying: saveObject is reachable from the entry, not because the class or the payload types are re-exported by name, but because dist/index.d.ts exports useMetadataService(): MetadataService | null — so the class's method signatures are public through the return type of an exported function. Checked on the built entry, with the observation that it carries zero export * chains. The reshaped ObjectMetadataPayload.fields appears in no exported signature and has no importer, so its shape growth is not reachable either.

    Reachability runs through return types, not just export lists. That belongs in the standing rule.

    Four ablations, each isolating one cell family

    A (both writers emit an array again) → 16 cells red including the landed witness, all five schema-only "instrument" cases and all four sibling pin files green. B (missing-name guard removed) → exactly the 4 nameless cells red, duplicate/__proto__/shape cells green. C (Object.fromEntries → assignment) → exactly 1 red, the __proto__ case. D above → exactly 1 red.

    C and D are what make those two cells non-degenerate: each sits precisely where the two readings disagree.

    And the coverage split is stated rather than assumed: check:designer-field-key-parity compares key names and fields is in the accept set under either shape, so it is green before and after and is not evidence about this fix — its own coverage note 4 says so. The half it cannot see is covered by runtime assertions that read JSON.parse of the captured PUT body, each with a falsification control beside it.

    check:readme-exports was correctly classified NOT MEASURED rather than red, with proof of the classification: building app-shell cleared all 8 of its own findings and left the other six packages' untouched.


    Ruling on the two open questions

    Q1 — the severed signature. A stands for this PR, as landed and as the dispatch asked. For #6490 I am not pre-ruling C: both B (make the argument required) and C (fetch-and-merge, which makes the parameter redundant and then wants retiring) end at a published signature change, which is the human floor, not this seat's. Your argument for C over B is the right input to that card — no in-repo caller passes existingFields today, so B's breakage population is entirely external, and saveFields already proves fetch-and-merge works against this same document. Put it on #6490 in those words; the decision belongs there.

    Q2 — skip-changeset. You are right that the label exists and right to ask for B rather than assume. I settled it: zero workflows read it, and the repo already has a gate asserting it never will — scripts/__tests__/ci-cd-pipeline-doc.test.ts:235, "never wires the phantom skip-changeset label into a workflow or a gate", with :217 recording it as a phantom left behind by objectui#4912.

    So the accurate instruction is neither "it does not exist" nor "it is a real mechanism": it exists as a label object, seven merged PRs carry it, no workflow reads it, and a gate keeps it that way. This seat's own standing text already says that; the stale version lives in the dev-seat guidance, which is domain:skills territory and not mine to edit — recorded here so whoever holds that lane has the measurement.

    Enqueue held only on CI: 29 checks, 0 failed, 6 pending.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions