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
36 changes: 36 additions & 0 deletions .changeset/adr-0110-action-declaration-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/runtime': major
'@objectstack/objectql': minor
'@objectstack/metadata': minor
---

**ADR-0110 — an action's identity is its `name`, and anything executable over a
governed surface must have a declaration.**

`POST /api/v1/actions/:object/:action` 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 (`{ name: 'complete_task', target:
'completeTask' }`) those are different strings, so the two documented callers
each worked on exactly the half the other broke: 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
capability gate and the ADR-0104 param contract (#3935).

- **D1/D2** — identity is always the declarative `name`; the handler key is
derived from the resolved declaration through a rotation now shared with the
MCP `run_action` bridge (`resolveActionHandlerKeys`, `executeRegisteredAction`).
The REST route previously rotated only the object key, never the handler key.
- **D3 (breaking)** — declaration resolution is a trichotomy. A genuinely
undeclared handler is **refused (404)** with the `defineAction` to add, rather
than executed ungated with system privileges; an unreachable metadata plane is
a **503** rather than a silent ungating (`MetadataManager.loadDiagnosed` tells
a clean miss from an outage). `OS_ALLOW_UNDECLARED_ACTIONS=1` is the migration
valve — it warns on every invocation and is removed in 18.
- **D5** — `reconcileActionRegistrations` plus `ObjectQLEngine.listRegisteredActions`
power a `kernel:ready` inventory logging every registered-but-undeclared
handler (refused at dispatch) and every declared script action bound to no
handler — the ADR-0078 converse, mechanised.
- **D6** — security-gate strictness is opt-**out** (`OS_ALLOW_*`), never opt-in.

Apps whose actions are all declared need no changes beyond gaining enforcement
of the `requiredPermissions` they already declared.
38 changes: 38 additions & 0 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,44 @@ and flags whitelists the mapping would *widen* so every edit stays reviewable.
`['upsert']`) strips to `[]`, which is **deny-all** — the object's API closes
rather than widening.

### An action must be declared to be invocable, and is identified by `name` (ADR-0110, #3935)

Two halves of the `/api/v1/actions/:object/:action` contract were broken in
complementary ways, which is why neither surfaced: the 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 (`{ name: 'complete_task', target: 'completeTask' }`) those differ — so
the documented `curl .../todo_task/complete_task` resolved the declaration and
then 404ed, while the Console's `target`-addressed call dispatched fine and
resolved **no** declaration, silently skipping the ADR-0066 D4 capability gate
and the ADR-0104 param contract.

Three changes land together:

- **Identity is `name`.** The URL, MCP `run_action`, and every future surface
identify an action by its declarative `name`. `target` is a binding
expression — polymorphic per type, `${param.X}`-interpolatable, and legally
non-unique — so it never identifies anything. The server derives the handler
key from the declaration it resolved, using the rotation the MCP bridge
already used. The Console posts `name` (objectui ships in lockstep).
- **An undeclared handler is refused.** `engine.registerAction` with no
matching declaration has no `requiredPermissions` to enforce and no param
contract to check, yet executes with system privileges. It now returns 404
naming the `defineAction` to add. Deleting a declaration used to *remove* an
action's gate while leaving it callable; removal now narrows.
- **An unreachable metadata plane refuses instead of degrading.** A loader
failure used to be indistinguishable from "no declaration", so an outage
silently ungated every action it could not see. That is now a **503** — the
same posture as the datasource entry below.

**Migration:** boot logs list every registered-but-undeclared handler under
`[action-governance]`, alongside declared script actions bound to no handler.
Declare each one. `OS_ALLOW_UNDECLARED_ACTIONS=1` runs them meanwhile, warning
on every invocation; **it is removed in 18**. Apps whose actions are all
declared — anything with working Console buttons — 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`.

### 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
48 changes: 48 additions & 0 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,13 @@ curl -b cookies.txt -X POST \
# → 200 { "success": true, "data": { "success": true, "data": ... } }
```

The URL names the action by its **`name`**, never by `target`. `target` binds
the action to whatever runs it — a handler key here, a flow id for
`type: 'flow'`, a URL for `type: 'url'` — so it is an implementation detail:
the server resolves your declaration by name and derives the handler key from
it. Rename the underlying function freely; as long as the declaration's
`target` follows, the public URL is unchanged.

Failures split three ways:

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

## Headless actions: declare it, then hide it

An action that should be callable but not appear in the UI is still a
**declared** action. Hiding is a property you set; it is not the absence of a
declaration:

```typescript
defineAction({
name: 'recalculate_commissions',
type: 'script',
target: 'recalcCommissions',
locations: [], // no UI surface
requiredPermissions: ['finance.admin'], // still gated
// `ai.exposed` is false by default — no MCP tool either
})
```

You keep the capability gate, the param contract, the audit trail, and Setup
visibility for admins. A declaration that no surface renders costs you nothing.

<Callout type="warn">
Registering a handler **without** a declaration is not a way to hide an action —
it is refused. An undeclared handler has no `requiredPermissions` to enforce and
no param contract to check, yet it would execute with system privileges, so the
server declines it:

```
Action 'recalc' on 'crm_account' has no declaration — add
`defineAction({ name: 'recalc', … })`, or register the handler under a declared
action's `target`.
```

If server-side logic should never be reachable over HTTP at all, do not register
it as an action — export a plain function and call it from your own code.
`engine.registerAction` means "publish this on the HTTP and MCP surfaces".

Migrating an app that has undeclared handlers? Boot logs list every one of them
under `[action-governance]`, and `OS_ALLOW_UNDECLARED_ACTIONS=1` runs them
(warning each time) while you add the declarations. That valve is removed in 18.
</Callout>

## Expose it to AI (MCP)

Actions are **not** AI-visible by default. Opting in takes two fields — and
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0110-action-identity-and-declaration-admission.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ADR-0110: An action's identity is its `name`; anything executable over a governed surface must have a declaration (the declaration-admission gate)

**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).
**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).
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`.
**Deciders**: ObjectStack Protocol Architects
**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)
**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)
Expand Down
34 changes: 32 additions & 2 deletions packages/metadata/src/metadata-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1395,23 +1395,53 @@ export class MetadataManager implements IMetadataService {
/**
* Load a single metadata item from loaders.
* Iterates through registered loaders until found.
*
* Returns `null` both when no loader HAS the item and when every loader
* FAILED — see {@link loadDiagnosed} when the caller must tell those apart.
*/
async load<T = any>(
type: string,
name: string,
options?: MetadataLoadOptions
): Promise<T | null> {
return (await this.loadDiagnosed<T>(type, name, options)).data;
}

/**
* `load`, plus whether the answer can be trusted as complete.
*
* [ADR-0110 D3] A miss and an outage are different facts with opposite
* security meanings, and plain `load` cannot express the difference: a
* loader that throws is warn-logged and skipped, so a database the metadata
* plane cannot reach returns the same `null` as a name that was never
* declared. Callers that gate on a declaration MUST NOT read that `null` as
* "the author declared no gate" — an availability failure would silently
* widen access (the REST `/actions` route's fail-open branch, #3935).
*
* `degraded` is true when at least one loader threw AND no loader answered
* with the item. The posture is deliberately conservative: with a loader
* down we cannot prove the item is absent, so we decline to claim it is.
* A clean miss (every loader answered, none had it) is NOT degraded.
*/
async loadDiagnosed<T = any>(
type: string,
name: string,
options?: MetadataLoadOptions
): Promise<{ data: T | null; degraded: boolean; errors: string[] }> {
const errors: string[] = [];
for (const loader of this.loaders.values()) {
try {
const result = await loader.load(type, name, options);
if (result.data) {
return result.data as T;
return { data: result.data as T, degraded: false, errors };
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
errors.push(`${loader.contract.name}: ${message}`);
this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
}
}
return null;
return { data: null, degraded: errors.length > 0, errors };
}

/**
Expand Down
25 changes: 25 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,31 @@ export class ObjectQL implements IDataEngine {
return entry.handler(ctx);
}

/**
* Every action handler currently registered, as `{ objectName, actionName }`
* pairs (plus the owning package when one was given).
*
* [ADR-0110 D5] The handler registry is one half of the
* declaration↔executable bijection; with no way to enumerate it, the other
* half could only be checked when someone happened to invoke a route. The
* boot reconciliation reads this to list handlers no declaration covers —
* which since D3 are refused at dispatch, so having the inventory is what
* makes that refusal a checklist instead of a support ticket.
*/
listRegisteredActions(): Array<{ objectName: string; actionName: string; package?: string }> {
const out: Array<{ objectName: string; actionName: string; package?: string }> = [];
for (const [key, entry] of this.actions.entries()) {
const sep = key.indexOf(':');
if (sep < 0) continue;
out.push({
objectName: key.slice(0, sep),
actionName: key.slice(sep + 1),
...(entry.package ? { package: entry.package } : {}),
});
}
return out;
}

/**
* Remove all actions registered by a specific package.
*/
Expand Down
Loading
Loading