Skip to content

Startup [action-governance] reports every OBJECT-LESS declared action as "registered handler with NO declaration — REFUSED at dispatch", and the route contradicts it #14123

Description

@os-warren

Found while giving an object-less action a UI home in the duly app (objectstack-ai/duly#27). Reported rather than worked around.

Measured on @objectstack/runtime 17.2.0 / @objectstack/spec 17.2.0, booting the app the way the CLI does (new AppPlugin({ ...stackConfig, onEnable })kernel.bootstrap()).

What happens

Two actions are declared with defineAction and locations: [] (the honest headless declaration the spec prescribes for an object-less action), and their handlers are registered under the canonical global key. Bootstrap logs:

WARN [action-governance] registered handlers with NO declaration — these are REFUSED at
dispatch (ADR-0110 D3) and there is no opt-out; declare each one with `defineAction`, or
drop the registration if nothing should invoke it over HTTP
{"count":2,"handlers":["global:duly_catalog_apply","global:duly_catalog_sync"]}

Both are declared with defineAction, and both are in the stack's actions collection. An object-BOUND action registered in the same function, in the same boot, is not reported.

Why the audit cannot see them

collectActionDeclarations builds its declaration set from two sources:

  • meta.listObjects() → each object's embedded obj.actions[]
  • meta.loadMany('action') → standalone actions

Probed in that same boot:

ql.registry.getItem('action','duly_catalog_apply')          = FOUND  objectName=(none)
ql.registry.getItem('action','duly_catalog_apply_to_people') = FOUND  objectName=duly_catalog_item
ql.getSchema('duly_catalog_item').actions                    = ["duly_catalog_apply_to_people"]
meta.loadMany('action')                                      = []
meta.load('action','duly_catalog_apply')                     = undefined
meta.load('action','duly_catalog_apply_to_people')           = undefined

The metadata plane holds no action rows at all. Object-bound actions survive the audit only because they ride listObjects()'s embedded array; every object-less action falls through the empty loadMany('action') and reads as undeclared. So the audit's verdict tracks whether an action happens to be object-bound, not whether it is declared.

The claim in the message is not true

The message states these are "REFUSED at dispatch (ADR-0110 D3) and there is no opt-out". The REST route does not agree. resolveRouteActionDeclaration tries, in order:

  1. ql.getSchema(objectName).actions — the embedded array
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action) — which is true for an object-less owner via isObjectLessActionKey
  3. meta.loadDiagnosed('action', name) / meta.load('action', name)

Rung 2 finds these actions (probe above). So POST /api/v1/actions/global/duly_catalog_apply resolves its declaration, passes the ADR-0066 D4 gate, and dispatches — while startup told the author it is dead.

Why it is worth fixing rather than tolerating

The remedy the message offers is actively harmful. "Declare each one with defineAction" is already done and cannot clear the warning. The other branch — "drop the registration if nothing should invoke it over HTTP" — would break a working path, and it is the branch an author reaches for after the first one fails. In our case those two actions are the product's onboarding path; deleting their registration on the strength of this line would have been a silent regression with a green pnpm validate.

It is also the inverse of the class ADR-0078 exists for: not a declaration nothing reads, but a true declaration an audit reports as absent. An audit that cries wolf on a correct, common shape trains authors to skip the whole [action-governance] block, including the other warning in it that is real (declared script actions with NO handler).

Suggested direction

Have the audit resolve declarations through the same path the route uses, rather than a second source of truth — ql.registry.getItem('action', name) as the fallback rung, so audit and dispatch cannot disagree. Failing that, the audit should at minimum not assert a dispatch outcome it has not checked.

Adjacent, both closed: #3913 (global actions unreachable — registered under global, REST probing *), #3935 (REST resolving declarations by name only).

Filing unassigned for triage.

Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions