fix(rest): name the conjunct that actually failed in OBJECT_API_METHOD_NOT_ALLOWED - #16511
Conversation
…D_NOT_ALLOWED An object declaring `apiMethods: ['get','list','update','bulk']` refused `deleteMany`, `createMany` and each op of a cross-object `POST /batch` with one indistinguishable envelope whose message named `bulk` while its own `allowed` array listed `bulk`. Every refusal was correct in outcome — `deleteMany` is `bulk AND delete`, `createMany` is `bulk AND create` — but the message named the conjunct that PASSED. The writeMode-refined `import` had the same shape: `import` derives from create OR update, so `update` alone puts `import` in the effective set while an `insert` import still needs `create`. The refusal now names a conjunct that is genuinely absent from the same envelope's `allowed` array, and still names `bulk` when the `bulk` primitive is the half the object withholds. The failing conjunct is found by asking the spec's own `isApiOperationAllowed` over a widened whitelist rather than by re-spelling its derivation table in the REST layer, so a future refinement is tracked with no second spelling to drift from it. `allowed` is unchanged in contents and in meaning: it is still the object's declared effective set, not the set the gate evaluated against. That field is read as a discriminator, so redefining it would silently retarget its consumer. No gate decision moved. The stdio MCP surface carries the same message template and needs no repair: `enforceApiExposure` passes no OperationCheckOptions, so its verdict reduces to membership in the very set it serializes, and its gated action set contains neither of the two conjunction-bearing operations. A structural pin now holds that, so re-opening the class from that door reddens instead of shipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
The first spelling asserted that the gate verdict equals membership in the serialized set. That identity holds for EVERY operation word when no options are passed, so no drift could ever break it — a phantom check dressed as the guard that keeps the refusal honest on this surface. It now derives, from the spec itself, the set of operations whose verdict MOVES under OperationCheckOptions — measured as exactly `bulk` and `import` — and asserts the gated action set contains neither. Adding a conjunction-bearing verb to the bridge, or threading options into the check, now reddens here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 13 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 7798c004e5da5afbe81cb0a07febba13b18f25ca && git checkout 7798c004e5da5afbe81cb0a07febba13b18f25ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c383352cb752245899b6ca7e2dc7d233405113ee a6e0736c1ae3d2dd9e1e902dbd951a566971ba7f && git checkout -B drift-repro c383352cb752245899b6ca7e2dc7d233405113ee && git merge --no-ff a6e0736c1ae3d2dd9e1e902dbd951a566971ba7f
node scripts/docs-audit/affected-docs.mjs --json c383352cb752245899b6ca7e2dc7d233405113ee |
Fixes #15416
OBJECT_API_METHOD_NOT_ALLOWEDnamed the conjunct that passed. An object declaringapiMethods: ['get','list','update','bulk']refuseddeleteMany,createManyand each op of across-object
POST /api/v1/batchwith one indistinguishable envelope:{ "error": "API operation 'bulk' is not allowed on object 'sys_user'", "code": "OBJECT_API_METHOD_NOT_ALLOWED", "allowed": ["get","list","update","bulk","aggregate","history","search","import","export"] }The message says
bulkis not allowed; the same envelope listsbulkas allowed.The measurement asked for first: is the failing conjunct reachable at the formatter?
Yes — it is local, and no plumbing was needed.
apiAccessDenialFromEnablealready receives thewhole conjunction: the
enableblock, the canonical operation, and theApiAccessOptscarryingbulkChild/writeModethat every bulk and import route passes toenforceApiAccess. The gatedoes not discard the child before the formatter runs — the formatter simply never asked which half
of the conjunction was false.
The measurement also widened the defect's shape by one case. The refusal is self-contradicting in
exactly the situations where
isApiOperationAllowedconsults its options, and there are two,not one:
bulk ∧ childdeleteManyisbulk ∧ delete,createManyisbulk ∧ createbulkis declared, so it is in the setimportrefined bywriteModeinsertimport needscreateimportderives from create ∨ update, soupdatealone puts it in the setWith no options passed the verdict reduces to membership in the very set that is serialized as
allowed, so message and set are two reads of one set and cannot disagree. That is the wholecharacterisation, and it is what makes the MCP finding below decidable rather than a guess.
What changed
The 405 message now names a conjunct that is genuinely absent from the same envelope's
allowedarray —
deletefordeleteMany,createforcreateMany,create/updatefor a refinedimport — and still names
bulkwhen thebulkprimitive is the half the object withholds.The failing conjunct is found by asking the spec's own
isApiOperationAllowedover a widenedwhitelist (smallest combination first), never by re-spelling
API_METHOD_DERIVATIONin the RESTlayer.
@objectstack/specstays the single derivation source of truth, so anotherwriteModeoranother derived verb is tracked with no second spelling to drift away from this one. The search
runs only on the 405 path, over six primitives.
allowedis untouched, in contents and in meaning — option 1, not option 2. It is still theobject's declared effective closure, not the set the gate evaluated against, because that array is
read as a discriminator: a declaration re-widened to create/update can still 405 for an unrelated
reason, so only the set proves WHICH gate answered. A sweep over the whole declaration space
asserts the array is byte-identical to
effectiveOperationsArray(resolveEffectiveApiMethods(...)).⛔ No gate decision moved.
updateManyandbatch, which need onlybulk, are still admitted;the status,
codeandobjectfields are unchanged. Only the name inside the message differs.The second emit site: one fix did NOT serve both, and MCP needed no repair
packages/mcp/src/stdio-data-bridge.tscarries the identical template. Reporting it rather thansilently fixing one, as the triage asked:
Both surfaces derive
allowedthe same way —effectiveOperationsArray(resolveEffectiveApiMethods(enable)).They differ on the check side, and that is the side that produces the contradiction.
enforceApiExposurepasses noOperationCheckOptionsat all, and itsGATED_ACTIONStable is sixwords —
query,get,create,update,delete,aggregate— containing neither of the twoconjunction-bearing operations. So on stdio the verdict is membership in the set it serializes, and
the class is structurally unreachable. There is nothing there for the REST repair to repair.
Copying the helper across would also mean giving it a shared home, and the only home both packages
can import from is
packages/spec/src/**— a published-contract surface and out of scope here.What this PR adds instead is the pin that keeps the claim true.
packages/mcpnow asserts, derivedfrom the spec rather than hand-copied, that the set of operations whose verdict moves under
OperationCheckOptionsis exactly['bulk','import'], and that the gated action set containsneither. Adding a conjunction-bearing verb to the bridge, or threading options into the check,
reddens there instead of re-opening this from the stdio door.
Tests
Both doors, with the failing conjunct asserted by name — every assertion reads the name back out of
the wire message and grades it against the same envelope's
allowedarray, so "some 405 happened"cannot satisfy any of them.
Ablated to prove they discriminate, each leg mutating committed code, proving the mutation reached
disk by hash and by anchor counts, and restoring from
HEAD:${operation}back in the messageallowedOperationsbulkentry toGATED_ACTIONSThe first spelling of the MCP structural assertion was
verdict === membership. That identity holdsfor every operation word when no options are passed, so it was a phantom check no drift could break;
it was replaced with the derived one described above, and the replacement is what the third leg
reddens.
Gates
85 of 85 derived families green (
dispatch-gates.mjsReconciliation total), plus the 37 "Artifactrosters" families run separately: 34 green, 2 NOT WIRED without PR context, and
check:react-declaration-parityNOT MEASURED — it needs an objectui SDUI manifest this repo does notcontain, and this diff touches no React surface.
check-system-context-censusreddened exactly as forewarned:rest-server.tsis anchored byabsolute line number and the helper shifted all eight anchors by +70. Repaired with the gate's own
--fix, which rewrote a pure shift and would have refused a population change. Verifiedstructurally, never by comparing line content: sites 105, packages 19, files 44, anchors 140,
non-read 27, row references 19, unheld 2 — identical before and after against a re-validated non-zero
control, with zero
isSystemtokens added or removed by the diff.验收备注
Out-of-scope observations, filed nowhere and noted here for the reviewing seat:
errorstring is the only wire field that changes. A client string-matching these bulk andimport refusals sees the new name; the machine-readable
codeandallowedare stable, which isthe contract such a client should have been reading.
upsertchild on an objectgranting neither
createnorupdate). Naming either is truthful and neither is inallowed;the enum order makes the choice deterministic. Naming both would need a message-shape change,
which is not this card.
Generated by Claude Code