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
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.
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.
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
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
flowwithallowRuntimeCreate: true(packages/spec/src/kernel/metadata-plugin.zod.ts:827), soauthoring a runtime flow named
opportunity_approvalwhile a package ships one is anordinary, accepted write.
Mechanism — three facts that are individually correct and collide
The SchemaRegistry stores both, deliberately. Items are keyed
packageId:name(packages/objectql/src/registry.ts:1148), and ADR-0048 section 3.4coexistence 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.listItemsreturns both, with no dedup and no precedence.packages/objectql/src/registry.ts:3394-3414:It filters by
packageIdonly when one is passed, and filters out disabled packages.Nothing collapses two entries that share a bare name.
The automation engine keys flows by bare name.
packages/services/service-automation/src/engine.ts:1252:and the boot pull (
packages/services/service-automation/src/plugin.ts:870-880) is:registerFlowends inthis.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
registerFlownor the flow pull warns on aname that is already present; the pull's only log is a count
(
plugin.ts:891Pulled N flow(s) from ObjectQL registry), and N counts theregistrations, not the distinct names.
listFlows/getFlowRuntimeStatescannot show it — they enumeratethis.flows.keys()(engine.ts:2600, 2638), which by then holds one entry.flow that "stopped working" or a runtime flow that "does something it was not authored
to do", depending on which won.
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)
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
mainindependent of whether that design is accepted, and becauseit is not in that card's scope.
Read at
origin/maind63b01436035f0866df8c22156e26ab2a064580d. Unassigned, not claimed.Generated by Claude Code