Skip to content

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

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-24393-adr-0104-d2-declarative-update
Oct 19, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-24393-adr-0104-d2-declarative-update

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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 d79dc48) — … 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 [WIP] Add Chinese version of the documentation #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 fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors #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 fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors #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 [WIP] Add Chinese version of the documentation #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 fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors #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

…with a handler

A dated note under D2 item 1, a status-line entry, and a one-line pointer
at the 2026-07-30 addendum's restatement: a declarative operation 'update'
action's empty or absent params list is an empty accept set, refused
through D2's own path, and at the MCP run_action door a D2 refusal carries
code + status, as at the REST door. Measured on origin/main ee882b8,
after merge commit d79dc48.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5823853f3ec9b2d97fe36c5a79b5aa9c5fc0c001
Local-runs: none
Scope: docs/adr/0104-field-runtime-value-shape-contract.md + packages/runtime/src/action-execution.ts, packages/spec/src/ui/action-params.zod.ts, packages/runtime/src/domains/actions.ts, packages/runtime/src/domains/mcp.ts, packages/runtime/src/error-envelope.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/mcp/src/mcp-http-tools.ts, packages/runtime/src/action-params-enforcement.test.ts, packages/runtime/src/action-declarative-update.test.ts, packages/mcp/src/mcp-coded-refusal.test.ts, docs/adr/0090-permission-model-v2-concept-convergence.md, docs/adr/0091-grant-lifecycle-and-recertification.md, docs/adr/0103-managedby-write-policy-and-engine-write-guard.md, docs/adr/0112-error-code-vocabulary-and-ledger.md

Rules face, docs/adr/** (Tier H: the maintainer's hand lands it; this record is the seat's review before that read). Inputs: card #24393 (body, triage 6255033727, unblock 6255723589, claim 6257175272, dev report 6257416741), #24268's landing note 6255674746, PR #24392 (merged as d79dc4883c, an ancestor of origin/main 97cb5c5a8), PR #24416's body, file list and net diff against the merge base 97cb5c5a8 (one file, +41 / -1; the branch holds the docs commit 4ca7af7f9 and a merge of origin/main), and the head's check-runs. Every code symbol below was read on origin/main 97cb5c5a8, which is the merge base, so what the note describes is the tree it would land on.

① Derived judgments

Each rule the note states, named right or wrong against the code it cites.

  1. enforceActionParams judges a param-less declarative update's bag against [] with the same validator — right. packages/runtime/src/action-execution.ts:1685 opens if (!declaresParams && !isDeclarativeUpdateAction(action)) return null;; for a declarative update resolveDeclaredActionParams (:1625) iterates Array.isArray(action?.params) ? action.params : [] and returns []; validateActionParams(resolved, bag) (packages/spec/src/ui/action-params.zod.ts:185) then files every bag key outside declared or allow as unknown_field (:217–:221). Pinned: action-params-enforcement.test.ts:88 refuses { subject } for both spellings of "declares none" (key absent, params: []), naming the key.
  2. The dispatcher built-ins are recordId, objectName, _selectedIds — right. ACTION_PARAM_BUILTIN_KEYS at action-params.zod.ts:87 is exactly that list, and validateActionParams defaults allow to it (:193). Pinned: action-params-enforcement.test.ts:96 accepts { recordId, objectName } on the param-less update.
  3. The same 400 at the REST door and the same OS_ALLOW_LAX_ACTION_PARAMS hatch, with no switch of its own — right. The REST door answers every paramError with deps.error(paramError, 400) (domains/actions.ts:676–:678), one call site for declared and param-less alike. The hatch is laxActionParams() (action-execution.ts:104, OS_ALLOW_LAX_ACTION_PARAMS === '1'), which turns the refusal into a one-per-key console.warn and a null (:1690–:1699); no other env var or config key is read on that path. Pinned: action-params-enforcement.test.ts:106 (accepts and warns once) and action-declarative-update.test.ts:354, :366 (REST 400, zero updates, row equal to freshRow()).
  4. An empty bag still writes the patch — right. validateActionParams([], {}) yields no issue; declarativeUpdateWrite (action-execution.ts:2213) returns { ...patch, ...params }. Pinned at both doors: action-declarative-update.test.ts:397 ({} and { params: {} } at REST, { recordId } and { recordId, params: {} } at MCP, each writing exactly { status: 'closed' }).
  5. The discriminator is operation alone, so every other param-less action keeps the pass-through — right. isDeclarativeUpdateAction is action?.operation === DECLARATIVE_UPDATE_OPERATION (:834–:836), the constant is 'update' (:805), and the docblock says the absence of a type clause is deliberate. The early return has no third condition, so a param-less script handler, an inline body, a flow target and an undeclared action all return null from the gate. Pinned for the handler leg: action-params-enforcement.test.ts:74 ({ name: 'ping' }, with and without params: []) and action-declarative-update.test.ts:432 (nudge_case reaches the registry with the undeclared key). The flow and inline-body legs follow from the predicate, not from a pin of their own; one precision the note need not carry: a flow action's bag is later widened by seedFlowActionParams (:961, record fields and row-id keys under the caller's params), downstream of the gate and not a D2 judgement.
  6. At MCP run_action, invokeBusinessAction throws every D2 refusal with code + status, the 400 and the code the REST door derives from it — right. action-execution.ts:2540–:2543: the one enforceActionParams call at that door throws Object.assign(new Error(paramError), { code: standardErrorCodeForHttpStatus(400), status: 400 }). The REST door's code comes from the same function: deps.error(paramError, 400) passes no code and no details, so buildApiError (packages/runtime/src/error-envelope.ts:120) falls to standardErrorCodeForHttpStatus(input.httpStatus), and HttpStatusErrorCodeMap[400] is VALIDATION_ERROR (packages/spec/src/api/errors.zod.ts:184). The runtime bridge's runAction (domains/mcp.ts:784–:787) returns that call with no catch of its own. Pinned: action-declarative-update.test.ts:381 (err.code VALIDATION_ERROR, err.status 400, the key named, zero updates).
  7. run_action renders a thrown code as the ADR-0112 envelope { error: { code, message, status } } — right. The tool's catch (packages/mcp/src/mcp-http-tools.ts:1281) is errorResultFromThrown, which reads the throw through thrownRefusalEnvelope (:422, a non-empty string code, the message, a numeric status) and renders jsonText({ error }) with isError: true (:337–:343, :372–:377); details is spread in only when the throw carries one, and the D2 throw carries none. The note anchors the exported reader rather than the two unexported renderers, which is the right anchor. Pinned: mcp-coded-refusal.test.ts:168 over HTTP /mcp, the run_action row (:105), a bridge throwing RECORD_NOT_FOUND. One precision, no wording change owed: "as at the REST door" is right for the members (code, message, status); the REST door spells the status as error.httpStatus beside the transport 400 (error-envelope.ts:122), which is ADR-0112's own end-state wording, while the MCP tool result spells it status. The note names only the MCP spelling and attributes nothing to the REST key.
  8. Pins, as cited — right, each for what it actually holds: action-params-enforcement.test.ts is the gate and the hatch (its own docblock says it pins the gate, not the validator); action-declarative-update.test.ts drives the real HttpDispatcher.handleActions (:211) and the real invokeBusinessAction (:220), pins both doors, nothing written, and the handler control; mcp-coded-refusal.test.ts pins run_action's rendering of a thrown code behind a fake bridge. The note claims no end-to-end pin; see ③ item 1.
  9. "(The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092, executed by runtime: execute the declarative row-level operation: 'update' action — the platform action route performs one data-plane update of the current record as the caller (runtime half of #14092) #15079)" — right. #14092 is the maintainer ruling of 2026-09-01 that introduced operation: 'update' (action-execution.ts:801, packages/spec/src/ui/action.zod.ts:554), and the #15079 describes in action-declarative-update.test.ts are its execution.
  10. Amendment form — right. The status-line entry is the indented list-form · **Amended** (date, refs) — … under the - **Status** bullet, the spelling ADR-0091 (:4) and ADR-0103 (:5) use, with ADR-0090's em dash (:4–:9); the dated blockquote under D2 item 1 is ADR-0090's form (:485, :535); the pointer at the 2026-07-30 addendum uses this ADR's own inline *(Amended …)* form (:435, :840 on origin/main). The status entry cites the card and the merge commit sha; the PR number rides as a convenience link.
  11. Prime Directive [WIP] Add Chinese version of the documentation #13, extend and never reverse — right. Neither original sentence is rewritten: on origin/main the two sentences sit at :195–:196 and :941–:942; at the head they survive verbatim at :201–:202 and :978–:979, and the diff's single - line is the split of the latter to admit the parenthetical, every word of it kept. No decision is added; every sentence of the note is a measurement of what d79dc4883c put on origin/main.
  12. The pointer at the second sentence reads true — right. "the boundary holds for actions with a handler; a declarative operation: 'update' action's empty or absent params list is an empty accept set — see the note under D2 item 1" states the gate's two legs exactly, and the sentence it follows sits under ## Addendum (2026-07-30) / ### D2 action params (:732, :897 on origin/main), so the PR body's "2026-07-30 附录" is the right section.
  13. The 8 new anchors resolve — right. The five symbol anchors name enforceActionParams (:1678), isDeclarativeUpdateAction (:834), invokeBusinessAction (:2450), ACTION_PARAM_BUILTIN_KEYS (:87) and thrownRefusalEnvelope (:422), each declared at the cited path on origin/main; the three file-level anchors name files that exist. CI's Lint · docs & code guards, which runs check-adr-symbol-anchors.mjs (lint.yml :2578), is success on this head.
  14. The PR body's 维护者速读 (草稿) says the same as the diff — right. One file, three additions (the dated note under D2 item 1, the status-line entry, the one-line pointer in the 2026-07-30 addendum); no original sentence deleted or rewritten; docs only, no behaviour change; the MCP clause described as two pins with no end-to-end one. Two readings: "回滚:revert 这一个提交即可" is true of the squash the queue lands, while the branch itself carries two commits (4ca7af7f9 and the merge); and "席位意见" is left blank, a draft placeholder for the seat to fill before the maintainer reads it. Neither is a finding.
  15. Check-runs on 5823853f3 — read at 2026-10-19T01:02:43Z: 41 runs, 2 in_progress (Lint · docs & code guards, Type Check · consumer gates), one failure (Check Changeset, first round). Read again at 2026-10-19T01:07:58Z: 43 runs, 31 success, 11 skipped, 1 failure, none in_progress. The one failure is the first-round Check Changeset (id 117840136807), which started at 2026-10-19T00:58:51Z, before the seat applied skip-changeset at 2026-10-19T01:01:53Z; its second round (id 117840854693) is skipped. Governed Surface Queue Guard, Type Check · workspace, Test Core and every Lint · job are success; Build Core is skipped (docs-only path filter). Green as the gate reads it.

Prime Directive #10 clause by clause: no sentence of the note states as fact a behaviour the code or its pins do not hold.

② Semver level

Docs only: the diff is one file under docs/adr/** and publishes nothing from any released package. skip-changeset is right. Clause-②: no on the PR body is right for a note that adds no decision and changes no accept set; the narrowing it records belongs to PR #24392's changeset (@objectstack/runtime minor, Clause-②: no (narrowing)), already landed.

③ Boundary flags

  1. The dev's caveat: the MCP clause is held by two pins meeting at one seam, with no end-to-end pin. Answered: the note's wording is as narrow as that. It states the producer half (invokeBusinessAction throws code + status) and the renderer half (run_action renders a thrown code through thrownRefusalEnvelope) as two sentences, cites one pin per half, and claims no end-to-end pin. The composition is two read seams with nothing between them: the bridge member returns the throw with no catch (domains/mcp.ts:787) and the tool's try has one catch, the renderer (mcp-http-tools.ts:1281). PR fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors #24392's uncommitted real-bridge measurement agrees. No escalation; a committed end-to-end pin would be a test card for the runtime lane if the seat wants one, not a wording change here.
  2. The code-name reading: D2 item 1 spells VALIDATION_FAILED; the dev reports both doors answer VALIDATION_ERROR. Measured on origin/main 97cb5c5a8. REST: deps.error(paramError, 400) with no code and no details, so buildApiError derives standardErrorCodeForHttpStatus(400) = VALIDATION_ERROR (error-envelope.ts:120, errors.zod.ts:184); pinned through the real dispatcher at action-declarative-update.test.ts:326, :359, :376. MCP: the throw stamps standardErrorCodeForHttpStatus(400) = VALIDATION_ERROR (action-execution.ts:2542); pinned at :390–:391. So: the two doors do not disagree (not 3); neither door drifted from a code it declares (not 2), because the D2 gate never builds the shape VALIDATION_FAILED names: that code is the ledger's record-level validation member carrying fields[] (error-code-ledger.zod.ts:302, packages/types/src/validation-failure.ts), and the gate returns one string, Invalid action params: …, with no details. It is 1, a record error, and slightly wider than one token: the sentence at head :199–:200 says "the standard 400 VALIDATION_FAILED per-field envelope", where "standard" names the catalog whose 400 member is VALIDATION_ERROR, and "per-field envelope" describes a fields[] shape neither door answers (each door answers one 400 whose message names every offending param). The fix is a docs-only amendment of that sentence on ADR-0104: the code token and the envelope phrase. Not this PR's FAIL: the note states no code, so it adds no second literal. Escalated to the seat to file or fold: file a documentation card against ADR-0104 D2 item 1, or fold the correction into this PR before the maintainer reads it (one more sentence under the same dated note, inside the same Tier H landing). Whether an AI caller should get a structured per-field details on a D2 refusal is a separate product question; ADR-0112 does not oblige it for a generic condition, and nothing here advertises it.
  3. Built-in keys on a param-less declarative update. Answered: the note says every key except the built-ins is refused at the gate and describes nothing about the write, which is exact; what the write does with a built-in that reaches the bag is PR fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors #24392's accepted note 3 (the engine's undeclared-field door refuses it as INVALID_FIELD unless the object declares such a field). No escalation.
  4. Placement (G2). Answered: one note at the decision it qualifies (D2 item 1), one status-line entry, one pointer at the restatement; the record says it in one place. No escalation.
  5. Labels. Answered: the dispatch named none, the dev wrote none, the seat applied skip-changeset; the first-round Check Changeset failure predates the label and its second round is skipped. No escalation.
  6. Commit trailers. The two commits carry the model-free pair AGENTS.md prescribes; no model identifier in the ADR text, the PR body or the trailers. No escalation.

Implemented-by: claude/issue-24393-adr-0104-d2-declarative-update
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 (seat check) · PR #24416 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-19T01:13Z

REWORK (patch round 1, same claim 6257175272, same dev) at head 5823853f3ec9b2d97fe36c5a79b5aa9c5fc0c001, after contract review 6257540342 (PASS, no FAIL item). One item, folded in from the review's ③ 2 so that the record is true in the one read the maintainer gives it. Everything else holds.

  • The item: D2 item 1's sentence just above the new note says a refusal is "the standard 400 VALIDATION_FAILED per-field envelope". The review measured both doors on origin/main 97cb5c5a8:

    • the REST door answers deps.error(paramError, 400), which buildApiError turns into the status-derived VALIDATION_ERROR;
    • the MCP run_action door stamps standardErrorCodeForHttpStatus(400);
    • both answer one message naming every offending param, with no fields[].
    • pinned at action-declarative-update.test.ts:326, :359, :376, :390–:391.

    So the doors agree, and the record's code and envelope phrase are wrong for every D2 refusal. VALIDATION_FAILED is the ledger's record-level fields[] member. The new note says "the same 400" right under that sentence, so a reader takes the wrong shape for the declarative update too.

  • The fix: one or two sentences in this PR's dated note (⛔ the original sentence stays verbatim, Prime Directive [WIP] Add Chinese version of the documentation #13). They say what a D2 refusal is on main: 400 VALIDATION_ERROR, one message naming every offending param, no fields[], at both doors, citing those pins. Add the same correction to the status-line entry if it states a code.

  • The PR body: name the correction, and put it in the ## 维护者速读(草稿)'s 「改了什么」. Return the body edits as pr_body_edits pairs.

  • After the push: a round-2 contract review on the new head, then the seat's maintainer brief and seat check.


Generated by Claude Code

The dated note under D2 item 1 now says a D2 refusal is one 400
VALIDATION_ERROR naming every offending param, with no fields[], at the
REST and MCP doors, and that item 1's "400 VALIDATION_FAILED per-field
envelope" names the ledger's record-level fields[] member instead. The
status-line entry carries the same correction. The original sentence stays
verbatim.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 485dc436201a5bf0b7776dec10ae2a8351b7027f
Local-runs: none
Scope: docs/adr/0104-field-runtime-value-shape-contract.md + packages/runtime/src/action-execution.ts, packages/spec/src/ui/action-params.zod.ts, packages/runtime/src/domains/actions.ts, packages/runtime/src/http-dispatcher.ts, packages/runtime/src/domains/mcp.ts, packages/runtime/src/error-envelope.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/types/src/validation-failure.ts, packages/mcp/src/mcp-http-tools.ts, packages/runtime/src/action-params-enforcement.test.ts, packages/runtime/src/action-declarative-update.test.ts, packages/mcp/src/mcp-coded-refusal.test.ts

Round 2, rules face, docs/adr/** (Tier H: the maintainer's hand lands it; this record is the seat's review before that read, not an approval). Round 1 is record 6257540342 on head 5823853f3 (PASS); its ③ 2 measured that D2 item 1's "standard 400 VALIDATION_FAILED per-field envelope" names the wrong code and shape, and REWORK 6257559771 folded that correction into this PR. This round judges the new head on its own: the delta git diff 5823853f3 fce8981f8 (one file, +24 / −5), the merge 485dc4362 of origin/main 71fa166e9, the net diff against the merge base (one file, +60 / −1), the PR body as the seat updated it from the dev's pairs (report 6257719261 on #24393), and the head's check-runs. Every code symbol below was read on refs/review/pr-24416 at 485dc4362; for each cited file that tree is byte-equal to origin/main 71fa166e9 (item 9), so the lines cited are the tree the note would land on.

① Derived judgments

Each sentence the delta adds, named right or wrong against the code it cites.

  1. "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[]" — right, clause by clause.

    • One 400 VALIDATION_ERROR. REST: domains/actions.ts:676–:678 answers deps.error(paramError, 400); the dispatcher's deps.error forwards its three positionals to this.error(message, code, details) (http-dispatcher.ts:563), whose second parameter is the HTTP status (:1158, httpStatus) and which receives no code and no extra that way; apiErrorResponse hands that to buildApiError (error-envelope.ts:117–:125), where code is input.code ?? promoted ?? standardErrorCodeForHttpStatus(input.httpStatus) (:120), splitSemanticCode(undefined) promotes nothing, and HttpStatusErrorCodeMap[400] is VALIDATION_ERROR (errors.zod.ts:184, :205–:207). MCP: action-execution.ts:2540–:2542 throws Object.assign(new Error(paramError), { code: standardErrorCodeForHttpStatus(400), status: 400 }). Pinned: action-declarative-update.test.ts:325–:326, :358–:359, :375–:376 (REST, res.status 400 and res.body.error.code), :390–:391 (MCP, err.code and err.status).
    • The message joins one issue per offending param, each naming it. enforceActionParams (action-execution.ts:1688–:1691) maps each issue to its message, joins them with '; ', and returns Invalid action params: + that summary. validateActionParams (action-params.zod.ts:185–:227) files at most one issue per declared param (a missing required param continues at :201, so it never also collects invalid_shape) and one unknown_field per undeclared key (:217–:223), and every message quotes the param's name (:199, :212, :222). Pinned: :327 (/Invalid action params/), :361 and :392 ("note"), action-params-enforcement.test.ts:68–:71 (title), :88–:93 ("subject").
    • With no fields[]. REST: deps.error passes no details, so buildApiError spreads none (:124) and no extra (:123); the safe step at :1159–:1162 rewrites a message only at 500 and above. MCP: the throw carries code and status only, and thrownRefusalEnvelope (mcp-http-tools.ts:422–:430) reads code, message and a numeric status. Held by construction; no pin asserts the absence (③ 3).
    • At both doors and for every action. git grep enforceActionParams\( finds exactly the two call sites above and the definition; neither door branches on the action's kind after the gate answers, so a declared-params action and a param-less declarative update get the one string shape (the gate's two legs, :1684–:1685), and a handler action declaring none gets no refusal at all (:1685, pinned action-params-enforcement.test.ts:74–:79), so "every action" is every action a D2 refusal reaches. Pinned on a declared-params action at REST (:319–:332: triage_case, :81–:88, declares params: [{ name: 'priority', type: 'text', required: true }] at :86) and on the param-less close_case at REST (:354–:378) and at MCP (:381–:395). One precision: no committed pin drives a declared-params action through the MCP door; that leg follows from the single unconditional throw at :2540–:2542, which reads only the string the gate returned (③ 2). The note claims no such pin (item 5).
  2. "The REST door passes no code, so buildApiError derives it from the status through standardErrorCodeForHttpStatus; the MCP throw below stamps that function's answer" — right. The two arguments of deps.error(paramError, 400) are message and status; the fourth parameter of this.error, code, is unreachable from deps.error (:563, :1158); buildApiError:120 is the derivation. The MCP throw at :2542 calls the same exported function (errors.zod.ts:205), imported at action-execution.ts:24. "Below" is right: the note's invokeBusinessAction sentence follows in the same blockquote.

  3. "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" — right. error-code-ledger.zod.ts:302 reads 'VALIDATION_FAILED', // record-level validation; carries fields[] (#3977) (in the @objectstack/rest section, :191; the same code sits in five more package sections, one code). packages/types/src/validation-failure.ts:47–:51 declares ValidationFailureDetails as code: 'VALIDATION_FAILED' plus fields: unknown[], and validationFailure(message, fields) (:76–:82) is its constructor. git grep VALIDATION_FAILED over action-execution.ts and action-params.zod.ts finds no hit; the three hits in domains/actions.ts (:200, :266, :1084) are comments on the _activation body reader and on errorFromThrown mapping a handler's thrown ValidationError, other paths than the D2 gate. So "names the wrong code and the wrong shape" is a judgment the measurements hold: "standard" names the catalog whose 400 member is VALIDATION_ERROR, and "per-field envelope" names fields[], which neither door answers.

  4. The status-line sentence: "A D2 refusal is one 400 VALIDATION_ERROR naming every offending param, with no fields[], at both doors, which corrects item 1's "400 VALIDATION_FAILED per-field envelope"" — right; the same assertions as item 1 in one sentence, and the quoted phrase is item 1's own wording at head :202–:203.

  5. The pin parenthetical: "(both doors: VALIDATION_ERROR and 400 on a declared and a param-less action at REST and on the MCP throw, nothing written, and the handler control)" — right, for what each pin holds: :326 (REST, declared-params triage_case), :359 and :376 (REST, param-less close_case, an undeclared key and a patch key sent as a param), :390–:391 (the MCP throw's code and status), "nothing written" (:330, :362, :377, :393, each toHaveLength(0), the three close_case ones with the row equal to freshRow()), "the handler control" (:432–:442, nudge_case reaches the registry). The "declared and a param-less" pair is qualified "at REST", and the MCP leg is pinned on the param-less action; the PR body's pin list reads the same way. The rig drives the real HttpDispatcher.handleActions (:211) and the real invokeBusinessAction with the real callData (:220–:230).

  6. "No other decision in D2 changes: the code and shape above correct the record, they add no rule" — right. The delta adds no accept set, no code token the runtime does not already answer, no switch; every added sentence is a measurement of d79dc4883c's tree, and the ledger and both doors answered VALIDATION_ERROR before this PR.

  7. The reflow — "An empty bag still writes the patch. The discriminator is operation alone…" survive word for word; the delta moves a line break and nothing else.

  8. Prime Directive [WIP] Add Chinese version of the documentation #13, extend and never reverse — right. Item 1 at head :193–:205 is byte-equal to origin/main :184–:196, the line "400 VALIDATION_FAILED per-field envelope before the handler runs." (head :203) included; the 2026-07-30 sentence at head :997–:998 equals origin/main :941–:942. The net diff is +60 / −1, the one - being the split that admits the parenthetical. The correction is dated (2026-10-19) in both places, cites the card, the PR and the merge commit, and reads as "corrects item 1's …" and "the code and shape above correct the record, they add no rule". D2's decision (unknown keys are a validation error, enforced before the handler runs, with one escape hatch) stands untouched; what was wrong was the code token and the envelope phrase, and the amendment says so beside the original rather than over it.

  9. The merge moved nothing the note cites — right. 485dc4362 has parents fce8981f8 and 71fa166e9; git merge-base origin/main refs/review/pr-24416 is 71fa166e9, as the PR body says. git diff 97cb5c5a8 71fa166e9 --stat over the 18 files this record reads (the 14 in Scope, and ADR-0090, -0091, -0103, -0112 that round 1 read beside them) is empty; the 8 files 71fa166e9 touched are packages/cloud-connection/src/cloud-connection-plugin.ts, two marketplace files, packages/mcp/src/connect-ui.ts, packages/mcp/src/metadata-completeness.ts, one docs mdx and two changesets.

  10. The three new anchors resolve — right. packages/runtime/src/error-envelope.ts#buildApiError (:117, exported), packages/spec/src/api/errors.zod.ts#standardErrorCodeForHttpStatus (:205, exported), packages/types/src/validation-failure.ts (exists). The note now carries 11 anchors (7 symbol, 4 file-level), as the PR body counts. Lint · docs & code guards, which runs check-adr-symbol-anchors.mjs, is success on this head.

  11. The PR body's maintainer brief draft (## 维护者速读(草稿), "what changed") says the same as the diff — right. One file; three additions (the dated note under D2 item 1, the status-line entry, the pointer in the 2026-07-30 addendum); the round-1 correction folded into the note and the status entry; both doors answer 400 VALIDATION_ERROR with one message naming each offending param and no fields[]; VALIDATION_FAILED named as the record-level fields[] code D2 never answers; no original sentence deleted or rewritten. The body's figures re-measure: "+60 / −1", "merge base 71fa166e9", "Head: 485dc4362", "fce8981f8 +24/-5", "11 new anchors (7 symbol, 4 file-level)", and "PR fix(cloud-connection,mcp): in-system copy says "this system" or drops the noun, not "this environment" (cloud#3002 ruling A) #24413, which touches no file the note cites". Two readings carried from round 1 still hold and are not findings: "revert this one commit" is true of the squash the maintainer lands, while the branch itself carries four commits (4ca7af7f9, a merge, fce8981f8, a merge); the seat-opinion slot is blank, the seat's to fill before the maintainer reads it.

  12. Check-runs on 485dc4362 — read at 2026-10-19T01:41Z: 46 runs, all completed, 35 success, 11 skipped, 0 failure, 0 in_progress. The skipped ones are path-filtered or label-gated: Build Core, Build Docs, Dogfood Verify CLI, the dogfood shard, Temporal Conformance, Console Pin Gate, Packed-tarball smoke (opt-in), both rounds of Check Changeset (labels on the PR: documentation, size/s, skip-changeset), and the Check PR Size and Auto Label of the re-run that followed the body edit (their first runs are success). Governed Surface Queue Guard, Test Core and its six shards, every Type Check · and every Lint · job are success. The PR is draft. Green as the gate reads it.

  13. Commit trailers — fce8981f8 and 485dc4362 carry the model-free pair AGENTS.md prescribes; no model identifier in the ADR text, the PR body or the trailers.

Prime Directive #10 clause by clause: no sentence the delta adds states as fact a behaviour the code or its pins do not hold; the two places where the claim rests on the code's construction rather than on a door-level pin are named in ③ 2 and ③ 3 and are not claimed as pins by the note.

② Semver level

Docs only: the diff is one file under docs/adr/** and publishes nothing from any released package. skip-changeset is right and applied. Clause-②: no on the PR body is right: the note adds no decision and changes no accept set, and the correction records a code the ledger and both doors already answer; the narrowing it describes belongs to PR #24392's changeset, already landed.

③ Boundary flags

  1. The dev's deviation: the status-line sentence the instruction did not require. The REWORK said to add the correction to the status-line entry "if it states a code"; the entry stated none, so nothing was owed there. The dev added one sentence anyway and reported it as a deviation with its revert (drop the sentence). Judged: the sentence is true (item 4); the status line is where this ADR already summarises what each addendum changed, so a reader of the header alone now learns the record was corrected; and it adds no second literal that could drift from the note, since both spell the same code. Keep it. The seat may drop it on taste, and the note alone would still carry the correction. No escalation.
  2. The MCP leg of "for every action" has no door-level pin for a declared-params action. Held by the single unconditional throw at action-execution.ts:2540–:2542, which reads only the gate's string, and by the gate's own declared-params pin at action-params-enforcement.test.ts:68. The note cites pins for what they hold and claims no more. A committed pin, if the seat wants one, is a test card for the runtime lane, the same shape as round 1's ③ 1 on the end-to-end composition; not a wording change here.
  3. "with no fields[]" is held by construction, not by an absence assertion. deps.error(paramError, 400) passes no details and the MCP throw carries none; no pin asserts that neither envelope carries fields. Same answer as 2: a test card if wanted, no wording change owed.
  4. Round 1's ③ 2 is closed by this round. The correction sits in the dated note and the status entry, the original sentence verbatim, inside the same Tier H landing. Round 1's ③ 1 and 3–6 are untouched by the delta and stand as read there.
  5. Tier H. The PR stays draft; this record is the seat's review before the maintainer reads it, not an approval, and the queue guard's green carries no landing authority on docs/adr/**.

Implemented-by: claude/issue-24393-adr-0104-d2-declarative-update
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 · PR #24416 · domain:spec seat 2 · zhuangjianguo · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-19T01:42Z

改了什么
只改 ADR-0104 一份文本,+60/−1,不碰代码,也不发版。三处:

  • D2 第 1 条下加一条 2026-10-19 的修订说明,头部状态行同步加一条 Amended 记录;
  • 07-30 附录里重复这条边界的那句话后面,加一行指向说明的指引;
  • 原文一个字都没改。

说明里写了两件事:

为什么改

风险与代价(含回滚)

  • 纯文本,不改任何运行时行为。
  • 回滚 = 撤回这次合并(squash 后是一个提交)。

席位意见
同意合并。契约复核两轮都判 PASS,没有 FAIL 项:第 1 轮 6257540342,第 2 轮 6257846085。说明里的每条执行规则和错误码,都逐行对过 main 上的代码与测试。

另有两处没有测试直接固定,复核认为措辞已经够窄,不必改:

  • MCP 入口对「声明了参数的动作」的拒绝;
  • 「没有 fields[]」这一点。

⚠️ 有一处需要你定:如果你认为原文的「逐字段 VALIDATION_FAILED 信封」才是当初定下的目标,应该改代码去对齐它,请在本 PR 留言,席位会另立 runtime 代码卡,这条说明随之改写。

你要做的(一个动作)
用 os-zhuang 或 hotlong 在本 PR 上提交 Approve(你上次是批准后亲手翻 ready、入队,这样也可以),或亲手合并。

席位核对 (seat check)

ACCEPT at head 485dc436201a5bf0b7776dec10ae2a8351b7027f (claim 6257175272; reports 6257416741 and 6257719261; REWORK 6257559771). Tier H (docs/adr/**): the PR stays draft until an authorised approval. needs-user-decision goes on this PR in this act, and review is requested from os-zhuang and hotlong.

item reading
Shape Draft, base main, assignee zhuangjianguo. Line 1 is Fixes #24393, and Clause-②: no starts a line; there is no other closing keyword. One file, +60/−1, governed (check-governed-merges: 1 of 1 path hits the register). skip-changeset (docs only). mergeable_state: clean.
Premises measured P1: the two pass-through sentences were present on main. P2: enforceActionParams judges a param-less declarative update against []. P3: at MCP run_action, a D2 refusal carries code + status. All were measured by the dev, and every cited line was re-read by both review rounds.
Gates (check-runs on the head) 46 runs: 35 success, 11 skipped. check-expected-skips: OK. Dev: 19 derived families, all exit 0, reconciled with --ran.
Accepted deviations The status-line sentence that announces the code correction. The review judged it true and in place.
Out of scope Two absence-of-pin observations from round 2 (③ 2, ③ 3): an MCP door-level pin for a declared-params refusal, and an assertion that no fields[] appears. Both are held by construction and stated no wider than their pins. They are not filed: no producer is harmed. Dropped.
Landing After an approval by os-zhuang or hotlong, or the maintainer's merge: pr_ready then automerge_enable. The merge closes #24393.

Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 19, 2026 03:55
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 19, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Approval in, seat landing · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-19T03:56Z


Generated by Claude Code

Merged via the queue into main with commit 7e1ce9d Oct 19, 2026
54 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-24393-adr-0104-d2-declarative-update branch October 19, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants