From c1431a2b44f54e71a0702085d37d5bb377227559 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:47:01 +0000 Subject: [PATCH 1/4] fix(runtime)!: callData asks each nested-relation condition's target its exposure before the engine reads it The query fallback (no protocol service) and the aggregate arm hand a caller's where to the ObjectQL engine, which lowers a nested-relation condition by reading the related object as the caller. Each condition's target is now asked the gate the addressed object is asked, for list, through the shared relationConditionSites walk, and an unserved target refuses the call with the data door's 404 OBJECT_API_DISABLED / 405 OBJECT_API_METHOD_NOT_ALLOWED. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...-calldata-relation-target-exposure.test.ts | 155 ++++++++++++++++++ packages/runtime/src/action-execution.ts | 116 ++++++++++++- 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts diff --git a/packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts b/packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts new file mode 100644 index 00000000000..fe941868e29 --- /dev/null +++ b/packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22823] Census row 10 of #22661 — `callData` hands a caller's `where` to the + * ObjectQL ENGINE on two arms: the `query` fallback (no protocol service + * resolved) and the `aggregate` arm (which never takes the protocol). The engine + * lowers a NESTED-RELATION condition by reading the related object as the + * caller, so each condition's TARGET is asked the exposure gate the addressed + * object is asked, for `list`, before the engine is called, and a target the + * gate does not serve refuses the call with the data door's own answer (`404 + * OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` with the + * effective operation set). + * + * Asserted on the envelope (`code` and `status`) and on the engine: a refused + * condition never reaches `find` / `aggregate`. Each refused shape is paired + * with a CONTROL the gate serves, which reaches the engine unchanged, and the + * protocol path is pinned untouched — with the protocol service present the + * query is the data door's to judge, and this door hands it over verbatim. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { callData, type ActionExecutionDeps } from './action-execution.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; + +const REQ = { request: {} } as HttpProtocolContext; +const MEMBER = { userId: 'rc_member', isSystem: false, positions: [], permissions: [] } as any; + +const lookup = (name: string, reference: string) => ({ name, label: name, type: 'lookup', reference }); + +const object = (name: string, enable?: Record, extra: Record = {}) => ({ + name, + label: name, + ...(enable ? { enable } : {}), + fields: { + id: { name: 'id', label: 'ID', type: 'text' }, + name: { name: 'name', label: 'Name', type: 'text' }, + ...extra, + }, +}); + +const SCHEMAS: Record = { + rc_source: object('rc_source', undefined, { + hidden: lookup('hidden', 'rc_hidden'), + closed: lookup('closed', 'rc_closed'), + denyall: lookup('denyall', 'rc_denyall'), + getonly: lookup('getonly', 'rc_getonly'), + listonly: lookup('listonly', 'rc_listonly'), + open: lookup('open', 'rc_open'), + }), + // `apiEnabled: false` is judged FIRST: a whitelist granting `list` beside it changes nothing. + rc_hidden: object('rc_hidden', { apiEnabled: false, apiMethods: ['list'] }), + rc_closed: object('rc_closed', { apiMethods: ['create'] }), + rc_denyall: object('rc_denyall', { apiMethods: [] }), + rc_getonly: object('rc_getonly', { apiMethods: ['get'] }), + rc_listonly: object('rc_listonly', { apiMethods: ['list'] }), + rc_open: object('rc_open'), +}; + +/** Each refused target, the door's answer, and the effective set a 405 names. */ +const REFUSED = { + hidden: { status: 404, code: 'OBJECT_API_DISABLED', allowed: undefined }, + closed: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['create', 'import'] }, + denyall: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: [] }, + getonly: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['get'] }, +} as const; +const SERVED = ['listonly', 'open'] as const; + +/** + * The engine `callData` falls back to, with its registry (the schema the + * engine's relation lowering reads) and spies on the two verbs a caller's + * `where` reaches. `withProtocol` adds the data door, which then serves the + * query arm. + */ +function makeHarness(opts: { withProtocol?: boolean } = {}) { + const find = vi.fn(async () => [{ id: 's1', name: 'source' }]); + const aggregate = vi.fn(async () => [{ n: 1 }]); + const findData = vi.fn(async (req: any) => ({ object: req.object, records: [], total: 0, hasMore: false })); + const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, aggregate }; + const services: Record = { + metadata: { getObject: async (n: string) => SCHEMAS[n] }, + objectql: engine, + ...(opts.withProtocol ? { protocol: { findData } } : {}), + }; + const deps: ActionExecutionDeps = { + resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any, + getObjectQL: async () => engine, + }; + return { deps, find, aggregate, findData }; +} + +const refusal = (e: any) => ({ code: e?.code, status: e?.status, object: e?.object, param: e?.param, allowed: e?.allowed }); + +describe('[#22823] callData asks each nested-relation condition\'s target its exposure before the engine reads it', () => { + for (const [rel, answer] of Object.entries(REFUSED)) { + it(`query fallback: ${rel} is refused ${answer.status} ${answer.code}, top level and under a combinator`, async () => { + const h = makeHarness(); + for (const where of [{ [rel]: { name: 'x' } }, { name: 'source', $or: [{ [rel]: { name: 'x' } }] }]) { + const err: any = await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where } }, undefined, undefined, MEMBER) + .catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(refusal(err)).toEqual({ code: answer.code, status: answer.status, object: `rc_${rel}`, param: `where.${rel}`, allowed: answer.allowed }); + } + expect(h.find).not.toHaveBeenCalled(); + }); + + it(`aggregate arm: ${rel} is refused ${answer.status} ${answer.code} before the engine aggregates`, async () => { + const h = makeHarness(); + const err: any = await callData( + h.deps, REQ, 'aggregate', + { object: 'rc_source', where: { [rel]: { name: 'x' } }, aggregations: [{ function: 'count', alias: 'n' }] }, + undefined, undefined, MEMBER, + ).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(refusal(err)).toEqual({ code: answer.code, status: answer.status, object: `rc_${rel}`, param: `where.${rel}`, allowed: answer.allowed }); + expect(h.aggregate).not.toHaveBeenCalled(); + }); + } + + for (const rel of SERVED) { + it(`CONTROL ${rel}: served, the condition reaches the engine unchanged on both arms`, async () => { + const h = makeHarness(); + await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { [rel]: { name: 'x' } } } }, undefined, undefined, MEMBER); + expect(h.find).toHaveBeenCalledTimes(1); + expect((h.find.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } }); + await callData( + h.deps, REQ, 'aggregate', + { object: 'rc_source', where: { [rel]: { name: 'x' } }, aggregations: [{ function: 'count', alias: 'n' }] }, + undefined, undefined, MEMBER, + ); + expect(h.aggregate).toHaveBeenCalledTimes(1); + expect((h.aggregate.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } }); + }); + } + + it('CONTROL: a plain id comparand on the same lookups is the source\'s own column and is served', async () => { + const h = makeHarness(); + await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { hidden: 'h1', getonly: { $in: ['g1'] } } } }, undefined, undefined, MEMBER); + expect(h.find).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: a system context is not asked, as the addressed object\'s gate is not', async () => { + const h = makeHarness(); + await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { hidden: { name: 'x' } } } }, undefined, undefined, { isSystem: true } as any); + expect(h.find).toHaveBeenCalledTimes(1); + }); + + it('the protocol path is untouched: the data door gets the query verbatim and the engine is never called here', async () => { + const h = makeHarness({ withProtocol: true }); + const query = { where: { hidden: { name: 'x' } } }; + await callData(h.deps, REQ, 'query', { object: 'rc_source', query }, undefined, undefined, MEMBER); + expect(h.findData).toHaveBeenCalledTimes(1); + expect((h.findData.mock.calls[0] as any[])[0].query).toEqual(query); + expect(h.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 01991a5ab62..1381f933c33 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -18,7 +18,7 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } from '@objectstack/spec/ui'; // [#15124] The facade's `find` judges its own parameter against the SAME // declaration its type names, so the refusal and the type cannot drift apart. -import { EngineQueryOptionsSchema } from '@objectstack/spec/data'; +import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it @@ -30,7 +30,12 @@ import { type AIActionConfirmation, type ActionConfirmationRequiredDetails, } from '@objectstack/spec/contracts'; -import { checkApiExposure } from './api-exposure.js'; +import { checkApiExposure, type ApiExposureDecision, type ObjectApiDef } from './api-exposure.js'; +// [#22823] The ONE walk every door that hands a caller's filter to the engine +// takes to find its nested-relation conditions and each one's target — the data +// door's and the MCP stdio bridge's, imported rather than re-walked so this door +// cannot disagree with them about which conditions a filter holds. +import { relationConditionSites } from '@objectstack/core'; // [#9446] The ONE #9378 status table. Imported rather than re-read here: this // door's blanket `FLOW_FAILED` was the second of three readings of one engine // result, and a second definition of the rule is what let the doors diverge. @@ -145,6 +150,11 @@ export interface ActionExecutionDeps { * engine's live registry first, then the metadata service. `undefined` when * neither answers — callers FAIL CLOSED on that (echo no record), because an * unjudged record is exactly the one that may carry a stored credential. + * + * [#22823] It is also the read {@link refuseUnservedRelationTargets} takes, for + * the filtered object's field map and each condition target's `enable` block: + * the engine's own registry first is the schema the engine's relation lowering + * reads. That caller falls OPEN on `undefined`, as the exposure gate does. */ export async function resolveWriteResponseSchema( deps: ActionExecutionDeps, @@ -168,6 +178,94 @@ export async function resolveWriteResponseSchema( } } +/** + * [#22823] The runtime action a NESTED-RELATION condition's TARGET is judged as + * by this door's exposure gate: `query`, which the spec's derivation + * (`DATA_ACTION_TO_API_OPERATION`) maps to `list`. A condition is a predicate + * over the whole target — the engine runs `find(target, { where: condition })` + * bounded by a match cap, not by ids the caller holds — which is the read the + * target's own filtered query performs. The data door judges the same target as + * `list` (its own `RELATION_CONDITION_TARGET_OPERATION`, `@objectstack/metadata-protocol`), + * and so does the MCP stdio bridge (`GATED_ACTIONS.query`). + */ +const RELATION_CONDITION_TARGET_ACTION = 'query'; +/** The canonical operation that action derives to, named in the refusal. */ +const RELATION_CONDITION_TARGET_OPERATION = DATA_ACTION_TO_API_OPERATION[RELATION_CONDITION_TARGET_ACTION]; + +/** + * [#22823] Census row 10 of #22661. `callData` hands a caller's `where` to the + * ObjectQL ENGINE on two arms: the `query`/`find` fallback that serves when no + * protocol service resolves, and the `aggregate` arm, which never takes the + * protocol. The engine lowers each NESTED-RELATION condition + * (`{ : { : … } }`) by reading the related + * object as the caller (`ObjectQL.lowerRelationConditions`) and keeping the + * rows whose related record matched, so the condition is a predicate over an + * object nobody addressed. The exposure gate at the top of `callData` judges + * only the ADDRESSED object. + * + * So each condition's TARGET is asked the same gate the addressed object is + * asked ({@link checkApiExposure}, for {@link RELATION_CONDITION_TARGET_ACTION}), + * before the engine is called, and a target the gate does not serve REFUSES the + * call. Refused, not withheld: dropping the condition would widen the result. + * The conditions are found by the one walk the data door + * (`refuseUnservedRelationTarget`, #22737) and the MCP stdio bridge + * (`enforceRelationTargetExposure`, #22777) take, `relationConditionSites` from + * `@objectstack/core`, and the refusal is the data door's: the protocol path + * this fallback stands in for answers the same condition `404 + * OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`, and one + * `callData` must not give two answers depending on whether the deployment + * registered the protocol slot (the #5138 rule this file already keeps). + * + * Callers skip it for a system context, exactly as they skip the addressed + * gate: these flags govern API exposure, not the engine's own reads. A schema + * that cannot be resolved finds no condition (the filtered object) or serves + * (a target), the gate's own fail-open. + */ +async function refuseUnservedRelationTargets( + deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, + engine: unknown, + object: string, + where: unknown, + scopeId?: string, +): Promise { + if (where == null || typeof where !== 'object') return; + const schema = await resolveWriteResponseSchema(deps, requestContext, engine, object, scopeId); + const fields = (schema as { fields?: Record } | undefined)?.fields; + for (const site of relationConditionSites(fields, where)) { + const target = await resolveWriteResponseSchema(deps, requestContext, engine, site.target, scopeId); + const gate = checkApiExposure(target as ObjectApiDef | undefined, RELATION_CONDITION_TARGET_ACTION); + if (!gate.allowed) throw unservedRelationTargetError(site.target, `where.${site.field}`, gate); + } +} + +/** + * [#22823] The data door's refusal of an unserved nested-relation condition + * target, field for field (`refuseUnservedRelationTarget`): `code` and `status` + * by the gate's verdict, the TARGET as `object` (the route's object is not the + * one switched off), the condition's position as `param`, and the effective + * operation set as `allowed` on a 405. + */ +function unservedRelationTargetError(target: string, param: string, gate: ApiExposureDecision): Error { + const consequence = `, so the condition on it at '${param}' was not evaluated and the query was not run. ` + + 'Filter on the lookup\'s stored id instead, or remove the condition.'; + const disabled = gate.status === 404; + const err: any = new Error(disabled + ? `Object '${target}' is not exposed via the API${consequence}` + : `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`); + if (disabled) { + err.code = 'OBJECT_API_DISABLED'; + err.status = 404; + } else { + err.code = 'OBJECT_API_METHOD_NOT_ALLOWED'; + err.status = 405; + err.allowed = gate.allowedOperations ?? []; + } + err.object = target; + err.param = param; + return err; +} + /** The engine for a schema lookup, or `undefined` — a lookup failure is judged by the caller's fail-closed branch. */ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: HttpProtocolContext | undefined, envId?: string): Promise { try { @@ -390,6 +488,12 @@ export async function callData(deps: ActionExecutionDeps, `normalization this path serves only canonical QueryAST keys (${FALLBACK_QUERY_KEYS.join(', ')}).`, }; } + // [#22823] …and each nested-relation condition's TARGET, before the + // engine reads it — the protocol path asks the same of the same + // sites (`refuseUnservedRelationTarget`). + if (!executionContext?.isSystem) { + await refuseUnservedRelationTargets(deps, requestContext, ql, params.object, bag.where, scopeId); + } let all = await ql.find(params.object, findOpts(bag)); if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value; if (!all) all = []; @@ -416,6 +520,14 @@ export async function callData(deps: ActionExecutionDeps, const engine = (await deps.getObjectQL(requestContext, scopeId)) ?? await deps.resolveService(requestContext, 'objectql', scopeId).catch(() => null); if (engine && typeof engine.aggregate === 'function') { + // [#22823] This arm takes no protocol, so it is the engine's door on + // every assembly: each nested-relation condition's TARGET in `where` + // is asked before the engine aggregates, as the query fallback asks + // it (the engine lowers relation conditions in `where` only, never + // in an aggregation's own `filter`). + if (!executionContext?.isSystem) { + await refuseUnservedRelationTargets(deps, requestContext, engine, params.object, params.where, scopeId); + } const rows = await engine.aggregate( params.object, { From 95be4b56812bdcdd776b245c648ed693cdb11f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:57:27 +0000 Subject: [PATCH 2/4] fix(runtime,spec,core): census row 10 classified, ledger provenance for runtime's exposure codes, changesets The census pin names callData as the third door that reaches the engine's relation lowering through the shared walk, held by its own package's pin. The error-code ledger lists OBJECT_API_DISABLED and OBJECT_API_METHOD_NOT_ALLOWED under @objectstack/runtime, the provenance check:error-code-provenance requires of an emitting package. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...823-calldata-fallback-relation-exposure.md | 38 +++++++++++++++++++ ...2823-spec-ledger-runtime-exposure-codes.md | 7 ++++ .../second-object-read-exposure.pin.test.ts | 15 ++++++-- .../spec/src/api/error-code-ledger.zod.ts | 10 +++++ 4 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 .changeset/22823-calldata-fallback-relation-exposure.md create mode 100644 .changeset/22823-spec-ledger-runtime-exposure-codes.md diff --git a/.changeset/22823-calldata-fallback-relation-exposure.md b/.changeset/22823-calldata-fallback-relation-exposure.md new file mode 100644 index 00000000000..7c9ec3940a8 --- /dev/null +++ b/.changeset/22823-calldata-fallback-relation-exposure.md @@ -0,0 +1,38 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: `callData` asks each nested-relation condition's related object its declared exposure before the engine reads it, on the query fallback and the aggregate arm (#22823) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + +**FROM.** The runtime dispatcher's data bridge, `callData`, judged only the object a call addresses against its `enable` block, and then handed the caller's `where` to the ObjectQL engine at two positions: + +- the `query` / `find` fallback, which serves when no protocol service resolves (an assembly that composes the engine plugin with `registerProtocol: false` and no `MetadataProtocolPlugin`); +- the `aggregate` arm, which never takes the protocol service, so on every assembly (the MCP `aggregate_records` tool over HTTP reaches it). + +At both, such a condition was evaluated whatever the related object's `enable` block declared, including a related object every data door refuses when it is addressed. + +**TO.** `callData` asks each condition's related object the exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is called. A related object the gate does not serve refuses the call with the data door's own exposure answer, the one the protocol path gives the same condition: + +- `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. +- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. + +The refusal names the related object (`object`) and the condition's position (`param`, `where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. A related object with no `enable` block, or a whitelist that grants `list`, is served exactly as before. With the protocol service present the `query` arm is unchanged: the data door judges the condition, as it has since #22737. + +**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + +**Bindings.** + +- **Refused, not withheld.** Dropping a condition would widen the result. +- **One gate, one walk.** The gate is the one `callData` already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door and the MCP stdio bridge use. +- **A system context is not asked**, as it is not asked for the addressed object: these flags govern API exposure, not the engine's own reads. A flow's or a hook's own query calls the engine directly and never passes `callData`. +- **No new error code.** `@objectstack/runtime` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. + +**Measured producers.** Read on `origin/main` `75b6d6a048`: no shipped composition reaches the `query` fallback. Every non-test `ObjectQLPlugin` construction under `packages/` and `examples/` keeps the built-in protocol (`os serve`, the standalone stack, the dev plugin, the verify harness, the kernel factory), and `registerProtocol: false` appears only in tests. The `aggregate` arm is reached on every assembly that serves the MCP route over HTTP. Deployed compositions were NOT MEASURED. diff --git a/.changeset/22823-spec-ledger-runtime-exposure-codes.md b/.changeset/22823-spec-ledger-runtime-exposure-codes.md new file mode 100644 index 00000000000..5a5c7165489 --- /dev/null +++ b/.changeset/22823-spec-ledger-runtime-exposure-codes.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the error-code ledger records `@objectstack/runtime` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22823) + +The runtime dispatcher's `callData` now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, on its `query` fallback and its `aggregate` arm, so `ERROR_CODE_LEDGER['@objectstack/runtime']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered. No export, schema or generated artifact moves. diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index a961f473dcc..cd6fb0ba145 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -48,7 +48,14 @@ * targets are resolved inside `relationConditionSites` (`@objectstack/core`), * which is the caller classified below. The bridge asks each target the * decision for `list` (`enforceRelationTargetExposure`, #22777), held by its - * own package's pin rather than by a row here. Nor, for the same reason, a + * own package's pin rather than by a row here. The runtime's `callData` is a + * third such door: its `query` fallback (no protocol service resolved) and its + * `aggregate` arm hand a caller's `where` to the same engine lowering, and + * `refuseUnservedRelationTargets` (`runtime/src/action-execution.ts`) asks each + * target the gate the addressed object is asked, for `list`, through the same + * walk (#22823, census row 10), held by its own package's pin + * (`runtime/src/action-execution-calldata-relation-target-exposure.test.ts`) + * rather than by a row here. Nor, for the same reason, a * door that drives `runImport` with a protocol of its own: plugin-auth's * identity import takes its cells' targets from the `import-field-meta.ts` map * classified below, and its run protocol carries the declaration reader the @@ -178,9 +185,9 @@ const CALLERS: Record = { 'packages/core/src/utils/relation-condition-sites.ts': { kind: 'not-a-served-read', reason: - "finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " + - "each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " + - "stdio data bridge's `enforceRelationTargetExposure`", + "finds a filter's nested-relation conditions and each one's target; reads no record. Every door that calls it asks " + + "each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget`, the MCP " + + "stdio data bridge's `enforceRelationTargetExposure` and the runtime `callData`'s `refuseUnservedRelationTargets`", }, 'packages/lint/src/object-graph.ts': { kind: 'not-a-served-read', diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 8d5ab43b2a8..cd029c8d7cf 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -459,6 +459,16 @@ export const ERROR_CODE_LEDGER = { 'FLOW_NO_START_NODE', 'INVALID_OR_EXPIRED', // share-link token 'NEEDS_PASSWORD', // share link requires a password + // [#22823] The data door's two exposure codes, stamped by `callData`'s + // nested-relation walk (`refuseUnservedRelationTargets`, + // `action-execution.ts`) when a condition's related object does not serve + // `list`: 404 for `apiEnabled: false`, 405 with `allowed` for a whitelist. + // Reached on the `query` fallback (no protocol service resolved) and on the + // `aggregate` arm. The protocol path's answer for the same condition is + // `@objectstack/metadata-protocol`'s row; `@objectstack/rest` keeps its own + // rows for the addressed object. Provenance, not identity (see above). + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', // [#7557] `DELETE /packages/:id` on the DISPATCHER door — per-item failures // used to ride inside a 200 with a hardcoded `success: true`. Second // EMITTER of the code `@objectstack/rest` already registers for the From 9e9aa0b7af7386e821d15c1aa928a19e73ae1f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:57:43 +0000 Subject: [PATCH 3/4] docs(changeset): the pending #22739 note no longer names the identity import as an unjudged protocol Since #22800 the identity import's run protocol carries the declaration reader, so the parenthetical stated a false example. Only that parenthetical is reworded; the rule it illustrates is unchanged. A deliberate correction of a pending note this PR did not add. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/22739-import-reference-target-exposure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22739-import-reference-target-exposure.md b/.changeset/22739-import-reference-target-exposure.md index f01f64334ec..b95cc300590 100644 --- a/.changeset/22739-import-reference-target-exposure.md +++ b/.changeset/22739-import-reference-target-exposure.md @@ -24,7 +24,7 @@ Clause-②: no (narrowing) **Bindings.** -- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged. +- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it is not judged (plugin-auth's identity import carries the reader since #22800). - **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. - **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change. From bd79104296c7cb6b81710c9312d5f92e102df6af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:18:11 +0000 Subject: [PATCH 4/4] refactor(runtime): callData decides the system bypass once, for the addressed object and the relation targets One read of the execution context's system flag, so the system-context census row for callData's exposure bypass keeps describing one decision. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 1381f933c33..079f932376e 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -216,8 +216,8 @@ const RELATION_CONDITION_TARGET_OPERATION = DATA_ACTION_TO_API_OPERATION[RELATIO * `callData` must not give two answers depending on whether the deployment * registered the protocol slot (the #5138 rule this file already keeps). * - * Callers skip it for a system context, exactly as they skip the addressed - * gate: these flags govern API exposure, not the engine's own reads. A schema + * `callData` skips it for a system context on the one decision that skips the + * addressed gate: these flags govern API exposure, not the engine's own reads. A schema * that cannot be resolved finds no condition (the filtered object) or serves * (a target), the gate's own fail-open. */ @@ -287,7 +287,11 @@ export async function callData(deps: ActionExecutionDeps, // Honour the object's `apiEnabled` / `apiMethods` declarations for // external traffic. System/internal contexts bypass — these flags // govern API *exposure*, not internal engine self-writes. - if (!executionContext?.isSystem && params?.object) { + // [#22823] Decided ONCE, here: the same bypass covers each nested-relation + // condition's target, asked below on the two arms that hand the engine a + // caller's `where` ({@link refuseUnservedRelationTargets}). + const exposureGated = !executionContext?.isSystem; + if (exposureGated && params?.object) { let def: any; try { const meta = await deps.resolveService(requestContext, 'metadata', scopeId); @@ -491,7 +495,7 @@ export async function callData(deps: ActionExecutionDeps, // [#22823] …and each nested-relation condition's TARGET, before the // engine reads it — the protocol path asks the same of the same // sites (`refuseUnservedRelationTarget`). - if (!executionContext?.isSystem) { + if (exposureGated) { await refuseUnservedRelationTargets(deps, requestContext, ql, params.object, bag.where, scopeId); } let all = await ql.find(params.object, findOpts(bag)); @@ -525,7 +529,7 @@ export async function callData(deps: ActionExecutionDeps, // is asked before the engine aggregates, as the query fallback asks // it (the engine lowers relation conditions in `where` only, never // in an aggregation's own `filter`). - if (!executionContext?.isSystem) { + if (exposureGated) { await refuseUnservedRelationTargets(deps, requestContext, engine, params.object, params.where, scopeId); } const rows = await engine.aggregate(