Skip to content

Commit ffb003c

Browse files
authored
feat(runtime,objectql,metadata)!: ADR-0110 — action identity is name; refuse undeclared executables (#3935) (#3958)
The REST /actions route resolved the DECLARATION from the URL segment as a name but dispatched the HANDLER using that same segment as a registry key. For a target-bound action those differ, so the documented curl resolved the declaration then 404ed, while the Console's target-addressed call dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4 gate and the ADR-0104 param contract. D1/D2 — identity is the declarative name on every surface; handler-key candidates are derived from the resolved declaration via a rotation now shared with the MCP run_action bridge. D3 (BREAKING) — resolution is a trichotomy: declared -> gate + dispatch; metadata plane unreachable -> 503 (MetadataManager.loadDiagnosed tells a clean miss from an outage); genuinely undeclared -> 404 naming the defineAction to add, instead of executing ungated with system privileges. OS_ALLOW_UNDECLARED_ACTIONS=1 is the migration valve, removed in 18. D5 — reconcileActionRegistrations + ObjectQLEngine.listRegisteredActions power a kernel:ready inventory of undeclared handlers and unbound declarations. D6 — security-gate strictness is opt-out, never opt-in. Ships in lockstep with objectui#2970.
1 parent a4a9944 commit ffb003c

14 files changed

Lines changed: 955 additions & 57 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/runtime': major
3+
'@objectstack/objectql': minor
4+
'@objectstack/metadata': minor
5+
---
6+
7+
**ADR-0110 — an action's identity is its `name`, and anything executable over a
8+
governed surface must have a declaration.**
9+
10+
`POST /api/v1/actions/:object/:action` resolved the DECLARATION from the URL
11+
segment as a `name` but dispatched the HANDLER using that same segment as a
12+
registry key. For a target-bound action (`{ name: 'complete_task', target:
13+
'completeTask' }`) those are different strings, so the two documented callers
14+
each worked on exactly the half the other broke: the documented curl resolved
15+
the declaration then 404ed, while the Console's `target`-addressed call
16+
dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4
17+
capability gate and the ADR-0104 param contract (#3935).
18+
19+
- **D1/D2** — identity is always the declarative `name`; the handler key is
20+
derived from the resolved declaration through a rotation now shared with the
21+
MCP `run_action` bridge (`resolveActionHandlerKeys`, `executeRegisteredAction`).
22+
The REST route previously rotated only the object key, never the handler key.
23+
- **D3 (breaking)** — declaration resolution is a trichotomy. A genuinely
24+
undeclared handler is **refused (404)** with the `defineAction` to add, rather
25+
than executed ungated with system privileges; an unreachable metadata plane is
26+
a **503** rather than a silent ungating (`MetadataManager.loadDiagnosed` tells
27+
a clean miss from an outage). `OS_ALLOW_UNDECLARED_ACTIONS=1` is the migration
28+
valve — it warns on every invocation and is removed in 18.
29+
- **D5**`reconcileActionRegistrations` plus `ObjectQLEngine.listRegisteredActions`
30+
power a `kernel:ready` inventory logging every registered-but-undeclared
31+
handler (refused at dispatch) and every declared script action bound to no
32+
handler — the ADR-0078 converse, mechanised.
33+
- **D6** — security-gate strictness is opt-**out** (`OS_ALLOW_*`), never opt-in.
34+
35+
Apps whose actions are all declared need no changes beyond gaining enforcement
36+
of the `requiredPermissions` they already declared.

content/docs/releases/v17.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,44 @@ and flags whitelists the mapping would *widen* so every edit stays reviewable.
156156
`['upsert']`) strips to `[]`, which is **deny-all** — the object's API closes
157157
rather than widening.
158158

159+
### An action must be declared to be invocable, and is identified by `name` (ADR-0110, #3935)
160+
161+
Two halves of the `/api/v1/actions/:object/:action` contract were broken in
162+
complementary ways, which is why neither surfaced: the route resolved the
163+
**declaration** from the URL segment as a `name`, but dispatched the
164+
**handler** using that same segment as a registry key. For a target-bound
165+
action (`{ name: 'complete_task', target: 'completeTask' }`) those differ — so
166+
the documented `curl .../todo_task/complete_task` resolved the declaration and
167+
then 404ed, while the Console's `target`-addressed call dispatched fine and
168+
resolved **no** declaration, silently skipping the ADR-0066 D4 capability gate
169+
and the ADR-0104 param contract.
170+
171+
Three changes land together:
172+
173+
- **Identity is `name`.** The URL, MCP `run_action`, and every future surface
174+
identify an action by its declarative `name`. `target` is a binding
175+
expression — polymorphic per type, `${param.X}`-interpolatable, and legally
176+
non-unique — so it never identifies anything. The server derives the handler
177+
key from the declaration it resolved, using the rotation the MCP bridge
178+
already used. The Console posts `name` (objectui ships in lockstep).
179+
- **An undeclared handler is refused.** `engine.registerAction` with no
180+
matching declaration has no `requiredPermissions` to enforce and no param
181+
contract to check, yet executes with system privileges. It now returns 404
182+
naming the `defineAction` to add. Deleting a declaration used to *remove* an
183+
action's gate while leaving it callable; removal now narrows.
184+
- **An unreachable metadata plane refuses instead of degrading.** A loader
185+
failure used to be indistinguishable from "no declaration", so an outage
186+
silently ungated every action it could not see. That is now a **503** — the
187+
same posture as the datasource entry below.
188+
189+
**Migration:** boot logs list every registered-but-undeclared handler under
190+
`[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`.
196+
159197
### A flow run with no trigger user may not touch data (#3760)
160198

161199
An effective `runAs: 'user'` run that resolved **no trigger user** used to

content/docs/ui/actions.mdx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,13 @@ curl -b cookies.txt -X POST \
232232
# → 200 { "success": true, "data": { "success": true, "data": ... } }
233233
```
234234

235+
The URL names the action by its **`name`**, never by `target`. `target` binds
236+
the action to whatever runs it — a handler key here, a flow id for
237+
`type: 'flow'`, a URL for `type: 'url'` — so it is an implementation detail:
238+
the server resolves your declaration by name and derives the handler key from
239+
it. Rename the underlying function freely; as long as the declaration's
240+
`target` follows, the public URL is unchanged.
241+
235242
Failures split three ways:
236243

237244
- **It ran and rejected** — HTTP **200**, `data: { "success": false, "error",
@@ -264,6 +271,47 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
264271
| `api` | **400**it dispatches on `target`; call that endpoint directly. |
265272
| `url` / `modal` / `form` | **400**client-side navigation; there is nothing for the server to run. |
266273

274+
## Headless actions: declare it, then hide it
275+
276+
An action that should be callable but not appear in the UI is still a
277+
**declared** action. Hiding is a property you set; it is not the absence of a
278+
declaration:
279+
280+
```typescript
281+
defineAction({
282+
name: 'recalculate_commissions',
283+
type: 'script',
284+
target: 'recalcCommissions',
285+
locations: [], // no UI surface
286+
requiredPermissions: ['finance.admin'], // still gated
287+
// `ai.exposed` is false by default — no MCP tool either
288+
})
289+
```
290+
291+
You keep the capability gate, the param contract, the audit trail, and Setup
292+
visibility for admins. A declaration that no surface renders costs you nothing.
293+
294+
<Callout type="warn">
295+
Registering a handler **without** a declaration is not a way to hide an action —
296+
it is refused. An undeclared handler has no `requiredPermissions` to enforce and
297+
no param contract to check, yet it would execute with system privileges, so the
298+
server declines it:
299+
300+
```
301+
Action 'recalc' on 'crm_account' has no declaration — add
302+
`defineAction({ name: 'recalc', … })`, or register the handler under a declared
303+
action's `target`.
304+
```
305+
306+
If server-side logic should never be reachable over HTTP at all, do not register
307+
it as an action — export a plain function and call it from your own code.
308+
`engine.registerAction` means "publish this on the HTTP and MCP surfaces".
309+
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.
313+
</Callout>
314+
267315
## Expose it to AI (MCP)
268316

269317
Actions are **not** AI-visible by default. Opting in takes two fieldsand

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# ADR-0110: An action's identity is its `name`; anything executable over a governed surface must have a declaration (the declaration-admission gate)
22

3-
**Status**: Proposed (2026-07-29) — **targeted at protocol 17** (`@objectstack/spec@17.0.0-rc.0`, in RC now). The fail-closed inversion lands in that major rather than staging across two, joining its two siblings already in the v17 breaking set: *"A flow run with no trigger user may not touch data"* (#3760 — missing identity fails closed) and *"A datasource that cannot connect fails the boot"* (#3741/#3758/#3826 — an unreachable dependency refuses rather than degrades).
3+
**Status**: **Accepted — implemented** (2026-07-29) in protocol 17 (`@objectstack/spec@17.0.0-rc.0`). The fail-closed inversion landed in that major rather than staging across two, joining its two siblings in the v17 breaking set: *"A flow run with no trigger user may not touch data"* (#3760 — missing identity fails closed) and *"A datasource that cannot connect fails the boot"* (#3741/#3758/#3826 — an unreachable dependency refuses rather than degrades).
4+
Evidence: D1/D2 — `action-execution.ts` (`resolveActionHandlerKeys`, `executeRegisteredAction`) shared by `domains/actions.ts` and `invokeBusinessAction`, pinned by `http-dispatcher.actions-identity-addressing.test.ts` (the documented curl now gates AND dispatches). D3 — the trichotomy in `domains/actions.ts`, reading the `degraded` signal `MetadataManager.loadDiagnosed` supplies (`metadata-manager.ts`). D5 — `reconcileActionRegistrations` + `ObjectQLEngine.listRegisteredActions`, wired to the `kernel:ready` inventory in `app-plugin.ts`, covered by `action-reconciliation.test.ts`. D1 client — `../objectui` `useConsoleActionRuntime.tsx`. D4 + migration — `content/docs/ui/actions.mdx`, `content/docs/releases/v17.mdx`.
45
**Deciders**: ObjectStack Protocol Architects
56
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove gate; a security property that parses but does nothing is worse than absent), [ADR-0066](./0066-unified-authorization-model.md) (D4 — action `requiredPermissions`, dual-surface, server is source of truth), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata — this ADR is its **converse**: no silently ungoverned executable), [ADR-0096](./0096-execution-surface-identity-admission.md) (missing **identity** must not fail open; this ADR extends the same posture to missing **declaration**), [ADR-0104](./0104-field-runtime-value-shape-contract.md) (D2 — declared action param contract), [ADR-0109](./0109-ai-tool-authoring-model.md) (every declarative action materialises `action_<name>`; the declaration IS the AI-facing capability)
67
**Consumers**: `@objectstack/runtime` (`domains/actions.ts`, `action-execution.ts`), `@objectstack/objectql` (`registerAction` contract), `@objectstack/lint` (new reconciliation rule), `../objectui` (`useConsoleActionRuntime` invocation URL), `content/docs/ui/actions.mdx` (public REST contract)

packages/metadata/src/metadata-manager.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,23 +1395,53 @@ export class MetadataManager implements IMetadataService {
13951395
/**
13961396
* Load a single metadata item from loaders.
13971397
* Iterates through registered loaders until found.
1398+
*
1399+
* Returns `null` both when no loader HAS the item and when every loader
1400+
* FAILED — see {@link loadDiagnosed} when the caller must tell those apart.
13981401
*/
13991402
async load<T = any>(
14001403
type: string,
14011404
name: string,
14021405
options?: MetadataLoadOptions
14031406
): Promise<T | null> {
1407+
return (await this.loadDiagnosed<T>(type, name, options)).data;
1408+
}
1409+
1410+
/**
1411+
* `load`, plus whether the answer can be trusted as complete.
1412+
*
1413+
* [ADR-0110 D3] A miss and an outage are different facts with opposite
1414+
* security meanings, and plain `load` cannot express the difference: a
1415+
* loader that throws is warn-logged and skipped, so a database the metadata
1416+
* plane cannot reach returns the same `null` as a name that was never
1417+
* declared. Callers that gate on a declaration MUST NOT read that `null` as
1418+
* "the author declared no gate" — an availability failure would silently
1419+
* widen access (the REST `/actions` route's fail-open branch, #3935).
1420+
*
1421+
* `degraded` is true when at least one loader threw AND no loader answered
1422+
* with the item. The posture is deliberately conservative: with a loader
1423+
* down we cannot prove the item is absent, so we decline to claim it is.
1424+
* A clean miss (every loader answered, none had it) is NOT degraded.
1425+
*/
1426+
async loadDiagnosed<T = any>(
1427+
type: string,
1428+
name: string,
1429+
options?: MetadataLoadOptions
1430+
): Promise<{ data: T | null; degraded: boolean; errors: string[] }> {
1431+
const errors: string[] = [];
14041432
for (const loader of this.loaders.values()) {
14051433
try {
14061434
const result = await loader.load(type, name, options);
14071435
if (result.data) {
1408-
return result.data as T;
1436+
return { data: result.data as T, degraded: false, errors };
14091437
}
14101438
} catch (e) {
1439+
const message = e instanceof Error ? e.message : String(e);
1440+
errors.push(`${loader.contract.name}: ${message}`);
14111441
this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
14121442
}
14131443
}
1414-
return null;
1444+
return { data: null, degraded: errors.length > 0, errors };
14151445
}
14161446

14171447
/**

packages/objectql/src/engine.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,31 @@ export class ObjectQL implements IDataEngine {
745745
return entry.handler(ctx);
746746
}
747747

748+
/**
749+
* Every action handler currently registered, as `{ objectName, actionName }`
750+
* pairs (plus the owning package when one was given).
751+
*
752+
* [ADR-0110 D5] The handler registry is one half of the
753+
* declaration↔executable bijection; with no way to enumerate it, the other
754+
* half could only be checked when someone happened to invoke a route. The
755+
* boot reconciliation reads this to list handlers no declaration covers —
756+
* which since D3 are refused at dispatch, so having the inventory is what
757+
* makes that refusal a checklist instead of a support ticket.
758+
*/
759+
listRegisteredActions(): Array<{ objectName: string; actionName: string; package?: string }> {
760+
const out: Array<{ objectName: string; actionName: string; package?: string }> = [];
761+
for (const [key, entry] of this.actions.entries()) {
762+
const sep = key.indexOf(':');
763+
if (sep < 0) continue;
764+
out.push({
765+
objectName: key.slice(0, sep),
766+
actionName: key.slice(sep + 1),
767+
...(entry.package ? { package: entry.package } : {}),
768+
});
769+
}
770+
return out;
771+
}
772+
748773
/**
749774
* Remove all actions registered by a specific package.
750775
*/

0 commit comments

Comments
 (0)