Skip to content

Commit 347f460

Browse files
authored
feat(runtime)!: the undeclared-action refusal has no opt-out — drop OS_ALLOW_UNDECLARED_ACTIONS (ADR-0110 D3 revised) (#3987)
D3 as accepted refused an undeclared handler but shipped a migration valve that ran it anyway, slated for removal in 18. Removed before 17 ships. It contradicts the ruling it accompanies: a flag that executes an ungoverned, system-elevated handler IS the fail-open D3 closes, and ADR-0049's trichotomy has no 'enforced unless a flag says otherwise' state. And it had no observed users — a reconciliation sweep across platform packages, every example and every plugin found the only engine.registerAction call sites are app-todo's eight, all declared. What the valve bought is covered without it: the app still boots, declared actions still work, D5's inventory names each offender at startup, and the 404 names the defineAction to add. A regression test pins that the retired variable has no effect, so a stale deployment script fails loudly instead of silently reopening the gate. ADR-0110 carries a dated revision note rather than a rewritten history; D6 now says explicitly that its strongest form is no flag at all.
1 parent 94a0bbc commit 347f460

8 files changed

Lines changed: 122 additions & 70 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/runtime': major
3+
---
4+
5+
**[ADR-0110 D3, revised] The undeclared-action refusal has no opt-out —
6+
`OS_ALLOW_UNDECLARED_ACTIONS` is removed before 17 ships.**
7+
8+
D3 as accepted refused an undeclared handler but shipped
9+
`OS_ALLOW_UNDECLARED_ACTIONS=1` as a migration valve that ran it anyway,
10+
"slated for removal in 18". Removed now, for two reasons:
11+
12+
- **It contradicts the ruling it accompanies.** A flag that executes an
13+
ungoverned, system-elevated handler *is* the fail-open D3 closes. ADR-0049's
14+
trichotomy has no "enforced unless a flag says otherwise" state.
15+
- **It had no observed users.** A reconciliation sweep across the platform
16+
packages, every example and every plugin found the only `engine.registerAction`
17+
call sites are `app-todo`'s eight, all declared. The valve would have shipped
18+
a documented way to reopen the gate for a population nobody has ever seen.
19+
20+
What it was buying is covered without it: the app still boots, every declared
21+
action still works, D5's boot inventory names each offender at startup, and the
22+
404 names the `defineAction` to add. Migration costs a code change rather than
23+
an env var — the correct price for reopening an authorization gate.
24+
25+
Setting the retired variable has no effect; a regression test pins that, so a
26+
stale deployment script fails loudly rather than silently re-opening the gate.

content/docs/releases/v17.mdx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,19 @@ Three changes land together:
188188

189189
**Migration:** boot logs list every registered-but-undeclared handler under
190190
`[action-governance]`, alongside declared script actions bound to no handler.
191-
Declare each one. `OS_ALLOW_UNDECLARED_ACTIONS=1` runs them meanwhile, warning
192-
on every invocation; **it is removed in 18**. Apps whose actions are all
193-
declared — anything with working Console buttons — need no changes, other than
194-
gaining enforcement of the `requiredPermissions` they already declared. Callers
195-
that hard-coded a `target` in an action URL switch to the action's `name`.
191+
Declare each one with `defineAction`, or drop the registration if nothing should
192+
invoke it over HTTP. **There is no opt-out flag** — a switch that ran an
193+
ungoverned handler would be the same fail-open this change closes, and a sweep
194+
of the platform, every package and every example found zero undeclared handlers,
195+
so it would have shipped a way to reopen the gate for a case nobody has
196+
observed. The failure is bounded: the app still boots and every declared action
197+
still works; only an undeclared one returns 404, and it names the `defineAction`
198+
to add.
199+
200+
Apps whose actions are all declared — anything with working Console buttons —
201+
need no changes, other than gaining enforcement of the `requiredPermissions`
202+
they already declared. Callers that hard-coded a `target` in an action URL
203+
switch to the action's `name`.
196204

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

content/docs/ui/actions.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,11 @@ If server-side logic should never be reachable over HTTP at all, do not register
307307
it as an action — export a plain function and call it from your own code.
308308
`engine.registerAction` means "publish this on the HTTP and MCP surfaces".
309309

310-
Migrating an app that has undeclared handlers? Boot logs list every one of them
311-
under `[action-governance]`, and `OS_ALLOW_UNDECLARED_ACTIONS=1` runs them
312-
(warning each time) while you add the declarations. That valve is removed in 18.
310+
Migrating an app that has undeclared handlers? Startup lists every one of them
311+
under `[action-governance]`, with the object and key to declare. There is no
312+
flag that runs them meanwhile — one would be the same ungoverned execution this
313+
rule exists to prevent — so declare each one, or drop the registration if
314+
nothing should invoke it over HTTP.
313315
</Callout>
314316

315317
## Expose it to AI (MCP)

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

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,8 @@ on every surface — `target` is never an identity key; **D2** every dispatch
5252
surface resolves the declaration *first* and derives handler-key candidates
5353
*from* it (the MCP order becomes the shared order); **D3** declaration
5454
resolution is a trichotomy — found → gate-then-dispatch, metadata plane
55-
unreachable → 503, genuinely undeclared → **refuse, fail-closed in 17**,
56-
with `OS_ALLOW_UNDECLARED_ACTIONS=1` as a documented migration valve slated
57-
for removal in 18; **D4** "declared but hidden" is the blessed pattern for
55+
unreachable → 503, genuinely undeclared → **refuse, fail-closed in 17,
56+
with no opt-out** (see the D3 revision note); **D4** "declared but hidden" is the blessed pattern for
5857
headless actions — "undeclared but executable" has no remaining legitimate
5958
use; **D5** a reconciliation lint + boot check: every `registerAction` key
6059
must reconcile to a declaration; **D6** security-gate strictness is
@@ -217,7 +216,7 @@ documented curl 404s.
217216
|---|---|
218217
| **Declaration found** | Gate against it, then dispatch (D2). |
219218
| **Metadata plane unreachable** | **503.** An availability failure is not an authorization decision; the gate an author declared must not evaporate during an outage. (The ADR-0096 posture — "no context" is a defect, never an authorization — applied to "no declaration source".) |
220-
| **Genuinely undeclared** | **Refuse** (404) with a prescriptive error: `Action 'x' on 'y' has no declaration — add defineAction({ name: 'x', … }) or register it under a declared action's target`. Migration valve: `OS_ALLOW_UNDECLARED_ACTIONS=1` executes it and warns per invocation. |
219+
| **Genuinely undeclared** | **Refuse** (404) with a prescriptive error: `Action 'x' on 'y' has no declaration — add defineAction({ name: 'x', … }) or register it under a declared action's target`. No opt-out — see the revision note below. |
221220

222221
**Fail-closed in 17 — no cross-major staging.** An earlier draft staged this
223222
over two majors (warn → CI-only → flip). Protocol 17 is in RC *now*, so the
@@ -243,14 +242,30 @@ Three things must ship **together, in 17**, or the refusal is undiagnosable:
243242
one it is a checklist.
244243

245244
Plus a `content/docs/releases/v17.mdx` migration entry, alongside its two
246-
siblings, naming the valve and its removal target.
247-
248-
**The valve is temporary by construction.** `OS_ALLOW_UNDECLARED_ACTIONS=1`
249-
exists so an upgrade is never blocked at 3am, not so the old behaviour
250-
survives. It warns on every invocation (not just at boot), is documented as
251-
a migration aid rather than a supported mode, and is **slated for removal in
252-
18** — recorded here so it does not silently become permanent, which is the
253-
usual fate of escape hatches with no stated end.
245+
siblings, naming the inventory as the migration path.
246+
247+
> **Revision (2026-07-30, before 17 shipped) — the migration valve is gone.**
248+
> As accepted, D3 shipped `OS_ALLOW_UNDECLARED_ACTIONS=1`: an opt-out that ran
249+
> the undeclared handler anyway, warning per invocation, "slated for removal in
250+
> 18". It was removed before the release on two grounds.
251+
>
252+
> **It contradicts the ruling it accompanies.** A flag that executes an
253+
> ungoverned, system-elevated handler *is* the fail-open D3 closes. Shipping it
254+
> would have preserved the hole in configurable form, and ADR-0049's trichotomy
255+
> does not have a "enforced unless a flag says otherwise" state.
256+
>
257+
> **It had no observed users.** A reconciliation sweep across the platform
258+
> packages, every example and every plugin found the only `engine.registerAction`
259+
> call sites are `app-todo`'s eight, all declared. The valve would have shipped
260+
> a documented way to reopen the gate for a population nobody has ever seen —
261+
> and escape hatches with no users are the ones that quietly become permanent,
262+
> which the removal-in-18 note was itself an admission of.
263+
>
264+
> What the valve was buying is covered without it: the app still boots, every
265+
> declared action still works, D5's inventory names each offender at startup,
266+
> and the 404 names the `defineAction` to add. The migration costs a code
267+
> change rather than an env var — which, for reopening an authorization gate,
268+
> is the correct price.
254269
255270
The deletion inversion closes in 17: deleting a declaration moves the action
256271
from "gated" straight to "refused". Removal narrows, immediately.
@@ -288,13 +303,21 @@ agent can act on.
288303

289304
`OS_ACTION_PARAMS_STRICT_ENABLED` (opt-**in** strict) is an acceptable
290305
shape for a *param-contract* ratchet (DX concern, warn-first). It is not an
291-
acceptable shape for an *authorization* gate: D3's refusal stages toward
292-
**strict-by-default with an opt-out escape hatch**, and any future gate
293-
follows the same direction. A flag spelled `OS_ALLOW_*` (opt out of
294-
enforcement) is the sanctioned shape for security; `OS_*_STRICT_ENABLED`
306+
acceptable shape for an *authorization* gate: an enforcement that ships off
307+
for everyone who never read the release notes is not enforcement. A flag
308+
spelled `OS_ALLOW_*` (opt **out** of enforcement) is the sanctioned shape
309+
where a security escape hatch is warranted at all; `OS_*_STRICT_ENABLED`
295310
(opt in to enforcement) is reserved for non-security contracts. Existing
296311
flags are not renamed by this ADR; new ones conform.
297312

313+
**The strongest form of this rule is no flag at all.** D3 originally carried
314+
an `OS_ALLOW_UNDECLARED_ACTIONS` opt-out — correctly *spelled* under this
315+
ruling, and still wrong, because what it opted out of was the gate itself
316+
rather than a strictness dial around it (see the D3 revision note). So D6
317+
governs the naming and direction of a security flag *where one is
318+
justified*; it is not a licence to add one. An authorization boundary with a
319+
documented bypass is the boundary its bypass describes.
320+
298321
## Alternatives considered
299322

300323
- **Make the route resolve declarations by `target` too.** Rejected: it
@@ -361,8 +384,8 @@ ones); the engine-side aliasing alternative (deferred); any change to how
361384
non-breaking for every correctly-declared action.
362385
2. **D5** — reconciliation lint + boot inventory, so orphans are findable
363386
before the refusal exists.
364-
3. **D3/D6** — refusal ON by default, `OS_ALLOW_UNDECLARED_ACTIONS` as the
365-
documented valve, plus the `v17.mdx` migration entry. Same PR flips this
387+
3. **D3/D6** — refusal ON by default with no opt-out, plus the `v17.mdx`
388+
migration entry. Same PR flips this
366389
ADR to `Accepted — implemented` with evidence (the PRIORITIZATION
367390
status-hygiene rule).
368391

packages/runtime/src/action-execution.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,6 @@ function actionParamsStrict(): boolean {
2929
return typeof process !== 'undefined' && process.env?.OS_ACTION_PARAMS_STRICT_ENABLED === '1';
3030
}
3131

32-
/**
33-
* [ADR-0110 D3/D6] Migration valve for executing an action that has no
34-
* declaration. Enforcement is the DEFAULT and this opts OUT of it — the
35-
* direction matters: a security gate whose strictness is opt-in (as
36-
* `OS_ACTION_PARAMS_STRICT_ENABLED` is, acceptably, for a DX contract) ships
37-
* open for everyone who never read the release notes. `OS_ALLOW_*` is the
38-
* sanctioned shape for a security escape hatch; it warns on every invocation
39-
* and is slated for removal in 18.
40-
*/
41-
export function undeclaredActionsAllowed(deps: ActionExecutionDeps): boolean {
42-
return typeof process !== 'undefined' && process.env?.OS_ALLOW_UNDECLARED_ACTIONS === '1';
43-
}
4432

4533
const _warnedActionParams = new Set<string>();
4634
function warnActionParamsOnce(key: string, message: string): void {

packages/runtime/src/app-plugin.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,9 @@ export class AppPlugin implements Plugin {
721721
);
722722
if (undeclaredHandlers.length > 0) {
723723
ctx.logger.warn(
724-
'[action-governance] registered handlers with NO declaration — these are refused ' +
725-
'at dispatch (ADR-0110 D3); declare each one with `defineAction`, or set ' +
726-
'OS_ALLOW_UNDECLARED_ACTIONS=1 to run them during migration (removed in 18)',
724+
'[action-governance] registered handlers with NO declaration — these are REFUSED ' +
725+
'at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with ' +
726+
'`defineAction`, or drop the registration if nothing should invoke it over HTTP',
727727
{
728728
appId,
729729
count: undeclaredHandlers.length,

packages/runtime/src/domains/actions.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -185,27 +185,29 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
185185
// A handler with no declaration is invisible to every governance
186186
// surface — ADR-0066 D4 has no `requiredPermissions` to read, ADR-0104
187187
// no param contract, ADR-0109 materialises no `action_<name>` tool —
188-
// yet it executes TRUSTED. Refuse, and say what to add. The valve is
189-
// for an upgrade that cannot stop to declare one at 3am; it warns on
190-
// every invocation and is slated for removal in 18.
188+
// yet it executes TRUSTED. Refuse, and say what to add.
189+
//
190+
// There is no opt-out. An earlier draft shipped
191+
// `OS_ALLOW_UNDECLARED_ACTIONS` as a migration valve slated for removal
192+
// in 18; it was dropped before 17 went out. A flag that runs an
193+
// ungoverned handler IS the fail-open this ruling exists to close, so
194+
// keeping one would have preserved the hole in configurable form —
195+
// and a reconciliation sweep found the platform, every package and
196+
// every example carry zero undeclared handlers, so it would have
197+
// shipped a documented way to reopen the gate for a population nobody
198+
// has ever observed. The boot inventory (D5) names the offenders and
199+
// the 404 below names the fix, which is the whole migration path.
191200
if (!actionDef) {
192-
if (!actionExec.undeclaredActionsAllowed(deps)) {
193-
return {
194-
handled: true,
195-
response: deps.error(
196-
`Action '${actionName}' on '${objectName}' has no declaration — ` +
197-
`add \`defineAction({ name: '${actionName}', … })\`, or register the handler under a ` +
198-
`declared action's \`target\`. Undeclared handlers cannot be permission-gated ` +
199-
`(ADR-0110 D3); set OS_ALLOW_UNDECLARED_ACTIONS=1 to run it during migration.`,
200-
404,
201-
),
202-
};
203-
}
204-
console.warn(
205-
`[action-governance] UNDECLARED action '${objectName}/${actionName}' executed under ` +
206-
`OS_ALLOW_UNDECLARED_ACTIONS — it is ungated (no requiredPermissions, no param contract) ` +
207-
`and invisible to the AI surface. Declare it; the valve is removed in 18.`,
208-
);
201+
return {
202+
handled: true,
203+
response: deps.error(
204+
`Action '${actionName}' on '${objectName}' has no declaration — ` +
205+
`add \`defineAction({ name: '${actionName}', … })\`, or register the handler under a ` +
206+
`declared action's \`target\`. Undeclared handlers cannot be permission-gated ` +
207+
`(ADR-0110 D3); startup logs every one of them under [action-governance].`,
208+
404,
209+
),
210+
};
209211
}
210212

211213
// ── 1/3: declared → gate against it ──

packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,7 @@ describe('REST /actions — script dispatch is unchanged (#3915 regression guard
353353
// [ADR-0110 D3] An UNDECLARED action used to run here, ungated — this test
354354
// asserted exactly that. A handler with no declaration has no
355355
// `requiredPermissions` to enforce, no param contract, and materialises no
356-
// `action_<name>` tool, yet it executes TRUSTED; it now refuses. The valve
357-
// test below is the only path that still runs it.
356+
// `action_<name>` tool, yet it executes TRUSTED; it now refuses.
358357
it('refuses an UNDECLARED action with a prescriptive error instead of running it ungated', async () => {
359358
const { dispatcher, executeAction } = makeDispatcher({ objectDef: { name: 'crm_lead', actions: [] } });
360359

@@ -363,24 +362,28 @@ describe('REST /actions — script dispatch is unchanged (#3915 regression guard
363362
expect(res.response.status).toBe(404);
364363
expect(res.response.body.error.message).toMatch(/has no declaration/i);
365364
expect(res.response.body.error.message).toMatch(/defineAction\(\{ name: 'handler_only'/);
366-
expect(res.response.body.error.message).toMatch(/OS_ALLOW_UNDECLARED_ACTIONS=1/);
365+
// The way out is the boot inventory, not a flag.
366+
expect(res.response.body.error.message).toMatch(/\[action-governance\]/);
367367
expect(executeAction).not.toHaveBeenCalled();
368368
});
369369

370-
it('runs an UNDECLARED action when the migration valve is set, warning every time', async () => {
370+
// The refusal has NO opt-out. A draft of ADR-0110 shipped
371+
// `OS_ALLOW_UNDECLARED_ACTIONS` as a migration valve; it was dropped before
372+
// 17 went out, because a flag that runs an ungoverned handler IS the
373+
// fail-open the ruling closes. This pins that no environment variable
374+
// resurrects the old behaviour — the failure a stale deployment script
375+
// would otherwise produce is silent re-opening, not a loud error.
376+
it('refuses regardless of the retired OS_ALLOW_UNDECLARED_ACTIONS flag', async () => {
371377
const prev = process.env.OS_ALLOW_UNDECLARED_ACTIONS;
372378
process.env.OS_ALLOW_UNDECLARED_ACTIONS = '1';
373-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
374379
try {
375380
const { dispatcher, executeAction } = makeDispatcher({ objectDef: { name: 'crm_lead', actions: [] } });
376381

377382
const res = await dispatcher.handleActions('/crm_lead/handler_only', 'POST', {}, ctxFor());
378383

379-
expect(executeAction).toHaveBeenCalledTimes(1);
380-
expect(res.response.body.data.success).toBe(true);
381-
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/UNDECLARED action 'crm_lead\/handler_only'/));
384+
expect(res.response.status).toBe(404);
385+
expect(executeAction).not.toHaveBeenCalled();
382386
} finally {
383-
warn.mockRestore();
384387
if (prev === undefined) delete process.env.OS_ALLOW_UNDECLARED_ACTIONS;
385388
else process.env.OS_ALLOW_UNDECLARED_ACTIONS = prev;
386389
}

0 commit comments

Comments
 (0)