From e7d7cc1c33367cb185ded8c7c758b3a7e708f280 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:37:04 +0000 Subject: [PATCH 1/5] fix(runtime): a declarative update action refuses an ungranted caller 403 before the subject-load verdict, as the data door does Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3 --- packages/runtime/src/action-execution.ts | 91 +++++ ...clarative-update-refusal-order.pin.test.ts | 350 ++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 packages/runtime/src/declarative-update-refusal-order.pin.test.ts diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 1c4f91e21ee..97cc3fe33dc 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -24,6 +24,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import type { IHttpServer, IObjectQLEngine, InternalFieldEvaluationQuery, QueryEvaluationGate, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { standardErrorCodeForHttpStatus, type MetadataProtocol } from '@objectstack/spec/api'; +import { renderOperationMessage } from '@objectstack/spec/system'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it // is imported, never hand-spelled: the door that refuses and the client that // retries have to agree on it, and two doors each writing their own 'confirm' @@ -3709,6 +3710,88 @@ export function refuseDeniedSubjectLoad( throw recordNotFoundError(objectName, recordId ?? ''); } +/** + * [#30848] The declarative update's object-level update refusal, judged before + * its subject-load verdict is: the data door's order, "the update is judged + * before the record is looked up" (`http-protocol.mdx`). + * + * ## What was wrong + * + * A caller who may read an object but holds no edit grant on it, writing a + * row their scope hides (or an id that names nothing), was answered + * `404 RECORD_NOT_FOUND` by a declarative `operation: 'update'` action, + * through REST `/actions` and MCP `run_action` both, while the data door + * answered the same write `403 PERMISSION_DENIED` (`PATCH /data`, MCP + * `update_record`). The action door consumed {@link refuseDeniedSubjectLoad}'s + * 404 first, so the write, which would have been refused for the object + * before any row was read, was never asked. On a row the caller can see, the + * write is asked and both doors already answered 403. + * + * ## The ask, and why it is not a second decision + * + * Asked only when the subject load was denied: on a delivered row the write + * below carries the caller's identity and the data plane answers its own + * refusal, the same one this ask stands in for. The question is the security + * service's `explain` for `update` on the object, the contract's "would the + * middleware allow this operation?" bottom line, computed by the enforcement + * walk and not re-derived here from permission sets (the REST import-template + * gate asks `create` the same way). ⛔ No row is named: the answer must not + * depend on whether the id names a row, or the door becomes an existence + * oracle. + * + * - `allowed` ⇒ no refusal here; the caller who may update the object keeps + * the not-found answer, one answer for a hidden row and a missing id. + * - a denial, or an `explain` that throws ⇒ the data door's own refusal: + * `403 PERMISSION_DENIED` with the middleware's user-facing sentence + * (`permission_denied`, the caller's locale, the deployment's override). + * A throw is a denial, never read as a grant. + * - a system context, no security service, or one without `explain` ⇒ no + * refusal here: no permission set exists to deny with, as the data door. + */ +export async function refuseUngrantedDeclarativeUpdate( + deps: ActionExecutionDeps, + requestContext: HttpProtocolContext | undefined, + objectName: string, + ec: any, + envId?: string, +): Promise { + if (ec?.isSystem === true) return; + let security: { explain?: (request: { object: string; operation: string }, context?: any) => Promise<{ allowed?: boolean } | undefined> } | undefined; + try { + security = await deps.resolveService(requestContext as HttpProtocolContext, 'security', envId); + } catch { + security = undefined; + } + if (!security || typeof security.explain !== 'function') return; + let allowed: boolean; + try { + allowed = (await security.explain({ object: objectName, operation: DECLARATIVE_UPDATE_OPERATION }, ec))?.allowed === true; + } catch { + allowed = false; + } + if (allowed) return; + let translate: ((key: string, locale: string, params?: Record) => string) | undefined; + try { + const i18n: { t?: unknown } | null | undefined = await deps.resolveService(requestContext as HttpProtocolContext, 'i18n', envId); + if (i18n && typeof i18n.t === 'function') { + const t = i18n.t as (key: string, locale: string, params?: Record) => string; + translate = (key, locale, params) => t.call(i18n, key, locale, params); + } + } catch { + translate = undefined; + } + const message = renderOperationMessage( + { messageKey: 'permission_denied' }, + { locale: typeof ec?.locale === 'string' ? ec.locale : undefined, translate }, + ); + throw Object.assign(new Error(message), { + name: 'PermissionDeniedError', + code: 'PERMISSION_DENIED', + status: 403, + statusCode: 403, + }); +} + /** * Contract point 3 as the two action doors' dispatch reads it, for every * branch: {@link refuseDeniedSubjectLoad}, except for a same-origin @@ -4166,6 +4249,14 @@ export async function executeDeclarativeUpdateAction( // the flow door and the script/body door now call too. Behaviour here is // unchanged to the byte — what changed is that this is no longer the only // door that reads the verdict, so the rule cannot be inert two doors over. + // + // [#30848] …but the object-level update refusal comes first, as at the + // data door: a caller with no edit grant gets the write's 403 whether or + // not the id names a row it can read. Asked only on a denied load: a + // delivered row reaches the write below, which answers that 403 itself. + if (subject.recordLoadDenied) { + await refuseUngrantedDeclarativeUpdate(_deps, wiring.requestContext, objectName, ec, envId); + } refuseDeniedSubjectLoad(objectName, recordId, subject); // ── contract point 2: ONE data-plane update, AS THE CALLER ─────────────── diff --git a/packages/runtime/src/declarative-update-refusal-order.pin.test.ts b/packages/runtime/src/declarative-update-refusal-order.pin.test.ts new file mode 100644 index 00000000000..4b2938e8801 --- /dev/null +++ b/packages/runtime/src/declarative-update-refusal-order.pin.test.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30848] A declarative update action answers the write's refusal in the data + * door's order: the object-level update refusal before the record lookup. + * + * ## What was wrong + * + * A caller who may read an object but holds no edit grant on it, writing a row + * its scope hides or an id that names nothing, was answered + * `404 RECORD_NOT_FOUND` by an `operation: 'update'` action (REST `/actions`, + * MCP `run_action`) and `403 PERMISSION_DENIED` by the data door on the same + * row (`PATCH /data`, MCP `update_record`). The action door refused on the + * subject load's verdict before the write was asked. `http-protocol.mdx`: + * "Refusals come before the record lookup"; `actions.mdx`: a declarative + * update fires "exactly as for a user edit". + * + * ## What is pinned, on one booted kernel + * + * {reader (read, no edit), editor (read + edit)} × {a row the caller sees, a + * row its private-OWD scope hides, an id that names nothing} × four doors: + * the data door and the action door, each over REST and over the runtime's MCP + * bridge (the `update_record` and `run_action` members the MCP tools call). + * + * 1. ⭐ every door answers each (caller, row) cell with the same status and + * code: the reader `403 PERMISSION_DENIED` on all three rows, the editor + * `200` on its row and `404 RECORD_NOT_FOUND` on the other two; + * 2. ⭐ no existence oracle: within each door, for each caller, the hidden + * row and the missing id get the same answer, body included, once the + * id the caller sent is written out; + * 3. a refused write leaves the row untouched. + * + * The MCP leg drives the runtime's bridge (`buildMcpBridge`, served at `/mcp`) + * through a stand-in `mcp` service that calls the two bridge members and + * reports the thrown `status` and `code`, which is what the MCP tools read. + * The tools' own envelope rendering is `@objectstack/mcp`'s and is not under + * test here. + * + * Composition: the plugin set `unresolved-object-name.actions-door.pin.test.ts` + * boots (it mirrors `objectstack dev` / `serve`); the boot is paid in + * `beforeAll`, never inside a case. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, securityCatalogReaderOf } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/rest'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const BOOT_TIMEOUT = 180_000; +const ORIGIN = 'http://localhost:3000'; +const API = '/api/v1'; +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; +const ORG = 'org_pin_30848'; +const READER = { email: 'pin-30848-reader@example.invalid', password: 'Reader-Pass-123' }; +const EDITOR = { email: 'pin-30848-editor@example.invalid', password: 'Editor-Pass-123' }; +const OBJECT = 'pin_step'; +const ACTION = 'decide'; +const EDITOR_SET = 'pin_30848_editor'; +const MISSING = 'pin_30848_no_such_row'; +const PATCH = { decision: 'approved' }; + +const PIN_APP: any = { + manifest: { id: 'com.pin.declarativeUpdateOrder30848', name: 'Declarative update refusal order pins', version: '1.0.0' }, + objects: [ + { + name: OBJECT, + label: 'Pin step', + // Owner-only rows: each member's scope hides the other's row. + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + decision: { type: 'text', label: 'Decision' }, + }, + actions: [ + { + name: ACTION, + label: 'Decide', + operation: 'update', + patch: PATCH, + ai: { exposed: true, description: 'Approve this step.' }, + }, + ], + }, + ], + permissions: [ + { + name: 'pin_30848_reader_default', + label: 'Pin reader default', + isDefault: true, + objects: { [OBJECT]: { allowRead: true, allowCreate: true } }, + }, + { + name: EDITOR_SET, + label: 'Pin editor', + objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true } }, + }, + ], +}; + +/** + * The `mcp` slot the `/mcp` route hands the runtime's bridge to. It calls the + * one bridge member the JSON-RPC-ish body names and answers what it threw: + * the `status` and `code` the MCP tools read, and the message. + */ +function bridgeProbeMcpPlugin(): Plugin { + return { + name: 'com.objectstack.test.pin-30848-bridge-probe', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('mcp', { + handleHttpRequest: async (_req: Request, opts: { bridge: any; parsedBody: any }) => { + const { member, recordId } = opts.parsedBody ?? {}; + let answer: Record; + try { + if (member === 'update_record') await opts.bridge.update(OBJECT, recordId, PATCH); + else await opts.bridge.runAction(ACTION, { objectName: OBJECT, recordId }); + answer = { status: 200 }; + } catch (err: any) { + answer = { status: err?.status ?? err?.statusCode, code: err?.code, message: err?.message }; + } + return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } }); + }, + renderSkill: () => '# skill', + }); + }, + }; +} + +let kernel: any; +let httpServer: any; +let app: any; +let engine: any; +let adminToken: string; +let prevNodeEnv: string | undefined; +const tokens: Record = { reader: '', editor: '' }; +const ownRow: Record = { reader: '', editor: '' }; + +type Caller = 'reader' | 'editor'; +type Row = 'visible' | 'hidden' | 'missing'; +type Door = 'data REST' | 'data MCP' | 'action REST' | 'action MCP'; +const DOORS: readonly Door[] = ['data REST', 'data MCP', 'action REST', 'action MCP']; + +interface Answer { status: number; code?: unknown; body: unknown } + +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); +const as = (token: string, method: string, path: string, payload?: unknown) => + req(path, { + method, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${token}`, + }, + ...(payload !== undefined ? { body: JSON.stringify(payload) } : {}), + }); + +async function readJson(res: Response): Promise { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** The ADR-0112 code, wherever the door's envelope carries it. */ +const codeOf = (payload: any): unknown => payload?.error?.code ?? payload?.code; + +/** The id `row` names for `caller`: its own row, the other caller's row, or no row. */ +const idOf = (caller: Caller, row: Row): string => + row === 'visible' ? ownRow[caller] : row === 'hidden' ? ownRow[caller === 'reader' ? 'editor' : 'reader'] : MISSING; + +/** Knock on `door` once as `caller` for `id`, and read the answer. */ +async function knock(door: Door, caller: Caller, id: string): Promise { + const token = tokens[caller]; + if (door === 'data REST' || door === 'action REST') { + const res = door === 'data REST' + ? await as(token, 'PATCH', `/data/${OBJECT}/${id}`, PATCH) + : await as(token, 'POST', `/actions/${OBJECT}/${ACTION}/${id}`, { params: {} }); + const body = await readJson(res); + return { status: res.status, ...(res.status >= 400 ? { code: codeOf(body) } : {}), body }; + } + const res = await as(token, 'POST', '/mcp', { member: door === 'data MCP' ? 'update_record' : 'run_action', recordId: id }); + const body = await readJson(res); + expect(res.status, `the /mcp route itself answered ${res.status}: ${JSON.stringify(body)}`).toBe(200); + return { status: body.status, ...(body.status >= 400 ? { code: body.code } : {}), body }; +} + +/** `a` with the id the caller sent written out, so the answers for two ids compare. */ +const withoutId = (a: Answer, id: string): Answer => JSON.parse(JSON.stringify(a).split(id).join('ID')) as Answer; + +const decisionOf = async (id: string): Promise => + (await engine.findOne(OBJECT, { where: { id }, context: { isSystem: true } }))?.decision; + +async function signIn(who: { email: string; password: string }): Promise { + const res = await req('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(who), + }); + if (!res.ok) throw new Error(`pin signIn failed: ${res.status} ${await res.text()}`); + return (await res.json() as any).token; +} + +async function signUpMember(who: { email: string; password: string }, n: number): Promise { + // Default audience posture is invite_only: enter through a pending invitation. + await engine.insert( + 'sys_invitation', + { + id: `inv_pin_30848_${n}`, + email: who.email, + status: 'pending', + organization_id: ORG, + role: 'member', + inviter_id: 'usr_pin_30848', + expires_at: new Date(Date.now() + 3_600_000), + }, + { context: { isSystem: true } }, + ); + const res = await req('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: who.email, password: who.password, name: `pin member ${n}` }), + }); + if (!res.ok) throw new Error(`pin signUp failed: ${res.status} ${await res.text()}`); + return (await res.json() as any).token; +} + +beforeAll(async () => { + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it + + kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(new AppPlugin(PIN_APP)); + await kernel.use(new PlatformObjectsPlugin()); + await kernel.use(new AuthPlugin({ secret: 'declarative-update-order-30848-secret', autoDefaultOrganization: false })); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.use(new SharingServicePlugin()); + await kernel.use(bridgeProbeMcpPlugin()); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + + httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + engine = await kernel.getServiceAsync('objectql'); + + adminToken = await signIn(ADMIN); + tokens.reader = await signUpMember(READER, 1); + tokens.editor = await signUpMember(EDITOR, 2); + + const editorSet = await securityCatalogReaderOf(engine)?.resolve('permission', EDITOR_SET); + if (!editorSet?.definition?.name) throw new Error(`pin precondition: the catalog serves no '${EDITOR_SET}'`); + const editorId = (await engine.findOne('sys_user', { where: { email: EDITOR.email }, context: { isSystem: true } }))?.id; + if (!editorId) throw new Error('pin precondition: the editor was not provisioned'); + await engine.insert( + 'sys_user_permission_set', + { user_id: editorId, permission_set: editorSet.definition.name }, + { context: { isSystem: true } }, + ); + + for (const caller of ['reader', 'editor'] as const) { + const res = await as(tokens[caller], 'POST', `/data/${OBJECT}`, { name: `${caller} step`, decision: 'pending' }); + const body = await readJson(res); + if (res.status >= 300) throw new Error(`pin seed refused for the ${caller}: ${res.status} ${JSON.stringify(body)}`); + ownRow[caller] = String(body?.id ?? body?.record?.id ?? body?.data?.id ?? ''); + if (!ownRow[caller]) throw new Error(`pin seed for the ${caller} carried no id: ${JSON.stringify(body)}`); + } +}, BOOT_TIMEOUT); + +afterAll(async () => { + try { await httpServer?.close?.(); } catch { /* best-effort */ } + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; +}, 60_000); + +describe('[#30848] preconditions', () => { + it('each caller reads its own row and not the other one', async () => { + for (const caller of ['reader', 'editor'] as const) { + const own = await as(tokens[caller], 'GET', `/data/${OBJECT}/${idOf(caller, 'visible')}`); + expect(own.status, `${caller} reads its own row`).toBe(200); + const hidden = await as(tokens[caller], 'GET', `/data/${OBJECT}/${idOf(caller, 'hidden')}`); + const body = await readJson(hidden); + expect({ status: hidden.status, code: codeOf(body) }, `${caller} does not read the other row`) + .toEqual({ status: 404, code: 'RECORD_NOT_FOUND' }); + } + }); + + it('the administrator reads both rows (the hidden row exists)', async () => { + for (const caller of ['reader', 'editor'] as const) { + const res = await as(adminToken, 'GET', `/data/${OBJECT}/${ownRow[caller]}`); + expect(res.status).toBe(200); + } + }); +}); + +const REFUSED_403 = { status: 403, code: 'PERMISSION_DENIED' } as const; +const NOT_FOUND = { status: 404, code: 'RECORD_NOT_FOUND' } as const; + +const EXPECTED: Record> = { + reader: { visible: REFUSED_403, hidden: REFUSED_403, missing: REFUSED_403 }, + editor: { visible: { status: 200 }, hidden: NOT_FOUND, missing: NOT_FOUND }, +}; + +describe('[#30848] one answer per (caller, row), whatever the door', () => { + for (const caller of ['reader', 'editor'] as const) { + for (const row of ['hidden', 'missing', 'visible'] as const) { + it(`${caller} × ${row} row: every door answers ${EXPECTED[caller][row].status}`, async () => { + const id = idOf(caller, row); + const before = row === 'missing' ? undefined : await decisionOf(id); + const answers: Record = {}; + for (const door of DOORS) { + const a = await knock(door, caller, id); + answers[door] = { status: a.status, ...(a.code !== undefined ? { code: a.code } : {}) }; + } + expect(answers).toEqual(Object.fromEntries(DOORS.map((d) => [d, EXPECTED[caller][row]]))); + if (EXPECTED[caller][row].status >= 400 && row !== 'missing') { + expect(await decisionOf(id), 'a refused write leaves the row untouched').toBe(before); + } + }); + } + } +}); + +describe('[#30848] no existence oracle: a hidden row and a missing id answer alike within each door', () => { + for (const caller of ['reader', 'editor'] as const) { + for (const door of DOORS) { + it(`${caller} through ${door}`, async () => { + const hiddenId = idOf(caller, 'hidden'); + const hidden = withoutId(await knock(door, caller, hiddenId), hiddenId); + const missing = withoutId(await knock(door, caller, MISSING), MISSING); + expect(hidden.status).toBeGreaterThanOrEqual(400); + expect(hidden).toEqual(missing); + }); + } + } +}); From 8b12303105a3b883f3a491cb81a1d1420df9021d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:42:16 +0000 Subject: [PATCH 2/5] test(runtime): pin the declarative update refusal order across both doors on a booted kernel Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3 --- .../30848-declarative-update-refusal-order.md | 12 ++++++++++++ .../declarative-update-refusal-order.pin.test.ts | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 .changeset/30848-declarative-update-refusal-order.md diff --git a/.changeset/30848-declarative-update-refusal-order.md b/.changeset/30848-declarative-update-refusal-order.md new file mode 100644 index 00000000000..d5fa5b5fc05 --- /dev/null +++ b/.changeset/30848-declarative-update-refusal-order.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): a declarative update action refuses a caller with no edit grant `403 PERMISSION_DENIED`, as the data door does, whether or not the record exists or is visible to them + +Clause-②: no — no request that succeeded is refused and no shape changes: one refusal's code moves from `404 RECORD_NOT_FOUND` to the data door's `403 PERMISSION_DENIED`, for a caller who may not update the object +Metadata-protocol: none + +An `operation: 'update'` action (REST `POST /actions/:object/:action/:id`, MCP `run_action`) now judges the caller's update permission on the object before it answers on the record lookup, the order the data door already keeps ("Refusals come before the record lookup"). A caller who may read the object but holds no edit grant on it, writing a record their access hides or an id that names no record, was answered `404 RECORD_NOT_FOUND` by the action and `403 PERMISSION_DENIED` by `PATCH /data` and MCP `update_record` for the same write. Both doors now answer `403 PERMISSION_DENIED`, with the data door's sentence. + +A caller who may update the object keeps `404 RECORD_NOT_FOUND`, one answer for a hidden record and a missing id, so neither door tells a caller whether a record it cannot see exists. A record the caller can read but not edit was already answered `403` by both doors and is unchanged. diff --git a/packages/runtime/src/declarative-update-refusal-order.pin.test.ts b/packages/runtime/src/declarative-update-refusal-order.pin.test.ts index 4b2938e8801..3c67b330128 100644 --- a/packages/runtime/src/declarative-update-refusal-order.pin.test.ts +++ b/packages/runtime/src/declarative-update-refusal-order.pin.test.ts @@ -27,7 +27,8 @@ * `200` on its row and `404 RECORD_NOT_FOUND` on the other two; * 2. ⭐ no existence oracle: within each door, for each caller, the hidden * row and the missing id get the same answer, body included, once the - * id the caller sent is written out; + * id the caller sent is written out; and the reader's refusal on the row + * it can see is that same answer too; * 3. a refused write leaves the row untouched. * * The MCP leg drives the runtime's bridge (`buildMcpBridge`, served at `/mcp`) @@ -86,7 +87,7 @@ const PIN_APP: any = { label: 'Decide', operation: 'update', patch: PATCH, - ai: { exposed: true, description: 'Approve this step.' }, + ai: { exposed: true, description: 'Approve this approval step on behalf of its approver.' }, }, ], }, @@ -347,4 +348,15 @@ describe('[#30848] no existence oracle: a hidden row and a missing id answer ali }); } } + + // The reader's refusal is the write's, whatever the row: the row it can see + // gets the same answer, sentence included, as the two it cannot. + for (const door of DOORS) { + it(`reader through ${door}: the visible row's refusal is the same answer`, async () => { + const visibleId = idOf('reader', 'visible'); + const visible = withoutId(await knock(door, 'reader', visibleId), visibleId); + const missing = withoutId(await knock(door, 'reader', MISSING), MISSING); + expect(visible).toEqual(missing); + }); + } }); From ad748b983a6866fde86e95f1e28a4a60c7ea6487 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 08:04:49 +0000 Subject: [PATCH 3/5] docs(runtime): the declarative update docblock names the object-level 403 ahead of the not-found pair Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3 --- .changeset/30848-declarative-update-refusal-order.md | 4 +++- packages/runtime/src/action-execution.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.changeset/30848-declarative-update-refusal-order.md b/.changeset/30848-declarative-update-refusal-order.md index d5fa5b5fc05..8c9c13bf273 100644 --- a/.changeset/30848-declarative-update-refusal-order.md +++ b/.changeset/30848-declarative-update-refusal-order.md @@ -4,9 +4,11 @@ fix(runtime): a declarative update action refuses a caller with no edit grant `403 PERMISSION_DENIED`, as the data door does, whether or not the record exists or is visible to them -Clause-②: no — no request that succeeded is refused and no shape changes: one refusal's code moves from `404 RECORD_NOT_FOUND` to the data door's `403 PERMISSION_DENIED`, for a caller who may not update the object +Clause-②: no Metadata-protocol: none An `operation: 'update'` action (REST `POST /actions/:object/:action/:id`, MCP `run_action`) now judges the caller's update permission on the object before it answers on the record lookup, the order the data door already keeps ("Refusals come before the record lookup"). A caller who may read the object but holds no edit grant on it, writing a record their access hides or an id that names no record, was answered `404 RECORD_NOT_FOUND` by the action and `403 PERMISSION_DENIED` by `PATCH /data` and MCP `update_record` for the same write. Both doors now answer `403 PERMISSION_DENIED`, with the data door's sentence. A caller who may update the object keeps `404 RECORD_NOT_FOUND`, one answer for a hidden record and a missing id, so neither door tells a caller whether a record it cannot see exists. A record the caller can read but not edit was already answered `403` by both doors and is unchanged. + +No request that succeeded is refused, and no shape changes: for a caller who may not update the object, one refusal's code moves from `404 RECORD_NOT_FOUND` to the data door's `403 PERMISSION_DENIED`. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 97cc3fe33dc..7c7d5b2fb44 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -4171,6 +4171,11 @@ function declarativeUpdateRefusal(message: string, status: number): Error { * inventing a 403 for the pair would answer a question the platform declines to * answer everywhere else. See the note on `loadActionSubjectRecord`. * + * [#30848] Except for a caller who may not update the OBJECT at all: the data + * door refuses that write `403` before it looks the record up, so the pair + * gets that 403 here too ({@link refuseUngrantedDeclarativeUpdate}), one + * answer for both members of the pair, and the pair stays undistinguished. + * * ## What is NOT checked here * * `visible` — contract point 6. It is a per-record UI predicate the client From 2dc4bdfa7ea2e757280d923b1857fd9baac81acf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 08:15:04 +0000 Subject: [PATCH 4/5] fix(runtime): ask explain for a system context too, as the middleware bypasses it, so the census holds no new elevation read Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3 --- packages/runtime/src/action-execution.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 7c7d5b2fb44..202ba5a1d00 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -3745,8 +3745,9 @@ export function refuseDeniedSubjectLoad( * `403 PERMISSION_DENIED` with the middleware's user-facing sentence * (`permission_denied`, the caller's locale, the deployment's override). * A throw is a denial, never read as a grant. - * - a system context, no security service, or one without `explain` ⇒ no - * refusal here: no permission set exists to deny with, as the data door. + * - no security service, or one without `explain` ⇒ no refusal here: no + * permission set exists to deny with, as the data door. (A system context + * is `explain`'s own bypass, as it is the middleware's: asked, not skipped.) */ export async function refuseUngrantedDeclarativeUpdate( deps: ActionExecutionDeps, @@ -3755,7 +3756,6 @@ export async function refuseUngrantedDeclarativeUpdate( ec: any, envId?: string, ): Promise { - if (ec?.isSystem === true) return; let security: { explain?: (request: { object: string; operation: string }, context?: any) => Promise<{ allowed?: boolean } | undefined> } | undefined; try { security = await deps.resolveService(requestContext as HttpProtocolContext, 'security', envId); From 5f17e7c8feabe06e769b7846af2e133d141eaa4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 09:19:47 +0000 Subject: [PATCH 5/5] test(dogfood): a no-edit member's declarative update on a ghost id answers the write's 403; the edit-grant holder carries the ghost-id 404 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3 --- ...se-anonymous-deny-surfaces.dogfood.test.ts | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 1f6621475a2..3e503a5abaa 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -581,8 +581,18 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => // ⛔ The case above is left as it was. Narrowing IT to 404 would make this // file's anonymity proof fail for reasons belonging to another proof, which // is what its own comment warns against. - it('[#7867] a member hitting the action with a NONEXISTENT record id gets 404 RECORD_NOT_FOUND', async () => { - const r = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} }); + // + // [#30848] `showcase_mark_done` is a declarative update (`operation: + // 'update'`), and a declarative update is judged as the write it performs: + // a caller who may not update the object is refused before the record is + // looked up, as the data door's PATCH refuses it. The member holds no edit + // grant on `showcase_task`, so its answer for the ghost id is that refusal, + // 403 PERMISSION_DENIED, and no longer the lookup's 404. #7867's proof, that + // a ghost id answers the lookup's 404 rather than a 400 from further down + // the pipeline, is carried by a caller who passes that refusal: the dev + // administrator, who holds the edit grant. + it('[#7867] a caller holding the edit grant hitting the action with a NONEXISTENT record id gets 404 RECORD_NOT_FOUND', async () => { + const r = await stack.apiAs(adminToken, 'POST', ACTION, { params: {} }); expect(r.status, 'a ghost record id must answer 404, not a 400 from further down the pipeline').toBe(404); const body = (await r.json()) as Record; @@ -596,32 +606,52 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(String(err.message ?? '')).not.toMatch(/HookConditionError/); }); - it('[#7867] …the SAME answer the REST data surface gives for that same id', async () => { + it('[#30848] a member with no edit grant hitting the action with a NONEXISTENT record id gets 403 PERMISSION_DENIED', async () => { + const r = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} }); + + expect(r.status, 'a caller who may not update the object is refused the write before the lookup').toBe(403); + const body = (await r.json()) as Record; + const err = (isRecord(body.error) ? body.error : {}) as Record; + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.httpStatus).toBe(403); + // Still never the 400 shapes #7867 removed. + expect(String(err.message ?? '')).not.toMatch(/not bound for this operation/); + expect(String(err.message ?? '')).not.toMatch(/HookConditionError/); + }); + + it('[#7867, #30848] …the SAME answer the REST data surface gives that caller for that same id', async () => { // The comparison that made #7867 measurable in the first place: one id, one // object, one process, one second — and two surfaces that disagreed // (`/actions` → 400, `/data` → 404). Both are asserted here, so they cannot // drift apart again without this gate saying so. // - // The data surface that answers this the way the action does is the record - // READ: the action loads its record through the read door, and the member - // reads `showcase_task`. A PATCH is not that surface for this caller: an - // update by id is judged before the record is looked up, as a delete is, and - // the member holds no edit grant on `showcase_task` — so it is refused the - // write whatever the id (`update-by-id-refusal-order.dogfood.test.ts`). + // The data surface that answers this the way the action does is the + // record's UPDATE, the write the action performs: an update by id is + // judged before the record is looked up, as a delete is + // (`update-by-id-refusal-order.dogfood.test.ts`). For the member, who holds + // no edit grant on `showcase_task`, both refuse the write whatever the id; + // for the administrator, who holds it, both answer the lookup's not-found. const ghostId = ACTION.slice(ACTION.lastIndexOf('/') + 1); - const rest = await stack.apiAs(memberToken, 'GET', `/data/showcase_task/${ghostId}`); - expect(rest.status).toBe(404); - const body = (await rest.json()) as Record; // `@objectstack/rest` answers the flat envelope; the dispatcher-mounted // `/actions` answers its own wrapper. Different shells, one code — each read // in its own shape, with no `??` chain across the two (#5632's rule, which // this file already enforces for the 401 bodies). - expect(body.code).toBe('RECORD_NOT_FOUND'); - - const patch = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ghostId}`, { done: true }); - expect(patch.status).toBe(403); - expect(((await patch.json()) as Record).code).toBe('PERMISSION_DENIED'); + const memberPatch = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ghostId}`, { done: true }); + expect(memberPatch.status).toBe(403); + expect(((await memberPatch.json()) as Record).code).toBe('PERMISSION_DENIED'); + const memberAction = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} }); + expect(memberAction.status).toBe(403); + const memberActionBody = (await memberAction.json()) as Record; + expect(((isRecord(memberActionBody.error) ? memberActionBody.error : {}) as Record).code).toBe('PERMISSION_DENIED'); + + const adminPatch = await stack.apiAs(adminToken, 'PATCH', `/data/showcase_task/${ghostId}`, { done: true }); + expect(adminPatch.status).toBe(404); + expect(((await adminPatch.json()) as Record).code).toBe('RECORD_NOT_FOUND'); + const adminAction = await stack.apiAs(adminToken, 'POST', ACTION, { params: {} }); + expect(adminAction.status).toBe(404); + const adminActionBody = (await adminAction.json()) as Record; + expect(((isRecord(adminActionBody.error) ? adminActionBody.error : {}) as Record).code).toBe('RECORD_NOT_FOUND'); }); // ── /automation (dispatcher-mounted; runtime domains/automation.ts) ─────