You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/adr/0110-action-identity-and-declaration-admission.md
+41-18Lines changed: 41 additions & 18 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -52,9 +52,8 @@ on every surface — `target` is never an identity key; **D2** every dispatch
52
52
surface resolves the declaration *first* and derives handler-key candidates
53
53
*from* it (the MCP order becomes the shared order); **D3** declaration
54
54
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
58
57
headless actions — "undeclared but executable" has no remaining legitimate
59
58
use; **D5** a reconciliation lint + boot check: every `registerAction` key
60
59
must reconcile to a declaration; **D6** security-gate strictness is
@@ -217,7 +216,7 @@ documented curl 404s.
217
216
|---|---|
218
217
|**Declaration found**| Gate against it, then dispatch (D2). |
219
218
|**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. |
221
220
222
221
**Fail-closed in 17 — no cross-major staging.** An earlier draft staged this
223
222
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:
243
242
one it is a checklist.
244
243
245
244
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.
254
269
255
270
The deletion inversion closes in 17: deleting a declaration moves the action
256
271
from "gated" straight to "refused". Removal narrows, immediately.
@@ -288,13 +303,21 @@ agent can act on.
288
303
289
304
`OS_ACTION_PARAMS_STRICT_ENABLED` (opt-**in** strict) is an acceptable
290
305
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`
295
310
(opt in to enforcement) is reserved for non-security contracts. Existing
296
311
flags are not renamed by this ADR; new ones conform.
297
312
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
+
298
321
## Alternatives considered
299
322
300
323
-**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
361
384
non-breaking for every correctly-declared action.
362
385
2.**D5** — reconciliation lint + boot inventory, so orphans are findable
363
386
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
366
389
ADR to `Accepted — implemented` with evidence (the PRIORITIZATION
0 commit comments