Skip to content

defineStack accepts two actions sharing one name without a word — including two GLOBAL ones, where the <object>:<name> handler map then silently shadows one #14124

Description

@os-warren

Found while adding an object-bound twin of an object-less action in the duly app (objectstack-ai/duly#27), where "just declare it twice under one name" was the first shape considered. Measured before it was rejected.

@objectstack/spec 17.2.0.

What was measured

Two actions in one stack's actions collection may share a name. defineStack accepts both, emits nothing, and both survive into stack.actions:

// one global + one object-bound, both named `duly_dup_probe`
stack.actions            = ["global:duly_dup_probe", "duly_catalog_item:duly_dup_probe"]
object.actions           = ["duly_dup_probe/BOUND"]        // the bound one merged in

// two GLOBALS, both named `duly_dup2`
outcome                  = accepted
stack.actions            = ["duly_dup2/FIRST", "duly_dup2/SECOND"]

The cross-reference walk is awake for the adjacent case — a nav runAction naming an action that does not exist throws defineStack cross-reference validation failed (1 issue). It says nothing about a runAction whose name resolves to two actions.

Why the two-globals case is the sharp one

Handler dispatch is an exact-string Map lookup on <object>:<name> (executeAction), and registration is keyed the same way. Two globals sharing a name therefore collapse to one key: whichever handler registers second wins, and the other action is a live, declared, permission-gated button whose handler is unreachable. Nothing at author time, build time or boot says so — the declaration is legal, the registration succeeds, and the loser fails only when a user clicks it.

The global + object-bound pair is less lethal (the two occupy distinct keys, and resolveRouteActionDeclaration prefers the object's embedded array) but still ambiguous for every by-name consumer: nav runAction, validate-action-name-refs, and ql.registry.getItem('action', name), which is keyed by bare name and can only return one of them.

Shape

This is the ADR-0078 / ADR-0049 family arriving at the collection level rather than at a key: the metadata parses, renders and reads as wired, and one of the two declarations quietly does nothing. It is also exactly the shape an AI-authored app produces — copying a declaration to place it somewhere new and forgetting to rename it is the natural edit, and no gate catches it.

Suggested direction

Reject a duplicate <objectName ?? 'global'>:<name> within one stack's action set at defineStack time, with the offending pair named. If the global + object-bound pair is judged legitimate (one action, two placements), reject only the same-scope collision and leave that one alone — but then the by-name consumers above need a documented precedence rather than an implicit one.

Worked around in the application by giving the twin a distinct name (duly_catalog_apply_to_people), which is what we would want authors to do anyway — but that was a measurement result, not something the platform told us.

Filing unassigned for triage.

Generated by Claude Code

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions