Repository navigation
Commit 7e1ce9d
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
16 | 25 | | |
17 | 26 | | |
18 | 27 | | |
| |||
194 | 203 | | |
195 | 204 | | |
196 | 205 | | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
197 | 253 | | |
198 | 254 | | |
199 | 255 | | |
| |||
939 | 995 | | |
940 | 996 | | |
941 | 997 | | |
942 | | - | |
| 998 | + | |
| 999 | + | |
| 1000 | + | |
| 1001 | + | |
943 | 1002 | | |
944 | 1003 | | |
945 | 1004 | | |
| |||
0 commit comments