Skip to content

Commit 7e1ce9d

Browse files
docs(adr): ADR-0104 D2 — the param-less pass-through is scoped to actions with a handler; a declarative update's empty params list is an empty accept set (#24416)
Fixes #24393 Clause-②: no Dispatched by the `domain:spec` seat 2 PM (`session_019oFY6Hi35J7atvgVFnmoaK`, claim comment 6257175272 on #24393). This dev ran as an `os-dev` subagent of that session. Branched from `origin/main` `ee882b845`. Before the final push, `origin/main` `97cb5c5a8` was merged in (PR #24408, which touches ADR-0095 and ADR-0090 only). Patch round 1 (REWORK `6257559771`, from contract review `6257540342` ③ 2) added the correction commit `fce8981f8`, then merged `origin/main` `71fa166e9` (PR #24413, which touches no file the note cites). Head: `485dc4362`. This is a Tier H governed surface (`docs/adr/**`). The PR stays draft and lands only by the maintainer's hand. No changeset: the diff is one ADR file and publishes nothing. ## What changed One file, `docs/adr/0104-field-runtime-value-shape-contract.md`, +60 / −1. No decision is added. Every sentence is a measurement of `origin/main` after merge commit `d79dc4883c` (PR #24392, card #24268). 1. **Status line.** One `· **Amended** (2026-10-19, #24268, PR #24392, merge commit `d79dc4883c`) — …` entry. It uses the list-form spelling of ADR-0103 and ADR-0091, with ADR-0090's em dash. 2. **A dated blockquote under D2 item 1.** It sits right after the sentence at the old `:195`–`:196`, which stays as written: `> **Amended 2026-10-19 (…) — the param-less pass-through is scoped to actions with a handler.**` It states the three points of the card's "What the record lacks", each with its symbol anchor, and names the pins that hold them. 3. **A one-line pointer at the 2026-07-30 addendum's restatement** (the old `:941`–`:942`). It uses this ADR's own inline form, `*(Amended …: …)*`, as its earlier refinements do: the boundary holds for actions with a handler; a declarative update's empty or absent list is an empty accept set; see the note under D2 item 1. 4. **Patch round 1: what a D2 refusal is on `main`.** D2 item 1's sentence just above the note says a refusal is "the standard `400 VALIDATION_FAILED` per-field envelope", and the note said "the same 400" right under it. The note now says what a D2 refusal is, at both doors and for every action: one `400 VALIDATION_ERROR` whose message (`Invalid action params: …`) joins one issue per offending param, each naming it, with no `fields[]`. The REST door's `deps.error(paramError, 400)` passes no code, so `buildApiError` (`packages/runtime/src/error-envelope.ts`) derives it through `standardErrorCodeForHttpStatus(400)`; the MCP throw stamps that function's answer. `VALIDATION_FAILED` is the ADR-0112 ledger's record-level validation member, which carries `fields[]` (`packages/types/src/validation-failure.ts`), and the D2 gate never builds it. Pins, re-measured on `485dc4362`: `packages/runtime/src/action-declarative-update.test.ts:326` (REST, a declared-params action), `:359` and `:376` (REST, the param-less `close_case`), `:390`–`:391` (the MCP throw's `code` and `status`). The status-line entry carries the same correction in one sentence. The original sentence stays verbatim (Prime Directive #13), and the note's closing line now reads "No other decision in D2 changes: the code and shape above correct the record, they add no rule." **Placement (G2).** The note sits once, at D2 item 1, where the boundary is decided. The second sentence gets a pointer, not a second copy, so the record says it in one place. Neither original sentence is rewritten (Prime Directive #13: extend the record, never reverse it). ## Premises, each measured on `origin/main` `ee882b845` before writing - **P1: true.** Lines 195–196 read: "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 read: "Actions declaring no `params` keep their pass-through — that boundary is unchanged from D2's landing." A grep of this ADR for `pass-through`, `param-less` and "declaring no" finds no third statement. The same grep over the seven other ADRs that cite ADR-0104 finds none. - **P2: true, with one precision.** - `enforceActionParams` (`packages/runtime/src/action-execution.ts`) opens with `if (!declaresParams && !isDeclarativeUpdateAction(action)) return null;`. For a declarative update, `resolveDeclaredActionParams` returns `[]`. `validateActionParams` (`packages/spec/src/ui/action-params.zod.ts`) then refuses every key outside `ACTION_PARAM_BUILTIN_KEYS` (`recordId`, `objectName`, `_selectedIds`) as `unknown_field`. - The result is the same `Invalid action params: …` string every D2 refusal returns. The REST door answers it with `deps.error(paramError, 400)` (`packages/runtime/src/domains/actions.ts`). `OS_ALLOW_LAX_ACTION_PARAMS=1` (`laxActionParams()`) warns once and accepts. - Pins from PR #24392. `packages/runtime/src/action-params-enforcement.test.ts`: both spellings of "declares none" (absent and `[]`) refuse and name the key; the empty bag and the built-ins are accepted; the hatch warns and accepts. `packages/runtime/src/action-declarative-update.test.ts`, describe `#24268`: REST answers 400 `VALIDATION_ERROR` with zero updates, for an undeclared key and for a `patch` key sent as a param; MCP refuses the same way; an empty bag writes the patch at both doors; a declared param wins over `patch`; the handler control `nudge_case` reaches the registry. - **The precision.** `isDeclarativeUpdateAction` is `action?.operation === 'update'` and nothing else. So "a handler action keeps the pass-through" covers every param-less action that is not a declarative update: a script handler, an inline `body`, a `flow` target, and an action with no declaration at all. The note names the first three. - **P3: true, held by two pins that meet at one seam.** - **Producer.** `invokeBusinessAction` throws every D2 refusal at that door as `Object.assign(new Error(paramError), { code: standardErrorCodeForHttpStatus(400), status: 400 })`. The runtime bridge's `runAction` (`packages/runtime/src/domains/mcp.ts`) returns that call with no catch of its own. Pinned in `action-declarative-update.test.ts`, "MCP `run_action`: the same undeclared key is refused with `code` + `status`, before any write": `err.code` is `VALIDATION_ERROR` and `err.status` is 400. - **Renderer.** `run_action` (`packages/mcp/src/mcp-http-tools.ts`) catches with `errorResultFromThrown`. That renders any thrown string `code` through `thrownRefusalEnvelope` as `{ error: { code, message, status } }` with `isError: true`. Pinned in `packages/mcp/src/mcp-coded-refusal.test.ts`, the `run_action` row over HTTP `/mcp`, where a fake bridge throws `RECORD_NOT_FOUND`. - **What no committed pin holds:** the two halves composed end to end for a D2 refusal, from the real runtime bridge into the real MCP renderer. PR #24392 measured that once with a throwaway dogfood file and did not commit it (its reading: `{"error":{"code":"VALIDATION_ERROR",…,"status":400}}`). The note states the clause as the two pinned halves and cites both pins. It does not claim an end-to-end pin. ## Local gates, at head `485dc4362` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` ran with no paths. Its change set, taken from merge base `71fa166e9`, is this one file with 61 changed lines. It derived 19 commands, the same set as in round 0. All 19 exited 0 on the patch-round-1 head, re-run after a container restart: - `node scripts/check-adr-links.mjs` → exit 0 - `node scripts/check-adr-links.mjs --self-test` → exit 0 - `node scripts/check-adr-symbol-anchors.mjs` → exit 0 - `node scripts/check-adr-symbol-anchors.mjs --self-test` → exit 0 - `node scripts/check-ci-filter-parity.mjs` → exit 0 - `node scripts/check-closing-keyword-parity.mjs` → exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` → exit 0 - `node scripts/check-comment-mask-corpus.mjs` → exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` → exit 0 - `pnpm check:adr-anchors` → exit 0 - `pnpm check:cross-package-test-inputs` → exit 0 - `pnpm check:doc-authoring` → exit 0 - `pnpm check:driver-memory-census` → exit 0 - `pnpm check:gitlink-declared` → exit 0 - `pnpm check:nul-bytes` → exit 0 - `pnpm check:pm-governed-merges` → exit 0 - `pnpm check:pm-prior-rulings` → exit 0 - `pnpm check:refd-timer-probe` → exit 0 - `pnpm check:watch-hint-literal` → exit 0 - `dispatch-gates --ran`: `19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN`. - Verdict lines: `✅ check-adr-symbol-anchors: 2368 anchors across 142 records resolve … 0 line anchors survive …`; `✅ check-adr-links: 770 relative link destination(s) under docs/adr/ resolve`; `check-adr-anchors: OK (75 anchored file(s), …)`; `check-nul-bytes: OK (…)`; `✓ doc authoring guard: 423 files clean`. - `scripts/symbol-anchors.mjs#extractAnchors` reads the 11 new anchors (7 symbol, 4 file-level) out of the edited file, so the anchor gate's green above judged them. - In patch round 1, `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` ran under the verify lock first (4 of 4 cached, `VERDICT command-exit 0`), and `check:doc-formula-expressions` then exited 0. In round 0, on the pre-merge head `4ca7af7f9`, `check:doc-formula-expressions` first exited 3 (`PREREQUISITE NOT MET`: `@objectstack/formula` and `@objectstack/lint` were not built). It exited 0 after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` ran under the verify lock. That first exit is not a finding. - No package is touched, so no build closure, package test or typecheck is owed. The repo-wide scans are CI's. ## Acceptance notes 1. **A code spelling D2 item 1 gets wrong, now corrected by the note (patch round 1, What changed item 4).** It says failures return "the standard `400 VALIDATION_FAILED` per-field envelope". A D2 refusal is one `400 VALIDATION_ERROR` naming every offending param, with no `fields[]`, at both doors. That sentence predates this card and stays verbatim (Prime Directive #13); the note and the status-line entry state what a D2 refusal is on `main`. 2. **Built-in keys on a param-less declarative update.** `recordId`, `objectName` and `_selectedIds` stay allowlisted by the D2 validator and still reach `declarativeUpdateWrite`'s bag (PR #24392's acceptance note 3). The note says every key "except the dispatcher built-ins" is refused. It does not describe what the write does with them. 3. **Placement not taken:** a full addendum section at the end of the ADR. The card asked for about one paragraph, and the decision it qualifies is D2 item 1, so the note sits there. 4. **Labels.** The dispatch named none, so this PR opened with none. `Check Changeset` reads red without `skip-changeset`. The diff publishes nothing (`docs/adr/**` only). ## 维护者速读(草稿) **改了什么**:只改了一个 ADR 文件(ADR-0104),一共加了三处。D2 第 1 条下面加了一段带日期的说明。状态行加了一条 Amended 记录。2026-07-30 附录里重复这条边界的那句话后面,加了一行指向这段说明的指引。第 1 轮修订在这段说明里又补了一处更正:D2 第 1 条原文说拒收时返回"`400 VALIDATION_FAILED` 逐字段信封",但实际上 REST 和 MCP 两个入口返回的都是 `400 VALIDATION_ERROR`,一条消息里点出每个出错的参数,没有 `fields[]`。`VALIDATION_FAILED` 是记录级校验用的错误码,带 `fields[]`,D2 从来不会返回这种形状。状态行也写上了这条更正。原文一句都没删、没改。 **为什么改**:ADR-0104 D2 原来写的是"没有声明 params 的动作保持放行"。写这句话的时候,每个动作都有自己的处理代码,由作者自己决定怎么处理传进来的参数。后来有了声明式的 `operation: 'update'` 动作。它没有处理代码,传进来的参数会直接写进记录。PR #24392 已经让这种动作在参数列表为空或缺省时,拒收所有没声明的参数。代码已经变了,ADR 还是旧说法。以后改动作入口的人都会拿 ADR 当依据,照旧说法判断就会判错。 **风险与代价(含回滚)**:只改文档,不改任何代码,也不改任何行为。主要风险是措辞不准,所以每一句都对照了 `origin/main` 上的代码和测试。有一处要说明:MCP 入口的返回格式由两个测试分别守住,一个守运行时抛出的错误,一个守 MCP 的渲染。没有一个端到端的测试把两半连起来,所以说明里按两半来写,没有说成一个端到端的保证。回滚:revert 这一个提交即可。 **席位意见**: **你要做的**:读一遍 D2 第 1 条下面那段说明。同意就批准这个 PR。这是 Tier H 文件,要由你批准后才能合并。 --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2be3d7a commit 7e1ce9d

1 file changed

Lines changed: 60 additions & 1 deletion

File tree

‎docs/adr/0104-field-runtime-value-shape-contract.md‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
physical column — a string column holding the bare `sys_file` id, switched
1414
per deployment on the `adr-0104-file-references` flag, never per version;
1515
driver card #15989 implements it.
16+
· **Amended** (2026-10-19, #24268, PR #24392, merge commit `d79dc4883c`) —
17+
D2's param-less pass-through is scoped to actions with a handler: a
18+
declarative `operation: 'update'` action's empty or absent `params` list is
19+
an empty accept set, refused through D2's own path, and at the MCP
20+
`run_action` door a D2 refusal carries `code` + `status` (the ADR-0112
21+
envelope), as at the REST door. A D2 refusal is one `400 VALIDATION_ERROR`
22+
naming every offending param, with no `fields[]`, at both doors, which
23+
corrects item 1's "`400 VALIDATION_FAILED` per-field envelope". See the
24+
note under D2 item 1.
1625
- **Date**: 2026-07-22
1726
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
1827
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
@@ -194,6 +203,53 @@ free. Three changes:
194203
`400 VALIDATION_FAILED` per-field envelope *before* the handler runs.
195204
Actions declaring no `params` keep today's pass-through (there is nothing
196205
to check against), so existing param-less actions are untouched.
206+
207+
> **Amended 2026-10-19 (#24268, PR #24392, merge commit `d79dc4883c`) —
208+
> the param-less pass-through is scoped to actions with a handler.** The
209+
> sentence above was written when every action had a handler. A
210+
> declarative `operation: 'update'` action (#14092, executed by #15079) has
211+
> none: its bag is merged over `patch` and written to the row, so "there is
212+
> nothing to check against" does not hold for it. Its declared `params`
213+
> list is its whole wire write set, and an empty or absent list is an
214+
> **empty accept set**, not an absent contract.
215+
> `packages/runtime/src/action-execution.ts#enforceActionParams` judges
216+
> such a bag against `[]` with the same validator, so every key except the
217+
> dispatcher built-ins
218+
> (`packages/spec/src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS`:
219+
> `recordId`, `objectName`, `_selectedIds`) is refused through this item's
220+
> path: the same 400 at the REST door and the same
221+
> `OS_ALLOW_LAX_ACTION_PARAMS` escape hatch, with no switch of its own.
222+
> **What a D2 refusal is, at both doors and for every action:** one
223+
> `400 VALIDATION_ERROR` whose message (`Invalid action params: …`) joins
224+
> one issue per offending param, each naming it, with no `fields[]`. The
225+
> REST door passes no code, so
226+
> `packages/runtime/src/error-envelope.ts#buildApiError` derives it from
227+
> the status through
228+
> `packages/spec/src/api/errors.zod.ts#standardErrorCodeForHttpStatus`;
229+
> the MCP throw below stamps that function's answer. So item 1's "the
230+
> standard `400 VALIDATION_FAILED` per-field envelope" names the wrong
231+
> code and the wrong shape: `VALIDATION_FAILED` is the ADR-0112 ledger's
232+
> record-level validation member, which carries `fields[]`
233+
> (`packages/types/src/validation-failure.ts`), and the D2 gate never
234+
> builds it. An empty bag still writes the `patch`. The discriminator is
235+
> `operation`
236+
> alone (`packages/runtime/src/action-execution.ts#isDeclarativeUpdateAction`),
237+
> so every other param-less action (a script handler, an inline `body`, a
238+
> `flow` target) keeps the pass-through. At the MCP `run_action` door,
239+
> `packages/runtime/src/action-execution.ts#invokeBusinessAction` throws
240+
> every D2 refusal with `code` + `status`, the 400 and the code the REST
241+
> door derives from it, and `run_action` renders a thrown code as the
242+
> ADR-0112 envelope `{ error: { code, message, status } }`
243+
> (`packages/mcp/src/mcp-http-tools.ts#thrownRefusalEnvelope`), as at the
244+
> REST door. Pinned in `packages/runtime/src/action-params-enforcement.test.ts`
245+
> (the gate, the hatch), `packages/runtime/src/action-declarative-update.test.ts`
246+
> (both doors: `VALIDATION_ERROR` and 400 on a declared and a param-less
247+
> action at REST and on the MCP throw, nothing written, and the handler
248+
> control) and
249+
> `packages/mcp/src/mcp-coded-refusal.test.ts` (`run_action`'s rendering of
250+
> a thrown code). No other decision in D2 changes: the code and shape
251+
> above correct the record, they add no rule.
252+
197253
2. **Typed handler surface.** `defineAction` today validates config only. It
198254
gains a typed companion so registered handlers stop being
199255
`(ctx: any) => any`: `ctx.params` is typed by mapping each declared param
@@ -939,7 +995,10 @@ explicitly-set case; the v17 release notes carry the migration section (what
939995
a rejected bag looks like, how to fix the caller, the escape hatch's name).
940996

941997
Actions declaring no `params` keep their pass-through — that boundary is
942-
unchanged from D2's landing. And the asymmetry with D1 stays load-bearing in
998+
unchanged from D2's landing. *(Amended 2026-10-19, #24268: the boundary holds
999+
for actions with a handler; a declarative `operation: 'update'` action's
1000+
empty or absent `params` list is an empty accept set — see the note under D2
1001+
item 1.)* And the asymmetry with D1 stays load-bearing in
9431002
both directions: a version flip is wrong where complete per-deployment
9441003
evidence is *possible* (D1's data), and right where it is impossible in
9451004
principle and the failure mode is a self-describing 400 to the party who can

0 commit comments

Comments
 (0)