Skip to content

A scalar data.id beside a DIFFERENT scalar where.id silently drops the where.id predicate — the one unhonoured-predicate shape #11009's refusal deliberately left standing #11142

Description

@os-zhuang

Found while implementing #11009 (the unhonoured by-id predicate refusal). Filed separately and unassigned: acting on it would reverse a pinned #5748 verdict, which is a maintainer decision, not a rider on that card.

The shape

await engine.update(obj, { id: 'rec_1', title: 'x' }, { where: { id: 'rec_2' } });

resolveEngineUpdateDispatch rules by-id with expectId: 'rec_1' — the payload id outranks where (#5748, pinned in ENGINE_UPDATE_DISPATCH_CASES as 'a SCALAR data.id still wins over a scalar where.id'). The where.id = 'rec_2' predicate is then discarded by the by-id driver path exactly the way #11009's extra keys were: the caller wrote "update rec_1 where id = rec_2" — a condition that can never hold — and the write lands on rec_1 anyway, with no diagnostic.

Why #11009 did not cover it

#11009's refusal fires on where keys beyond id ("a by-id dispatch carrying predicate keys beyond id", per its triage route). Here the only where key IS id, so the refusal does not reach it — and making it reach would flip a case the shared case-set pins with an explicit expectId assertion, i.e. a partial rollback of #5748's ruling A. The #6435 block in engine.ts records the same boundary from the other side: widening beyond the ruled scope "is a separate decision, not a rider".

When the two ids are EQUAL the shape is redundant-but-honoured (the REST ingress folds the path id into the payload, so data.id === where.id is a normal spelling); only the UNEQUAL case is a silently-dropped declaration.

What a fix has to decide

  1. Refuse a by-id dispatch whose where.id is a truthy scalar different from the bound payload id (same loud-refusal family as ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 / A compare-and-set where on a by-id update is silently inert — the extra predicate keys never reach the driver, and SqlHttpOutbox.redeliver's status guard is one of them #11009). Cheap, but changes a pinned verdict — needs a ruling.
  2. Leave it, on the grounds that the payload id is the row address and a conflicting where.id is a caller bug too rare to arm. That keeps one silently-inert predicate shape in a family that has otherwise been made loud.

Refs: packages/metadata-core/src/engine-update-dispatch.ts (case 'a SCALAR data.id still wins over a scalar where.id'), #5748, #6435, #11009.

Activity

  1. added theissue type on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: 入决策箱(needs-user-decision + domain:engine,类型 Bug)。收紧它要部分回滚 #5748 的 pinned 裁决 A,只有维护者能翻已裁 pin。

    一句话问题:调用方写「更新 rec_1,条件是 id = rec_2」——一个永远不可能成立的条件——平台不报错,直接把 rec_1 改了。这是「静默丢弃谓词」家族里被上一轮裁决刻意留下的最后一格。

    选项(客户可感知代价):

    业务直译:A = 转账单上「收款人」和「确认收款人」不一致就退回;B = 以第一格为准直接打款。

    四轴:业务需求——形状罕见(实测由 #11009 实施时发现,非用户上报),拉动弱;长远合理——家族一致性:同类静默丢弃已全部改响亮,留一格特例是长期认知负担;防 AI 错——A 显著更优:矛盾输入被当场打回,而不是落库后无从追查;阶段聚焦——A 是一次窄改动+pin 反转,无扩散。

    推荐 A,回退 B(裁 B 则在派发用例注释里写明「此格 deliberately 静默」并引用本裁决,让下一个实施者不再重开)。回一个字母即可。

    裁后执行:A ⇒ engine 席派发,pin 反转遵循「被打断行为的 pin 反转为拒绝 pin,不是删除」纪律,拒收用例带 code+status 双断言;B ⇒ 关卡并在 ENGINE_UPDATE_DISPATCH_CASES 上补裁决注释(一行,引本卡)。

    [facets-block]


    Generated by Claude Code

  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling recorded (2026-08-23, live PM chat, verbatim: 「其他同意你的意见」— adopting this card's recommendation A): refuse a by-id dispatch whose where.id is a truthy scalar different from the bound payload id — same loud-refusal family as #5748/#11009. This deliberately reverses the #5748-pinned case 'a SCALAR data.id still wins over a scalar where.id' for the UNEQUAL shape only; the equal-ids spelling (REST folds path id into payload) stays honored.

    State: needs-user-decision → pm:queue (same stroke). Dispatch constraints: pin-reversal discipline — the interrupted behavior's pin flips to a refusal pin, never deleted; refusal case carries code+status assertions; implementer surveys non-REST ingresses for payload-priority reliance (the recorded confidence gap) and reports readings in the PR — a measured contrary fact forks back here, no silent re-ruling.


    Generated by Claude Code

  4. self-assigned this
    on Aug 23, 2026
  5. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim · seat domain:engine · session session_01RfyXxZ2WPjcjhuXpiQQc3y · branch claude/issue-11142-where-id-conflict-refusal

    Dispatching on the recorded maintainer ruling of 2026-08-23 (option A, verbatim 「其他同意你的意见」), which is the comment directly above this one.

    Tier: claude-fable-5, mandatory. This card changes contract accept/reject behaviour — a call that resolves today must reject after it — which is the clause-② condition read from the card's content, not a discretionary escalation. needs:contract-review attached in the same stroke as the claim.

    Declared file surface (for same-file serialization against other cards in this lane):

    • packages/metadata-core/src/engine-update-dispatch.ts — resolveEngineUpdateDispatch and ENGINE_UPDATE_DISPATCH_CASES, including the pinned case 'a SCALAR data.id still wins over a scalar where.id'.
    • whatever refusal site the by-id path actually raises from, to be named in the PR once measured.

    This surface is a prediction; if the landed change is narrower, say so in the PR and the seat releases the hold early.

    Dispatch constraints carried from the ruling:

    1. Pin-reversal discipline — the interrupted behaviour's pin flips to a refusal pin. It is never deleted. The equal-ids spelling (REST folds the path id into the payload) stays honoured and must keep its own passing case.
    2. The refusal case carries code + status double assertions, matching the ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748/A compare-and-set where on a by-id update is silently inert — the extra predicate keys never reach the driver, and SqlHttpOutbox.redeliver's status guard is one of them #11009 family.
    3. Survey non-REST ingresses for payload-priority reliance — this is the confidence gap the decision block recorded, and the ruling requires the readings be reported in the PR. Zero-hit discipline applies: a zero result is not a reading until a known-present control confirms the query is live.
    4. Scope is the UNEQUAL truthy scalar case only. Widening beyond it is the exact thing update 的 **by-id** 路径同样把非标量 data.id 交给驱动写主键列(#6262 的孪生形状,where.id 胜出时) #6435 and A compare-and-set where on a by-id update is silently inert — the extra predicate keys never reach the driver, and SqlHttpOutbox.redeliver's status guard is one of them #11009 each declined as "a separate decision, not a rider".
    5. A measured contrary fact forks back to this card — stop and report, never re-rule silently.

    Generated by Claude Code

  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Correction — needs:contract-review removed (I attached it in error 20 minutes ago).

    Reason on record, because a stripped label and one that was never attached are indistinguishable in the evidence, and this one carries gate semantics.

    I attached it in the claim above on the reasoning "clause ② applies to this card, so the label applies." That conflates two different things. Read from the label's own definition (scripts/pm/ensure-pm-labels.sh, and CONTRACT_REVIEW_TIER at scripts/pm/dispatch-gates.mjs:2816):

    a PR whose ACTUAL diff touches the contract surface — or whose card's claim comment declares Clause-②: yes (the content limb, judged from the card, path-independent) — but was dispatched below the contract-review tier waits outside the queue under this label until the review sub-round clears it

    Both limbs are conditioned on dispatched below tier. The label's own description says the same: "Clause-② enqueue gate: dispatched below contract-review tier — blocked until the review clears it."

    This card was dispatched at claude-fable-5, which is the contract-review tier. So the clause-② obligation was discharged by the dispatch itself, not deferred to a review sub-round. Leaving the label on would have parked a correctly-dispatched card outside the enqueue gate waiting for a review that has no reason to run — a self-inflicted block, and precisely the failure mode the label exists to prevent in the other direction.

    Unchanged: clause ② does apply to this card (it changes contract accept/reject behaviour), the dispatch tier is claude-fable-5 and mandatory, and every constraint in the claim comment stands. The only thing that changed is a label that was never true.

    Same reading applies to #10746, which I checked in the same pass: dispatched at claude-fable-5 with Clause-②: yes declared, and correctly carrying no needs:contract-review. That absence is right, not a missing pair.


    Generated by Claude Code

  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Liveness probe answer · session session_01RfyXxZ2WPjcjhuXpiQQc3y · not the dev report (that follows at PR time, marked os-dev-report).

    1. Alive and progressing — implementation, tests, reverse verification and most derived gates are done; currently finishing the gate union, then merge of moved main, push, and draft PR.
    2. Local commits: 2, complete (fix + regenerated docs/changeset). Not pushed yet — the branch on the remote is still the write-route probe.
    3. Not blocked on anything the PM owns.
    4. Premise held, re-verified against moved main: on origin/main at ee2ff455a the pinned case 'a SCALAR data.id still wins over a scalar where.id' still exists in ENGINE_UPDATE_DISPATCH_CASES (grep count 1), and the unequal shape is still silently honoured — measured behaviourally, not assumed: rebuilding origin/main's dispatch module and probing through the built dist gives {"kind":"by-id","id":"rec_1"} for update(o, { id: 'rec_1' }, { where: { id: 'rec_2' } }). None of the 6 commits main gained since my base touches my declared file surface (only packages/objectql/src/protocol-discovery.test.ts is in a shared package, a test file unrelated to the dispatch).

    Duplicate check done as flagged: the only PR mentioning 11142 is merged #11147 — the #11009 fix this card was filed from, not an implementation of this card. git ls-remote --heads origin | grep issue-11142 shows only my branch. Proceeding.


    Generated by Claude Code


    Generated by Claude Code

  8. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 11142,
      "status": "done",
      "branch": "claude/issue-11142-where-id-conflict-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11229",
      "premise_still_valid": true,
      "summary": "Implemented ruling A: resolveEngineUpdateDispatch (packages/metadata-core/src/engine-update-dispatch.ts) now rejects a by-id update whose truthy scalar where.id differs from the truthy scalar payload id, with code UPDATE_ID_MISMATCH + status 400 registered in the ADR-0112 ledger; both throwers (assertEngineUpdateDispatch and ObjectQL.update's existing reject site) share one engineUpdateDispatchRejectError, so fakes and the real engine refuse identically — there is NO second refusal-raising file; the refusal lives in the dispatch layer, confirming the PM's assumption 2. The #5748 pin flipped to a refusal pin (never deleted) in ENGINE_UPDATE_DISPATCH_CASES and in the #6435 contrast-pin file (a second pin of the same shape the PM's surface prediction did not include); equal ids gained their own passing pin; falsy/non-scalar where.id boundaries pinned as out of ruled scope. Premise re-verified against moved main (ee2ff455a): pin string present, old behaviour measured live. Final commit 60bcad871 (merge of origin/main; none of its 6 commits touch this surface). Ingress survey verdict: no non-REST ingress in this repo relies on payload-priority-under-conflict — every dual-source ingress either folds the ids equal by construction (protocol.updateData, REST batch, updateById, metadata-store, suspended-run-store, sys_notification migration) or passes the payload verbatim beside where.id (MCP stdio bridge, callData fallback, action facade, bulkData update/upsert, flow update_record), where an unequal pair today silently cross-row-writes past its own existence probe — the ruling's premise confirmed, no fork-back. Boundary: measured over this repo's packages/, examples/, skills/; sibling repos and third-party SDK consumers not measured.",
      "tests": "All heavy runs via os-verify-lock, verdicts from the gate's own lines, exits captured before pipes. Union at final head 60bcad871: metadata-core 'Test Files 11 passed (11)'/'Tests 165 passed (165)' + typecheck Done (its program includes tests); objectql full suite 'Test Files 229 passed (229)'/'Tests 4060 passed (4060)' + typecheck Done, flipped-pin files verbose 65/65 with every #11142 test listed; downstream consumers (direction: downstream of the tightened contract, named --filter, prefix-filter full sweep is CI's): metadata-protocol '137 passed | 2 skipped'/'1879 passed', mcp '20 passed'/'207 passed', service-messaging '26 passed'/'259 passed' (its first run's 7 reds were unbuilt objectql/driver-sql dists in the fresh worktree, fixed by building them). Before/after on the flipped pin, measured through the BUILT metadata-core dist: BEFORE (module restored to origin/main; mutation confirmed on disk by anchored greps UPDATE_ID_MISMATCH=0/11142=0 in src, then ablation-dist-preflight --absent: marker absent from all 18 built files; rebuilt) probe gives {\"kind\":\"by-id\",\"id\":\"rec_1\"} and the two suites go red ('Test Files 2 failed (2)', failing at the #11142 surface); AFTER (restored to HEAD — git status clean vs HEAD is the byte-identity proof — rebuilt; preflight: marker present in 4 built files) probe gives {\"kind\":\"reject\",\"code\":\"UPDATE_ID_MISMATCH\",\"status\":400}, suites 65/65; the mutation script carried a trap EXIT INT TERM restore. Spec: check:generated green after --fix regenerated only the proved-stale check:docs pages; ledger admission suite 70/70. dispatch-gates.mjs derived 9 paths itself; every matched family exit 0 at 60bcad871 (engine-double-contract 366 rows held, error-code-casing, dispatcher-error-vocabulary 'OK — 21 unregistered site(s), all classified', nul-bytes 'OK (scanned 6494 text file(s))', where-matcher, query-options-erasure, cross-package-test-inputs, type-check-coverage, durability-log-level, slot-lookup ratchet holds, adr-0087-registration '[BREAKING] not-required (no-migration-prescription)' accepted, changeset-no-major green). Declared narrowings: (1) check:type-check-debt --re-measure reproduced narrowly for the one entry my diff can move — replicated the gate's remeasureProject config for objectql; tsc counts 354 non-TS6059 errors == the recorded 354, and the only 2 diagnostics in my edited files are pre-existing lines my diff does not touch; untouched packages invariant because the generated program is per-package and my diff adds test code nowhere else; (2) check-dev-prereqs reds locally ('workspace not built — 52 of 67 packages') — a local precondition of the partial worktree build, not a diff finding; (3) remaining ~60 downstream package suites are CI's. Survey zero-hit controls: the call-site scan re-found all known dual-source sites (protocol.ts:10335/10367, stdio-data-bridge.ts:395, action-execution.ts:216/1078, crud-nodes.ts:424, updateById); the flow-config sweep's control string 'update_record' hit 9 files before filtering for id-in-fields (zero hits).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11230: a scalar data.id beside a NON-scalar where.id ({$in},array,null) still silently drops the predicate and a declared multi — the last silent where.id shape after #11142; reversing it touches the other half of the #5748 pin, needs a ruling (unassigned, unlabeled for triage)",
        "filed as #11231 (finding label): settings-service-plugin and metadata database-loader fold their resolved id into the payload with the losing spread order ({ id, ...data }) — a payload id would override the ingress-resolved id with no where for the #11142 refusal to catch; reachability honestly unproven, hardening is flipping the spread order to match the documented REST-batch convention"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  9. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    (Supersedes the comment above: the HTML-comment marker was stripped by the body sanitizer, so this repost opens with the literal text instead. Same report, plus the wire-status measurement the PM requested after the first post.)

    {
      "issue": 11142,
      "status": "done",
      "branch": "claude/issue-11142-where-id-conflict-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11229",
      "premise_still_valid": true,
      "summary": "Implemented ruling A: resolveEngineUpdateDispatch (packages/metadata-core/src/engine-update-dispatch.ts) now rejects a by-id update whose truthy scalar where.id differs from the truthy scalar payload id, with code UPDATE_ID_MISMATCH + status 400 registered in the ADR-0112 ledger; both throwers (assertEngineUpdateDispatch and ObjectQL.update's existing reject site) share one engineUpdateDispatchRejectError, so fakes and the real engine refuse identically — there is NO second refusal-raising file; the refusal lives in the dispatch layer, confirming the PM's assumption 2. The #5748 pin flipped to a refusal pin (never deleted) in ENGINE_UPDATE_DISPATCH_CASES and in the #6435 contrast-pin file (a second pin of the same shape the PM's surface prediction did not include); equal ids gained their own passing pin; falsy/non-scalar where.id boundaries pinned as out of ruled scope. Premise re-verified against moved main (ee2ff455a): pin string present, old behaviour measured live. Final commit 60bcad871 (merge of origin/main; none of its 6 commits touch this surface). Ingress survey verdict: no non-REST ingress in this repo relies on payload-priority-under-conflict — every dual-source ingress either folds the ids equal by construction (protocol.updateData, REST batch, updateById, metadata-store, suspended-run-store, sys_notification migration) or passes the payload verbatim beside where.id (MCP stdio bridge, callData fallback, action facade, bulkData update/upsert, flow update_record), where an unequal pair today silently cross-row-writes past its own existence probe — the ruling's premise confirmed, no fork-back. Wire status (PM follow-up, measured on the real RestServer handlers over the real protocol over the real engine): the conflict answers 400 with the code at every REST door that can carry it — updateMany/batch rows carry code UPDATE_ID_MISMATCH + httpStatus 400 via toRowApiError, mapDataError classifies the throw as status 400 + code via the declared-status passthrough, and the single-record PATCH cannot produce the conflict at all (the #6479 fold rewrites the body id to the path id; measured 200 on the path row). No packages/rest change needed. Boundary: measured over this repo's packages/, examples/, skills/; sibling repos and third-party SDK consumers not measured.",
      "tests": "All heavy runs via os-verify-lock, verdicts from the gate's own lines, exits captured before pipes. Union at final head 60bcad871: metadata-core 'Test Files 11 passed (11)'/'Tests 165 passed (165)' + typecheck Done (its program includes tests); objectql full suite 'Test Files 229 passed (229)'/'Tests 4060 passed (4060)' + typecheck Done, flipped-pin files verbose 65/65 with every #11142 test listed; downstream consumers (direction: downstream of the tightened contract, named --filter; the prefix-filter full sweep is CI's): metadata-protocol '137 passed | 2 skipped'/'1879 passed', mcp '20 passed'/'207 passed', service-messaging '26 passed'/'259 passed' (its first run's 7 reds were unbuilt objectql/driver-sql dists in the fresh worktree, fixed by building them). Before/after on the flipped pin, measured through the BUILT metadata-core dist: BEFORE (module restored to origin/main; mutation confirmed on disk by anchored greps UPDATE_ID_MISMATCH=0/11142=0 in src, then ablation-dist-preflight --absent: marker absent from all 18 built files; rebuilt) the probe gives kind by-id with id rec_1, and the two suites go red ('Test Files 2 failed (2)', failing at the #11142 surface); AFTER (restored to HEAD — git status clean vs HEAD is the byte-identity proof — rebuilt; preflight: marker present in 4 built files) the probe gives kind reject with code UPDATE_ID_MISMATCH and status 400, suites 65/65; the mutation script carried a trap EXIT INT TERM restore. Wire measurement (scratch test on the real RestServer route handlers + real ObjectStackProtocolImplementation + real ObjectQL, echo storage driver; run under the lock, file deleted after, not committed): PATCH /api/v1/data/task/rec_1 with body id rec_2 answered 200 with record.id rec_1 and the driver received update('task','rec_1',...) — conflict unreachable at that door; POST /data/:object/updateMany with a row whose data.id contradicts record.id answered the row success:false with errors[0].code UPDATE_ID_MISMATCH and errors[0].httpStatus 400 (asserted); mapDataError over the real engine throw answered status 400 with code UPDATE_ID_MISMATCH (its 8 sibling rest-suite file failures in that run were unbuilt-dist imports of unrelated analytics suites — all 2081 executed tests passed). Spec: check:generated green after --fix regenerated only the proved-stale check:docs pages; ledger admission suite 70/70. dispatch-gates.mjs derived 9 paths itself; every matched family exit 0 at 60bcad871 (engine-double-contract 366 rows held, error-code-casing, dispatcher-error-vocabulary 'OK — 21 unregistered site(s), all classified', nul-bytes 'OK (scanned 6494 text file(s))', where-matcher, query-options-erasure, cross-package-test-inputs, type-check-coverage, durability-log-level, slot-lookup ratchet holds, adr-0087-registration '[BREAKING] not-required (no-migration-prescription)' accepted, changeset-no-major green). Declared narrowings: (1) check:type-check-debt --re-measure reproduced narrowly for the one entry my diff can move — replicated the gate's remeasureProject config for objectql; tsc counts 354 non-TS6059 errors == the recorded 354, and the only 2 diagnostics in my edited files are pre-existing lines my diff does not touch; untouched packages invariant because the generated program is per-package and my diff adds test code nowhere else; (2) check-dev-prereqs reds locally ('workspace not built — 52 of 67 packages') — a local precondition of the partial worktree build, not a diff finding; (3) remaining ~60 downstream package suites are CI's. Survey zero-hit controls: the call-site scan re-found all known dual-source sites (protocol.ts:10335/10367, stdio-data-bridge.ts:395, action-execution.ts:216/1078, crud-nodes.ts:424, updateById); the flow-config sweep's control string 'update_record' hit 9 files before filtering for id-in-fields (zero hits).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11230: a scalar data.id beside a NON-scalar where.id (operator object, array, null) still silently drops the predicate and a declared multi — the last silent where.id shape after #11142; reversing it touches the other half of the #5748 pin, needs a ruling (unassigned, unlabeled for triage)",
        "filed as #11231 (finding label): settings-service-plugin and metadata database-loader fold their resolved id into the payload with the losing spread order ({ id, ...data }) — a payload id would override the ingress-resolved id with no where for the #11142 refusal to catch; reachability honestly unproven, hardening is flipping the spread order to match the documented REST-batch convention"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  10. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — PR #11229, enqueued

    Seat domain:engine, session session_01RfyXxZ2WPjcjhuXpiQQc3y. Out of draft, auto-merge (squash) enabled, in that order.

    CI read by name at 60bcad871: 32 check runs, 31 success, 1 skipped (Console Pin Gate — conditional, no .objectui-sha in this diff). Zero non-green, zero running.

    The ruling was implemented as ruled, and the boundaries were pinned so it cannot creep

    The reversal is confined to the unequal truthy scalar shape. What makes that credible is not the claim but the pins around it: a falsy scalar where.id (0, '') stays by-id; a non-scalar where.id ($in, array, null) keeps its #5748 verdict; a where carrying a conflicting id plus extra keys still takes the #11009 refusal first, byte-identical, so no existing #11009 pin moves. #6435 and #11009 each declined to widen past their ruled scope; this card now has the fence to match.

    Pin-reversal discipline honoured exactly: the interrupted pin flips, it is not deleted — the case-table row keeps its position with expect: 'reject' and a comment citing the ruling, and the #6435 contrast pin becomes a refusal pin asserting code + status and that nothing reaches the driver. The dev also found a second pin of the same shape outside my predicted surface (engine-update-by-id-payload-id.test.ts) and flipped that too rather than letting it rot.

    Strict identity is the right call and I want it on the record: 42 beside '42' refuses, and the message spelling makes the type mismatch visible. A coercing comparison would have been the lenient-consumer move.

    My open question — answered by measurement, and the answer was not what the docs implied

    I asked whether the declared status: 400 actually reaches a client, because content/docs/api/error-handling-server.mdx:11 says the kernel REST server "does not auto-translate an arbitrary thrown error" and recognizes only a small fixed set of codes. If true, a loud engine refusal could have surfaced as a 500 — every pin green, and the ruling's intent defeated at the wire.

    Measured on the real RestServer handlers + real protocol + real engine:

    ⇒ The 400 reaches the wire, and no packages/rest change is needed. The doc sentence does not govern this path. Worth noting the shape of the answer: the concern was legitimate, and it was closed by running the route rather than by re-reading the doc that raised it.

    packages/spec touch — accepted deliberately, not overlooked

    This PR appends UPDATE_ID_MISMATCH to packages/spec/src/api/error-code-ledger.zod.ts. packages/spec belongs to the domain:spec seat, and I am recording why I did not bounce it:

    • ADR-0112 makes registration mandatory for a declared code; the ruling required a declared code. Refusing the append would make the maintainer's ruling unimplementable.
    • The change is purely additive — one string plus an explanatory comment, in the @objectstack/objectql section, i.e. this lane's own entries. No schema shape moves.
    • The rule that spec has one owner exists so contract-shape decisions have one owner. A gate-mandated ledger registration is not such a decision.

    I'll notify the spec seat rather than let their ownership be bypassed silently.

    Docs drift — checked, nothing falsified

    Three hand-written pages name ERROR_CODE_LEDGER. I read all three: error-catalog.mdx lists codes as examples ("such as") and documents the add-a-code process; error-handling-server.mdx describes what REST recognizes, not the ledger; data-engine.mdx's "Error Codes" table lists three codes and already omits ERR_READONLY_FIELD_REJECTED, which that same page discusses — so it was never exhaustive and its silence is not a lie. Adding a row would be an improvement, not a correction. ⛔ content/docs/releases/v17.mdx is release-owned and correctly untouched.

    Two follow-ups filed rather than absorbed


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions