You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
finding(app-shell): saveObject called without existingFields still PUTs a body ObjectSchema refuses — fields is REQUIRED, and {} is not a safe default #6490
objectui#6240 fixed the SHAPE: when a caller passes existingFields, toObjectPayload now emits the name-keyed map and the body parses. When a caller does NOT pass it, fields is omitted and the body is still refused — unchanged from before that card, and deliberately so.
Why objectui#6240 did not simply default to {}
Because {} parses green and PUT /api/v1/meta/object/:name is an UPSERT. Emitting {} for a caller that just did not supply the argument would land as "this object has no fields" and delete every field of the object on a save that only meant to rename it — trading a loud, harmless 422 for silent data loss. That is pinned as a positive control (omits fields entirely when the caller supplied none — it does NOT write {}), with an ablation showing that defaulting to {} reds that one case.
The decision this needs
saveObject(obj, existingFields?: FieldMetadataPayload[]) is public API — MetadataService is reachable from app-shell's entry through useMetadataService(): MetadataService | null, so its signature is part of the published surface even though the class is not re-exported by name. Three readings, none of them free:
Make the fields argument required. Honest — the method cannot write a valid document without it — but a breaking signature change for any consumer calling saveObject(obj).
Fetch and merge, the way saveFields already does.saveFields GETs the current document and spreads it, so an object save could preserve the stored fields instead of asking the caller to hand them back. Costs a request; makes the parameter redundant, which then wants retiring on its own terms (ADR-0049 shape).
Leave it and refuse client-side. Throw before the request when no fields are supplied, so the author gets the diagnosis without a round trip. Same outcome as today, better message, still a behaviour change for callers relying on the request being issued.
No in-repo caller passes existingFields at all today (the only call sites are tests), so the population this breaks is entirely external consumers — which is what makes it a judgement rather than a mechanical fix.
Ruled: saveObject's fields argument becomes REQUIRED — a compile-time tightening that only breaks calls which were already a guaranteed 422 at runtime (breaking-in-name-only). ⛔ Options declined, recorded so neither is implemented as a shortcut: fetch-and-merge (option 2) builds capability for a zero-pull path and makes the parameter redundant; defaulting to {} stays forbidden per objectui#6240's own pin — {} parses green and, under the UPSERT, deletes every field of the object on a save that meant to rename it.
Dispatch notes: changeset states in words that previously-omitted-argument calls were already a guaranteed 422 (behaviour unchanged at runtime, signature now honest); the objectui#6240 positive control ("omits fields entirely when the caller supplied none — it does NOT write {}") moves with the signature rather than being deleted; Clause-② judged at claim time by the dispatching seat (a published-surface signature narrowing whose runtime accept/reject behaviour is unchanged — the maintainer ruling here is the authority for the narrowing itself).
Clause-② judgement, which the ruling assigned to this seat: no — with the condition that flips it stated
The change makes a parameter required. Measured against the clause's two triggers:
Widens the public surface? No — it narrows it. Nothing new is exported, nothing new is accepted.
Moves contract accept/reject behaviour? No at runtime, which is the accept/reject the clause is about: an omitted-argument call was already a guaranteed 422. The compile-time surface tightens, and the maintainer's 2026-08-27 ruling is itself the authority for that narrowing.
⚠️What would flip this to yes, and you must stop if you hit it: if making the signature honest turns out to require adding any new export — a helper, a type alias, a re-export from the barrel — that is public-surface widening and the tier changes. Stop and report; do not add it and carry on. This lane got exactly that wrong earlier today on #6527, where a change judged "no widening" landed a registry on a published barrel. I am pinning the condition rather than trusting the judgement to hold through implementation.
The ruling, and the three dispatch notes it carries
Maintainer, 2026-08-27, verbatim: 「其他接受」 — accepting Option 1. The fields argument becomes required: a compile-time tightening that only breaks calls which were already a guaranteed 422 at runtime.
⛔ Both declined options, recorded so neither is taken as a shortcut:
Not fetch-and-merge. It builds capability for a path with zero measured pull and makes the parameter redundant, incurring an ADR-0049 retirement obligation.
The #6240 positive control moves with the signature — ⛔ it is not deleted. That pin reads "omits fields entirely when the caller supplied none — it does NOT write {}". Carry it forward against the new signature rather than removing it as newly-unreachable. A pin deleted because its subject changed shape is the failure mode this lane has hit twice today from the other direction.
The changeset must say, in words, that previously-omitted-argument calls were already a guaranteed 422 — runtime behaviour unchanged, signature now honest. That sentence is the whole justification for a breaking-in-name-only narrowing, and without it the entry reads as an unexplained break.
Scope
MetadataService.saveObject in packages/app-shell, its call sites, and the #6240 pin. In-repo production callers were measured as zero (only tests call it), so the call-site work should be small — if it is not, that is a finding worth reporting, not a licence to widen.
Let the changeset gate rule the bump. ⛔ If a gate refuses an honest declaration, stop and report — never re-score a breaking change down to satisfy a gate.
{
"issue": 6490,
"status": "done",
"branch": "claude/issue-6490-saveobject-fields-required",
"pr": "https://github.com/objectstack-ai/objectui/pull/6643",
"premise_still_valid": true,
"summary": "Implemented the maintainer ruling (2026-08-27, 「其他接受」 = Option 1): MetadataService.saveObject's second argument is now required — one production character, `existingFields?: FieldMetadataPayload[]` to `existingFields: FieldMetadataPayload[]`. Runtime accept/reject is unchanged; the omitted-argument body was already a guaranteed 422, which the carried-forward pin measures in this branch rather than quoting (safeParse issues equal invalid_type @ fields, against the installed @objectstack/spec 17.2.0). NO NEW EXPORT WAS NEEDED, so Clause-② stays 'no' and the stop-and-report condition was not reached: zero added export statements in the diff. The objectui#6240 anti-wipe control moved with the signature instead of being deleted — its runtime half still guards the path a JavaScript consumer reaches (toObjectPayload's parameter deliberately stays optional as the last line of defence), and its call now sits under a @ts-expect-error that is the new compile-time half. Neither declined option was implemented: no {} default, no fetch-and-merge. One adjacent cell newly pinned because the required parameter makes it canonical: an EMPTY list is a statement, not a missing argument — [] writes {} and wipes, unchanged behaviour, pinned so the two readings of 'no fields' stay distinguishable.",
"tests": "All measurements taken at final commit 2503a710 (tree clean, HEAD unmoved since). Heavy runs serialized through scripts/pm/os-verify-lock.sh; verdicts quoted from the gates' own printed lines, never from a bare $?. (1) pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json): VERDICT command-exit 0. (2) DOWNSTREAM CONSUMER closure, prefix filter '...@object-ui/app-shell' = consumers: 'Scope: 4 of 47 workspace projects' — app-shell, @object-ui/console, example-byo-backend-console, example-console-starter — each printing 'Done', VERDICT command-exit 0. First attempt was red on 'Cannot find module @object-ui/plugin-*' — unbuilt sibling plugins, not this change; green after building console's own dependency closure (35 of 47 projects). (3) pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/services/ from the REPO ROOT: Test Files 10 passed (10), Tests 102 passed (102). Note: the package-dir invocation is refused by this repo's objectui#3378 false-green guard — that refusal is NOT MEASURED, not a red, and was re-run correctly. (4) The two pins by name, verbose reporter, both green: 'omits fields entirely when the caller supplied none — it does NOT write {}' and 'objectui#6490 · an EMPTY list is a STATEMENT, not a missing argument — it writes {}'. WHY THE #6240 PIN PASSES UNDER THE NEW SIGNATURE: its subject was never the type, it was the request bytes. The runtime path it measures is unchanged and still reachable from JavaScript, so the same three assertions hold; the only thing added is a @ts-expect-error making the call compile, which simultaneously became the pin for this card. (5) REVERSE VERIFICATION, direction predicted before the run (red, on an UNUSED directive — not on the call): reverting the signature to optional gave 'error TS2578: Unused @ts-expect-error directive.' at MetadataService.objectPayloadFieldsMap.test.ts(236,5), VERDICT command-exit 2. Mutation confirmed on disk by counting BOTH spellings (required 1 to 0, optional 0 to 1) plus git hash-object differing from the HEAD blob; restore confirmed by the hash returning to the HEAD blob 0a523117 and git diff HEAD empty; trap with absolute paths armed throughout. No rebuild needed for either leg and this is stated rather than assumed: MetadataService.ts is compiled from SOURCE by both tsc projects, and tsconfig.test.json's empty paths only redirects cross-package specifiers to built .d.ts. (6) CALL SITES: four omitted-argument sites, all in MetadataService.saveAdvisories.test.ts, now pass the smallest well-formed list. Anchored grep repo-wide over ts/tsx/js/jsx excluding node_modules and dist: one-argument calls = 1 hit, and it is the deliberate ts-expect-error line 238; POSITIVE CONTROL in the same query shape (two-argument calls) = 16 hits, so a zero in the first row would have meant something; plus a balanced-paren scanner for multi-line single-argument calls the regex cannot see — same single hit. (7) GATES, each quoted from its own verdict line: changeset:check — 'All workspace packages are in the changeset fixed group' and 'No changeset declares a major bump'; check:control-bytes — 'OK (scanned 5488 tracked text file(s); skipped 85 binary)'; check:designer-field-key-parity — 'designer-field-key-parity: OK' with ObjectMetadataPayload still 6 declared keys, none added; check:self-import and check:phantom-deps both green. Also self-scanned all four edited files for control bytes: clean. (8) LINT, NARROWED AND DECLARED: eslint . inside packages/app-shell, byte-identical to what turbo run lint invokes for this package. Population and count read from eslint's own --format json output, not guessed — 1006 files linted, 0 errors, 2775 pre-existing warnings; the three edited files report 0 errors and one pre-existing no-explicit-any warning at MetadataService.ts:640, outside both diff hunks. Invariance measured, not assumed: eslint.config.js declares no parserOptions.project and no projectService, so type-aware linting is off and no untouched file's verdict can move. Repo-wide pnpm lint is left to CI.",
"mcp_calls": "4 — issue get, issue get_comments, create_pull_request, this comment. Readbacks and the PR verification went through a repo-scoped REST GET (probed 200 on this seat), so they cost no MCP budget.",
"open_questions": [],
"out_of_scope_findings": []
}
Notes the JSON cannot carry:
Bump is minor, and it is not a re-score to satisfy a gate. AGENTS.md §版本号策略 defines the honest declaration in this repo: objectui's major is pinned to the @objectstack major so "same major ⇒ compatible" holds across the two repos, and all publishable packages share one fixed group, so one major would carry 39 packages off that line. The written convention is that objectui's own breaking changes ship as minor with the break spelled out in the body — which the changeset does, in words, including the required sentence that previously-omitted-argument calls were already a guaranteed 422. scripts/check-changeset-no-major.mjs mechanically enforces that policy; it was not consulted to pick the score.
skip-changeset deliberately NOT applied. This PR ships a changeset, and in this repo that label is the phantom object — zero workflows or scripts read it — so applying it would only hang a false status on the PR.
ACCEPT — PR #6643, flipped ready and armed for the queue.
Entry qualification, head 2503a710: 29 checks, 29 returned against total_count: 29. 26 success, 3 skipped, zero failures, zero cancelled, zero still running.
My Clause-② condition held. I judged "no" with the stop-and-report trigger set on any new export. The diff adds zero export statements, so the narrowing never became a widening and the trigger correctly never fired.
⭐ Making the parameter required creates a NEW path to the wipe — and the dev saw it
This is the part I did not anticipate. Before the change, a caller with no field list could only omit the argument, and got a harmless 422. After it, they cannot omit — so the natural thing to reach for is []. And [] is not a refusal: it means "this object has no fields", writes {}, and under the upsert performs exactly the wipe the whole card exists to prevent.
So the fix rerouted callers toward the hazard, and the response was to pin the distinction rather than leave it implicit:
an EMPTY list is a STATEMENT, not a missing argument — it writes {}
with the note that this is the same authoritative reading saveFields gives its own empty list, and that the behaviour is unchanged — what changed is that the required parameter is now what routes a caller with nothing to hand toward it.
The #6240 control moved and split in two, rather than being deleted
I ordered it carried forward, not dropped. It came through doing more work than before:
Compile-time half — the call now sits under a @ts-expect-error, and an unused @ts-expect-error is itself an error in this project, so the line goes red if the argument ever returns to optional. That is the only direction the narrowing can regress in.
Runtime half — still reachable and still measured. MetadataService is public API via useMetadataService, so a JavaScript consumer, or a TypeScript one that casts past the types, can still arrive with no field list. What it must get is the loud 422, never the silent wipe.
Consistently, toObjectPayload's internal fields?stays optional on purpose, documented in place as "the last line of defence against the wipe, so it may not be deleted as newly-unreachable code". That is the right instinct: the type now says no in-repo caller reaches that branch, and the branch exists precisely for callers who ignore the types.
The 422 is measured on this branch, not quoted from the card
ObjectSchema.safeParse({ name: 'account', label: 'Account' }) → invalid_type @ fields against the installed @objectstack/spec 17.2.0, and metadata-protocol's saveMetaItem parses the whole item against that same schema and throws before it persists. So "the omitted-argument call was already a guaranteed 422" is a measurement, which is what entitles the changeset to call this breaking-in-name-only.
Reverse verification predicted the right failure shape, too: reverting the signature to optional yields TS2578 unused directive — red on the directive, not on the call. Predicting which of those two goes red is the difference between understanding the pin and hoping.
The call-site census carries a control and covers its own blind spot: one-argument calls = 1 (the deliberate @ts-expect-error), two-argument calls = 16 as the positive control, plus a balanced-paren scanner for the multi-line single-argument calls a regex cannot see — same single hit.
On the bump: scored minor with the break spelled out, and explicitly not as a re-score to satisfy a gate — the dev states AGENTS.md §版本号策略 defines minor-plus-prose as the honest declaration for objectui's own breaking changes, and that the gate "was not consulted to pick the score". I have carried that reasoning to #6366 as evidence, where the same convention is under an open ruling.
Closure will be confirmed from closed_by_pull_requests after the merge, not assumed from the Fixes line.
Activity
os-zhuang commentedon Aug 27, 2026
Maintainer ruling recorded — Option 1/A: the fields argument becomes required
Provenance: maintainer, 2026-08-27, PM chat decision-inbox batch 4 (adjudication session
session_01DKWDdUJ2XNRESVVWUvcpnh), verbatim: 「其他接受」 — accepting the triage recommendation (2026-08-26T05:54Z facet block).Ruled:
saveObject's fields argument becomes REQUIRED — a compile-time tightening that only breaks calls which were already a guaranteed 422 at runtime (breaking-in-name-only). ⛔ Options declined, recorded so neither is implemented as a shortcut: fetch-and-merge (option 2) builds capability for a zero-pull path and makes the parameter redundant; defaulting to{}stays forbidden per objectui#6240's own pin —{}parses green and, under the UPSERT, deletes every field of the object on a save that meant to rename it.Dispatch notes: changeset states in words that previously-omitted-argument calls were already a guaranteed 422 (behaviour unchanged at runtime, signature now honest); the objectui#6240 positive control ("omits
fieldsentirely when the caller supplied none — it does NOT write{}") moves with the signature rather than being deleted; Clause-② judged at claim time by the dispatching seat (a published-surface signature narrowing whose runtime accept/reject behaviour is unchanged — the maintainer ruling here is the authority for the narrowing itself).State:
needs-user-decision→pm:queue.Generated by Claude Code
os-sales commentedon Aug 28, 2026
Claim:
domain:uiexecution seat, session8ca04858-ea8e-5b85-9182-de59aa49e00c, branchclaude/issue-6490-saveobject-fields-required.Clause-② judgement, which the ruling assigned to this seat: no — with the condition that flips it stated
The change makes a parameter required. Measured against the clause's two triggers:
The ruling, and the three dispatch notes it carries
Maintainer, 2026-08-27, verbatim: 「其他接受」 — accepting Option 1. The fields argument becomes required: a compile-time tightening that only breaks calls which were already a guaranteed 422 at runtime.
⛔ Both declined options, recorded so neither is taken as a shortcut:
{}. This stays forbidden by The designer's object payload sendsfieldsas an ARRAY whereObjectSchemarequires a map — a value-level rejection the key-name parity gate cannot see #6240's own pin:{}parses green and, under the UPSERT semantics, deletes every field of the object on a save that only meant to rename it. If you find yourself reaching for a default, that is the trap this card exists to keep shut.The #6240 positive control moves with the signature — ⛔ it is not deleted. That pin reads "omits
fieldsentirely when the caller supplied none — it does NOT write{}". Carry it forward against the new signature rather than removing it as newly-unreachable. A pin deleted because its subject changed shape is the failure mode this lane has hit twice today from the other direction.The changeset must say, in words, that previously-omitted-argument calls were already a guaranteed 422 — runtime behaviour unchanged, signature now honest. That sentence is the whole justification for a breaking-in-name-only narrowing, and without it the entry reads as an unexplained break.
Scope
MetadataService.saveObjectinpackages/app-shell, its call sites, and the #6240 pin. In-repo production callers were measured as zero (only tests call it), so the call-site work should be small — if it is not, that is a finding worth reporting, not a licence to widen.Let the changeset gate rule the bump. ⛔ If a gate refuses an honest declaration, stop and report — never re-score a breaking change down to satisfy a gate.
Draft PR,
Fixes #6490.Generated by Claude Code
os-sales commentedon Aug 28, 2026
os-dev-report
{ "issue": 6490, "status": "done", "branch": "claude/issue-6490-saveobject-fields-required", "pr": "https://github.com/objectstack-ai/objectui/pull/6643", "premise_still_valid": true, "summary": "Implemented the maintainer ruling (2026-08-27, 「其他接受」 = Option 1): MetadataService.saveObject's second argument is now required — one production character, `existingFields?: FieldMetadataPayload[]` to `existingFields: FieldMetadataPayload[]`. Runtime accept/reject is unchanged; the omitted-argument body was already a guaranteed 422, which the carried-forward pin measures in this branch rather than quoting (safeParse issues equal invalid_type @ fields, against the installed @objectstack/spec 17.2.0). NO NEW EXPORT WAS NEEDED, so Clause-② stays 'no' and the stop-and-report condition was not reached: zero added export statements in the diff. The objectui#6240 anti-wipe control moved with the signature instead of being deleted — its runtime half still guards the path a JavaScript consumer reaches (toObjectPayload's parameter deliberately stays optional as the last line of defence), and its call now sits under a @ts-expect-error that is the new compile-time half. Neither declined option was implemented: no {} default, no fetch-and-merge. One adjacent cell newly pinned because the required parameter makes it canonical: an EMPTY list is a statement, not a missing argument — [] writes {} and wipes, unchanged behaviour, pinned so the two readings of 'no fields' stay distinguishable.", "tests": "All measurements taken at final commit 2503a710 (tree clean, HEAD unmoved since). Heavy runs serialized through scripts/pm/os-verify-lock.sh; verdicts quoted from the gates' own printed lines, never from a bare $?. (1) pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json): VERDICT command-exit 0. (2) DOWNSTREAM CONSUMER closure, prefix filter '...@object-ui/app-shell' = consumers: 'Scope: 4 of 47 workspace projects' — app-shell, @object-ui/console, example-byo-backend-console, example-console-starter — each printing 'Done', VERDICT command-exit 0. First attempt was red on 'Cannot find module @object-ui/plugin-*' — unbuilt sibling plugins, not this change; green after building console's own dependency closure (35 of 47 projects). (3) pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/services/ from the REPO ROOT: Test Files 10 passed (10), Tests 102 passed (102). Note: the package-dir invocation is refused by this repo's objectui#3378 false-green guard — that refusal is NOT MEASURED, not a red, and was re-run correctly. (4) The two pins by name, verbose reporter, both green: 'omits fields entirely when the caller supplied none — it does NOT write {}' and 'objectui#6490 · an EMPTY list is a STATEMENT, not a missing argument — it writes {}'. WHY THE #6240 PIN PASSES UNDER THE NEW SIGNATURE: its subject was never the type, it was the request bytes. The runtime path it measures is unchanged and still reachable from JavaScript, so the same three assertions hold; the only thing added is a @ts-expect-error making the call compile, which simultaneously became the pin for this card. (5) REVERSE VERIFICATION, direction predicted before the run (red, on an UNUSED directive — not on the call): reverting the signature to optional gave 'error TS2578: Unused @ts-expect-error directive.' at MetadataService.objectPayloadFieldsMap.test.ts(236,5), VERDICT command-exit 2. Mutation confirmed on disk by counting BOTH spellings (required 1 to 0, optional 0 to 1) plus git hash-object differing from the HEAD blob; restore confirmed by the hash returning to the HEAD blob 0a523117 and git diff HEAD empty; trap with absolute paths armed throughout. No rebuild needed for either leg and this is stated rather than assumed: MetadataService.ts is compiled from SOURCE by both tsc projects, and tsconfig.test.json's empty paths only redirects cross-package specifiers to built .d.ts. (6) CALL SITES: four omitted-argument sites, all in MetadataService.saveAdvisories.test.ts, now pass the smallest well-formed list. Anchored grep repo-wide over ts/tsx/js/jsx excluding node_modules and dist: one-argument calls = 1 hit, and it is the deliberate ts-expect-error line 238; POSITIVE CONTROL in the same query shape (two-argument calls) = 16 hits, so a zero in the first row would have meant something; plus a balanced-paren scanner for multi-line single-argument calls the regex cannot see — same single hit. (7) GATES, each quoted from its own verdict line: changeset:check — 'All workspace packages are in the changeset fixed group' and 'No changeset declares a major bump'; check:control-bytes — 'OK (scanned 5488 tracked text file(s); skipped 85 binary)'; check:designer-field-key-parity — 'designer-field-key-parity: OK' with ObjectMetadataPayload still 6 declared keys, none added; check:self-import and check:phantom-deps both green. Also self-scanned all four edited files for control bytes: clean. (8) LINT, NARROWED AND DECLARED: eslint . inside packages/app-shell, byte-identical to what turbo run lint invokes for this package. Population and count read from eslint's own --format json output, not guessed — 1006 files linted, 0 errors, 2775 pre-existing warnings; the three edited files report 0 errors and one pre-existing no-explicit-any warning at MetadataService.ts:640, outside both diff hunks. Invariance measured, not assumed: eslint.config.js declares no parserOptions.project and no projectService, so type-aware linting is off and no untouched file's verdict can move. Repo-wide pnpm lint is left to CI.", "mcp_calls": "4 — issue get, issue get_comments, create_pull_request, this comment. Readbacks and the PR verification went through a repo-scoped REST GET (probed 200 on this seat), so they cost no MCP budget.", "open_questions": [], "out_of_scope_findings": [] }Notes the JSON cannot carry:
minor, and it is not a re-score to satisfy a gate. AGENTS.md §版本号策略 defines the honest declaration in this repo: objectui's major is pinned to the@objectstackmajor so "same major ⇒ compatible" holds across the two repos, and all publishable packages share onefixedgroup, so onemajorwould carry 39 packages off that line. The written convention is that objectui's own breaking changes ship asminorwith the break spelled out in the body — which the changeset does, in words, including the required sentence that previously-omitted-argument calls were already a guaranteed 422.scripts/check-changeset-no-major.mjsmechanically enforces that policy; it was not consulted to pick the score.skip-changesetdeliberately NOT applied. This PR ships a changeset, and in this repo that label is the phantom object — zero workflows or scripts read it — so applying it would only hang a false status on the PR..changeset/6240-object-payload-fields-map.mdends with "saveObject(obj, existingFields)keeps itsFieldMetadataPayload[]parameter type … so no caller's call site changes." That stays true of The designer's object payload sendsfieldsas an ARRAY whereObjectSchemarequires a map — a value-level rejection the key-name parity gate cannot see #6240's own change; it will simply sit beside this entry in the same release. Editing another card's changeset would be scope widening, so it is flagged rather than touched.Generated by Claude Code
os-sales commentedon Aug 28, 2026
ACCEPT — PR #6643, flipped ready and armed for the queue.
Entry qualification, head
2503a710: 29 checks, 29 returned againsttotal_count: 29. 26 success, 3 skipped, zero failures, zero cancelled, zero still running.My Clause-② condition held. I judged "no" with the stop-and-report trigger set on any new export. The diff adds zero export statements, so the narrowing never became a widening and the trigger correctly never fired.
⭐ Making the parameter required creates a NEW path to the wipe — and the dev saw it
This is the part I did not anticipate. Before the change, a caller with no field list could only omit the argument, and got a harmless 422. After it, they cannot omit — so the natural thing to reach for is
[]. And[]is not a refusal: it means "this object has no fields", writes{}, and under the upsert performs exactly the wipe the whole card exists to prevent.So the fix rerouted callers toward the hazard, and the response was to pin the distinction rather than leave it implicit:
with the note that this is the same authoritative reading
saveFieldsgives its own empty list, and that the behaviour is unchanged — what changed is that the required parameter is now what routes a caller with nothing to hand toward it.The #6240 control moved and split in two, rather than being deleted
I ordered it carried forward, not dropped. It came through doing more work than before:
@ts-expect-error, and an unused@ts-expect-erroris itself an error in this project, so the line goes red if the argument ever returns to optional. That is the only direction the narrowing can regress in.MetadataServiceis public API viauseMetadataService, so a JavaScript consumer, or a TypeScript one that casts past the types, can still arrive with no field list. What it must get is the loud 422, never the silent wipe.Consistently,
toObjectPayload's internalfields?stays optional on purpose, documented in place as "the last line of defence against the wipe, so it may not be deleted as newly-unreachable code". That is the right instinct: the type now says no in-repo caller reaches that branch, and the branch exists precisely for callers who ignore the types.The 422 is measured on this branch, not quoted from the card
ObjectSchema.safeParse({ name: 'account', label: 'Account' })→invalid_type @ fieldsagainst the installed@objectstack/spec17.2.0, andmetadata-protocol'ssaveMetaItemparses the whole item against that same schema and throws before it persists. So "the omitted-argument call was already a guaranteed 422" is a measurement, which is what entitles the changeset to call this breaking-in-name-only.Reverse verification predicted the right failure shape, too: reverting the signature to optional yields TS2578 unused directive — red on the directive, not on the call. Predicting which of those two goes red is the difference between understanding the pin and hoping.
The call-site census carries a control and covers its own blind spot: one-argument calls = 1 (the deliberate
@ts-expect-error), two-argument calls = 16 as the positive control, plus a balanced-paren scanner for the multi-line single-argument calls a regex cannot see — same single hit.On the bump: scored
minorwith the break spelled out, and explicitly not as a re-score to satisfy a gate — the dev states AGENTS.md §版本号策略 defines minor-plus-prose as the honest declaration for objectui's own breaking changes, and that the gate "was not consulted to pick the score". I have carried that reasoning to #6366 as evidence, where the same convention is under an open ruling.Closure will be confirmed from
closed_by_pull_requestsafter the merge, not assumed from theFixesline.Generated by Claude Code