Skip to content

fix(services,metadata): bind the row the ingress resolved, not the row the payload names - #11625

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-11231-ingress-id-spread-order
Aug 24, 2026
Merged

fix(services,metadata): bind the row the ingress resolved, not the row the payload names#11625
os-sam merged 1 commit into
mainfrom
claude/issue-11231-ingress-id-spread-order

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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-supplied
data.id spread over the id the ingress had just resolved and silently retargeted
the write. Both now spell it { ...data, id }, the convention already documented at
the repo's other two ingresses: rest-server.ts's batch arm ("the operation's id
AFTER the spread, so it wins") and protocol.updateData (#6479).

siteid sourcechange
packages/services/service-settings/src/settings-service-plugin.tscaller's where.ideng.update(objectName, { ...data, id }, driverOpts)
packages/metadata/src/loaders/database-loader.tsthe id parameter (existing.id)this.engine.update(table, { ...data, id })

Neither site can be caught downstream: both pass no where to the engine, so the
payload is the only id the engine ever sees, and the conflicting-id refusal
(UPDATE_ID_MISMATCH, 400 — #11142/#11230) needs two disagreeing declarations before
it 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 id in data. This is hardening the fragile pattern the
card 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 } on origin/main at 2cf5a96cc7
matched lines read, not hit counts:

packages/services/service-settings/src/settings-service-plugin.ts:469: return eng.update(objectName, { id, ...data }, driverOpts);
packages/metadata/src/loaders/database-loader.ts:276: return this.engine.update(table, { id, ...data });

Searches for 11231 across issues and PRs (title and body) returned 0 — no
split-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 id names a different row than the one
the ingress resolved, and asserts the resolved row is still the row bound. A pin
exercising a payload without an id passes 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 update asks
assertEngineUpdateDispatch, the predicate ObjectQL.update itself dispatches on, so
they cannot be kinder or stricter than a running server.

Against the unfixed sources, both pins fail with the payload-named row:

FAIL src/settings-engine-id-fold-wins.test.ts > binds the row `where.id` resolved, not the row the payload claims
AssertionError: expected { kind: 'by-id', …(1) } to deeply equal { kind: 'by-id', …(1) }
- "id": "sys_setting_resolved",
+ "id": "sys_setting_claimed",
Tests 3 failed | 1 passed (4) <- the 1 pass is the multi branch, untouched by this change
FAIL src/loaders/database-loader-update-id-fold-wins.test.ts > binds the row named by the id parameter…
AssertionError: expected { kind: 'by-id', id: 'meta_claimed' } to deeply equal { kind: 'by-id', id: 'meta_resolved' }
Tests 3 failed (3)

Re-confirmed as a trap-guarded ablation after the fix landed (both fix lines reverted
on disk, anchored grep in both directions proving the mutation reached disk):
SITE1_EXIT=1 (3 failed | 1 passed), SITE2_EXIT=1 (3 failed). Both files restored
byte-identically — git hash-object equal to the HEAD blob, non-empty, git status
clean. The pins resolve the SUT through relative imports from src, never dist, so
no 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-settings suite: 29 files, 514 tests passed; tsc --noEmit clean.
  • @objectstack/metadata suite: 33 files, 618 tests passed.
  • 18 gate families green (exit code captured before any pipe), including the two this
    change kind moves: check:engine-double-contract (OK — 397 pinned, 133 in the DEBT
    ledger, 2 exempt
    ) and check:where-matcher (292 matchers, 0 silently-wrong… none
    new
    ), plus check:type-check-coverage, check:test-source-alias,
    check:cross-package-test-inputs, check:published-files, check:nul-bytes, the
    four changeset gates, and check-ci-filter-parity.

Two things this PR deliberately does not do

  1. The new doubles sit outside check:engine-double-contract's scan scope.
    Discovery needs the verb plus ≥2 engine siblings; these declare only update. The
    gate 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 assertEngineUpdateDispatch directly. Bringing them under the
    ratchet was tried: adding find/insert siblings makes the gate discover and pin
    both, 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 than
    widened unilaterally — one command to redo if wanted.
  2. @objectstack/metadata carries 89 pre-existing tsc --noEmit errors (mostly
    extensionless relative imports in existing test files). Nothing in CI invokes bare
    tsc there — the package has no typecheck script and its build is tsup. My new
    test 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. Not
    touched further; it is not this card's surface.

check:type-check-debt --re-measure was not run locally — it needs the whole
workspace closure built, which does not fit the foreground budget. Declared narrowing;
CI runs the farm regardless.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3637731e2811d375fe98187bc67f3e8a0b4585capackageMentionDocs.

Which tree this was computed on

This run read content/docs from 294ffefdcee13f1c8995ac747f7ba1025d1ef64e — the merge of head 8e8dab829cf33924a4bb425c7aa98a70c8b63d42 into base 3637731e2811d375fe98187bc67f3e8a0b4585ca, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@os-samClaude

Copy link
Copy Markdown
Collaborator

Seat ruling: A, as shipped. Do not bring the new doubles under check:engine-double-contract in this PR.

The dev escalated this rather than deciding it, which was right — the fence it would have crossed ("those two files plus their tests, nothing else") is one I set, so widening it is mine to authorise, not the dev's to take. It also tried B, measured what it cost, and reverted cleanly with git hash-object proof of a byte-identical restore. That is the correct shape for an escalation: bring back a measurement, not a request.

Ruling A, and the deciding reason is not the one either of us led with:

B would require adding find/insert verbs to the doubles that the tests do not use. The gate's discovery needs the verb plus ≥2 engine siblings, so making it see these doubles means growing them with surface that exists only to be discovered. That is the tail wagging the dog: a test double should model what the code under test calls, and nothing more. A double padded to satisfy a discovery heuristic is a worse double, and the next reader cannot tell which verbs are load-bearing.

The supporting reasons:

  • There is no looseness to protect against today. Both doubles call assertEngineUpdateDispatch directly — the same predicate ObjectQL.update dispatches on — so they cannot be kinder or stricter than a running server. The gap B closes is future protection of these test files, not any present unsoundness.
  • The cost is concrete and shared; the benefit is speculative.scripts/engine-double-contract.pinned.json is a hot 379-row artifact that every parallel agent adding a double writes to. It was touched by another PR in this same shift. Paying merge-conflict surface on a shared ledger, now, on a PR whose substance is a three-character flip, to buy a hypothetical future catch, is the wrong trade.

⛔ This ruling is scoped to this PR. It is not a finding that the doubles should never be covered — if they later grow real find/insert usage, discovery picks them up for free and the row should be written then.

Verified independently before ruling

  • 5 changed files, exactly the declared surface: 2 source, 2 test, 1 changeset
  • zero forbidden paths; zero ratchet/baseline/pinned.json files touched — confirming the B attempt really was reverted, not partially left behind
  • both fix lines are exactly the spread-order flip:
    { id, ...data }{ ...data, id }, at settings-service-plugin.ts and database-loader.ts
  • docs-drift: "2 anchors from 2 changed packages; no hand-written page names any of them"

Two things in this PR worth keeping visible

It does not overclaim. It states plainly that no wrong write is reachable today — both sites' callers build fresh field literals and never put an id in data. This is hardening a fragile shape one refactor away from biting, not closing a live hole. A PR that had claimed a live vulnerability here would have been wrong.

It explains why nothing downstream can catch it, which is what makes the fold the entire trust boundary: both sites pass no where, so the payload is the only id the engine ever sees, and UPDATE_ID_MISMATCH (#11142/#11230) needs two disagreeing declarations before it can fire. With one declaration there is nothing to disagree with.

Landing when CI is green. Clause-② no.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review August 24, 2026 09:57
@os-sam
os-sam added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit e7f56d6Aug 24, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-11231-ingress-id-spread-order branch August 24, 2026 10:11
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sam@claude