Uh oh!
There was an error while loading. Please reload this page.
fix(services,metadata): bind the row the ingress resolved, not the row the payload names - #11625
Conversation
…w the payload names
Two ingresses resolved an authoritative row id and folded it into the write
payload with the losing spread order — `{ id, ...data }` — so a caller-supplied
`data.id` spread over the id the ingress had just resolved and silently
retargeted the write:
- `wrapEngineAsSettingsEngine`'s by-id `update` branch, whose id comes from the
caller's `where.id`.
- `DatabaseLoader._update`, whose id arrives as a separate parameter every
caller resolves first (`existing.id`).
Both now spell it `{ ...data, id }` — the operation's id after the spread, so it
wins — matching the convention already documented at `rest-server.ts`'s batch
update arm and at `protocol.updateData` (#6479).
Neither site can be caught downstream: both pass no `where` to the engine, so
the payload is the only id the engine sees and the conflicting-id refusal
(`UPDATE_ID_MISMATCH`, 400) needs two disagreeing declarations before it can
fire. The fold is the entire trust boundary at both sites.
Each site is pinned with a payload whose `id` names a DIFFERENT row than the one
the ingress resolved; the doubles ask the producer's own
`assertEngineUpdateDispatch` which row a call binds. Measured against the
unfixed sources both pins fail (`meta_claimed` / `sys_setting_claimed` where the
resolved id was required); a pin using a payload without an `id` would have
passed against both spellings.
Part of #11231
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4📓 Docs Drift Check2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 294ffefdcee13f1c8995ac747f7ba1025d1ef64e && git checkout 294ffefdcee13f1c8995ac747f7ba1025d1ef64e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3637731e2811d375fe98187bc67f3e8a0b4585ca 8e8dab829cf33924a4bb425c7aa98a70c8b63d42 && git checkout -B drift-repro 3637731e2811d375fe98187bc67f3e8a0b4585ca && git merge --no-ff 8e8dab829cf33924a4bb425c7aa98a70c8b63d42
node scripts/docs-audit/affected-docs.mjs --json 3637731e2811d375fe98187bc67f3e8a0b4585ca |
os-sam
commented
Aug 24, 2026
Seat ruling: A, as shipped. Do not bring the new doubles under |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11231
Two ingresses resolved an authoritative row id and then folded it into the write
payload with the losing spread order —
{ id, ...data }— so a caller-supplieddata.idspread over the id the ingress had just resolved and silently retargetedthe write. Both now spell it
{ ...data, id }, the convention already documented atthe repo's other two ingresses:
rest-server.ts's batch arm ("the operation's idAFTER the spread, so it wins") and
protocol.updateData(#6479).packages/services/service-settings/src/settings-service-plugin.tswhere.ideng.update(objectName, { ...data, id }, driverOpts)packages/metadata/src/loaders/database-loader.tsidparameter (existing.id)this.engine.update(table, { ...data, id })Neither site can be caught downstream: both pass no
whereto the engine, so thepayload is the only id the engine ever sees, and the conflicting-id refusal
(
UPDATE_ID_MISMATCH, 400 — #11142/#11230) needs two disagreeing declarations beforeit can fire. The fold is the entire trust boundary at both sites.
No wrong write was reachable today — both sites' callers build fresh field
literals and never put an
idindata. This is hardening the fragile pattern thecard filed, one refactor (a caller handing back a row copy; rows carry
id) from the#6479 shape.
Premise re-measured before writing code
Both sites still carried
{ id, ...data }onorigin/mainat2cf5a96cc7—matched lines read, not hit counts:
Searches for
11231across issues and PRs (title and body) returned 0 — nosplit-out or sibling card had already done this.
The pins assert a CONFLICT, and were measured failing first
Each pin hands the ingress a payload whose
idnames a different row than the onethe ingress resolved, and asserts the resolved row is still the row bound. A pin
exercising a payload without an
idpasses on both spellings and measures nothing —that false green is the specific thing these are shaped to avoid.
The doubles do not re-derive which row a call binds: their
updateasksassertEngineUpdateDispatch, the predicateObjectQL.updateitself dispatches on, sothey cannot be kinder or stricter than a running server.
Against the unfixed sources, both pins fail with the payload-named row:
Re-confirmed as a trap-guarded ablation after the fix landed (both fix lines reverted
on disk, anchored
grepin both directions proving the mutation reached disk):SITE1_EXIT=1(3 failed | 1 passed),SITE2_EXIT=1(3 failed). Both files restoredbyte-identically —
git hash-objectequal to theHEADblob, non-empty,git statusclean. The pins resolve the SUT through relative imports from
src, neverdist, sono rebuild sits between the mutation and the result — proven by the mutation flipping
the outcome with no build step.
Verification — all at the pushed commit
8e8dab829c@objectstack/service-settingssuite: 29 files, 514 tests passed;tsc --noEmitclean.@objectstack/metadatasuite: 33 files, 618 tests passed.change kind moves:
check:engine-double-contract(OK — 397 pinned, 133 in the DEBTledger, 2 exempt) and
check:where-matcher(292 matchers, 0 silently-wrong… nonenew), plus
check:type-check-coverage,check:test-source-alias,check:cross-package-test-inputs,check:published-files,check:nul-bytes, thefour changeset gates, and
check-ci-filter-parity.Two things this PR deliberately does not do
check:engine-double-contract's scan scope.Discovery needs the verb plus ≥2 engine siblings; these declare only
update. Thegate is green but does not name them — measured, not assumed (379 pinned rows listed
exhaustively, neither file among them). They honour the contract by construction
anyway, calling
assertEngineUpdateDispatchdirectly. Bringing them under theratchet was tried: adding
find/insertsiblings makes the gate discover and pinboth, and it then requires a row in
scripts/engine-double-contract.pinned.json(
--write), which is outside this card's declared file surface. Reverted rather thanwidened unilaterally — one command to redo if wanted.
@objectstack/metadatacarries 89 pre-existingtsc --noEmiterrors (mostlyextensionless relative imports in existing test files). Nothing in CI invokes bare
tscthere — the package has notypecheckscript and its build istsup. My newtest contributes 0 of them: it was 90 with the neighbouring file's extensionless
spelling copied, and 89 once the import was corrected to
./database-loader.js. Nottouched further; it is not this card's surface.
check:type-check-debt --re-measurewas not run locally — it needs the wholeworkspace closure built, which does not fit the foreground budget. Declared narrowing;
CI runs the farm regardless.
Generated by Claude Code
Generated by Claude Code