Skip to content

Packaged flow silently replaced by a same-named runtime flow: listItems returns both, the engine keys flows by bare name, and Map order decides the winner #11997

Description

@claude

The defect

A runtime-authored flow that reuses a packaged flow's name silently and
non-deterministically replaces the packaged flow in the automation engine, with no
diagnostic, no receipt, and no way for an admin to tell which one is armed.

This is reachable today: the metadata registry declares flow with
allowRuntimeCreate: true (packages/spec/src/kernel/metadata-plugin.zod.ts:827), so
authoring a runtime flow named opportunity_approval while a package ships one is an
ordinary, accepted write.

Mechanism — three facts that are individually correct and collide

  1. The SchemaRegistry stores both, deliberately. Items are keyed
    packageId:name (packages/objectql/src/registry.ts:1148), and ADR-0048 section 3.4
    coexistence is pinned in packages/objectql/src/registry-cross-package-collision.test.ts:20-40
    — two packages shipping one bare name legitimately coexist, reachable via
    package-scoped getItem.

  2. listItems returns both, with no dedup and no precedence.
    packages/objectql/src/registry.ts:3394-3414:

    const items = Array.from(this.metadata.get(type)?.values() || []) as T[];
    

    It filters by packageId only when one is passed, and filters out disabled packages.
    Nothing collapses two entries that share a bare name.

  3. The automation engine keys flows by bare name.
    packages/services/service-automation/src/engine.ts:1252:

    private flows = new Map<string, FlowParsed>();
    

    and the boot pull (packages/services/service-automation/src/plugin.ts:870-880) is:

    const flows = ql?.registry?.listItems?.('flow') ?? [];
    for (const f of flows) { ... this.engine.registerFlow(def.name, def as never); ... }
    

    registerFlow ends in this.flows.set(name, parsed) (engine.ts:2527).

⇒ Both entries are pulled, both are registered under the same key, and the last one in
Map iteration order wins
. The survivor is decided by registration order into the
registry, which is boot load order — not by any declared precedence.

Why it is worth a card rather than a note

  • It is silent in both directions. Neither registerFlow nor the flow pull warns on a
    name that is already present; the pull's only log is a count
    (plugin.ts:891Pulled N flow(s) from ObjectQL registry), and N counts the
    registrations, not the distinct names.
  • listFlows / getFlowRuntimeStates cannot show it — they enumerate
    this.flows.keys() (engine.ts:2600, 2638), which by then holds one entry.
  • The trigger binding follows the survivor, so the observable symptom is a packaged
    flow that "stopped working" or a runtime flow that "does something it was not authored
    to do", depending on which won.
  • The precedent for the neighbouring type went the other way deliberately: for permission
    sets, a save targeting a package-declared name is refused loudly with a message
    naming the sanctioned path (packages/plugins/plugin-security/src/packaged-permission-set-lock.ts,
    maintainer ruling 2026-08-24, "lock the base, clone to customize"). Flows have no
    equivalent guard.

Not measured

I did not run this end to end — this is a static read of the three code paths above,
found while measuring the trigger-dispatch path for the packaged-flow customization design
work. The registration-order claim in particular deserves a runtime confirmation before
anyone prices a fix.

Possible directions (not a recommendation — this needs triage first)

  • refuse the runtime write when a package declares the name (the permission-set shape); or
  • make the engine's flow map package-aware, which is a much larger change; or
  • at minimum, warn at pull time when one bare name is registered twice, so the
    condition stops being invisible.

Provenance

Found while measuring the trigger-dispatch path for the packaged-flow clone-to-customize
design (#11665, section 2.2 of the design document posted there). Filed separately because
it is a live defect on main independent of whether that design is accepted, and because
it is not in that card's scope.

Read at origin/maind63b01436035f0866df8c22156e26ab2a064580d. Unassigned, not claimed.


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