Repository navigation
fix(runtime): callData's addressed-object exposure refusal carries the data door's code on the /data and /mcp doors - #23773
Conversation
…e data door's code
refuseUnexposedDataAction threw { statusCode, message } with no code, so the
dispatcher's /data door derived RESOURCE_NOT_FOUND / METHOD_NOT_ALLOWED from
the status and the /mcp tools rendered the message alone. It now throws the
same coded refusal the nested-relation target gate already threw, from one
shared 404/405 -> code table: 404 OBJECT_API_DISABLED, or 405
OBJECT_API_METHOD_NOT_ALLOWED with the effective operation set as allowed.
Statuses and messages are unchanged.
Claude-Session: https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9
Co-authored-by: Claude <noreply@anthropic.com>
…on every verb and the /mcp bridge Claude-Session: https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9 Co-authored-by: Claude <noreply@anthropic.com>
The dispatcher /data door pins assert code and status on each door and across them; data-path-object asserts OBJECT_API_DISABLED at the dispatcher exit; endpoint-executor drives the real gate's throw; the MCP rendering row for the HTTP bridge's exposure gate moves to the coded rendering; and two comments that named the old code-less shape are corrected. Claude-Session: https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9 Co-authored-by: Claude <noreply@anthropic.com>
…re refusal Claude-Session: https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9 Co-authored-by: Claude <noreply@anthropic.com>
…the reads the rig reaches check:engine-double-contract pinned its update/delete as looser than ObjectQL's; with the protocol present they are never reached. Claude-Session: https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9 Co-authored-by: Claude <noreply@anthropic.com>
…posure-refusal-code
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 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 7df99d741677b48c6267d1a4e52689843cb70364 && git checkout 7df99d741677b48c6267d1a4e52689843cb70364
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 b2666435dcaa2b2ca744c4ef03b1ba4484df3559 && git checkout -B drift-repro 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 && git merge --no-ff b2666435dcaa2b2ca744c4ef03b1ba4484df3559
node scripts/docs-audit/affected-docs.mjs --json 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5
|
席位核对 · ACCEPT · PR #23773 @
|
Fixes #23528
Clause-②: no
What changed
packages/runtime/src/action-execution.ts, the producer.refuseUnexposedDataActionis the exposure gate everycallDataasks first. The runtime dispatcher's/datadomain also asks it before its request gates. It threw{ statusCode: gate.status ?? 403, message }with nocode.Errorcarrying:codeandstatus:OBJECT_API_DISABLED/ 404, orOBJECT_API_METHOD_NOT_ALLOWED/ 405;object;allowedon a 405.exposureRefusalFields. Both exposure refusals in the file stamp from it: this one, and the nested-relation target refusal (unservedRelationTargetError), which used to spell the table inline. Each keeps its own message text.What a caller reads now, for the same declaration:
apiEnabled: falseapiMethods/data(and declared endpoints), before404 RESOURCE_NOT_FOUND405 METHOD_NOT_ALLOWED/data(and declared endpoints), after404 OBJECT_API_DISABLED405 OBJECT_API_METHOD_NOT_ALLOWED/mcpdata tools, before/mcpdata tools, after{ error: { code: OBJECT_API_DISABLED, message, status: 404 } }{ error: { code: OBJECT_API_METHOD_NOT_ALLOWED, message, status: 405 } }RestServer, MCP stdioOBJECT_API_DISABLEDOBJECT_API_METHOD_NOT_ALLOWEDChangeset:
.changeset/23528-exposure-refusal-code.md,@objectstack/runtimepatch. It names the FROM and TO codes for a caller that branched on the derived ones.Mechanism readings (the dispatch's hypotheses)
534ca13ebwasaction-execution.ts:311,throw { statusCode: gate.status ?? 403, message: gate.reason ?? 'API access denied' }. Confirmed.?? 403arm.checkApiExposure(api-exposure.ts:113) denies with exactly two statuses:404whenapiEnabled === false(:124) and405for a whitelist miss (:139). Every denial also setsreason. So the?? 403arm and the'API access denied'fallback were unreachable, and the shared mapping has no third arm. The message fallback stays only becausereasonis typed optional./datadoor answeredMETHOD_NOT_ALLOWED/RESOURCE_NOT_FOUND, as PR fix(rest,runtime)!: a JSON null /data create or query is refused 400 on both doors; the dispatcher query validates FindDataRequestSchema and asks exposure first #23617 measured. On its rig (a real store,RestServer's registered handler besideHttpDispatcher.dispatch()), both doors now answer the samestatusANDcodefor every body (pins below). Statuses did not move.statusCode: 40x, the gate's messages and the prose that names the gate's shape, repo-wide. Every pin that held the old semantics moved in this PR (table below).ERROR_CODE_LEDGER['@objectstack/runtime']already lists both codes. No ledger row is added andcheck:error-code-provenancehas nothing new to hold. Its comment names only the nested-relation walk as the runtime's emitter (see Acceptance notes).Pins
runtime/src/action-execution-addressed-exposure-refusal.test.ts(new, 16 cases)ax_hidden(apiEnabled: false) andax_getonly(apiMethods: ['get']). EverycallDataaction (create,get,update,delete,query,find,aggregate) onax_hiddenrejects with anErrorcarryingcodeOBJECT_API_DISABLED,status404,object, and the gate's message, and asks neither the protocol nor the engine.createonax_getonlygives 405OBJECT_API_METHOD_NOT_ALLOWEDwithallowed: ['get']. Every/mcpbridge data member (buildMcpBridge:query,get,aggregate,create,update,remove) hands its tool the same coded refusal. Two controls: a granted verb is served, and a system context is not asked.runtime/src/domains/data-write-request-gate.test.ts(PR #23617's rig){ status, code }equal to405 OBJECT_API_METHOD_NOT_ALLOWED/404 OBJECT_API_DISABLEDfor every body; across doors: each body's{ status, code }equalruntime/src/domains/data-path-object.test.tstoMatchObject({ statusCode: 404 }); the wireerror.codeRESOURCE_NOT_FOUNDcodeOBJECT_API_DISABLED,status404 and the path object; the dispatcher's realerrorFromThrownanswers a conformant envelope witherror.codeOBJECT_API_DISABLEDruntime/src/endpoint-executor.test.tsstatusCoderow namedcallData's exposure gate as its producerstatusCode, with the same assertions. A new row drives the REAL gate's throw through the endpoint failure mapping:404,error.codeOBJECT_API_DISABLED.mcp/src/mcp-coded-refusal.test.ts(PR #23526){ statusCode, message }(nocode) answers its bare message"{ error: { code: 'OBJECT_API_DISABLED', message, status: 404 } }. Plus a 405 row. The generic code-less rows (new Error('engine exploded')) still hold "nothing is invented".qa/http-conformance/src/hono-declared-envelope.conformance.test.tsstatusCode-only throw is "the shapecallData's exposure gate throws"callDatathrows when no data service resolves" ({ statusCode: 503 }, which its control row already cites).Ablation
The fix was committed first. The mutation went through
node scripts/ablation-replace.mjs, with the anchor required to hit once, a restore trap, and the restore proven by blob hash plus an emptygit diff HEAD. The subject is imported from source by relative path in every file run, so nodist/leg applies.throw { statusCode: gate.status ?? 403, message: … }. Anchor count 1 to 0; blob270d6e439622to25ab1fa94db0.b6b23e102, six files. 29 failed, 152 passed (181):data-path-objectrows;data-write-request-gate: the catch-all door's 6, and the 6 cross-door rows;endpoint-executorrow.RestServer's 6 exposure rows, the controls, and the relation-target suite (action-execution-calldata-relation-target-exposure.test.ts, which pins the refactoredunservedRelationTargetError).44a7517f5, after the engine fake was trimmed. Re-run on the new file: 14 failed, 2 passed (the two controls).270d6e439622, which is HEAD's;git diff HEADempty.Verification
pnpm --filter '@objectstack/runtime^...' build:VERDICT command-exit 0. After the merge ofmain:turbo run build --filter=@objectstack/runtime... --filter=@objectstack/mcp..., 32/32 tasks, exit 0. Before the final gate run: a fullturbo run build --filter=!@objectstack/docs, 75/75 tasks, exit 0.@objectstack/runtimetest, thelocalproject, ate95c13308, run as six--shardslices. Each slice was one foreground run under the verify lock, because the whole project does not fit the container's foreground cap. Each slice:VERDICT command-exit 0, 66 files passed.repoproject (test:repo) is CI's.@objectstack/runtimetypecheck: exit 0.tsc --noEmitpasses, andcheck:test-typecheck: OK(27 files and 190 errors held in the shrink-only debt ledger, none new).@objectstack/mcp. Test: 40 files, 479 passed,VERDICT command-exit 0. Typecheck: exit 0,check:test-typecheck: OK.tsconfig.test.jsonprogram, counted with--listFiles: all 5.origin/main(140238a47), atb2666435d: the six targeted runtime files, 181 passed;mcp-coded-refusal.test.ts, 43 passed;pnpm --filter @objectstack/spec check:generated, all 14 artifacts up to date.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 families from this change set. All 65 ran onb2666435d, each exit code captured before any pipe.--ranreads: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN".check-dispatcher-error-vocabulary: OK;✓ every derivable runtime status is documented, and every documented status is reachable(check:error-status-conformance);check:route-envelope✓;check-engine-double-contract: OK;check-nul-bytes: OK;✓ check:dual-build-cjs-loads — 110 published require entry point(s) across 68 package(s) load.check:engine-double-contractwent red once, on this PR's first head. It pinned the new test's engine fake: itsupdate/deletewere looser than ObjectQL's. With the protocol present that rig never reaches them, so the fake now carries onlyfind/aggregate(44a7517f5).check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET). It was re-run after the full build.pnpm lintfirst, and the rest of the farm are CI's.Acceptance notes
ERROR_CODE_LEDGER['@objectstack/runtime']'s comment (packages/spec/src/api/error-code-ledger.zod.ts) namescallData's nested-relation walk as the runtime's emitter of the two codes. It also says "@objectstack/restkeeps its own rows for the addressed object". After this PR the runtime stamps them for the addressed object too. The rows are right and the provenance gate is green; only the prose is narrower than the emitter set. It was left alone becausepackages/specis outside this surface.checkApiExposure'sreason: "object is not exposed via the API" or "API operation 'OP' is not allowed for this object". It does not name the object, whileRestServer's does ("Object 'NAME' is not exposed via the API"). Kept as is: the card asks for the code and the status.objectand, on a 405,allowed. The dispatcher'serrorFromThrownrelays neither, because itsdetailstakes onlyissues,fieldsand a non-stringcode.RestServer's 405 body carries both. The relation-target refusal was already in the same position. Observed from source only, not measured at a door.checkApiExposure's docblock still says "The only caller iscallData". PR fix(rest,runtime)!: a JSON null /data create or query is refused 400 on both doors; the dispatcher query validates FindDataRequestSchema and asks exposure first #23617 already noted that as untrue, and it is outside this surface.Generated by Claude Code