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:
ql.getSchema(objectName).actions — the embedded arrayql.registry.getItem('action', actionName), accepted when ownsRoute(action) — which is true for an object-less owner via isObjectLessActionKeymeta.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
Found while giving an object-less action a UI home in the
dulyapp (objectstack-ai/duly#27). Reported rather than worked around.Measured on
@objectstack/runtime17.2.0 /@objectstack/spec17.2.0, booting the app the way the CLI does (new AppPlugin({ ...stackConfig, onEnable })→kernel.bootstrap()).What happens
Two actions are declared with
defineActionandlocations: [](the honest headless declaration the spec prescribes for an object-less action), and their handlers are registered under the canonicalglobalkey. Bootstrap logs:Both are declared with
defineAction, and both are in the stack'sactionscollection. An object-BOUND action registered in the same function, in the same boot, is not reported.Why the audit cannot see them
collectActionDeclarationsbuilds its declaration set from two sources:meta.listObjects()→ each object's embeddedobj.actions[]meta.loadMany('action')→ standalone actionsProbed in that same boot:
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 emptyloadMany('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.
resolveRouteActionDeclarationtries, in order:ql.getSchema(objectName).actions— the embedded arrayql.registry.getItem('action', actionName), accepted whenownsRoute(action)— which is true for an object-less owner viaisObjectLessActionKeymeta.loadDiagnosed('action', name)/meta.load('action', name)Rung 2 finds these actions (probe above). So
POST /api/v1/actions/global/duly_catalog_applyresolves 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 greenpnpm 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 bynameonly).Filing unassigned for triage.
Generated by Claude Code