Skip to content

fix(runtime): callData's addressed-object exposure refusal carries the data door's code on the /data and /mcp doors - #23773

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23528-exposure-refusal-code
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23528-exposure-refusal-code

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23528
Clause-②: no

What changed

packages/runtime/src/action-execution.ts, the producer.

  • refuseUnexposedDataAction is the exposure gate every callData asks first. The runtime dispatcher's /data domain also asks it before its request gates. It threw { statusCode: gate.status ?? 403, message } with no code.
  • It now throws an Error carrying:
    • code and status: OBJECT_API_DISABLED / 404, or OBJECT_API_METHOD_NOT_ALLOWED / 405;
    • the addressed object as object;
    • the effective operation set as allowed on a 405.
  • The 404/405 to code mapping is now one function, 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.
  • The status and the message are unchanged.

What a caller reads now, for the same declaration:

door apiEnabled: false verb outside apiMethods
dispatcher /data (and declared endpoints), before 404 RESOURCE_NOT_FOUND 405 METHOD_NOT_ALLOWED
dispatcher /data (and declared endpoints), after 404 OBJECT_API_DISABLED 405 OBJECT_API_METHOD_NOT_ALLOWED
/mcp data tools, before the message alone the message alone
/mcp data tools, after { error: { code: OBJECT_API_DISABLED, message, status: 404 } } { error: { code: OBJECT_API_METHOD_NOT_ALLOWED, message, status: 405 } }
RestServer, MCP stdio unchanged: OBJECT_API_DISABLED unchanged: OBJECT_API_METHOD_NOT_ALLOWED

Changeset: .changeset/23528-exposure-refusal-code.md, @objectstack/runtime patch. It names the FROM and TO codes for a caller that branched on the derived ones.

Mechanism readings (the dispatch's hypotheses)

  1. The throw at 534ca13eb was action-execution.ts:311, throw { statusCode: gate.status ?? 403, message: gate.reason ?? 'API access denied' }. Confirmed.
  2. The ?? 403 arm. checkApiExposure (api-exposure.ts:113) denies with exactly two statuses: 404 when apiEnabled === false (:124) and 405 for a whitelist miss (:139). Every denial also sets reason. So the ?? 403 arm and the 'API access denied' fallback were unreachable, and the shared mapping has no third arm. The message fallback stays only because reason is typed optional.
  3. The dispatcher /data door answered METHOD_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 beside HttpDispatcher.dispatch()), both doors now answer the same status AND code for every body (pins below). Statuses did not move.
  4. Pin sweep. I grepped both codes, the derived codes, 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).
  5. ERROR_CODE_LEDGER['@objectstack/runtime'] already lists both codes. No ledger row is added and check:error-code-provenance has nothing new to hold. Its comment names only the nested-relation walk as the runtime's emitter (see Acceptance notes).

Pins

file before after
runtime/src/action-execution-addressed-exposure-refusal.test.ts (new, 16 cases) none The card's pin, on fixture objects ax_hidden (apiEnabled: false) and ax_getonly (apiMethods: ['get']). Every callData action (create, get, update, delete, query, find, aggregate) on ax_hidden rejects with an Error carrying code OBJECT_API_DISABLED, status 404, object, and the gate's message, and asks neither the protocol nor the engine. create on ax_getonly gives 405 OBJECT_API_METHOD_NOT_ALLOWED with allowed: ['get']. Every /mcp bridge 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) per door: status only; across doors: statuses equal, "the two doors' exposure envelopes carry different codes" per door: { status, code } equal to 405 OBJECT_API_METHOD_NOT_ALLOWED / 404 OBJECT_API_DISABLED for every body; across doors: each body's { status, code } equal
runtime/src/domains/data-path-object.test.ts the throw toMatchObject({ statusCode: 404 }); the wire error.code RESOURCE_NOT_FOUND the throw carries code OBJECT_API_DISABLED, status 404 and the path object; the dispatcher's real errorFromThrown answers a conformant envelope with error.code OBJECT_API_DISABLED
runtime/src/endpoint-executor.test.ts the statusCode row named callData's exposure gate as its producer That row now names the producers that still throw statusCode, with the same assertions. A new row drives the REAL gate's throw through the endpoint failure mapping: 404, error.code OBJECT_API_DISABLED.
mcp/src/mcp-coded-refusal.test.ts (PR #23526) "the HTTP bridge exposure gate's { statusCode, message } (no code) answers its bare message" Moved to the coded rendering. 404: { 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.ts header comment: a statusCode-only throw is "the shape callData's exposure gate throws" Comment only: "the shape callData throws 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 empty git diff HEAD. The subject is imported from source by relative path in every file run, so no dist/ leg applies.

  • Mutation. The gate's new throw was replaced with the old throw { statusCode: gate.status ?? 403, message: … }. Anchor count 1 to 0; blob 270d6e439622 to 25ab1fa94db0.
  • Leg 1, at b6b23e102, six files. 29 failed, 152 passed (181):
    • the 14 new non-control pins;
    • both data-path-object rows;
    • 12 dispatcher rows in data-write-request-gate: the catch-all door's 6, and the 6 cross-door rows;
    • the new endpoint-executor row.
  • What stayed green in leg 1. RestServer's 6 exposure rows, the controls, and the relation-target suite (action-execution-calldata-relation-target-exposure.test.ts, which pins the refactored unservedRelationTargetError).
  • Leg 2, at 44a7517f5, after the engine fake was trimmed. Re-run on the new file: 14 failed, 2 passed (the two controls).
  • Restore, both legs. Blob back to 270d6e439622, which is HEAD's; git diff HEAD empty.

Verification

  • Dependency closure. pnpm --filter '@objectstack/runtime^...' build: VERDICT command-exit 0. After the merge of main: turbo run build --filter=@objectstack/runtime... --filter=@objectstack/mcp..., 32/32 tasks, exit 0. Before the final gate run: a full turbo run build --filter=!@objectstack/docs, 75/75 tasks, exit 0.
  • @objectstack/runtime test, the local project, at e95c13308, run as six --shard slices. 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.
    • Totals: 396 files, 5,935 tests passed, 19 skipped.
    • The repo project (test:repo) is CI's.
  • @objectstack/runtime typecheck: exit 0. tsc --noEmit passes, and check: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.
  • Every touched test file is compiled by its package's tsconfig.test.json program, counted with --listFiles: all 5.
  • After the merge of origin/main (140238a47), at b2666435d: 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.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 families from this change set. All 65 ran on b2666435d, each exit code captured before any pipe.
    • --ran reads: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN".
    • Verdict lines include: 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-contract went red once, on this PR's first head. It pinned the new test's engine fake: its update/delete were looser than ObjectQL's. With the protocol present that rig never reaches them, so the fake now carries only find / aggregate (44a7517f5).
    • The first run of check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET). It was re-run after the full build.
  • CI's runs. The repo-wide scans, with pnpm lint first, and the rest of the farm are CI's.

Acceptance notes

  • Ledger prose. ERROR_CODE_LEDGER['@objectstack/runtime']'s comment (packages/spec/src/api/error-code-ledger.zod.ts) names callData's nested-relation walk as the runtime's emitter of the two codes. It also says "@objectstack/rest keeps 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 because packages/spec is outside this surface.
  • Message wording. The addressed refusal's message is checkApiExposure's reason: "object is not exposed via the API" or "API operation 'OP' is not allowed for this object". It does not name the object, while RestServer's does ("Object 'NAME' is not exposed via the API"). Kept as is: the card asks for the code and the status.
  • Context fields on the dispatcher wire. The thrown refusal carries object and, on a 405, allowed. The dispatcher's errorFromThrown relays neither, because its details takes only issues, fields and a non-string code. 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.
  • Docblock left as is. checkApiExposure's docblock still says "The only caller is callData". 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

…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>
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 5 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via refuseUnexposedDataAction (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via OBJECT_API_DISABLED (literal, a string literal in exposureRefusalFields; a string literal in unservedRelationTargetError), OBJECT_API_METHOD_NOT_ALLOWED (literal, a string literal in exposureRefusalFields; a string literal in unservedRelationTargetError))
  • content/docs/ui/setup-app.mdx (via OBJECT_API_DISABLED (literal, a string literal in exposureRefusalFields; a string literal in unservedRelationTargetError))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via OBJECT_API_METHOD_NOT_ALLOWED (literal, a string literal in exposureRefusalFields; a string literal in unservedRelationTargetError))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7df99d741677b48c6267d1a4e52689843cb70364 — the merge of head b2666435dcaa2b2ca744c4ef03b1ba4484df3559 into base 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 941c78ae2e502edb581bcb540e2b62cc1d8fb0a5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · PR #23773 @ b2666435dc (#23528) · domain:spec seat 1 · os-elon-musk · session session_01HjR8RUwTPQ3TgbfKihmxp9 · 2026-10-16T01:06Z

Reviewer of record. Every item below was checked against GitHub, not against the report (os-dev-report 6205586258 on #23528).

  • Shape:
    • Draft, base main. The first line is Fixes #23528, the body's only closing keyword, then Clause-②: no, matching claim 6203688106.
    • 8 files, +329/−54, none governed.
    • No published schema and no Clause-②: yes: the two codes already sit in ERROR_CODE_LEDGER (REST answers them), and statuses do not move. So no contract review is owed; CI plus this read decide it.
  • The card's "done", read in the diff:
    • refuseUnexposedDataAction now throws an Error built from exposureRefusalFields(gate): 404 OBJECT_API_DISABLED, or 405 OBJECT_API_METHOD_NOT_ALLOWED with allowed. The addressed object is object, and the message is unchanged.
    • The nested-relation target refusal stamps from the same table. One table means one callData never answers one verdict with two codes, and that is not a second spelling of the mapping.
    • The removed ?? 403 arm was unreachable: the report measured that checkApiExposure denies only with 404 or 405 and always sets reason, and the table has no third arm.
  • Pin sweep (the dispatch's two clauses): pins move rather than disappear, and each asserts the new code plus the status:
  • Changeset: @objectstack/runtime patch, sentences checked against the diff. The FROM → TO table matches the three doors (dispatcher /data and declared endpoints 404/405 code change, /mcp text → coded envelope), and "the status and the message do not change" matches the throw.
  • Out of scope: Acceptance notes only, no producer and no wrong door answer.
    • the ledger comment naming only the nested-relation emitter;
    • the addressed refusal's message not naming the object;
    • the dispatcher's errorFromThrown not relaying object / allowed.
  • CI on this head at this act: 27 success, 6 skipped, 6 pending, 0 failure. The seat readies and arms auto-merge once every check is green.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 01:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 01:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit 9fcea20 Oct 16, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23528-exposure-refusal-code branch October 16, 2026 01:46
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

2 participants