Skip to content

fix(objectql): refuse undeclared update fields at the schema, before the beforeUpdate hooks (#8738) - #8775

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-8738-update-declared-field-door
Aug 15, 2026
Merged

fix(objectql): refuse undeclared update fields at the schema, before the beforeUpdate hooks (#8738)#8775
hotlong merged 4 commits into
mainfrom
claude/issue-8738-update-declared-field-door

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8738

Verified at 6ab4424fd — the merged head, and the sha every number below was measured at.

The card's inferred half, measured first

#8738 is explicit that its central claim was never measured, and asks for a reproduction before anyone implements. Run on origin/main @ e5eeb499c with a real ObjectQL and a recording driver, one mistyped key. Both claims reproduce, on both branches of the verb:

observableby-idmulti (predicate)
the undeclared key reaches the driverdriver.update received itdriver.updateMany received it
who refused itthe driverthe driver
beforeUpdate ran firstyesyes
refusal envelope from the engineno code, no statusno code, no status

The recorded payload was { name, zzz_nonexistent_field, description: 'derived-for-bad' }description is the hook's derived value, not the caller's. So the hook did not merely run: its output travelled into the statement the driver then rejected, which is the card's period_label = 'Q3 2026' shape one verb over.

What lands

The door is the insert path's (#8682), given its second caller rather than a second predicate: undeclaredInsertFieldErrorsundeclaredWriteFieldErrors, one condition, one implementation. It runs as the first act inside update()'s middleware body — after middleware (which may legitimately rewrite data), and before the prior-record read, the beforeUpdate hooks, both readonly strips, validation and the statement.

Placing it by the insert path's rule lands it one step earlier in this verb than a naive reading suggests, and two consequences are deliberate:

  • Ahead of the prior-record read, so a refused write costs no driver round-trip either. That read serves previous, the readonlyWhen gate and the not-found gate — none of which a refused payload reaches.
  • Ahead of the dispatch ladder's own reject verdict, so a call that is both mis-keyed and missing its id/multi is answered on the payload. The ladder's message is about how to address rows and would send an author hunting for the wrong defect.

The wire answer is deliberately unchanged: INVALID_FIELD + 400, with the message byte-identical to the one mapDataError's driver-string branch produced, which @objectstack/rest re-emits verbatim. The refusal moved; the answer did not. #8737's three no-opinion cases are reused unchanged and pinned as controls — absent field map, a map the door sees as empty, and id / created_at / updated_at. Schema drift stays the driver's to refuse.

Out of scope, as the card says: the value-logging half is already done. redactBoundStatement is untouched.

The fixture triage — one red, and it was not a field-map defect

The card warned to expect #8737's 49 reds. The objectql suite went to zero: those 49 were fixture defects that #8737 already repaired on main (four sys_metadata stubs given their real declarations, and the empty-map / platform-column rules moved into the door itself), so the update half inherits corrected fixtures. 207 files, 3643 tests, no failures.

The 43-package downstream sweep turned up exactly one red, and it is the class the card said to stop on rather than soften: runtime's undeclared-field-write-driver-split.integration.test.ts, which pins the #4271 product divergence — SQL fails the whole write, schemaless persists the stray key. Its case "persists it on UPDATE too" went red.

It is not a legitimate write the door newly refuses, and here is the measurement that decides it. On current main, #8737's insert door already refuses a caller-supplied undeclared key on the schemaless (memory) driver: INVALID_FIELD / 400, nothing persisted. That question is merged, not open. The file survived #8737 only because its insert arm injects the typo through a hook body — which runs after the door — while its update arm took a shortcut through a caller payload, on the file's own stated reasoning that a beforeUpdate body "would only add the flat-input envelope to the thing under test". The door falsifies that equivalence: a caller payload no longer stands in for a body mutation.

So the repair is to the method, not to the door:

  • both update cases now carry a real beforeUpdate body, matching the insert arm. The split is measured again on both verbs — SQL no such column: stagee with the whole write lost, memory persisting it alongside the declared key;
  • the caller-payload half is pinned separately as what it now is: a schema refusal on both families, with no split to observe;
  • the file header's methodology note is rewritten, because a passing test whose stated reason is false is worse than a failing one.

The three prose surfaces the file guards — the two lint messages and content/docs/automation/hook-bodies.mdx "What still happens at runtime" — describe body writes, which the door does not touch. Read and confirmed accurate as written; no doc change is needed, and none is made.

Verification — all at 6ab4424fd

  • pnpm --filter @objectstack/objectql test207 files, 3643 tests, 0 failures; typecheck clean.
  • pnpm --filter @objectstack/runtime test157 files, 2393 tests, 0 failures; typecheck clean.
  • Downstream consumer sweep, --filter '...@objectstack/objectql' (prefix form = the 43 packages that DEPEND on objectql), after a full pnpm build, run in chunks: 43/43 green, packages/qa/dogfood included (110 files, 779 tests — it boots the real example apps). Zero Unknown field refusals anywhere. That sweep is the evidence the narrowed accept set costs no live caller anything.
  • Reverse verification, direction predicted before running: 7 red / 7 green, and it matched exactly with no divergence. The 7 reds are the ordering pins and the refusal — the ordering pin fails as expected [ 'bad' ] to deeply equal [], i.e. the hook ran, which is the card's subject failing loudly. The 7 greens are the 3 positive controls and the 4 no-opinion cases, which must pass on origin/main too, since they assert the door has no verdict. engine.ts was restored from the commit and proved byte-identical by git hash-object (eac1ff8e33f1fab21a891cab274268da42f0c7d1).
  • Gates, re-derived from the real diff with scripts/pm/dispatch-gates.mjs (three-dot form) and re-run at this head after the merge: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:objectui-changeset, check:stack-collection-maps, check:nul-bytes, check:query-options-erasure, check:type-check-coverage, check:type-check-debt (--re-measure, 33 entries, none above ceiling), plus the five changeset / engine-ratio scripts. All green.

Two ratchets moved and were settled at the author's end rather than by raising a ledger: check:query-options-erasure (test surface 240 → 242 — the new options bags are typed, not erased, back at 240) and check:type-check-debt (objectql TEST_DEBT 355 → 356 on one TS6133 unused parameter — corrected, re-measured at 355).

Changeset

patch. An undeclared update key was already refused, so the client-facing answer does not move. What a caller observes changing is stated plainly in the changeset: an in-process caller of ObjectQL.update() that caught the old failure saw the driver's raw error — no code, no status, the bound statement in its message — and now sees the ADR-0112 envelope. A caller matching on the driver's SQL text rather than the envelope is the one shape that has to change.


Generated by Claude Code

…beforeUpdate (#8738)
The insert path's declared-field door (#8682) applied to the second write verb —
the same function, given its second caller and a verb-neutral name, rather than a
second predicate to drift against.
The card filed this half as INFERRED and asked for a reproduction first. Both
claims reproduce on origin/main @ e5eeb49: an undeclared key reached
driver.update on the by-id branch and driver.updateMany on the predicate branch
and was refused THERE, after beforeUpdate had run and stamped a derived value
onto the payload the driver then rejected. Unlike insert there is no autonumber,
so nothing durable is consumed — the hook side effect and declared = enforced
(PD #10) are the case, and the hook run is what the suite pins.
The wire answer is unchanged: the same 400 INVALID_FIELD with the same message.
#8737's three no-opinion cases (absent map, empty map, id/created_at/updated_at)
are reused unchanged and pinned as controls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
… verbs (#8738)
The one red in the downstream sweep that was NOT a fixture field-map defect.
`undeclared-field-write-driver-split` pinned the schemaless family PERSISTING an
undeclared key on update by calling engine.update() with the typo in the
CALLER's payload — on the file's stated reasoning that a beforeUpdate body
"would only add the flat-input envelope to the thing under test".
The declared-field door falsifies that equivalence, and the same thing is
already true of insert on main: measured here, a caller-supplied undeclared key
on the MEMORY driver is refused INVALID_FIELD/400 with nothing persisted, by
#8737's insert door. The file survived that only because its insert arm injects
the key through a hook body, which runs after the door.
So both update cases now carry a real beforeUpdate body, matching the insert
arm: the key is added below the engine's validation, still reaches the driver,
and the split the file exists to pin is measured on both verbs (SQL: `no such
column: stagee`, whole write lost; memory: persisted alongside the declared
key). The caller-payload half is pinned separately as what it now is — a schema
refusal on BOTH families, with no split to observe.
The three prose surfaces the file guards (the two lint messages and
content/docs/automation/hook-bodies.mdx "What still happens at runtime")
describe BODY writes, which the door does not touch, so they stay accurate as
written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…parameter (#8738)
Two ratchets the new test code moved, both fixed at the author's end rather than
by raising a ledger:
- check:query-options-erasure — the test surface grew 240 → 242. The two new
`as any` options bags are not deliberately off-contract input, so they are
TYPED (EngineUpdateOptions for the predicate branch, EngineQueryOptions for
the read-back) instead of erased. Back at the 240 ceiling.
- check:type-check-debt — objectql's TEST_DEBT re-measured 355 → 356 on one
TS6133 (an unused `object` parameter in the recording driver's `create`).
Renamed to `_object`; re-measured at 355, matching the ledger, with zero
errors attributable to this card's files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 11:44pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx(via packages/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)

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

  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)

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.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@hotlong
hotlong marked this pull request as ready for review August 15, 2026 00:16
@hotlong
hotlong added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 3d0ded8Aug 15, 2026
27 checks passed
@hotlong
hotlong deleted the claude/issue-8738-update-declared-field-door branch August 15, 2026 00:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine.update() has no declared-field door either — an undeclared key still reaches the driver, after the beforeUpdate hooks have run

2 participants

@hotlong@claude