Skip to content

fix(rest): name the conjunct that actually failed in OBJECT_API_METHOD_NOT_ALLOWED - #16511

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-15416-method-not-allowed-names-failing-conjunct
Sep 7, 2026
Merged

fix(rest): name the conjunct that actually failed in OBJECT_API_METHOD_NOT_ALLOWED#16511
os-sales merged 2 commits into
mainfrom
claude/issue-15416-method-not-allowed-names-failing-conjunct

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #15416

OBJECT_API_METHOD_NOT_ALLOWED named the conjunct that passed. An object declaring
apiMethods: ['get','list','update','bulk'] refused deleteMany, createMany and each op of a
cross-object POST /api/v1/batch with 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 bulk is not allowed; the same envelope lists bulk as allowed.

The measurement asked for first: is the failing conjunct reachable at the formatter?

Yes — it is local, and no plumbing was needed. apiAccessDenialFromEnable already receives the
whole conjunction: the enable block, the canonical operation, and the ApiAccessOpts carrying
bulkChild / writeMode that every bulk and import route passes to enforceApiAccess. The gate
does 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 isApiOperationAllowed consults its options, and there are two,
not one:

shape conjunction why the set contains the named op
bulk ∧ child deleteMany is bulk ∧ delete, createMany is bulk ∧ create bulk is declared, so it is in the set
import refined by writeMode an insert import needs create import derives from create ∨ update, so update alone puts it in the set

With 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 whole
characterisation, 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 allowed
array — delete for deleteMany, create for createMany, create / update for a refined
import — 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 (smallest combination first), never by re-spelling API_METHOD_DERIVATION in the REST
layer. @objectstack/spec stays the single derivation source of truth, so another writeMode or
another 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.

allowed is untouched, in contents and in meaning — option 1, not option 2. It is still the
object'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. updateMany and batch, which need only bulk, are still admitted;
the status, code and object fields 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.ts carries the identical template. Reporting it rather than
silently fixing one, as the triage asked:

Both surfaces derive allowed the same way — effectiveOperationsArray(resolveEffectiveApiMethods(enable)).
They differ on the check side, and that is the side that produces the contradiction.
enforceApiExposure passes no OperationCheckOptions at all, and its GATED_ACTIONS table is six
words — query, get, create, update, delete, aggregate — containing neither of the two
conjunction-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/mcp now asserts, derived
from the spec rather than hand-copied, that the set of operations whose verdict moves under
OperationCheckOptions is exactly ['bulk','import'], and that the gated action set contains
neither. 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 allowed array, 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:

leg mutation result
REST formatter put ${operation} back in the message 8 of 16 red, including the whole-space sweep
MCP envelope ship the named op inside allowedOperations 4 of 27 red
MCP gated set add a bulk entry to GATED_ACTIONS 4 of 28 red, naming the verb

The first spelling of the MCP structural assertion was verdict === membership. That identity holds
for 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.mjs Reconciliation total), plus the 37 "Artifact
rosters" families run separately: 34 green, 2 NOT WIRED without PR context, and
check:react-declaration-parity NOT MEASURED — it needs an objectui SDUI manifest this repo does not
contain, and this diff touches no React surface.

check-system-context-census reddened exactly as forewarned: rest-server.ts is anchored by
absolute 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. Verified
structurally, 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 isSystem tokens added or removed by the diff.

验收备注

Out-of-scope observations, filed nowhere and noted here for the reviewing seat:

  • The error string is the only wire field that changes. A client string-matching these bulk and
    import refusals sees the new name; the machine-readable code and allowed are stable, which is
    the contract such a client should have been reading.
  • The refusal names one conjunct when two are missing at once (an upsert child on an object
    granting neither create nor update). Naming either is truthful and neither is in allowed;
    the enum order makes the choice deterministic. Naming both would need a message-shape change,
    which is not this card.

Generated by Claude Code

os-sales and others added 2 commits September 7, 2026 05:38
…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 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 c383352cb752245899b6ca7e2dc7d233405113eepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 7798c004e5da5afbe81cb0a07febba13b18f25ca — the merge of head a6e0736c1ae3d2dd9e1e902dbd951a566971ba7f into base c383352cb752245899b6ca7e2dc7d233405113ee, 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 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

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

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OBJECT_API_METHOD_NOT_ALLOWED names an operation its own allowed array contains — the refusal reports the conjunct that PASSED

1 participant