Skip to content
Merged
Show file tree
Hide file tree
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
53 changes: 53 additions & 0 deletions .changeset/adr-0104-d2-strict-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/runtime": major
---

feat(runtime)!: action params are enforced by default, and the opt-in is gone (#3438, ADR-0104 D2)

A request bag that violates an action's declared `params[]` — a missing
`required` param, a value outside its `options`, a scalar where `multiple`
declares an array, a non-id where a `reference` declares one, or a key the
action never declared — is now **rejected before the handler runs**:
`400 VALIDATION_FAILED` on REST, a thrown error on MCP. It used to be logged
and passed through.

```diff
- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default
+ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before
```

**What breaks.** Only calls that were *already* wrong. The declaration was a
complete contract that informed nothing but the client dialog, so a bag the
server accepted could still have been silently ignored by the handler — which
is exactly how a correctly-intended `reference: 'sys_user'` degraded into a
paste-a-UUID box (#3405) with a success envelope on top. Those calls now fail
loudly instead of quietly. Actions declaring no `params` are untouched, and the
dispatcher's own `recordId` / `objectName` are allowlisted
(`ACTION_PARAM_BUILTIN_KEYS`), so the keys dispatch itself merges in were never
candidates for the unknown-key error.

**Fixing a rejection** takes one edit at the call site: the message names the
offending param and the declared list. If an integration you cannot reach in
time is affected, set `OS_ALLOW_LAX_ACTION_PARAMS=1` to restore the old
pass-through — the violation still logs once per action, so the drift stays
visible rather than becoming invisible again.

**Why 17.0 rather than a warn window in 17 and the flip in 18.** R3 asked for
warn-then-error, and ADR-0104's 2026-07-30 addendum declined it on the merits
rather than postponing. What a violation strands is a **caller**, not data: the
rejection reaches a developer or an agent who can fix it in one edit, no stored
row is made unwritable, and the escape hatch makes it reversible in a restart.
Deferring that by a major would have charged every deployment a second upgrade
ceremony — 16→17 is already a substantial, tested migration — to postpone a
break that costs one edited call. v17 already carries harsher zero-window
flips (`allowExport` unset now means denied; an undeclared action handler 404s
with no opt-out at all), so holding the milder change to a stricter standard
would have been inconsistent rather than cautious.

For AI and MCP callers specifically — the population D2 was built for — a 400
is corrective feedback consumed in-loop, while a server-side warning is
feedback nobody ever reads.

D1's value-shape half went the opposite way for the opposite reason: it rejects
on the basis of **stored data**, which an author cannot edit their way out of,
so it stays gated per deployment on that deployment's own migration evidence.
13 changes: 7 additions & 6 deletions .changeset/adr-0104-d2-typed-action-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ read an untyped bag. D2 makes the declaration enforced and typed.
required presence, per-type value shape, and unknown keys (the dispatcher's
own `recordId` / `objectName` are allowlisted).

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

Not included: file/image params becoming `sys_file` references — that depends
on file-as-reference (ADR-0104 D3). Per-name static typing of `ctx.params` from
Expand Down
77 changes: 68 additions & 9 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,47 @@ need no changes, other than gaining enforcement of the `requiredPermissions`
they already declared. Callers that hard-coded a `target` in an action URL
switch to the action's `name`.

### An action's declared params are enforced at dispatch (ADR-0104 D2, #3438)

An action's `params[]` (`required`, `options`, `multiple`, `reference`) was a
complete contract that informed **only the client dialog**. The server passed
`reqBody.params` to the handler unvalidated on both the REST and MCP paths, so
a wrong bag could return a success envelope while the handler quietly ignored
it — the #3405 shape, one layer out. It is now checked before the handler runs;
a violation is `400 VALIDATION_FAILED` (REST) or a thrown error (MCP).

| Violation | Example |
|---|---|
| missing `required` param | declared `p_text` absent from the bag |
| value outside `options` | `p_priority: 'NOT_AN_OPTION'` |
| `multiple` shape | a bare string where an array is declared |
| `reference` shape | a non-id where `reference: 'sys_user'` is declared |
| undeclared key | `bogus: 123` |

```diff
- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default
+ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before
```

**Only already-wrong calls break**, and each rejection names the offending param
and the declared list, so the fix is one edit at the call site. Actions
declaring no `params` are untouched — there is nothing to validate against —
and the dispatcher's own `recordId` / `objectName` are allowlisted, so keys
dispatch merges in are never unknown-key errors. If an integration you cannot
reach in time is affected, `OS_ALLOW_LAX_ACTION_PARAMS=1` restores the old
pass-through in one restart; the violation still logs once per action, so the
drift stays visible rather than becoming invisible again.

The RC line carried this warn-first behind an `OS_ACTION_PARAMS_STRICT_ENABLED`
opt-in. **That variable does not exist in 17.0.** The window was closed on
purpose rather than deferred to 18.0: what a violation strands is a *caller*,
not data — no stored row becomes unwritable, the party who sees the error is
the party who can fix it, and the hatch makes it reversible. Deferring would
have charged every deployment a second upgrade ceremony to postpone a break
that costs one edited call. The value-shape half of the same ADR went the other
way for the opposite reason: it rejects on the basis of data already at rest,
so it stays gated per deployment (see *Files become platform records* below).

### A flow run with no trigger user may not touch data (#3760)

An effective `runAs: 'user'` run that resolved **no trigger user** used to
Expand Down Expand Up @@ -925,10 +966,10 @@ For media fields specifically:
toward an explicit `url` field, so "managed file" and "external link" stop
being the same declaration).

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

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

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

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

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

- **`expression` approvers.** A CEL expression resolves *who* approves at node
Expand Down Expand Up @@ -1237,9 +1288,17 @@ objectui commits on top of the pin 16.1.0 shipped.
- **Auth:** plan for the better-auth 1.7 account-identity backfill — check the
boot log for federated accounts whose IdP is no longer registered, and stamp or
remove those rows.
- **Actions:** check any programmatic caller that posts `params` — a bag the
server used to accept silently now 400s if it misses a `required` param,
breaks `options`/`multiple`/`reference`, or carries an undeclared key. The
error names the param and the declared list. `OS_ALLOW_LAX_ACTION_PARAMS=1`
buys time for an integration you cannot reach today.
- **Files:** run `os migrate files-to-references` (dry run first, then
`--apply`), reconcile, and only then set
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`.
`--apply`) and reconcile — passing its self-check is what turns on strict
media value shapes *and* released-file collection for this deployment, so
read the report before you `--apply`. Do **not** reach for
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED` to get there: it opts every value class
in at once, including ones with no migration behind them.
- **Datasources:** verify every declared datasource connects in every
environment — a bound datasource that cannot connect now fails the boot
instead of failing every later query.
Expand Down
5 changes: 4 additions & 1 deletion docs/adr/0104-field-runtime-value-shape-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ rejects a targetless inline lookup param) — a build failure the AI must fix, n
a runtime warning it never sees. The runtime **warn-first** posture (D1's
`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, D2's `OS_ACTION_PARAMS_STRICT_ENABLED`)
exists only to protect **already-deployed data** from stranding — it is not the
authoring gate.
authoring gate. *(D2's variable no longer exists: the 2026-07-30 addendum flips
that half strict-by-default in 17.0, leaving only the `OS_ALLOW_LAX_ACTION_PARAMS`
opt-out. The sentence's own logic is why — action params strand no deployed
data, so the posture it justifies never applied to them.)*

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

Expand Down
8 changes: 7 additions & 1 deletion docs/adr/0110-action-identity-and-declaration-admission.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ agent can act on.

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

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

*(Update, 2026-07-30: the one flag this section cited as tolerable no longer
exists. ADR-0104's addendum flipped the param contract strict-by-default in
17.0 and replaced the opt-in with the opt-out `OS_ALLOW_LAX_ACTION_PARAMS`
(#3438), which the RC-only lifetime of the old name made free. The rule is
unchanged; it simply has no remaining exception to point at.)*

**The strongest form of this rule is no flag at all.** D3 originally carried
an `OS_ALLOW_UNDECLARED_ACTIONS` opt-out — correctly *spelled* under this
ruling, and still wrong, because what it opted out of was the gate itself
Expand Down
51 changes: 27 additions & 24 deletions packages/qa/dogfood/test/action-params-contract.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
// lookup `p_account`. Its body echoes the received param keys. We drive the
// real `/actions/:object/:action` route to prove the declared contract is
// enforced BEFORE the body runs:
// - warn-first (default): a malformed bag still passes (legacy callers keep
// working; the drift is logged, not fatal).
// - strict (OS_ACTION_PARAMS_STRICT_ENABLED=1): the same bag is rejected 400
// - default (strict since 17.0, #3438): a malformed bag is rejected 400
// before the handler runs; a conformant bag passes.
// - escape hatch (OS_ALLOW_LAX_ACTION_PARAMS=1): the same malformed bag is
// accepted again, for the operator who must keep an integration
// dispatching while they fix its caller.
//
// The duals are this way round on purpose. Under warn-first the strict path was
// the one nobody reached without setting a variable; now the DEFAULT path is
// what every caller hits, so it is what the gate must prove — and the hatch,
// which is the branch nobody sets, is exactly the kind of thing that rots
// unnoticed unless a test drives it.

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

afterAll(async () => {
delete process.env.OS_ACTION_PARAMS_STRICT_ENABLED;
delete process.env.OS_ALLOW_LAX_ACTION_PARAMS;
await stack?.stop();
});

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

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

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

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