Skip to content

Commit d79dc48

Browse files
fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors (#24392)
Fixes #24268 Clause-②: no (narrowing). Basis: the two action doors stop accepting an undeclared wire param on a param-less `operation: 'update'` action, and nothing newly accepted or exported is added. The envelope changes at the MCP door (the ADR-0104 D2 refusal, and the declarative executor's own refusals, now carry `code` + `status`) add members to refusals that already refused; they accept nothing new. Owes one `CONTRACT_REVIEW_TIER` review before the queue. Dispatched by the `domain:cli` seat 1 (`session_01SYEJqDL3NXxrJRBVqaMu9T`, claim comment 6254028639); this dev ran as a subagent of that session. Branched from `origin/main` `b2e78701f` (one commit past the claim's read `d65826fa7`: #24373, which touches neither runtime file nor `.objectui-sha`). ## Measured before writing - **P1, the defect.** On `b2e78701f`, `enforceActionParams` returned `null` at once when `action.params` was empty or absent, and `declarativeUpdateWrite` merged `{ ...patch, ...params }`. Measured with this PR's conformance cases run against the unchanged source: 3 of 33 red. The REST door answered **200** (expected 400) for `params: { note: ... }` and for `params: { status: 'reopened' }` on the param-less `close_case` (`patch: { status: 'closed' }`). The MCP `run_action` door **resolved** instead of refusing. - **P2, the console half.** In objectui, `git merge-base --is-ancestor fb501bc894 0707b4ae37` exits **0** (ancestor). Exit 0 proves it even though that checkout is shallow (`--is-shallow-repository` answers `true`). `.objectui-sha` on this branch's base is `0707b4ae37`. At that sha, `ActionRunner.executeUpdateOperation` posts `{ ...collected }` and never `patch`. `serverActionHandler` strips `_rowRecord` and POSTs `{ recordId, params }`. So the vendored console does not post the patch as params, and this change does not break its declarative-update buttons. ## What changed - **`enforceActionParams`** (`packages/runtime/src/action-execution.ts:1678`). The early return now applies only to a param-less action that is **not** a declarative update. For `isDeclarativeUpdateAction(action)`, the declared list resolves to `[]`, and the existing validator then refuses every key that is not a dispatcher built-in (`recordId`, `objectName`, `_selectedIds`). The route is the existing D2 path: the same message (`Invalid action params: Unknown action param "KEY" ...`), the same 400 at the REST door, and the same `OS_ALLOW_LAX_ACTION_PARAMS=1` escape hatch, which warns and accepts. No new switch, permission, capability or config key. The docblock sentence "Actions that declare no `params` keep the pass-through" is replaced by a section saying which param-less actions keep it (those with a handler) and why a declarative update does not. - **MCP door envelope** (`action-execution.ts:2542`, in `invokeBusinessAction`). The D2 throw was a bare `Error`, which `@objectstack/mcp`'s `thrownRefusalEnvelope` renders message-only, because it reads a string `code`. It is now thrown with `code: standardErrorCodeForHttpStatus(400)` (`VALIDATION_ERROR`, the code the REST door's `deps.error(paramError, 400)` derives) and `status: 400`. This applies to every D2 refusal at that door, not only the new one (see Acceptance notes). - **The declarative executor's own refusals** (`declarativeUpdateRefusal`, `action-execution.ts:2221`: no `recordId`, nothing to write, an object-less action key). They threw `status` only, so over the real `/mcp` bridge they rendered message-only (measured; see Tests). They now carry `code: standardErrorCodeForHttpStatus(status)` beside the status, as the D2 throw does. The REST door already derived the same code from the status, so its answers are unchanged. - **Docblocks that became false or incomplete:** `declarativeUpdateWrite`; the `invokeBusinessAction` input comment ("silently accepted on one that declares none"); and `declarativeUpdateRefusal`'s ("No `code` literal is stamped here ... agree by construction"), which the measurement disproved. - **Published prose this PR made false**, edited only at the sentence (other lanes' files, listed for the seat's cross-lane declaration): - `content/docs/automation/flows.mdx`: "the same way an action with no `params` is untouched". It now names the declarative-update exception. - `packages/spec/src/contracts/ai-service.ts`: the TSDoc on `AI_ACTION_CONFIRMATION_MEMBER`, "On an action declaring no params that check is a pass-through". This is a comment only; there is no schema, `.describe` or generated artifact. - Checked and **not** false: the spec `patch` `.describe` (the precedence is unchanged), and `liveness/action.json` `/props/patch` ("the wire cannot widen the bag (an undeclared param is refused by ADR-0104 D2)"), which this PR makes true for the param-less case too. `content/docs/protocol/objectui/actions.mdx` and `content/docs/getting-started/build-with-claude-code.mdx` describe param-less updates invoked with no params; those still write. - **Changeset:** `.changeset/24268-declarative-update-undeclared-params-refused.md`, `@objectstack/runtime: minor`, `fix(runtime)!:`, line-initial `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, and a `**BREAKING**` sentence naming the consumers: objectstack-ai/hotcrm's `complete_task`, consoles vendored before objectui `fb501bc894`, and page action rows that forward static object `params` to a param-less declarative update (none in this repo; the row surface is #24394). ## Family: every door that executes a declarative update `git grep` over `packages/**` for `executeDeclarativeUpdateAction`, `declarativeUpdateWrite`, `isDeclarativeUpdateAction`, `invokeBusinessAction` and `runAction` (non-test sources). | Door | Path to the write | Verdict | |:---|:---|:---| | REST `POST /actions/OBJECT/ACTION[/ID]` (all three Hono mounts and the fallback seam reach `handleActionsRequest`) | `domains/actions.ts:676` `enforceActionParams`, then `:728` `executeDeclarativeUpdateAction` | **Refused**: 400 `VALIDATION_ERROR`, nothing written (pinned) | | MCP `run_action`: the runtime bridge `domains/mcp.ts` `runAction`, and `@objectstack/mcp` `mcp-http-tools.ts` `bridge.runAction` | `invokeBusinessAction`, then `enforceActionParams`, then `executeDeclarativeUpdateAction` | **Refused**: thrown with `code` `VALIDATION_ERROR` + `status` 400, nothing written (pinned) | | MCP `resume_run` (`domains/mcp.ts` `resumeActionRun`) | none | Not reachable: its candidate filter is `action.type === 'flow' && !isDeclarativeUpdateAction(action)` | | List view `bulkActionDefs` `operation: 'update'` (the def `declarativeUpdateWrite`'s docblock names) | none: objectui `useBulkExecutor` writes through `dataSource.update` / `bulkUpdate` (the data API) at `0707b4ae37` | Not reachable: it is not an action door and never calls `declarativeUpdateWrite`. Its collected values come from the def's own strict `params[]`. | | Anything else | `executeDeclarativeUpdateAction` has exactly the two callers above. `objectql`'s `action-governance.ts` and `lint`'s `validate-ai-tool-references.ts` only read `operation` for inventory and linting. | Not reachable | ## Consumers, measured `git grep -E "operation: ?['\"]update['\"]"` over `examples/**`, `packages/apps/**`, `packages/qa/**` and every test, at `ee0888855`: - **Param-less declarative update actions in this repo:** test fixtures only. `packages/runtime/src/action-declarative-update.test.ts` (`close_case`, standalone `escalate_case`, `flag_case`, `global_update`), `packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts` (`take_email`, posted with a `{}` body), and `packages/mcp/src/mcp-action-refusal-details.test.ts` (`close_case`, behind a fake bridge, no params). Two docs examples are invoked with no params: `resolve_ticket` (getting-started) and `task_complete` (protocol/objectui/actions). There are **zero** in `examples/**`, `packages/apps/**` and `packages/qa/**`: every hit there is an RLS policy, a `bulkActionDefs` entry or a batch op. - **Callers that send `params` to one:** zero in this repo before this PR. Every fixture invocation sends no `params` or an empty bag. No test or dogfood pin broke: the full runtime suite is green (below), so no fixture needed a fix. - **Out of repo:** objectstack-ai/hotcrm's `complete_task`, called with params, and any console vendored before objectui `fb501bc894` (both from the card); and page action rows (`action:button` / `action:icon` with `operation: 'update'`) whose static object `params` the console forwards to a param-less declarative update action, zero of them in this repo (the row surface is #24394). All three are named in the changeset. ## Tests New cases, all on the real doors: `HttpDispatcher.handleActions` for REST, and `invokeBusinessAction` wired to the real `callData` for MCP. The rig is the file's existing row-scoped, identity-honest engine double. - `packages/runtime/src/action-declarative-update.test.ts`, describe `#24268`: - REST: an undeclared key on param-less `close_case` gives 400, `error.code` `VALIDATION_ERROR`, a message naming `"note"`, **zero updates**, and the row is equal to `freshRow()`. - REST: a `patch` key sent as a param (`status`, the console shape before `fb501bc894`) is refused the same way, with no write. - MCP: the same key is thrown with `code` `VALIDATION_ERROR`, `status` 400, the key named, zero updates, and the row unchanged. - An absent or empty bag (`{}` and `{ params: {} }` at REST; `{ recordId }` and `{ recordId, params: {} }` at MCP) still writes exactly `{ status: 'closed' }`. - A declared param still writes and wins over `patch` at both doors: `{ status: 'triaged', priority: 'urgent' }`. - Control: a param-less handler action (`nudge_case`) with the same undeclared key still reaches the registry (404 not-found, `executeAction` called). The pass-through is kept. - `packages/runtime/src/action-declarative-update.test.ts`, describe `#15079 point 7` (patch round 1): the MCP door answers the no-`recordId` refusal (`close_case`) and the nothing-to-write refusal (`note_case`, a new params-only fixture action) with `code` `VALIDATION_ERROR` + `status` 400, and writes nothing. Those are the two members `thrownRefusalEnvelope` reads. - `packages/runtime/src/action-params-enforcement.test.ts`: the gate itself. A param-less update, with `params` absent or `[]`, refuses an undeclared key and names it. It accepts `{}` and the built-ins (the positive controls). `OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the key and warns once. The existing handler pass-through case was retitled and kept. Real-bridge measurement (patch round 1; a throwaway dogfood file, not committed): `bootStack` + `MCPServerPlugin`, `POST /mcp` `tools/call run_action` on a fixture object, so the runtime's `buildMcpBridge` throw is rendered by `@objectstack/mcp`. On `ee0888855`, no `recordId` and nothing-to-write each answered `isError: true` with the bare message and no JSON envelope, while the D2 control answered `{"error":{"code":"VALIDATION_ERROR",...,"status":400}}`. On `6466a6c05`, with runtime rebuilt, all three answered `{"error":{"code":"VALIDATION_ERROR","message":...,"status":400}}`, and the success control wrote. Runs at `6466a6c05`, each through `scripts/pm/os-verify-lock.sh`: - The two files: `Test Files 2 passed (2)`, `Tests 42 passed (42)`, `VERDICT command-exit 0`. - The full runtime suite (`pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`): `Test Files 421 passed (421)`, `Tests 6625 passed | 19 skipped (6644)`, `VERDICT command-exit 0`. ## Reverse verification The fix was committed first (`ee0888855` for legs A and B, `6466a6c05` for leg C). Each leg went through `node scripts/ablation-replace.mjs` in WRAP mode, so the restore is armed on exit, INT and TERM against the absolute path. - **Leg A, the gate reverted** (`if (!declaresParams && !isDeclarativeUpdateAction(action)) return null;` replaced by `if (!declaresParams) return null;`). The anchor went from 1 hit to 0, and the blob from `afc25f537838` to `23bc0e53137a`. **Exactly 5 red, 36 green.** Red were the three door refusals (REST undeclared, REST patch-key, MCP) and two gate cases (refuses an undeclared key; the lax switch's warn). Green were the empty-bag, declared-param and handler-control cases, as predicted. Restore: blob `afc25f537838` equals HEAD, and `git diff HEAD` is empty. - **Leg B, the MCP envelope reverted** (the throw went back to `throw new Error(paramError);`). The blob went from `afc25f537838` to `cf8007e82ea1`. **Exactly 1 red**: the MCP refusal case, with `expected undefined to be 'VALIDATION_ERROR'`. Restore: blob equals HEAD, and `git diff HEAD` is empty. - **Leg C, the executor refusal's code dropped** (patch round 1: `declarativeUpdateRefusal` back to `Object.assign(new Error(message), { status })`). The blob went from `f379ccfb9e51` to `257cb3d3a474`. **Exactly 1 red, 41 green**: the new MCP point-7 case, with `expected undefined to be 'VALIDATION_ERROR'`. The REST point-7 cases stayed green, because the REST door derives the code from the status. Restore: blob `f379ccfb9e51` equals HEAD, and `git diff HEAD` is empty. - Observed direction: red, the expected direction in all three legs. `git status --porcelain` was empty after each. ## Gates (local, at `6466a6c05`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands, the same set as at `ee0888855`. After `turbo run build --filter='!@objectstack/docs'` (76 tasks, 63 cached), all 110 exited 0 on the first pass. (At `ee0888855`, `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3, `PREREQUISITE NOT MET`, and exited 0 after the same build.) - `dispatch-gates --ran`: `110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero: every line carries its exit code). - The 11 declared-wide families (`init-service-contract`, `live-db-isolation`, `meta-type-normalized`, `optional-error-sink`, `resume-authority-declared`, `route-envelope`, `runner-env-posture`, `settings-bind-window`, `startup-registry-verdict`, `verify-stand-in`, `wildcard-fallthrough`): all exit 0. - The roster gates whose roster sits under a touched directory (`check-changeset-fixed`, `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census`): all exit 0. - `pnpm --filter @objectstack/runtime run typecheck` exited 0 at `6466a6c05`. `pnpm --filter @objectstack/spec run typecheck` exited 0 at `ee0888855`; round 1 does not touch `packages/spec`. Both new test files are in `tsconfig.test.json`'s program (`--listFiles`), and `check:test-typecheck` is OK. - `eslint --no-inline-config` over the 4 changed TypeScript files exited 0; in round 1, over the 2 it touches, exit 0. - Changeset gates: `check-changeset-no-major` (no major) and `check-adr-0087-registration` (`1 declared-breaking changeset ... not-required (no-migration-prescription)`) both exit 0. - Not run locally, declared to CI: `test:repo`, the full workspace typecheck lanes, and `pnpm lint` (the repo-wide scans). The spec package's own test suite was also not run: the spec diff is one TSDoc sentence, its typecheck and every derived spec family ran, and CI's `Test Core` runs the suite. - `origin/main` has since moved to `584b4225e` (6 commits past the base). None touches this diff's files or behaviour, and `git merge-tree` is clean. Not merged, per the claim: a merge happens only for a conflict, a main-caused red or a dequeue. - CI on `ee0888855` showed no red. Its six `Test Core` shards were cancelled when `6466a6c05` was pushed, and they rerun on that head. ## Acceptance notes 1. **ADR-0104 D2's text states the boundary this PR narrows.** Its lines 195-196 say "Actions declaring no `params` keep today's pass-through (there is nothing to check against), so existing param-less actions are untouched". Lines 941-942 say "Actions declaring no `params` keep their pass-through — that boundary is unchanged from D2's landing". Both were written before declarative updates existed (ruling #14092 came later), when every action had a handler. This PR keeps that boundary for handler actions and narrows it for `operation: 'update'`. Prime Directive #13 asks for an amended status line or an addendum on the ADR when a recorded decision changes. `docs/adr/**` is Tier H and outside this claim's surface, so it is untouched here. The seat filed the ADR record as #24393 (Tier H, its own PR, `Blocked-by: #24268`). 2. **Page action rows.** An `action:button` / `action:icon` row with `operation: 'update'` may carry an OBJECT `params` of static values. The spec's row judge counts those as values to write (`action-declarative-update.ts`, `staticParams`; the row `operation` describe says "a `patch` or `params` gives the values to write"). The console forwards them as wire params. After this PR, they are written only when the action the route resolves by name declares them; a param-less declaration refuses them. The row's own `patch` has not reached the route since objectui `fb501bc894`: the route writes its declaration's `patch`. There are zero page rows with `operation: 'update'` in this repo. The seat filed the row surface for the spec lane as #24394. This PR only names the rows in the changeset. 3. **Built-ins** (`recordId`, `objectName`, `_selectedIds`) stay allowlisted by the D2 validator and still reach `declarativeUpdateWrite`'s bag. The engine's undeclared-field write door then refuses them as `INVALID_FIELD` unless the object declares such a field. This is unchanged here. 4. **MCP envelope scope.** The `code` + `status` stamp covers every D2 refusal at `run_action`, including declared-params handler actions, which used to render message-only. The declarative executor's own refusals (`declarativeUpdateRefusal`: no `recordId`, nothing to write, an object-less key) were measured message-only over the real `/mcp` bridge and now carry the derived code too (patch round 1; see What changed). 5. **Stale analogies in internal comments, outside this surface.** `packages/services/service-automation/src/engine.ts` (about line 8601) and `screen-input-contract.ts` (about line 64) cite `enforceActionParams`' param-less pass-through as their model. They remain accurate for handler actions and were not edited. 6. **Handler actions** that declare no `params` keep the pass-through. They are out of this card's scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62e2b6d commit d79dc48

6 files changed

Lines changed: 265 additions & 22 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: an `operation: 'update'` action that declares no `params` refuses every undeclared wire param at both action doors, instead of writing it to the row
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes, and no stored shape, is added, removed, renamed or converted: the change is which wire params the two action doors accept for one declared action shape. No spec schema changes, so nothing exists for `objectstack migrate meta` to rewrite. A request is not metadata, and whether a value the client sends should become a declared param is a decision only the action's author can make. -->
10+
11+
**BREAKING** (the REST `/actions` door and the MCP `run_action` door now refuse, with a 400 `VALIDATION_ERROR`, a wire param that a declarative update action without `params` does not declare; measured consumers: objectstack-ai/hotcrm's `complete_task` called with such params; any console vendored before objectui `fb501bc894`, which posts the action's `patch` keys as `params`; and page action rows (`action:button` / `action:icon` with `operation: 'update'`) whose static object `params` the console forwards to a param-less declarative update action, none of which exist in this repo, with the row surface tracked in #24394), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A declarative update (`operation: 'update'` + `patch`) has no handler: the route merges the request's `params` over `patch` and writes the result to the current record. An action that declared no `params` skipped the ADR-0104 D2 param check altogether, so any key the caller sent was written too. An action declaring `patch: { status: 'completed' }` could rewrite any other field the caller may write, which is not what its author declared.
14+
15+
Now an empty or absent `params` list on a declarative update is an empty accept set. Every key that is neither a declared param nor a dispatcher built-in (`recordId`, `objectName`, `_selectedIds`) is refused before anything is read or written, through the same D2 path a declared-params action already takes: the same `Invalid action params: Unknown action param "KEY"` message, the same 400, and the same `OS_ALLOW_LAX_ACTION_PARAMS=1` escape hatch. The action still writes its `patch` when the request carries no `params` or an empty bag, and a declared param still writes and still wins over `patch`.
16+
17+
Unchanged: an action with a handler (`target`, `body`, or a flow) that declares no `params` keeps its pass-through.
18+
19+
Also at the MCP `run_action` door: every ADR-0104 D2 param refusal, and the declarative update's own refusals (no `recordId`, or nothing to write), now carry `code: 'VALIDATION_ERROR'` and `status: 400`, so the tool error renders the coded envelope the REST door already answers with, rather than the message alone.
20+
21+
What to do: declare the param on the action if the request's value should be written, or stop sending it, because the route applies `patch` from the declaration and never needs it on the wire.

‎content/docs/automation/flows.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1193,7 +1193,8 @@ is consumed: nothing is applied, the run stays parked, and the corrected
11931193
submission still lands.
11941194

11951195
Three shapes declare no contract and so keep the pass-through — the same way an
1196-
action with no `params` is untouched:
1196+
action with no `params` is untouched, unless it is a declarative
1197+
`operation: 'update'`, whose empty list is an empty accept set:
11971198

11981199
- an **object-form** screen (`config.objectName`), whose flat `fields` list is
11991200
empty by construction; the client persists the record through the normal write

‎packages/runtime/src/action-declarative-update.test.ts‎

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ const TRIAGE_CASE = {
8787
ai: { exposed: true, description: 'Triage this case.' },
8888
};
8989

90+
/** `params` only, no `patch` — the shape whose empty bag is point 7's "nothing to write". */
91+
const NOTE_CASE = {
92+
name: 'note_case',
93+
label: 'Note',
94+
operation: 'update',
95+
params: [{ name: 'note', type: 'text' }],
96+
ai: { exposed: true, description: 'Write a note on this case.' },
97+
};
98+
9099
/** The REVERSE pin's subject: a handler-less `type: 'script'` action with NO `operation`. */
91100
const HANDLER_ONLY = {
92101
name: 'nudge_case',
@@ -103,7 +112,7 @@ const OBJECT_DEF = {
103112
priority: { type: 'text' },
104113
note: { type: 'text' },
105114
},
106-
actions: [CLOSE_CASE, TRIAGE_CASE, HANDLER_ONLY],
115+
actions: [CLOSE_CASE, TRIAGE_CASE, NOTE_CASE, HANDLER_ONLY],
107116
};
108117

109118
/** The acting principal, as `resolveExecutionContext` builds one. */
@@ -323,6 +332,116 @@ describe('#15079 point 4 — the write is `{ ...patch, ...params }`, a param of
323332
});
324333
});
325334

335+
// ───────────────────────────────────────────────────────────────────────────
336+
// #24268 — the declared write set IS the accept set, also when it is empty
337+
// ───────────────────────────────────────────────────────────────────────────
338+
339+
/**
340+
* [#24268] A declarative update has no handler: its param bag is merged over
341+
* `patch` and WRITTEN to the row. So its declared `params` list is its whole
342+
* wire write set, and an empty or absent list is an EMPTY accept set — not the
343+
* absent contract a param-less HANDLER action has. Before this, `close_case`
344+
* (patch `{ status }`, no `params`) wrote any field the caller may write.
345+
*
346+
* Every refusal here is pinned on the envelope (`code` + `status`) and on "no
347+
* write happened" — never on a bare throw, which an unfixed door that failed
348+
* for any other reason would satisfy too.
349+
*/
350+
describe('#24268 — a param-less declarative update writes EXACTLY its patch, at both doors', () => {
351+
/** A key the object declares and the action does not: the widening the card measured. */
352+
const WIDENING = { note: 'WIDENED BY AN UNDECLARED PARAM' };
353+
354+
it('REST: an undeclared key is refused with the ADR-0104 D2 envelope, and the row is unchanged', async () => {
355+
const rig = makeRig();
356+
const res = await dispatchRest(OWNER, rig, `/${OBJECT}/close_case/${RECORD_ID}`, { params: WIDENING });
357+
358+
expect(res.status).toBe(400);
359+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
360+
// The named subject: the refusal says WHICH key, so the caller can fix it.
361+
expect(res.body.error.message).toContain('"note"');
362+
expect(rig.updates).toHaveLength(0);
363+
expect(rig.row).toEqual(freshRow());
364+
});
365+
366+
it('REST: a `patch` key sent as a param is undeclared too — the wire never re-states the patch', async () => {
367+
// The shape a console vendored before objectui fb501bc894 posts: the
368+
// patch keys as `params`. The route applies `patch` from the
369+
// declaration, so the copy on the wire is an undeclared param.
370+
const rig = makeRig();
371+
const res = await dispatchRest(OWNER, rig, `/${OBJECT}/close_case/${RECORD_ID}`, {
372+
params: { status: 'reopened' },
373+
});
374+
375+
expect(res.status).toBe(400);
376+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
377+
expect(rig.updates).toHaveLength(0);
378+
expect(rig.row).toEqual(freshRow());
379+
});
380+
381+
it('MCP `run_action`: the same undeclared key is refused with `code` + `status`, before any write', async () => {
382+
const rig = makeRig();
383+
const err: any = await dispatchMcp(OWNER, rig, 'close_case', { recordId: RECORD_ID, params: WIDENING })
384+
.then(() => undefined, (e: unknown) => e);
385+
386+
expect(err).toBeInstanceOf(Error);
387+
// What the MCP tool renderer reads off a throw (`thrownRefusalEnvelope`,
388+
// `@objectstack/mcp`): a string `code` and a numeric `status`. The code
389+
// is the one the REST door's `deps.error(…, 400)` derives.
390+
expect(err.code).toBe('VALIDATION_ERROR');
391+
expect(err.status).toBe(400);
392+
expect(String(err.message)).toContain('"note"');
393+
expect(rig.updates).toHaveLength(0);
394+
expect(rig.row).toEqual(freshRow());
395+
});
396+
397+
it('an ABSENT or EMPTY bag still writes the patch — at both doors', async () => {
398+
for (const body of [{}, { params: {} }]) {
399+
const rig = makeRig();
400+
const res = await dispatchRest(OWNER, rig, `/${OBJECT}/close_case/${RECORD_ID}`, body);
401+
expect(res.status, JSON.stringify(body)).toBe(200);
402+
expect(rig.updates.map((u) => u.data)).toEqual([{ status: 'closed' }]);
403+
expect(rig.row.status).toBe('closed');
404+
}
405+
for (const input of [{ recordId: RECORD_ID }, { recordId: RECORD_ID, params: {} }]) {
406+
const rig = makeRig();
407+
const out: any = await dispatchMcp(OWNER, rig, 'close_case', input);
408+
expect(out.ok, JSON.stringify(input)).toBe(true);
409+
expect(rig.updates.map((u) => u.data)).toEqual([{ status: 'closed' }]);
410+
expect(rig.row.status).toBe('closed');
411+
}
412+
});
413+
414+
it('a DECLARED param still writes and wins over `patch` — at both doors', async () => {
415+
const rest = makeRig();
416+
const res = await dispatchRest(OWNER, rest, `/${OBJECT}/triage_case/${RECORD_ID}`, {
417+
params: { priority: 'urgent' },
418+
});
419+
expect(res.status).toBe(200);
420+
expect(rest.updates.map((u) => u.data)).toEqual([{ status: 'triaged', priority: 'urgent' }]);
421+
422+
const mcp = makeRig();
423+
const out: any = await dispatchMcp(OWNER, mcp, 'triage_case', {
424+
recordId: RECORD_ID,
425+
params: { priority: 'urgent' },
426+
});
427+
expect(out.ok).toBe(true);
428+
expect(mcp.updates.map((u) => u.data)).toEqual([{ status: 'triaged', priority: 'urgent' }]);
429+
expect(mcp.row).toMatchObject({ status: 'triaged', priority: 'urgent' });
430+
});
431+
432+
it('the control: a param-less HANDLER action keeps the pass-through — the bag reaches the registry', async () => {
433+
// Not this card's population: a handler is the author's own code
434+
// deciding what its bag means. The registry miss (404) is the proof
435+
// the param gate let the undeclared key through, as it always did.
436+
const rig = makeRig();
437+
const res = await dispatchRest(OWNER, rig, `/${OBJECT}/nudge_case/${RECORD_ID}`, { params: WIDENING });
438+
439+
expect(res.status).toBe(404);
440+
expect(rig.ql.executeAction).toHaveBeenCalled();
441+
expect(rig.updates).toHaveLength(0);
442+
});
443+
});
444+
326445
// ───────────────────────────────────────────────────────────────────────────
327446
// Contract point 3 — permissions, hooks and validations, exactly as a user edit
328447
// ───────────────────────────────────────────────────────────────────────────
@@ -500,6 +619,33 @@ describe('#15079 point 7 — no current record is a located refusal, not a silen
500619
expect(res.body.error.message).toMatch(/nothing to write/);
501620
expect(rig.updates).toHaveLength(0);
502621
});
622+
623+
// [#24268] The MCP door renders a thrown refusal through `@objectstack/mcp`'s
624+
// `thrownRefusalEnvelope`, which reads a string `code` and a numeric
625+
// `status` off the throw — a throw with no `code` reaches the agent as a
626+
// bare sentence. Measured over the real `/mcp` bridge before the fix: both
627+
// refusals below answered message-only. So the pin is on the two members
628+
// that renderer reads, and on "nothing was written".
629+
it('the MCP door answers the same two refusals with `code` + `status`, and writes nothing', async () => {
630+
const noRecord = makeRig();
631+
const missing: any = await dispatchMcp(OWNER, noRecord, 'close_case', {})
632+
.then(() => undefined, (e: unknown) => e);
633+
expect(missing).toBeInstanceOf(Error);
634+
expect(missing.code).toBe('VALIDATION_ERROR');
635+
expect(missing.status).toBe(400);
636+
expect(String(missing.message)).toMatch(/recordId/);
637+
expect(noRecord.updates).toHaveLength(0);
638+
639+
const empty = makeRig();
640+
const nothing: any = await dispatchMcp(OWNER, empty, 'note_case', { recordId: RECORD_ID, params: {} })
641+
.then(() => undefined, (e: unknown) => e);
642+
expect(nothing).toBeInstanceOf(Error);
643+
expect(nothing.code).toBe('VALIDATION_ERROR');
644+
expect(nothing.status).toBe(400);
645+
expect(String(nothing.message)).toMatch(/nothing to write/);
646+
expect(empty.updates).toHaveLength(0);
647+
expect(empty.row).toEqual(freshRow());
648+
});
503649
});
504650

505651
// ───────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)