Skip to content

Commit 698cbc2

Browse files
authored
feat(runtime)!: action params are enforced by default, and the opt-in is gone (#3438) (#4213)
Executes the D2 half of ADR-0104's 2026-07-30 addendum: the warn-first window closes in 17.0 rather than 18.0. - 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 — whose sole cited exception this removes. OS_ACTION_PARAMS_STRICT_ENABLED never reached `latest` (RC-only), so it is deleted outright rather than deprecated for a release. - The opt-out path still warns once per action: opting out tolerates the drift rather than hiding it again. - Dogfood duals inverted — the default path is now what the gate proves, and the hatch gets the test so the branch nobody sets cannot rot. Also corrects docs the flip falsifies, plus two passages #3681 had left self-contradictory in the v17 notes (hard rejection described as arriving only via OS_DATA_VALUE_SHAPE_STRICT_ENABLED two paragraphs before saying media enforces per verified deployment; the upgrade checklist prescribed that env var as the route, which would opt in every value class including ones with no migration behind them).
1 parent 65a3a84 commit 698cbc2

9 files changed

Lines changed: 205 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: 68 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
@@ -925,10 +966,10 @@ For media fields specifically:
925966
toward an explicit `url` field, so "managed file" and "external link" stop
926967
being the same declaration).
927968

928-
**Value-shape checking is warn-first.** A not-yet-backfilled row still writes and
929-
the author gets a warning naming the field. Hard rejection arrives only when a
930-
deployment opts into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do
931-
*after* running the backfill and confirming reconciliation:
969+
**Value-shape checking follows your own migration, not the version number.** A
970+
not-yet-backfilled row still writes, and the author gets a warning naming the
971+
field. Media fields start *rejecting* malformed values only once **this
972+
deployment** has run the migration and passed its self-check:
932973

933974
```bash
934975
os migrate files-to-references # dry run: reports, writes nothing
@@ -938,9 +979,9 @@ os migrate files-to-references --apply # converts, verifies, records the flag
938979
The run backfills legacy file-field values (inline metadata blobs, own-resolver
939980
URLs, `data:` URIs) into owned `sys_file` references and reconciles the ownership
940981
ledger against what records actually hold. The **deployment-level flag it
941-
records** — never the platform version — is what authorises irreversible
942-
behaviour, and media value shapes enforce only once *this* deployment has
943-
verified its own migration.
982+
records** — never the platform version — is what authorises both strict media
983+
value shapes and irreversible file collection. Upgrading changes neither;
984+
running the migration does.
944985

945986
**Released-file collection is live behind that same flag** (#3459). On a
946987
verified deployment, a field file whose one owning record lets go — the field
@@ -952,6 +993,16 @@ reclaiming the row and its bytes. A deployment that never migrates keeps every
952993
released file forever: upgrading is not consent — passing your own
953994
migration's self-check is.
954995

996+
Two knobs sit either side of that flag on the value-shape half.
997+
`OS_ALLOW_LAX_MEDIA_VALUES=1` returns a verified deployment to warnings, for an
998+
operator who hits an unforeseen rejection and needs writes flowing while they
999+
diagnose. `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` goes the other way and opts
1000+
*every* value class into strict immediately — including the reference
1001+
(`lookup`/`user`/…) and structured-JSON (`location`/`address`/…) types, whose
1002+
own per-deployment gate is still being built (#3438). Those stay warn-only by
1003+
default until it lands, because unlike media they have no migration standing
1004+
behind them yet.
1005+
9551006
### Approvals: dynamic approver routing (#3447)
9561007

9571008
- **`expression` approvers.** A CEL expression resolves *who* approves at node
@@ -1237,9 +1288,17 @@ objectui commits on top of the pin 16.1.0 shipped.
12371288
- **Auth:** plan for the better-auth 1.7 account-identity backfill — check the
12381289
boot log for federated accounts whose IdP is no longer registered, and stamp or
12391290
remove those rows.
1291+
- **Actions:** check any programmatic caller that posts `params` — a bag the
1292+
server used to accept silently now 400s if it misses a `required` param,
1293+
breaks `options`/`multiple`/`reference`, or carries an undeclared key. The
1294+
error names the param and the declared list. `OS_ALLOW_LAX_ACTION_PARAMS=1`
1295+
buys time for an integration you cannot reach today.
12401296
- **Files:** run `os migrate files-to-references` (dry run first, then
1241-
`--apply`), reconcile, and only then set
1242-
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`.
1297+
`--apply`) and reconcile — passing its self-check is what turns on strict
1298+
media value shapes *and* released-file collection for this deployment, so
1299+
read the report before you `--apply`. Do **not** reach for
1300+
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED` to get there: it opts every value class
1301+
in at once, including ones with no migration behind them.
12431302
- **Datasources:** verify every declared datasource connects in every
12441303
environment — a bound datasource that cannot connect now fails the boot
12451304
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)