Skip to content

fix(objectql): the addressed row's primary key is not a dropped field (#8093) - #8143

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8093-dropped-fields-path-id
Aug 12, 2026
Merged

fix(objectql): the addressed row's primary key is not a dropped field (#8093)#8143
os-zhuang merged 1 commit into
mainfrom
claude/issue-8093-dropped-fields-path-id

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8093

Step 1 — the card's own fork, settled by execution

The card named a fork in its own body and refused to guess past it:

(The request body was not captured on the wire; the above is read off the client source in the current main checkout. If the running build does send id, this is a client bug instead — either way the toast is wrong.)

That fork decides which repo eats the fix, so it was measured before any fix was written, by driving the real ingress rather than by reading source: a real ObjectQL, a real ObjectStackProtocolImplementation, and a request body this side constructs, so its contents are not in question.

client body keys      : ["value"]
body has own "id"     : false

--- PATCH response ---
{
  "object": "sys_user_preference",
  "id": "4mekbFDEhx0QgC85",
  "droppedFields": [ { "object": "sys_user_preference", "fields": ["id"], "reason": "readonly" } ]
}

--- what the DRIVER was handed (the SET clause) ---
[ { "id": "4mekbFDEhx0QgC85", "data": { "value": [ ... ] } } ]

Verdict: the server half is real. The client half of the fork is disproved. The body carries no id key at all and the response still reproduces the card's payload byte for byte, including reason: "readonly". Nothing needs to move to repo:objectui; the reporter's inference was correct and this is domain:engine-core's to fix. The reporter's premise stands (premise_still_valid: true).

The third block is the other half of the measurement: the driver's SET clause never contained id. The strip is doing its job; only the report is untrue. That is what made a report-only fix the correct shape.

The mechanism, end to end

  1. The client sends { value: items }packages/data-objectstack/src/userState.ts:169, no id.
  2. metadata-protocol's updateData folds the path id into the write payload: { ...request.data, id: request.id } (REST PATCH /data/:object/:id:请求体里的标量 id 压过路径 :id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479, so a body id can no longer bind a row other than the one the URL, the If-Match check and the receipt all name). This fold is correct and is unchanged here.
  3. The engine snapshots suppliedValues from that payload — after the fold — so the address is now indistinguishable from something the caller typed.
  4. sys_user_preference.id is Field.text({ label: 'Preference ID', required: true, readonly: true }), so the static-readonly strip (security: 服务端 readonly 字段在 UPDATE 时未强制(可被用户上下文覆盖) #2948) removes it and reportDroppedFields reports it. "Preference ID" is the label the toast rendered as 「偏好设置 ID」.

Why this is worth a round, and not a cosmetic toast fix

The file that triggers it already documents this exact failure mode as previously fixed. userState.ts records why updated_at is no longer sent (#3431/#3794):

Sending it made every recents/favorites write pop a scary warning about a field the user never touched, drowning the real signal the toast exists for.

This is that same drowning, one field over. The console's internal recents trace runs on every org switch, so the cost is not the toast — it is that the warning channel is being trained to be ignored. A user who learns the amber toast is noise will ignore the one that matters. The same file also states the invariant being violated: "save() resolves without throwing, so UI mutations never surface a toast."

The fix

Two lines of behaviour in packages/objectql/src/engine.ts, under a comment that carries the reasoning:

const idAddressesThisRow =
  id !== undefined && id !== null
  && Object.prototype.hasOwnProperty.call(suppliedValues, 'id')
  && Object.is(suppliedValues.id, id);

...consumed by the one place that decides what a drop is:

const fields = Object.keys(before).filter(
  (k) => !(k in afterObj) && !(idAddressesThisRow && k === 'id'),
);

It narrows the report, not the strip. id still leaves the SET clause and must: a same-value primary-key write is a harmless no-op on SQL but an outright rejection on stores with immutable primary keys, and #6435's block already ruled that widening the strip to the truthy-scalar case "is a separate decision, not a rider here". The driver payload is byte-identical before and after, pinned in both test files.

It self-scopes to single-record update by construction. id is bound only on the by-id branch, so a predicate/multi write — which addresses nothing by key — still reports a caller-supplied id in full. It cannot collide with the #6437 primary_key strips either: those fire only when the dispatch has already RULED the value is not an identifier, which is exactly when it cannot equal the bound key.

Asked of suppliedValues, not of the live payload, so the answer survives a hook rewriting the key mid-write — the same reason that snapshot carries values at all (#5591).

strictReadonlyWrites moves with it, and that is the contract rather than a side effect: the option covers "every drop onFieldsDropped reports", coverage DERIVED from the reported set (#6437), so a non-drop must not be a refusal. A strict caller doing a single-record update of a platform object previously got ERR_READONLY_FIELD_REJECTED for its own row's address.

The invariant is now written down where the next reader looks for it — WriteObservabilityOptions in packages/spec/src/contracts/data-engine.ts — including what is deliberately still reported, so the boundary is not re-derived from the implementation next time.

Tests — both directions, because this is a narrowing

packages/rest/src/rest-update-path-id-not-a-dropped-field.test.ts (3 cases, the whole ingress: real engine, real protocol, real registered PATCH route) and packages/objectql/src/engine-update-addressing-id-not-dropped.test.ts (10 cases, the engine seam).

pinned direction
body omits the primary key no droppedFields key, no X-ObjectStack-Dropped-Fields header
a read-only field the caller did supply still reported, unchanged
both at once only the real refusal is listed; id does not ride along
the driver's SET clause unchanged in every case
primary_key strip, by-id and multi unchanged
predicate/multi write still reports a caller-supplied read-only field
object whose id is not readonly unchanged in both channels, id still reaches the driver
strictReadonlyWrites no refusal for the address; still refuses a real forgery, and its drops no longer lists id

Without the second row this change would be indistinguishable from "stop reporting dropped fields".

Reverse verification — prediction, then what actually happened

Predicted, written before the run: delete the !(idAddressesThisRow && k === 'id') term and the four "NONE / ONLY" rows go red, while every primary_key row, the not-readonly row and both strict rows stay green.

Measured: 6 failed / 4 passed — the prediction was wrong about the two strict rows, and how it was wrong is worth keeping. Both went red as well: reverting the report also restores the refusal, and the strict refusal's drops breakdown comes back as ['locked_note','id']. That is the #6437 derived-coverage contract behaving exactly as documented — the quiet and loud halves of the seam cannot move apart, so un-reporting the address un-refuses it in the same edit. Predicting them independent was the error, not the code. The 4 rows that did stay green are precisely the ones this change claims not to touch: both primary_key strips, the predicate/multi report, and the object whose id is not readonly.

Verification

gate result
@objectstack/objectql full suite 192 files / 3397 tests passed
@objectstack/rest full suite 102 files / 1674 tests passed
@objectstack/spec full suite 385 files / 10195 tests passed
@objectstack/service-automation (the named consumer of this seam) 79 files / 940 tests passed
typecheck (objectql, rest, spec) clean
pnpm check:query-options-erasure ratchet holds, 67 unswept sites, none new
pnpm check:type-check-debt --re-measure OK — 33 ledger entries re-measured, none above its recorded number

check:type-check-debt --re-measure first came back red at +1 (objectql) and +2 (rest) from the two new test files. Fixed at the source rather than by moving a baseline — a missing packageId argument to registry.registerObject in each, plus a missing .js extension on a relative import under nodenext. No baseline was raised and --lower was not run, although the gate reported nine entries that could be lowered.

Gates derived at implementation time from the real file surface via node scripts/pm/dispatch-gates.mjs, all green: check:adr-anchors, check:authz-resolver, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-formula-expressions, check:docs-audit-scope, check:durability-log-level, check:engine-double-contract, check:i18n, check:merge-driver, check:meta-type-normalized, check:objectui-changeset, check:release-body, check:spec-parsed-alias, check:stack-collection-maps, check:nul-bytes, check-changeset-no-major.mjs, check-engine-split-ratio.mjs.

One derived gate is red and is not from this branch: check:objectui-pin-fresh reports .objectui-sha stale relative to objectui main. That file is not in this diff (git diff --name-only origin/main does not list it; it was last touched by #7916), and the gate matched only because this PR adds a changeset. Its remedy is a maintainer pin bump (scripts/bump-objectui.sh), the recurring task last filed as the now-closed #6089.

Scope

The card's suggested fix had two halves and only the first is here:

  • Done: exclude the path/primary key from the read-only drop accounting on single-record update.
  • Deliberately not built: "optionally also let a write opt out of drop reporting for internal preference traces." That is a new opt-out surface on a public response shape — a product decision, not a bug fix. Not needed for this defect either: with the report made truthful, the internal trace has nothing to opt out of.
  • Not fixed client-side. The server was reporting something untrue; silencing the toast would have left the untruth and removed the evidence.

Filed rather than fixed here: #8141 — the read-only strip's server-side warn still calls the same addressing id a forged caller write, on every single-record update of a platform object. Measured after this fix (droppedFields: null, warn count: 1). It is the same untruth in the log channel, but fixing it means changing a strip helper shared by three call sites, and the honest alternative is #6435's explicitly separate decision — so it goes to triage with options rather than riding along here.

Cross-seat note

The producer of the droppedFields payload is packages/objectql/src/engine.tsdomain:engine-core's own package, located by symbol as the card required. It is not packages/objectql/src/metadata-facade.ts, so there is no collision with sibling card #7378.

packages/metadata-protocol/src/protocol.ts (seat #6367, domain:metadata) is the ingress that folds the path id into the payload and is therefore the trigger — but it is not modified by this PR, deliberately: that fold is #6479's cross-row-write fix and is correct. The engine is where the accounting is wrong, and fixing it there also fixes every other ingress (batch, GraphQL, flow update_record) in one place. The only other package touched is packages/spec, and only by a doc comment on the contract this change enforces.


Generated by Claude Code

…#8093)

`droppedFields` / `onFieldsDropped` reports fields the CALLER SUPPLIED and the
engine REFUSED. On a by-id update a payload `id` equal to the bound row is the
write's ADDRESS, not payload — it was refused nothing — yet it was reported as
a `readonly` drop on every object declaring `id` as `readonly: true`, i.e.
every platform object.

Measured through the real ingress before fixing anything, because the report's
premise was an inference off client source rather than a wire capture: a real
ObjectQL + a real ObjectStackProtocolImplementation, driven with a body that
provably has no `id` key, still answered
`droppedFields:[{fields:["id"],reason:"readonly"}]`. The server manufactures
it — `updateData` folds the path id into the payload (#6479) before the engine
snapshots `suppliedValues`.

The cost was not the toast: the console's recents trace runs on every org
switch, so the warning channel was being trained to be ignored — the same
failure #3431/#3794 fixed one field over (`updated_at`).

Narrows the REPORT, not the strip: `id` still leaves the SET clause, and must,
since a same-value primary-key write is rejected outright by stores with
immutable primary keys. Self-scopes to single-record update by keying on
equality with the bound key, so predicate/multi writes and the #6437
`primary_key` strips are untouched. `strictReadonlyWrites` moves with it by the
#6437 derived-coverage contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 5:54pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants