Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion docs/adr/0104-field-runtime-value-shape-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
physical column — a string column holding the bare `sys_file` id, switched
per deployment on the `adr-0104-file-references` flag, never per version;
driver card #15989 implements it.
· **Amended** (2026-10-19, #24268, PR #24392, merge commit `d79dc4883c`) —
D2's param-less pass-through is scoped to actions with a handler: 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` (the ADR-0112
envelope), as at the REST door. 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". See the
note under D2 item 1.
- **Date**: 2026-07-22
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
Expand Down Expand Up @@ -194,6 +203,53 @@ free. Three changes:
`400 VALIDATION_FAILED` per-field envelope *before* the handler runs.
Actions declaring no `params` keep today's pass-through (there is nothing
to check against), so existing param-less actions are untouched.

> **Amended 2026-10-19 (#24268, PR #24392, merge commit `d79dc4883c`) —
> the param-less pass-through is scoped to actions with a handler.** The
> sentence above was written when every action had a handler. A
> declarative `operation: 'update'` action (#14092, executed by #15079) has
> none: its bag is merged over `patch` and written to the row, so "there is
> nothing to check against" does not hold for it. Its declared `params`
> list is its whole wire write set, and an empty or absent list is an
> **empty accept set**, not an absent contract.
> `packages/runtime/src/action-execution.ts#enforceActionParams` judges
> such a bag against `[]` with the same validator, so every key except the
> dispatcher built-ins
> (`packages/spec/src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS`:
> `recordId`, `objectName`, `_selectedIds`) is refused through this item's
> path: the same 400 at the REST door and the same
> `OS_ALLOW_LAX_ACTION_PARAMS` escape hatch, with no switch of its own.
> **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 passes no code, so
> `packages/runtime/src/error-envelope.ts#buildApiError` derives it from
> the status through
> `packages/spec/src/api/errors.zod.ts#standardErrorCodeForHttpStatus`;
> the MCP throw below stamps that function's answer. So item 1's "the
> standard `400 VALIDATION_FAILED` per-field envelope" names the wrong
> code and the wrong shape: `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. An empty bag still writes the `patch`. The discriminator is
> `operation`
> alone (`packages/runtime/src/action-execution.ts#isDeclarativeUpdateAction`),
> so every other param-less action (a script handler, an inline `body`, a
> `flow` target) keeps the pass-through. At the MCP `run_action` door,
> `packages/runtime/src/action-execution.ts#invokeBusinessAction` throws
> every D2 refusal with `code` + `status`, the 400 and the code the REST
> door derives from it, and `run_action` renders a thrown code as the
> ADR-0112 envelope `{ error: { code, message, status } }`
> (`packages/mcp/src/mcp-http-tools.ts#thrownRefusalEnvelope`), as at the
> REST door. Pinned in `packages/runtime/src/action-params-enforcement.test.ts`
> (the gate, the hatch), `packages/runtime/src/action-declarative-update.test.ts`
> (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) and
> `packages/mcp/src/mcp-coded-refusal.test.ts` (`run_action`'s rendering of
> a thrown code). No other decision in D2 changes: the code and shape
> above correct the record, they add no rule.
2. **Typed handler surface.** `defineAction` today validates config only. It
gains a typed companion so registered handlers stop being
`(ctx: any) => any`: `ctx.params` is typed by mapping each declared param
Expand Down Expand Up @@ -939,7 +995,10 @@ explicitly-set case; the v17 release notes carry the migration section (what
a rejected bag looks like, how to fix the caller, the escape hatch's name).

Actions declaring no `params` keep their pass-through — that boundary is
unchanged from D2's landing. And the asymmetry with D1 stays load-bearing in
unchanged from D2's landing. *(Amended 2026-10-19, #24268: 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.)* And the asymmetry with D1 stays load-bearing in
both directions: a version flip is wrong where complete per-deployment
evidence is *possible* (D1's data), and right where it is impossible in
principle and the failure mode is a self-describing 400 to the party who can
Expand Down
Loading