Skip to content

Commit df3c392

Browse files
committed
feat(runtime)!: action params are enforced by default, and the opt-in is gone (#3438)
ADR-0104 D2 landed warn-first behind OS_ACTION_PARAMS_STRICT_ENABLED. The 2026-07-30 addendum decided that window closes in 17.0 rather than 18.0, so this executes it: - actionParamsStrict() becomes laxActionParams(): enforcement is the default and OS_ALLOW_LAX_ACTION_PARAMS=1 is the only knob — spelled OS_ALLOW_* per PD #9 and ADR-0110 D6, which this change also removes the sole cited exception from. The opt-in name never reached `latest` (RC-only), so it is deleted outright rather than deprecated for a release. - The escape-hatch path still warns once per action, so opting out tolerates the drift instead of hiding it again. - Dogfood duals are inverted: the DEFAULT path is now what the gate proves, and the hatch — the branch nobody sets — is what gets a test so it cannot rot unnoticed. Also corrects three docs the flip falsifies, plus two the earlier #3681 media work had already left self-contradictory in the v17 notes (the section said hard rejection arrives only via OS_DATA_VALUE_SHAPE_STRICT_ENABLED two paragraphs before saying media enforces per verified deployment; the upgrade checklist prescribed the env var as the way to get there, which would opt in every value class including the ones with no migration behind them). Verified: runtime 951, spec 7147, dogfood 425 (+3 skipped) all green; the D2 contract dogfood passes with the inverted duals; all ten spec artifact gates pass. Swept examples and packages for callers posting param bags — the todo MCP e2e invokes only param-less actions, showcase tests call handlers directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zgA8CQMJeJbFEjnbib1Uv
1 parent bca935b commit df3c392

9 files changed

Lines changed: 203 additions & 59 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/runtime": major
3+
---
4+
5+
feat(runtime)!: action params are enforced by default, and the opt-in is gone (#3438, ADR-0104 D2)
6+
7+
A request bag that violates an action's declared `params[]` — a missing
8+
`required` param, a value outside its `options`, a scalar where `multiple`
9+
declares an array, a non-id where a `reference` declares one, or a key the
10+
action never declared — is now **rejected before the handler runs**:
11+
`400 VALIDATION_FAILED` on REST, a thrown error on MCP. It used to be logged
12+
and passed through.
13+
14+
```diff
15+
- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default
16+
+ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before
17+
```
18+
19+
**What breaks.** Only calls that were *already* wrong. The declaration was a
20+
complete contract that informed nothing but the client dialog, so a bag the
21+
server accepted could still have been silently ignored by the handler — which
22+
is exactly how a correctly-intended `reference: 'sys_user'` degraded into a
23+
paste-a-UUID box (#3405) with a success envelope on top. Those calls now fail
24+
loudly instead of quietly. Actions declaring no `params` are untouched, and the
25+
dispatcher's own `recordId` / `objectName` are allowlisted
26+
(`ACTION_PARAM_BUILTIN_KEYS`), so the keys dispatch itself merges in were never
27+
candidates for the unknown-key error.
28+
29+
**Fixing a rejection** takes one edit at the call site: the message names the
30+
offending param and the declared list. If an integration you cannot reach in
31+
time is affected, set `OS_ALLOW_LAX_ACTION_PARAMS=1` to restore the old
32+
pass-through — the violation still logs once per action, so the drift stays
33+
visible rather than becoming invisible again.
34+
35+
**Why 17.0 rather than a warn window in 17 and the flip in 18.** R3 asked for
36+
warn-then-error, and ADR-0104's 2026-07-30 addendum declined it on the merits
37+
rather than postponing. What a violation strands is a **caller**, not data: the
38+
rejection reaches a developer or an agent who can fix it in one edit, no stored
39+
row is made unwritable, and the escape hatch makes it reversible in a restart.
40+
Deferring that by a major would have charged every deployment a second upgrade
41+
ceremony — 16→17 is already a substantial, tested migration — to postpone a
42+
break that costs one edited call. v17 already carries harsher zero-window
43+
flips (`allowExport` unset now means denied; an undeclared action handler 404s
44+
with no opt-out at all), so holding the milder change to a stricter standard
45+
would have been inconsistent rather than cautious.
46+
47+
For AI and MCP callers specifically — the population D2 was built for — a 400
48+
is corrective feedback consumed in-loop, while a server-side warning is
49+
feedback nobody ever reads.
50+
51+
D1's value-shape half went the opposite way for the opposite reason: it rejects
52+
on the basis of **stored data**, which an author cannot edit their way out of,
53+
so it stays gated per deployment on that deployment's own migration evidence.

.changeset/adr-0104-d2-typed-action-handlers.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ read an untyped bag. D2 makes the declaration enforced and typed.
2525
required presence, per-type value shape, and unknown keys (the dispatcher's
2626
own `recordId` / `objectName` are allowlisted).
2727

28-
**Warn-first rollout (ADR-0104 R3).** A violation is **logged and passes** by
29-
default — params that were silently wrong before keep working while the drift
30-
becomes visible. Set `OS_ACTION_PARAMS_STRICT_ENABLED=1` to reject with a
31-
`400 VALIDATION` (REST) / an error (MCP). Actions that declare no `params` are
32-
untouched (nothing to validate against). The flip to strict-by-default rides a
33-
later minor once telemetry is quiet.
28+
**Enforced by default.** A violation is rejected with a `400 VALIDATION_FAILED`
29+
(REST) / an error (MCP) before the handler runs. Actions that declare no
30+
`params` are untouched — there is nothing to validate against. This landed
31+
warn-first behind an `OS_ACTION_PARAMS_STRICT_ENABLED` opt-in during the 17.0
32+
RC line; that variable does **not** exist in 17.0 — see the companion entry
33+
"action params are enforced by default, and the opt-in is gone" for the
34+
reasoning and the `OS_ALLOW_LAX_ACTION_PARAMS` escape hatch.
3435

3536
Not included: file/image params becoming `sys_file` references — that depends
3637
on file-as-reference (ADR-0104 D3). Per-name static typing of `ctx.params` from

content/docs/releases/v17.mdx

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,47 @@ need no changes, other than gaining enforcement of the `requiredPermissions`
216216
they already declared. Callers that hard-coded a `target` in an action URL
217217
switch to the action's `name`.
218218

219+
### An action's declared params are enforced at dispatch (ADR-0104 D2, #3438)
220+
221+
An action's `params[]` (`required`, `options`, `multiple`, `reference`) was a
222+
complete contract that informed **only the client dialog**. The server passed
223+
`reqBody.params` to the handler unvalidated on both the REST and MCP paths, so
224+
a wrong bag could return a success envelope while the handler quietly ignored
225+
it — the #3405 shape, one layer out. It is now checked before the handler runs;
226+
a violation is `400 VALIDATION_FAILED` (REST) or a thrown error (MCP).
227+
228+
| Violation | Example |
229+
|---|---|
230+
| missing `required` param | declared `p_text` absent from the bag |
231+
| value outside `options` | `p_priority: 'NOT_AN_OPTION'` |
232+
| `multiple` shape | a bare string where an array is declared |
233+
| `reference` shape | a non-id where `reference: 'sys_user'` is declared |
234+
| undeclared key | `bogus: 123` |
235+
236+
```diff
237+
- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default
238+
+ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before
239+
```
240+
241+
**Only already-wrong calls break**, and each rejection names the offending param
242+
and the declared list, so the fix is one edit at the call site. Actions
243+
declaring no `params` are untouched — there is nothing to validate against —
244+
and the dispatcher's own `recordId` / `objectName` are allowlisted, so keys
245+
dispatch merges in are never unknown-key errors. If an integration you cannot
246+
reach in time is affected, `OS_ALLOW_LAX_ACTION_PARAMS=1` restores the old
247+
pass-through in one restart; the violation still logs once per action, so the
248+
drift stays visible rather than becoming invisible again.
249+
250+
The RC line carried this warn-first behind an `OS_ACTION_PARAMS_STRICT_ENABLED`
251+
opt-in. **That variable does not exist in 17.0.** The window was closed on
252+
purpose rather than deferred to 18.0: what a violation strands is a *caller*,
253+
not data — no stored row becomes unwritable, the party who sees the error is
254+
the party who can fix it, and the hatch makes it reversible. Deferring would
255+
have charged every deployment a second upgrade ceremony to postpone a break
256+
that costs one edited call. The value-shape half of the same ADR went the other
257+
way for the opposite reason: it rejects on the basis of data already at rest,
258+
so it stays gated per deployment (see *Files become platform records* below).
259+
219260
### A flow run with no trigger user may not touch data (#3760)
220261

221262
An effective `runAs: 'user'` run that resolved **no trigger user** used to
@@ -893,10 +934,10 @@ For media fields specifically:
893934
toward an explicit `url` field, so "managed file" and "external link" stop
894935
being the same declaration).
895936

896-
**Value-shape checking is warn-first.** A not-yet-backfilled row still writes and
897-
the author gets a warning naming the field. Hard rejection arrives only when a
898-
deployment opts into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do
899-
*after* running the backfill and confirming reconciliation:
937+
**Value-shape checking follows your own migration, not the version number.** A
938+
not-yet-backfilled row still writes, and the author gets a warning naming the
939+
field. Media fields start *rejecting* malformed values only once **this
940+
deployment** has run the migration and passed its self-check:
900941

901942
```bash
902943
os migrate files-to-references # dry run: reports, writes nothing
@@ -906,9 +947,18 @@ os migrate files-to-references --apply # converts, verifies, records the flag
906947
The run backfills legacy file-field values (inline metadata blobs, own-resolver
907948
URLs, `data:` URIs) into owned `sys_file` references and reconciles the ownership
908949
ledger against what records actually hold. The **deployment-level flag it
909-
records** — never the platform version — is what may later authorise
910-
irreversible behaviour, and media value shapes enforce only once *this*
911-
deployment has verified its own migration.
950+
records** — never the platform version — is what authorises both strict media
951+
value shapes and, later, irreversible file collection. Upgrading changes
952+
neither; running the migration does.
953+
954+
Two knobs sit either side of that flag. `OS_ALLOW_LAX_MEDIA_VALUES=1` returns a
955+
verified deployment to warnings, for an operator who hits an unforeseen
956+
rejection and needs writes flowing while they diagnose. `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`
957+
goes the other way and opts *every* value class into strict immediately —
958+
including the reference (`lookup`/`user`/…) and structured-JSON
959+
(`location`/`address`/…) types, whose own per-deployment gate is still being
960+
built (#3438). Those stay warn-only by default until it lands, because unlike
961+
media they have no migration standing behind them yet.
912962

913963
### Approvals: dynamic approver routing (#3447)
914964

@@ -1195,9 +1245,16 @@ objectui commits on top of the pin 16.1.0 shipped.
11951245
- **Auth:** plan for the better-auth 1.7 account-identity backfill — check the
11961246
boot log for federated accounts whose IdP is no longer registered, and stamp or
11971247
remove those rows.
1248+
- **Actions:** check any programmatic caller that posts `params` — a bag the
1249+
server used to accept silently now 400s if it misses a `required` param,
1250+
breaks `options`/`multiple`/`reference`, or carries an undeclared key. The
1251+
error names the param and the declared list. `OS_ALLOW_LAX_ACTION_PARAMS=1`
1252+
buys time for an integration you cannot reach today.
11981253
- **Files:** run `os migrate files-to-references` (dry run first, then
1199-
`--apply`), reconcile, and only then set
1200-
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`.
1254+
`--apply`) and reconcile — passing its self-check is what turns strict media
1255+
value shapes on for this deployment. Do **not** reach for
1256+
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED` to get there: it opts every value class
1257+
in at once, including ones with no migration behind them.
12011258
- **Datasources:** verify every declared datasource connects in every
12021259
environment — a bound datasource that cannot connect now fails the boot
12031260
instead of failing every later query.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,10 @@ rejects a targetless inline lookup param) — a build failure the AI must fix, n
414414
a runtime warning it never sees. The runtime **warn-first** posture (D1's
415415
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, D2's `OS_ACTION_PARAMS_STRICT_ENABLED`)
416416
exists only to protect **already-deployed data** from stranding — it is not the
417-
authoring gate.
417+
authoring gate. *(D2's variable no longer exists: the 2026-07-30 addendum flips
418+
that half strict-by-default in 17.0, leaving only the `OS_ALLOW_LAX_ACTION_PARAMS`
419+
opt-out. The sentence's own logic is why — action params strand no deployed
420+
data, so the posture it justifies never applied to them.)*
418421

419422
Therefore the target end-state is **two enforcement points, each with one job**:
420423

docs/adr/0110-action-identity-and-declaration-admission.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ agent can act on.
301301

302302
### D6 — Security-gate strictness is opt-out, never opt-in
303303

304-
`OS_ACTION_PARAMS_STRICT_ENABLED` (opt-**in** strict) is an acceptable
304+
`OS_ACTION_PARAMS_STRICT_ENABLED` (opt-**in** strict) was an acceptable
305305
shape for a *param-contract* ratchet (DX concern, warn-first). It is not an
306306
acceptable shape for an *authorization* gate: an enforcement that ships off
307307
for everyone who never read the release notes is not enforcement. A flag
@@ -310,6 +310,12 @@ where a security escape hatch is warranted at all; `OS_*_STRICT_ENABLED`
310310
(opt in to enforcement) is reserved for non-security contracts. Existing
311311
flags are not renamed by this ADR; new ones conform.
312312

313+
*(Update, 2026-07-30: the one flag this section cited as tolerable no longer
314+
exists. ADR-0104's addendum flipped the param contract strict-by-default in
315+
17.0 and replaced the opt-in with the opt-out `OS_ALLOW_LAX_ACTION_PARAMS`
316+
(#3438), which the RC-only lifetime of the old name made free. The rule is
317+
unchanged; it simply has no remaining exception to point at.)*
318+
313319
**The strongest form of this rule is no flag at all.** D3 originally carried
314320
an `OS_ALLOW_UNDECLARED_ACTIONS` opt-out — correctly *spelled* under this
315321
ruling, and still wrong, because what it opted out of was the gate itself

packages/qa/dogfood/test/action-params-contract.dogfood.test.ts

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77
// lookup `p_account`. Its body echoes the received param keys. We drive the
88
// real `/actions/:object/:action` route to prove the declared contract is
99
// enforced BEFORE the body runs:
10-
// - warn-first (default): a malformed bag still passes (legacy callers keep
11-
// working; the drift is logged, not fatal).
12-
// - strict (OS_ACTION_PARAMS_STRICT_ENABLED=1): the same bag is rejected 400
10+
// - default (strict since 17.0, #3438): a malformed bag is rejected 400
1311
// before the handler runs; a conformant bag passes.
12+
// - escape hatch (OS_ALLOW_LAX_ACTION_PARAMS=1): the same malformed bag is
13+
// accepted again, for the operator who must keep an integration
14+
// dispatching while they fix its caller.
15+
//
16+
// The duals are this way round on purpose. Under warn-first the strict path was
17+
// the one nobody reached without setting a variable; now the DEFAULT path is
18+
// what every caller hits, so it is what the gate must prove — and the hatch,
19+
// which is the branch nobody sets, is exactly the kind of thing that rots
20+
// unnoticed unless a test drives it.
1421

1522
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1623
import showcaseStack from '@objectstack/example-showcase';
@@ -28,7 +35,7 @@ describe('dogfood: action param contract enforced at dispatch (ADR-0104 D2)', ()
2835
}, 60_000);
2936

3037
afterAll(async () => {
31-
delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED;
38+
delete process.env.OS_ALLOW_LAX_ACTION_PARAMS;
3239
await stack?.stop();
3340
});
3441

@@ -37,33 +44,29 @@ describe('dogfood: action param contract enforced at dispatch (ADR-0104 D2)', ()
3744
const badBag = { p_priority: 'NOT_AN_OPTION', bogus: 123 };
3845
const goodBag = { p_text: 'Hello', p_priority: 'high' };
3946

40-
it('warn-first (default): a malformed param bag still passes and the body runs', async () => {
41-
delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED;
47+
it('default: a malformed param bag is rejected 400 before the handler runs', async () => {
48+
delete process.env.OS_ALLOW_LAX_ACTION_PARAMS;
4249
const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: badBag });
43-
expect(res.status, `expected pass-through, got ${res.status}: ${await res.clone().text()}`).toBeLessThan(300);
50+
expect(res.status).toBe(400);
51+
const text = await res.text();
52+
expect(text).toMatch(/p_text/); // required
53+
expect(text).toMatch(/p_priority/); // bad option
54+
expect(text).toMatch(/bogus/); // unknown key
4455
});
4556

46-
it('strict: the same malformed bag is rejected 400 before the handler runs', async () => {
47-
process.env.OS_ACTION_PARAMS_STRICT_ENABLED = '1';
48-
try {
49-
const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: badBag });
50-
expect(res.status).toBe(400);
51-
const text = await res.text();
52-
expect(text).toMatch(/p_text/); // required
53-
expect(text).toMatch(/p_priority/); // bad option
54-
expect(text).toMatch(/bogus/); // unknown key
55-
} finally {
56-
delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED;
57-
}
57+
it('default: a conformant bag passes (dispatcher built-in keys are allowlisted)', async () => {
58+
delete process.env.OS_ALLOW_LAX_ACTION_PARAMS;
59+
const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: goodBag });
60+
expect(res.status, `expected pass, got ${res.status}: ${await res.clone().text()}`).toBeLessThan(300);
5861
});
5962

60-
it('strict: a conformant bag passes (dispatcher built-in keys are allowlisted)', async () => {
61-
process.env.OS_ACTION_PARAMS_STRICT_ENABLED = '1';
63+
it('escape hatch: OS_ALLOW_LAX_ACTION_PARAMS=1 accepts the malformed bag again', async () => {
64+
process.env.OS_ALLOW_LAX_ACTION_PARAMS = '1';
6265
try {
63-
const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: goodBag });
64-
expect(res.status, `expected pass, got ${res.status}: ${await res.clone().text()}`).toBeLessThan(300);
66+
const res = await stack.apiAs(token, 'POST', ACTION_PATH, { params: badBag });
67+
expect(res.status, `expected pass-through, got ${res.status}: ${await res.clone().text()}`).toBeLessThan(300);
6568
} finally {
66-
delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED;
69+
delete process.env.OS_ALLOW_LAX_ACTION_PARAMS;
6770
}
6871
});
6972
});

0 commit comments

Comments
 (0)