diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index e9779a4af66a19878c352a3f3a2d829f30cfe02c..2af505ec75acb2918946940b2505dd8532780dd0 100644
--- a/arbor/CMakeLists.txt
+++ b/arbor/CMakeLists.txt
@@ -23,6 +23,7 @@ set(arbor_sources
     hardware/power.cpp
     io/locked_ostream.cpp
     io/serialize_hex.cpp
+    label_resolution.cpp
     lif_cell_group.cpp
     mc_cell_group.cpp
     mechcat.cpp
diff --git a/arbor/arbexcept.cpp b/arbor/arbexcept.cpp
index 2a384b517110c12fa50a77f9a3a6eb0d91cdcbd6..13158e3630b894271f1f4e60a9d90b912f388aca 100644
--- a/arbor/arbexcept.cpp
+++ b/arbor/arbexcept.cpp
@@ -22,37 +22,14 @@ bad_cell_description::bad_cell_description(cell_kind kind, cell_gid_type gid):
     kind(kind)
 {}
 
-bad_target_description::bad_target_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val):
-    arbor_exception(pprintf("Model building error on cell {}: recipe::num_targets(gid={}) = {} is greater than the number of synapses on the cell = {}", gid, gid, rec_val, cell_val)),
-    gid(gid), rec_val(rec_val), cell_val(cell_val)
-{}
-
-bad_source_description::bad_source_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val):
-    arbor_exception(pprintf("Model building error on cell {}: recipe::num_sources(gid={}) = {} is not equal to the number of detectors on the cell = {}", gid, gid, rec_val, cell_val)),
-    gid(gid), rec_val(rec_val), cell_val(cell_val)
-{}
-
 bad_connection_source_gid::bad_connection_source_gid(cell_gid_type gid, cell_gid_type src_gid, cell_size_type num_cells):
     arbor_exception(pprintf("Model building error on cell {}: connection source gid {} is out of range: there are only {} cells in the model, in the range [{}:{}].", gid, src_gid, num_cells, 0, num_cells-1)),
     gid(gid), src_gid(src_gid), num_cells(num_cells)
 {}
 
-bad_connection_source_lid::bad_connection_source_lid(cell_gid_type gid, cell_lid_type src_lid, cell_size_type num_sources):
-    arbor_exception(pprintf("Model building error on cell {}: connection source index {} is out of range. Cell {} has {} sources", gid, src_lid, gid, num_sources) +
-                    (num_sources? pprintf(", in the range [{}:{}].",  0, num_sources-1) : ".")),
-    gid(gid), src_lid(src_lid), num_sources(num_sources)
-{}
-
-bad_connection_target_lid::bad_connection_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets):
-    arbor_exception(pprintf("Model building error on cell {}: connection target index {} is out of range. Cell {} has {} targets", gid, tgt_lid, gid, num_targets) +
-                    (num_targets ? pprintf(", in the range [{}:{}].", 0, num_targets-1) : ".")),
-    gid(gid), tgt_lid(tgt_lid), num_targets(num_targets)
-{}
-
-bad_event_generator_target_lid::bad_event_generator_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets):
-    arbor_exception(pprintf("Model building error on cell {}: event_generator target index {} is out of range. Cell {} has {} targets", gid, tgt_lid, gid, num_targets) +
-                    (num_targets ? pprintf(", in the range [{}:{}].", 0, num_targets-1) : ".")),
-    gid(gid), tgt_lid(tgt_lid), num_targets(num_targets)
+bad_connection_label::bad_connection_label(cell_gid_type gid, const cell_tag_type& label, const std::string& msg):
+    arbor_exception(pprintf("Model building error on cell {}: connection endpoint label \"{}\": {}.", gid, label, msg)),
+    gid(gid), label(label)
 {}
 
 bad_global_property::bad_global_property(cell_kind kind):
@@ -71,10 +48,10 @@ gj_unsupported_domain_decomposition::gj_unsupported_domain_decomposition(cell_gi
     gid_1(gid_1)
 {}
 
-bad_gj_connection_lid::bad_gj_connection_lid(cell_gid_type gid, cell_member_type site):
-    arbor_exception(pprintf("Model building error on cell {}: gap junction index {} on cell {} does not exist)", gid, site.gid, site.index)),
+gj_unsupported_lid_selection_policy::gj_unsupported_lid_selection_policy(cell_gid_type gid, cell_tag_type label):
+    arbor_exception(pprintf("Model building error on cell {}: gap junction site label \"{}\" must be univalent.", gid, label)),
     gid(gid),
-    site(site)
+    label(label)
 {}
 
 gj_kind_mismatch::gj_kind_mismatch(cell_gid_type gid_0, cell_gid_type gid_1):
diff --git a/arbor/benchmark_cell_group.cpp b/arbor/benchmark_cell_group.cpp
index edd6dbe20d4a79884e87f9ce33e448887d70fd7a..a2ed5f34ddef0219eba640aedab38184cd8537d5 100644
--- a/arbor/benchmark_cell_group.cpp
+++ b/arbor/benchmark_cell_group.cpp
@@ -6,16 +6,19 @@
 #include <arbor/recipe.hpp>
 #include <arbor/schedule.hpp>
 
+#include "benchmark_cell_group.hpp"
 #include "cell_group.hpp"
+#include "label_resolution.hpp"
 #include "profile/profiler_macro.hpp"
-#include "benchmark_cell_group.hpp"
 
 #include "util/span.hpp"
 
 namespace arb {
 
 benchmark_cell_group::benchmark_cell_group(const std::vector<cell_gid_type>& gids,
-                                           const recipe& rec):
+                                           const recipe& rec,
+                                           cell_label_range& cg_sources,
+                                           cell_label_range& cg_targets):
     gids_(gids)
 {
     for (auto gid: gids_) {
@@ -29,6 +32,13 @@ benchmark_cell_group::benchmark_cell_group(const std::vector<cell_gid_type>& gid
         cells_.push_back(util::any_cast<benchmark_cell>(rec.get_cell_description(gid)));
     }
 
+    for (const auto& c: cells_) {
+        cg_sources.add_cell();
+        cg_targets.add_cell();
+        cg_sources.add_label(c.source, {0, 1});
+        cg_targets.add_label(c.target, {0, 1});
+    }
+
     reset();
 }
 
diff --git a/arbor/benchmark_cell_group.hpp b/arbor/benchmark_cell_group.hpp
index 62c27abf3b1baf27642f5e34e1481c594137e0fa..ea64cbcedd423a218a5da21aea80e485c197f8ee 100644
--- a/arbor/benchmark_cell_group.hpp
+++ b/arbor/benchmark_cell_group.hpp
@@ -8,12 +8,13 @@
 
 #include "cell_group.hpp"
 #include "epoch.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
 class benchmark_cell_group: public cell_group {
 public:
-    benchmark_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec);
+    benchmark_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets);
 
     cell_kind get_cell_kind() const override;
 
diff --git a/arbor/cable_cell.cpp b/arbor/cable_cell.cpp
index 35268b7e234a188aef7784d5f47215154eefa9d7..7cd703b409ec75fdb9cbdaf4914a5ec870be2824 100644
--- a/arbor/cable_cell.cpp
+++ b/arbor/cable_cell.cpp
@@ -50,8 +50,8 @@ struct cable_cell_impl {
     // The decorations on the cell.
     decor decorations;
 
-    // The lid ranges of placements.
-    std::vector<lid_range> placed_lid_ranges;
+    // The placeable label to lid_range map
+    dynamic_typed_map<constant_type<std::unordered_multimap<cell_tag_type, lid_range>>::type> labeled_lid_ranges;
 
     cable_cell_impl(const arb::morphology& m, const label_dict& labels, const decor& decorations):
         provider(m, labels),
@@ -79,7 +79,7 @@ struct cable_cell_impl {
     }
 
     template <typename Item>
-    lid_range place(const locset& ls, const Item& item) {
+    void place(const locset& ls, const Item& item, const cell_tag_type& label) {
         auto& mm = get_location_map(item);
         cell_lid_type& lid = placed_count.get<Item>();
         cell_lid_type first = lid;
@@ -88,7 +88,9 @@ struct cable_cell_impl {
             placed<Item> p{l, lid++, item};
             mm.push_back(p);
         }
-        return lid_range(first, lid);
+        auto range = lid_range(first, lid);
+        auto& lid_ranges = labeled_lid_ranges.get<Item>();
+        lid_ranges.insert(std::make_pair(label, range));
     }
 
     template <typename T>
@@ -134,13 +136,6 @@ struct cable_cell_impl {
     mextent concrete_region(const region& r) const {
         return thingify(r, provider);
     }
-
-    lid_range placed_lid_range(unsigned id) const {
-        if (id>=placed_lid_ranges.size()) {
-            throw cable_cell_error(util::pprintf("invalid placement identifier {}", id));
-        }
-        return placed_lid_ranges[id];
-    }
 };
 
 using impl_ptr = std::unique_ptr<cable_cell_impl, void (*)(cable_cell_impl*)>;
@@ -151,15 +146,12 @@ impl_ptr make_impl(cable_cell_impl* c) {
 void cable_cell_impl::init(const decor& d) {
     for (const auto& p: d.paintings()) {
         auto& where = p.first;
-        std::visit([this, &where] (auto&& what) {this->paint(where, what);},
-                   p.second);
+        std::visit([this, &where] (auto&& what) {this->paint(where, what);}, p.second);
     }
     for (const auto& p: d.placements()) {
-        auto& where = p.first;
-        auto lids =
-            std::visit([this, &where] (auto&& what) {return this->place(where, what);},
-                       p.second);
-        placed_lid_ranges.push_back(lids);
+        auto& where = std::get<0>(p);
+        auto& label = std::get<2>(p);
+        std::visit([this, &where, &label] (auto&& what) {return this->place(where, what, label);}, std::get<1>(p));
     }
 }
 
@@ -213,8 +205,16 @@ const cable_cell_parameter_set& cable_cell::default_parameters() const {
     return impl_->decorations.defaults();
 }
 
-lid_range cable_cell::placed_lid_range(unsigned id) const {
-    return impl_->placed_lid_range(id);
+const std::unordered_multimap<cell_tag_type, lid_range>& cable_cell::detector_ranges() const {
+    return impl_->labeled_lid_ranges.get<threshold_detector>();
+}
+
+const std::unordered_multimap<cell_tag_type, lid_range>& cable_cell::synapse_ranges() const {
+    return impl_->labeled_lid_ranges.get<mechanism_desc>();
+}
+
+const std::unordered_multimap<cell_tag_type, lid_range>& cable_cell::gap_junction_ranges() const {
+    return impl_->labeled_lid_ranges.get<gap_junction_site>();
 }
 
 } // namespace arb
diff --git a/arbor/cable_cell_param.cpp b/arbor/cable_cell_param.cpp
index c8666e451b863b3e56150b00f0f4c5d264670264..c238a48e57ee927133de5606f07d911614faee4d 100644
--- a/arbor/cable_cell_param.cpp
+++ b/arbor/cable_cell_param.cpp
@@ -113,9 +113,8 @@ void decor::paint(region where, paintable what) {
     paintings_.push_back({std::move(where), std::move(what)});
 }
 
-unsigned decor::place(locset where, placeable what) {
-    placements_.push_back({std::move(where), std::move(what)});
-    return std::size(placements_)-1;
+void decor::place(locset where, placeable what, cell_tag_type label) {
+    placements_.push_back({std::move(where), std::move(what), std::move(label)});
 }
 
 void decor::set_default(defaultable what) {
diff --git a/arbor/cell_group.hpp b/arbor/cell_group.hpp
index c624946b473ee24b45d4113556476ddb32804fb0..b89b82750ee7926884a80c9e1edea5ddef2adf7d 100644
--- a/arbor/cell_group.hpp
+++ b/arbor/cell_group.hpp
@@ -14,6 +14,12 @@
 #include "event_binner.hpp"
 #include "util/rangeutil.hpp"
 
+// The specialized cell_group constructors are expected to accept at least:
+// - The gid vector of the cells belonging to the cell_group.
+// - The recipe.
+// - 2 cell_label_range objects, one for the targets and one for the sources,
+//   that are to be filled during the construction of the cell group. These
+//   ranges are needed to map (gid, label) pairs to their corresponding lid sets.
 namespace arb {
 
 using event_lane_subrange = util::subrange_view_type<std::vector<pse_vector>>;
diff --git a/arbor/cell_group_factory.cpp b/arbor/cell_group_factory.cpp
index 68f3f9a294f15d3a1b1df92afd88d8e2a576a2a2..1351299fc8dfc5d1c3bfe20e0c915081b378dae9 100644
--- a/arbor/cell_group_factory.cpp
+++ b/arbor/cell_group_factory.cpp
@@ -26,29 +26,29 @@ cell_group_factory cell_kind_implementation(
 
     switch (ck) {
     case cell_kind::cable:
-        return [bk, ctx](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<mc_cell_group>(gids, rec, make_fvm_lowered_cell(bk, ctx));
+        return [bk, ctx](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<mc_cell_group>(gids, rec, cg_sources, cg_targets, make_fvm_lowered_cell(bk, ctx));
         };
 
     case cell_kind::spike_source:
         if (bk!=backend_kind::multicore) break;
 
-        return [](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<spike_source_cell_group>(gids, rec);
+        return [](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<spike_source_cell_group>(gids, rec, cg_sources, cg_targets);
         };
 
     case cell_kind::lif:
         if (bk!=backend_kind::multicore) break;
 
-        return [](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<lif_cell_group>(gids, rec);
+        return [](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<lif_cell_group>(gids, rec, cg_sources, cg_targets);
         };
 
     case cell_kind::benchmark:
         if (bk!=backend_kind::multicore) break;
 
-        return [](const gid_vector& gids, const recipe& rec) {
-            return make_cell_group<benchmark_cell_group>(gids, rec);
+        return [](const gid_vector& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets) {
+            return make_cell_group<benchmark_cell_group>(gids, rec, cg_sources, cg_targets);
         };
 
     default: ;
diff --git a/arbor/cell_group_factory.hpp b/arbor/cell_group_factory.hpp
index 903526532457bf0dbcf256cf1c18c003f1b754ed..7723d822b4af4f8978af8f4d4737deb3da40b720 100644
--- a/arbor/cell_group_factory.hpp
+++ b/arbor/cell_group_factory.hpp
@@ -18,7 +18,7 @@
 namespace arb {
 
 using cell_group_factory = std::function<
-        cell_group_ptr(const std::vector<cell_gid_type>&, const recipe&)>;
+        cell_group_ptr(const std::vector<cell_gid_type>&, const recipe&, cell_label_range& cg_sources, cell_label_range& cg_targets)>;
 
 cell_group_factory cell_kind_implementation(
         cell_kind, backend_kind, const execution_context&);
diff --git a/arbor/common_types_io.cpp b/arbor/common_types_io.cpp
index c08d0b66f614ca0ce5ca1d314fd95717dc4e11ac..366ba114f8ab323ce74e029a12fffa7adb4ac331 100644
--- a/arbor/common_types_io.cpp
+++ b/arbor/common_types_io.cpp
@@ -4,6 +4,16 @@
 
 namespace arb {
 
+std::ostream& operator<<(std::ostream& o, lid_selection_policy policy) {
+    switch (policy) {
+    case lid_selection_policy::round_robin:
+        return o << "round_robin";
+    case lid_selection_policy::assert_univalent:
+        return o << "univalent";
+    }
+    return o;
+}
+
 std::ostream& operator<<(std::ostream& o, arb::cell_member_type m) {
     return o << m.gid << ':' << m.index;
 }
diff --git a/arbor/communication/communicator.cpp b/arbor/communication/communicator.cpp
index 16b08fb5afc4ced9b1bc6e44967e0537bd73f815..30835fff384af68aed8838518ce1d3b5ee9d2ba7 100644
--- a/arbor/communication/communicator.cpp
+++ b/arbor/communication/communicator.cpp
@@ -23,8 +23,10 @@
 namespace arb {
 
 communicator::communicator(const recipe& rec,
-                          const domain_decomposition& dom_dec,
-                          execution_context& ctx)
+                           const domain_decomposition& dom_dec,
+                           const label_resolution_map& source_resolution_map,
+                           const label_resolution_map& target_resolution_map,
+                           execution_context& ctx)
 {
     distributed_ = ctx.distributed;
     thread_pool_ = ctx.thread_pool;
@@ -78,17 +80,10 @@ communicator::communicator(const recipe& rec,
     std::vector<cell_size_type> src_counts(num_domains_);
 
     for (const auto& cell: gid_infos) {
-        auto num_targets = rec.num_targets(cell.gid);
         for (auto c: cell.conns) {
             if (c.source.gid >= num_total_cells) {
                 throw arb::bad_connection_source_gid(cell.gid, c.source.gid, num_total_cells);
             }
-            if (auto num_sources = rec.num_sources(c.source.gid); c.source.index >= num_sources) {
-                throw arb::bad_connection_source_lid(cell.gid, c.source.index, num_sources);
-            }
-            if (c.dest >= num_targets) {
-                throw arb::bad_connection_target_lid(cell.gid, c.dest, num_targets);
-            }
             const auto src = dom_dec.gid_domain(c.source.gid);
             src_domains.push_back(src);
             src_counts[src]++;
@@ -102,10 +97,14 @@ communicator::communicator(const recipe& rec,
     util::make_partition(connection_part_, src_counts);
     auto offsets = connection_part_;
     std::size_t pos = 0;
+    auto target_resolver = resolver(&target_resolution_map);
     for (const auto& cell: gid_infos) {
-        for (auto c: cell.conns) {
+        auto source_resolver = resolver(&source_resolution_map);
+        for (const auto& c: cell.conns) {
             const auto i = offsets[src_domains[pos]]++;
-            connections_[i] = {c.source, c.dest, c.weight, c.delay, cell.index_on_domain};
+            auto src_lid = source_resolver.resolve(c.source);
+            auto tgt_lid = target_resolver.resolve({cell.gid, c.dest});
+            connections_[i] = {{c.source.gid, src_lid}, tgt_lid, c.weight, c.delay, cell.index_on_domain};
             ++pos;
         }
     }
diff --git a/arbor/communication/communicator.hpp b/arbor/communication/communicator.hpp
index 2cc98a78c508af1f0c0b30545b4dc4b2552641a6..33e8f2f743971e083c3a9d2f6875121f2e54c093 100644
--- a/arbor/communication/communicator.hpp
+++ b/arbor/communication/communicator.hpp
@@ -31,6 +31,8 @@ public:
 
     explicit communicator(const recipe& rec,
                           const domain_decomposition& dom_dec,
+                          const label_resolution_map& source_resolver,
+                          const label_resolution_map& target_resolver,
                           execution_context& ctx);
 
     /// The range of event queues that belong to cells in group i.
diff --git a/arbor/communication/dry_run_context.cpp b/arbor/communication/dry_run_context.cpp
index cfa6ddf0f50481b5082f8b9bdaa5a11f488a9f46..1ef5522015965caa3467f989d4253bb9a4b62032 100644
--- a/arbor/communication/dry_run_context.cpp
+++ b/arbor/communication/dry_run_context.cpp
@@ -4,8 +4,10 @@
 
 #include <arbor/spike.hpp>
 
-#include <distributed_context.hpp>
-#include <threading/threading.hpp>
+#include "distributed_context.hpp"
+#include "label_resolution.hpp"
+#include "threading/threading.hpp"
+#include "util/rangeutil.hpp"
 
 namespace arb {
 
@@ -24,7 +26,7 @@ struct dry_run_context_impl {
         gathered_spikes.reserve(local_size*num_ranks_);
 
         for (count_type i = 0; i < num_ranks_; i++) {
-            gathered_spikes.insert(gathered_spikes.end(), local_spikes.begin(), local_spikes.end());
+            util::append(gathered_spikes, local_spikes);
         }
 
         for (count_type i = 0; i < num_ranks_; i++) {
@@ -51,7 +53,7 @@ struct dry_run_context_impl {
         gathered_gids.reserve(local_size*num_ranks_);
 
         for (count_type i = 0; i < num_ranks_; i++) {
-            gathered_gids.insert(gathered_gids.end(), local_gids.begin(), local_gids.end());
+            util::append(gathered_gids, local_gids);
         }
 
         for (count_type i = 0; i < num_ranks_; i++) {
@@ -68,6 +70,25 @@ struct dry_run_context_impl {
         return gathered_vector<cell_gid_type>(std::move(gathered_gids), std::move(partition));
     }
 
+    cell_label_range gather_cell_label_range(const cell_label_range& local_ranges) const {
+        cell_label_range global_ranges;
+        for (unsigned i = 0; i < num_ranks_; i++) {
+            global_ranges.append(local_ranges);
+        }
+        return global_ranges;
+    }
+
+    cell_labels_and_gids gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        auto global_ranges = gather_cell_label_range(local_labels_and_gids.label_range);
+        auto gids = gather_gids(local_labels_and_gids.gids);
+        return cell_labels_and_gids(global_ranges, gids.values());
+    }
+
+    template <typename T>
+    std::vector<T> gather(T value, int) const {
+        return std::vector<T>(num_ranks_, value);
+    }
+
     int id() const { return 0; }
 
     int size() const { return num_ranks_; }
@@ -81,11 +102,6 @@ struct dry_run_context_impl {
     template <typename T>
     T sum(T value) const { return value * num_ranks_; }
 
-    template <typename T>
-    std::vector<T> gather(T value, int) const {
-        return std::vector<T>(num_ranks_, value);
-    }
-
     void barrier() const {}
 
     std::string name() const { return "dryrun"; }
diff --git a/arbor/communication/mpi.hpp b/arbor/communication/mpi.hpp
index f07412af6c1a548fb7b7ae7427963f1f913041b5..972e95e7a3df9f006dd56e206271944f679ae84d 100644
--- a/arbor/communication/mpi.hpp
+++ b/arbor/communication/mpi.hpp
@@ -12,6 +12,7 @@
 
 #include "communication/gathered_vector.hpp"
 #include "profile/profiler_macro.hpp"
+#include "util/rangeutil.hpp"
 #include "util/partition.hpp"
 
 namespace arb {
@@ -144,6 +145,47 @@ std::vector<T> gather_all(const std::vector<T>& values, MPI_Comm comm) {
     return buffer;
 }
 
+inline std::vector<std::string> gather_all(const std::vector<std::string>& values, MPI_Comm comm) {
+    using traits = mpi_traits<char>;
+    std::vector<int> counts_individual, counts_total, displs_individual, displs_total;
+
+    // vector of individual string sizes
+    std::vector<int> individual_sizes(values.size());
+    std::transform(values.begin(), values.end(), individual_sizes.begin(), [](const std::string& val){return int(val.size());});
+
+    counts_individual = gather_all(individual_sizes, comm);
+    counts_total      = gather_all(util::sum(individual_sizes, 0), comm);
+
+    util::make_partition(displs_total, counts_total);
+    std::vector<char> buffer(displs_total.back());
+
+    // Concatenate string data
+    std::string values_concat;
+    for (const auto& v: values) {
+        values_concat += v;
+    }
+
+    // Cast to ptr
+    // const_cast required for MPI implementations that don't use const* in
+    // their interfaces.
+    std::string::value_type* ptr = const_cast<std::string::value_type*>(values_concat.data());
+    MPI_OR_THROW(MPI_Allgatherv,
+                 ptr, counts_total[rank(comm)], traits::mpi_type(),  // send buffer
+                 buffer.data(), counts_total.data(), displs_total.data(), traits::mpi_type(), // receive buffer
+                 comm);
+
+    // Construct the vector of strings
+    std::vector<std::string> string_buffer;
+    string_buffer.reserve(counts_individual.size());
+
+    auto displs_individual_part = util::make_partition(displs_individual, counts_individual);
+    for (const auto& str_range: displs_individual_part) {
+        string_buffer.emplace_back(buffer.begin()+str_range.first, buffer.begin()+str_range.second);
+    }
+
+    return string_buffer;
+}
+
 /// Gather all of a distributed vector
 /// Retains the meta data (i.e. vector partition)
 template <typename T>
diff --git a/arbor/communication/mpi_context.cpp b/arbor/communication/mpi_context.cpp
index 9d0bc30b64f6a22614db6c10662a4c2f7c63e219..fa1e57c1388ea926719f798caf642bb8284ed2fc 100644
--- a/arbor/communication/mpi_context.cpp
+++ b/arbor/communication/mpi_context.cpp
@@ -14,6 +14,7 @@
 
 #include "communication/mpi.hpp"
 #include "distributed_context.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
@@ -38,6 +39,28 @@ struct mpi_context_impl {
         return mpi::gather_all_with_partition(local_gids, comm_);
     }
 
+    cell_label_range gather_cell_label_range(const cell_label_range& local_ranges) const {
+        std::vector<cell_size_type> sizes;
+        std::vector<cell_tag_type> labels;
+        std::vector<lid_range> ranges;
+        sizes  = mpi::gather_all(local_ranges.sizes(), comm_);
+        labels = mpi::gather_all(local_ranges.labels(), comm_);
+        ranges = mpi::gather_all(local_ranges.ranges(), comm_);
+        return cell_label_range(sizes, labels, ranges);
+    }
+
+    cell_labels_and_gids gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        auto global_ranges = gather_cell_label_range(local_labels_and_gids.label_range);
+        auto global_gids = mpi::gather_all(local_labels_and_gids.gids, comm_);
+
+        return cell_labels_and_gids(global_ranges, global_gids);
+    }
+
+    template <typename T>
+    std::vector<T> gather(T value, int root) const {
+        return mpi::gather(value, root, comm_);
+    }
+
     std::string name() const { return "MPI"; }
     int id() const { return rank_; }
     int size() const { return size_; }
@@ -57,11 +80,6 @@ struct mpi_context_impl {
         return mpi::reduce(value, MPI_SUM, comm_);
     }
 
-    template <typename T>
-    std::vector<T> gather(T value, int root) const {
-        return mpi::gather(value, root, comm_);
-    }
-
     void barrier() const {
         mpi::barrier(comm_);
     }
diff --git a/arbor/connection.hpp b/arbor/connection.hpp
index 05be955685a27c19bfd0addb4cdc073ec3c19c96..0779fda646d2daab5dab89bc662ccf58165f83cd 100644
--- a/arbor/connection.hpp
+++ b/arbor/connection.hpp
@@ -10,11 +10,11 @@ namespace arb {
 class connection {
 public:
     connection() = default;
-    connection( cell_member_type src,
-                cell_lid_type dest,
-                float w,
-                float d,
-                cell_gid_type didx=cell_gid_type(-1)):
+    connection(cell_member_type src,
+               cell_lid_type dest,
+               float w,
+               float d,
+               cell_gid_type didx=cell_gid_type(-1)):
         source_(src),
         destination_(dest),
         weight_(w),
diff --git a/arbor/distributed_context.hpp b/arbor/distributed_context.hpp
index 1037e3f2098be2af748e93b949c6a99641fe749f..ab3ec6587478e991760c9f7d46ef80cbfdce2631 100644
--- a/arbor/distributed_context.hpp
+++ b/arbor/distributed_context.hpp
@@ -7,6 +7,7 @@
 #include <arbor/util/pp_util.hpp>
 
 #include "communication/gathered_vector.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
@@ -63,6 +64,18 @@ public:
         return impl_->gather_gids(local_gids);
     }
 
+    cell_label_range gather_cell_label_range(const cell_label_range& local_ranges) const {
+        return impl_->gather_cell_label_range(local_ranges);
+    }
+
+    cell_labels_and_gids gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        return impl_->gather_cell_labels_and_gids(local_labels_and_gids);
+    }
+
+    std::vector<std::string> gather(std::string value, int root) const {
+        return impl_->gather(value, root);
+    }
+
     int id() const {
         return impl_->id();
     }
@@ -81,23 +94,24 @@ public:
 
     ARB_PP_FOREACH(ARB_PUBLIC_COLLECTIVES_, ARB_COLLECTIVE_TYPES_);
 
-    std::vector<std::string> gather(std::string value, int root) const {
-        return impl_->gather(value, root);
-    }
-
 private:
     struct interface {
         virtual gathered_vector<arb::spike>
             gather_spikes(const spike_vector& local_spikes) const = 0;
         virtual gathered_vector<cell_gid_type>
             gather_gids(const gid_vector& local_gids) const = 0;
+        virtual cell_label_range
+            gather_cell_label_range(const cell_label_range& local_ranges) const = 0;
+        virtual cell_labels_and_gids
+            gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const = 0;
+        virtual std::vector<std::string>
+            gather(std::string value, int root) const = 0;
         virtual int id() const = 0;
         virtual int size() const = 0;
         virtual void barrier() const = 0;
         virtual std::string name() const = 0;
 
         ARB_PP_FOREACH(ARB_INTERFACE_COLLECTIVES_, ARB_COLLECTIVE_TYPES_)
-        virtual std::vector<std::string> gather(std::string value, int root) const = 0;
 
         virtual ~interface() {}
     };
@@ -111,10 +125,22 @@ private:
         gather_spikes(const spike_vector& local_spikes) const override {
             return wrapped.gather_spikes(local_spikes);
         }
-        virtual gathered_vector<cell_gid_type>
+        gathered_vector<cell_gid_type>
         gather_gids(const gid_vector& local_gids) const override {
             return wrapped.gather_gids(local_gids);
         }
+        cell_label_range
+        gather_cell_label_range(const cell_label_range& local_ranges) const override {
+            return wrapped.gather_cell_label_range(local_ranges);
+        }
+        cell_labels_and_gids
+        gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const override {
+            return wrapped.gather_cell_labels_and_gids(local_labels_and_gids);
+        }
+        std::vector<std::string>
+        gather(std::string value, int root) const override {
+            return wrapped.gather(value, root);
+        }
         int id() const override {
             return wrapped.id();
         }
@@ -130,10 +156,6 @@ private:
 
         ARB_PP_FOREACH(ARB_WRAP_COLLECTIVES_, ARB_COLLECTIVE_TYPES_)
 
-        std::vector<std::string> gather(std::string value, int root) const override {
-            return wrapped.gather(value, root);
-        }
-
         Impl wrapped;
     };
 
@@ -157,6 +179,18 @@ struct local_context {
                 {0u, static_cast<count_type>(local_gids.size())}
         );
     }
+    cell_label_range
+    gather_cell_label_range(const cell_label_range& local_ranges) const {
+        return local_ranges;
+    }
+    cell_labels_and_gids
+    gather_cell_labels_and_gids(const cell_labels_and_gids& local_labels_and_gids) const {
+        return local_labels_and_gids;
+    }
+    template <typename T>
+    std::vector<T> gather(T value, int) const {
+        return {std::move(value)};
+    }
 
     int id() const { return 0; }
 
@@ -171,9 +205,6 @@ struct local_context {
     template <typename T>
     T sum(T value) const { return value; }
 
-    template <typename T>
-    std::vector<T> gather(T value, int) const { return {std::move(value)}; }
-
     void barrier() const {}
 
     std::string name() const { return "local"; }
diff --git a/arbor/fvm_layout.cpp b/arbor/fvm_layout.cpp
index 1ec586457867920fc22ab065b920604fe99f204f..d8a4ea8b1640766006f5991df855ee3299c54aea 100644
--- a/arbor/fvm_layout.cpp
+++ b/arbor/fvm_layout.cpp
@@ -24,7 +24,6 @@
 
 namespace arb {
 
-using util::append;
 using util::assign;
 using util::assign_by;
 using util::count_along;
@@ -208,7 +207,7 @@ cv_geometry cv_geometry_from_ends(const cable_cell& cell, const locset& lset) {
         }
 
         sort(cables);
-        append(geom.cv_cables, std::move(cables));
+        util::append(geom.cv_cables, std::move(cables));
         geom.cv_cables_divs.push_back(geom.cv_cables.size());
         ++cv_index;
     }
@@ -289,6 +288,7 @@ namespace impl {
 // Merge CV geometry lists in-place.
 
 cv_geometry& append(cv_geometry& geom, const cv_geometry& right) {
+    using util::append;
     using impl::tail;
     using impl::append_offset;
     using impl::append_divs;
@@ -322,6 +322,8 @@ cv_geometry& append(cv_geometry& geom, const cv_geometry& right) {
 // Combine two fvm_cv_geometry groups in-place.
 
 fvm_cv_discretization& append(fvm_cv_discretization& dczn, const fvm_cv_discretization& right) {
+    using util::append;
+
     append(dczn.geometry, right.geometry);
 
     append(dczn.face_conductance, right.face_conductance);
@@ -705,6 +707,7 @@ fvm_voltage_interpolant fvm_axial_current(const cable_cell& cell, const fvm_cv_d
 // Only target numbers need to be shifted.
 
 fvm_mechanism_data& append(fvm_mechanism_data& left, const fvm_mechanism_data& right) {
+    using util::append;
     using impl::append_offset;
     using impl::append_divs;
 
diff --git a/arbor/fvm_lowered_cell.hpp b/arbor/fvm_lowered_cell.hpp
index 3e857f8a4d98e044abd096259ca5dacbc7035c4f..38550be249a459afcf3b04261b2ea29b245101d7 100644
--- a/arbor/fvm_lowered_cell.hpp
+++ b/arbor/fvm_lowered_cell.hpp
@@ -193,17 +193,34 @@ struct probe_association_map {
     }
 };
 
+struct fvm_initialization_data {
+    // Map from gid to integration domain id
+    std::vector<fvm_index_type> cell_to_intdom;
+
+    // Handles for accessing lowered cell.
+    std::vector<target_handle> target_handles;
+
+    // Maps probe ids to probe handles and tags.
+    probe_association_map probe_map;
+
+    // Structs required for {gid, label} to lid resolution
+    cell_label_range source_data;
+    cell_label_range target_data;
+    cell_label_range gap_junction_data;
+
+    // Maps storing number of sources/targets per cell.
+    std::unordered_map<cell_gid_type, fvm_size_type> num_sources;
+    std::unordered_map<cell_gid_type, fvm_size_type> num_targets;
+};
+
 // Common base class for FVM implementation on host or gpu back-end.
 
 struct fvm_lowered_cell {
     virtual void reset() = 0;
 
-    virtual void initialize(
+    virtual fvm_initialization_data initialize(
         const std::vector<cell_gid_type>& gids,
-        const recipe& rec,
-        std::vector<fvm_index_type>& cell_to_intdom,
-        std::vector<target_handle>& target_handles,
-        probe_association_map& probe_map) = 0;
+        const recipe& rec) = 0;
 
     virtual fvm_integration_result integrate(
         fvm_value_type tfinal,
diff --git a/arbor/fvm_lowered_cell_impl.hpp b/arbor/fvm_lowered_cell_impl.hpp
index 8bdc287daa9c2e87dff461d0757b43bd4694d598..d427ad39f236b3d9c207bd20d0ee4045d1c6b8fb 100644
--- a/arbor/fvm_lowered_cell_impl.hpp
+++ b/arbor/fvm_lowered_cell_impl.hpp
@@ -26,6 +26,7 @@
 #include "execution_context.hpp"
 #include "fvm_layout.hpp"
 #include "fvm_lowered_cell.hpp"
+#include "label_resolution.hpp"
 #include "matrix.hpp"
 #include "profile/profiler_macro.hpp"
 #include "sampler_map.hpp"
@@ -50,12 +51,9 @@ public:
 
     void reset() override;
 
-    void initialize(
+    fvm_initialization_data initialize(
         const std::vector<cell_gid_type>& gids,
-        const recipe& rec,
-        std::vector<fvm_index_type>& cell_to_intdom,
-        std::vector<target_handle>& target_handles,
-        probe_association_map& probe_map) override;
+        const recipe& rec) override;
 
     fvm_integration_result integrate(
         value_type tfinal,
@@ -66,6 +64,7 @@ public:
     std::vector<fvm_gap_junction> fvm_gap_junctions(
         const std::vector<cable_cell>& cells,
         const std::vector<cell_gid_type>& gids,
+        const cell_label_range& gj_data,
         const recipe& rec,
         const fvm_cv_discretization& D);
 
@@ -367,12 +366,9 @@ void fvm_lowered_cell_impl<Backend>::assert_voltage_bounded(fvm_value_type bound
 }
 
 template <typename Backend>
-void fvm_lowered_cell_impl<Backend>::initialize(
+fvm_initialization_data fvm_lowered_cell_impl<Backend>::initialize(
     const std::vector<cell_gid_type>& gids,
-    const recipe& rec,
-    std::vector<fvm_index_type>& cell_to_intdom,
-    std::vector<target_handle>& target_handles,
-    probe_association_map& probe_map)
+    const recipe& rec)
 {
     using std::any_cast;
     using util::count_along;
@@ -380,6 +376,8 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     using util::value_by_key;
     using util::keys;
 
+    fvm_initialization_data fvm_info;
+
     set_gpu();
 
     std::vector<cable_cell> cells;
@@ -397,6 +395,34 @@ void fvm_lowered_cell_impl<Backend>::initialize(
                }
            });
 
+    // Populate source, target and gap_junction data vectors.
+    for (auto i : util::make_span(ncell)) {
+        auto gid = gids[i];
+        const auto& c = cells[i];
+
+        fvm_info.source_data.add_cell();
+        fvm_info.target_data.add_cell();
+        fvm_info.gap_junction_data.add_cell();
+
+        unsigned count = 0;
+        for (const auto& [label, range]: c.detector_ranges()) {
+            fvm_info.source_data.add_label(label, range);
+            count+=(range.end - range.begin);
+        }
+        fvm_info.num_sources[gid] = count;
+
+        count = 0;
+        for (const auto& [label, range]: c.synapse_ranges()) {
+            fvm_info.target_data.add_label(label, range);
+            count+=(range.end - range.begin);
+        }
+        fvm_info.num_targets[gid] = count;
+
+        for (const auto& [label, range]: c.gap_junction_ranges()) {
+            fvm_info.gap_junction_data.add_label(label, range);
+        }
+    }
+
     cable_cell_global_properties global_props;
     try {
         std::any rec_props = rec.get_global_properties(cell_kind::cable);
@@ -412,26 +438,6 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     // (Throws cable_cell_error on failure.)
     check_global_properties(global_props);
 
-    // Sanity check recipe; find max num_sources and
-    // create a list of the global identifiers for the spike sources
-
-    std::vector<fvm_size_type> nsources;
-    for (auto cell_idx: make_span(ncell)) {
-        cell_gid_type gid = gids[cell_idx];
-        auto& cell = cells[cell_idx];
-
-        auto num_sources = rec.num_sources(gid);
-        nsources.push_back(num_sources);
-
-        if (num_sources != cell.detectors().size()) {
-            throw arb::bad_source_description(gid, num_sources, cell.detectors().size());
-        }
-        auto cell_targets = util::sum_by(cell.synapses(), [](auto& syn) { return syn.second.size(); });
-        if (rec.num_targets(gid) > cell_targets) {
-            throw arb::bad_target_description(gid, rec.num_targets(gid), cell_targets);
-        }
-    }
-
     const mechanism_catalogue* catalogue = global_props.catalogue;
 
     // Mechanism instantiator helper.
@@ -443,7 +449,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     check_voltage_mV_ = global_props.membrane_voltage_limit_mV;
 
-    auto nintdom = fvm_intdom(rec, gids, cell_to_intdom);
+    auto nintdom = fvm_intdom(rec, gids, fvm_info.cell_to_intdom);
 
     // Discretize cells, build matrix.
 
@@ -451,11 +457,11 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     std::vector<index_type> cv_to_intdom(D.size());
     std::transform(D.geometry.cv_to_cell.begin(), D.geometry.cv_to_cell.end(), cv_to_intdom.begin(),
-                   [&cell_to_intdom](index_type i){ return cell_to_intdom[i]; });
+                   [&fvm_info](index_type i){ return fvm_info.cell_to_intdom[i]; });
 
     arb_assert(D.n_cell() == ncell);
     matrix_ = matrix<backend>(D.geometry.cv_parent, D.geometry.cell_cv_divs,
-                              D.cv_capacitance, D.face_conductance, D.cv_area, cell_to_intdom);
+                              D.cv_capacitance, D.face_conductance, D.cv_area, fvm_info.cell_to_intdom);
     sample_events_ = sample_event_stream(nintdom);
 
     // Discretize mechanism data.
@@ -464,16 +470,20 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     // Discretize and build gap junction info.
 
-    auto gj_vector = fvm_gap_junctions(cells, gids, rec, D);
+    auto gj_vector = fvm_gap_junctions(cells, gids, fvm_info.gap_junction_data, rec, D);
 
     // Fill src_to_spike and cv_to_cell vectors only if mechanisms with post_events implemented are present.
     post_events_ = mech_data.post_events;
-    auto max_detector = post_events_ ? util::max_value(nsources) : 0;
+    auto max_detector = 0;
+    if (post_events_) {
+        auto it = util::max_element_by(fvm_info.num_sources, [](auto elem) {return util::second(elem);});
+        max_detector = it->second;
+    }
     std::vector<fvm_index_type> src_to_spike, cv_to_cell;
 
     if (post_events_) {
         for (auto cell_idx: make_span(ncell)) {
-            for (auto lid: make_span(nsources[cell_idx])) {
+            for (auto lid: make_span(fvm_info.num_sources[gids[cell_idx]])) {
                 src_to_spike.push_back(cell_idx * max_detector + lid);
             }
         }
@@ -510,7 +520,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
         state_->configure_stimulus(mech_data.stimuli);
     }
 
-    target_handles.resize(mech_data.n_target);
+    fvm_info.target_handles.resize(mech_data.n_target);
 
     // Keep track of mechanisms by name for probe lookup.
     std::unordered_map<std::string, mechanism*> mechptr_by_name;
@@ -545,11 +555,11 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
                 target_handle handle(mech_id, i, cv_to_intdom[cv]);
                 if (config.multiplicity.empty()) {
-                    target_handles[config.target[i]] = handle;
+                    fvm_info.target_handles[config.target[i]] = handle;
                 }
                 else {
                     for (auto j: make_span(multiplicity_part[i])) {
-                        target_handles[config.target[j]] = handle;
+                        fvm_info.target_handles[config.target[j]] = handle;
                     }
                 }
             }
@@ -601,14 +611,14 @@ void fvm_lowered_cell_impl<Backend>::initialize(
         for (cell_lid_type i: count_along(rec_probes)) {
             probe_info& pi = rec_probes[i];
             resolve_probe_address(probe_data, cells, cell_idx, std::move(pi.address),
-                D, mech_data, target_handles, mechptr_by_name);
+                D, mech_data, fvm_info.target_handles, mechptr_by_name);
 
             if (!probe_data.empty()) {
                 cell_member_type probe_id{gid, i};
-                probe_map.tag[probe_id] = pi.tag;
+                fvm_info.probe_map.tag[probe_id] = pi.tag;
 
                 for (auto& data: probe_data) {
-                    probe_map.data.insert({probe_id, std::move(data)});
+                    fvm_info.probe_map.data.insert({probe_id, std::move(data)});
                 }
             }
         }
@@ -617,6 +627,8 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     threshold_watcher_ = backend::voltage_watcher(*state_, detector_cv, detector_threshold, context_);
 
     reset();
+
+    return fvm_info;
 }
 
 // Get vector of gap_junctions
@@ -624,39 +636,40 @@ template <typename Backend>
 std::vector<fvm_gap_junction> fvm_lowered_cell_impl<Backend>::fvm_gap_junctions(
         const std::vector<cable_cell>& cells,
         const std::vector<cell_gid_type>& gids,
+        const cell_label_range& gap_junction_data,
         const recipe& rec, const fvm_cv_discretization& D) {
 
-    std::vector<fvm_gap_junction> v;
+    std::vector<fvm_gap_junction> gj_vec;
 
     std::unordered_map<cell_gid_type, std::vector<unsigned>> gid_to_cvs;
     for (auto cell_idx: util::make_span(0, D.n_cell())) {
-        if (!rec.num_gap_junction_sites(gids[cell_idx])) continue;
+        if (rec.gap_junctions_on(gids[cell_idx]).empty()) continue;
 
-        gid_to_cvs[gids[cell_idx]].reserve(rec.num_gap_junction_sites(gids[cell_idx]));
         const auto& cell_gj = cells[cell_idx].gap_junction_sites();
+        gid_to_cvs[gids[cell_idx]].reserve(cell_gj.size());
 
         for (auto gj : cell_gj) {
             auto cv = D.geometry.location_cv(cell_idx, gj.loc, cv_prefer::cv_nonempty);
             gid_to_cvs[gids[cell_idx]].push_back(cv);
         }
     }
-
+    label_resolution_map resolution_map({gap_junction_data, gids});
+    auto gj_resolver = resolver(&resolution_map);
     for (auto gid: gids) {
         auto gj_list = rec.gap_junctions_on(gid);
-        for (auto g: gj_list) {
-            if (g.local >= gid_to_cvs[gid].size()) {
-                throw arb::bad_gj_connection_lid(gid, {gid, g.local});
+        for (const auto& g: gj_list) {
+            if (g.local.policy != lid_selection_policy::assert_univalent) {
+                throw gj_unsupported_lid_selection_policy(gid, g.local.tag);
             }
-            if (g.peer.index >= gid_to_cvs[g.peer.gid].size()) {
-                throw arb::bad_gj_connection_lid(gid, g.peer);
+            if (g.peer.label.policy != lid_selection_policy::assert_univalent) {
+                throw gj_unsupported_lid_selection_policy(g.peer.gid, g.peer.label.tag);
             }
-            auto cv0 = gid_to_cvs[gid][g.local];
-            auto cv1 = gid_to_cvs[g.peer.gid][g.peer.index];
-            v.push_back(fvm_gap_junction(std::make_pair(cv0, cv1), g.ggap * 1e3 / D.cv_area[cv0]));
+            auto cv_local = gid_to_cvs[gid][gj_resolver.resolve({gid, g.local})];
+            auto cv_peer = gid_to_cvs[g.peer.gid][gj_resolver.resolve(g.peer)];
+            gj_vec.emplace_back(fvm_gap_junction(std::make_pair(cv_local, cv_peer), g.ggap * 1e3 / D.cv_area[cv_local]));
         }
     }
-
-    return v;
+    return gj_vec;
 }
 
 template <typename Backend>
diff --git a/arbor/include/arbor/arbexcept.hpp b/arbor/include/arbor/arbexcept.hpp
index 2cc0c5ed2d9c872e5bea074ceffa7f2daf82fada..2ba55753b8468e5a6e320030b4007736408e4c12 100644
--- a/arbor/include/arbor/arbexcept.hpp
+++ b/arbor/include/arbor/arbexcept.hpp
@@ -42,43 +42,16 @@ struct bad_cell_description: arbor_exception {
     cell_kind kind;
 };
 
-struct bad_target_description: arbor_exception {
-    bad_target_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val);
-    cell_gid_type gid;
-    cell_size_type rec_val, cell_val;
-};
-
-struct bad_source_description: arbor_exception {
-    bad_source_description(cell_gid_type gid, cell_size_type rec_val, cell_size_type cell_val);
-    cell_gid_type gid;
-    cell_size_type rec_val, cell_val;
-};
-
 struct bad_connection_source_gid: arbor_exception {
     bad_connection_source_gid(cell_gid_type gid, cell_gid_type src_gid, cell_size_type num_cells);
     cell_gid_type gid, src_gid;
     cell_size_type num_cells;
 };
 
-struct bad_connection_source_lid: arbor_exception {
-    bad_connection_source_lid(cell_gid_type gid, cell_lid_type src_lid, cell_size_type num_sources);
-    cell_gid_type gid;
-    cell_lid_type src_lid;
-    cell_size_type num_sources;
-};
-
-struct bad_connection_target_lid: arbor_exception {
-    bad_connection_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets);
-    cell_gid_type gid;
-    cell_lid_type tgt_lid;
-    cell_size_type num_targets;
-};
-
-struct bad_event_generator_target_lid: arbor_exception {
-    bad_event_generator_target_lid(cell_gid_type gid, cell_lid_type tgt_lid, cell_size_type num_targets);
+struct bad_connection_label: arbor_exception {
+    bad_connection_label(cell_gid_type gid, const cell_tag_type& label, const std::string& msg);
     cell_gid_type gid;
-    cell_lid_type tgt_lid;
-    cell_size_type num_targets;
+    cell_tag_type label;
 };
 
 struct bad_global_property: arbor_exception {
@@ -96,10 +69,10 @@ struct gj_kind_mismatch: arbor_exception {
     cell_gid_type gid_0, gid_1;
 };
 
-struct bad_gj_connection_lid: arbor_exception {
-    bad_gj_connection_lid(cell_gid_type gid, cell_member_type site);
+struct gj_unsupported_lid_selection_policy: arbor_exception {
+    gj_unsupported_lid_selection_policy(cell_gid_type gid, cell_tag_type label);
     cell_gid_type gid;
-    cell_member_type site;
+    cell_tag_type label;
 };
 
 // Domain decomposition errors:
diff --git a/arbor/include/arbor/benchmark_cell.hpp b/arbor/include/arbor/benchmark_cell.hpp
index 4479502764e52c3c26f4bd4d97dfadd6e04dcc31..49d93d9f4f8e5d37d8d0e73284e406e909f67a72 100644
--- a/arbor/include/arbor/benchmark_cell.hpp
+++ b/arbor/include/arbor/benchmark_cell.hpp
@@ -8,12 +8,19 @@ namespace arb {
 // recipe::cell_kind(gid) returning cell_kind::benchmark
 
 struct benchmark_cell {
+    cell_tag_type source; // Label of source.
+    cell_tag_type target; // Label of target.
+
     // Describes the time points at which spikes are to be generated.
     schedule time_sequence;
 
     // Time taken in ms to advance the cell one ms of simulation time.
     // If equal to 1, then a single cell can be advanced in realtime 
     double realtime_ratio;
+
+    benchmark_cell() = delete;
+    benchmark_cell(cell_tag_type source, cell_tag_type target, schedule seq, double ratio):
+        source(source), target(target), time_sequence(seq), realtime_ratio(ratio) {};
 };
 
 } // namespace arb
diff --git a/arbor/include/arbor/cable_cell.hpp b/arbor/include/arbor/cable_cell.hpp
index cf267d79738c0c379161694c69a1e6adb9208acc..2eb12914c10601b624242a620b0b0a3a666631d6 100644
--- a/arbor/include/arbor/cable_cell.hpp
+++ b/arbor/include/arbor/cable_cell.hpp
@@ -21,16 +21,6 @@
 
 namespace arb {
 
-// Pair of indexes that describe range of local indices.
-// Returned by cable_cell::place() calls, so that the caller can
-// refer to targets, detectors, etc on the cell.
-struct lid_range {
-    cell_lid_type begin;
-    cell_lid_type end;
-    lid_range(cell_lid_type b, cell_lid_type e):
-        begin(b), end(e) {}
-};
-
 // `cable_sample_range` is simply a pair of `const double*` pointers describing the sequence
 // of double values associated with the cell-wide sample.
 
@@ -299,9 +289,10 @@ public:
     // The default parameter and ion settings on the cell.
     const cable_cell_parameter_set& default_parameters() const;
 
-    // The range of lids assigned to the items with placement index idx, where
-    // the placement index is the value returned by calling decor::place().
-    lid_range placed_lid_range(unsigned idx) const;
+    // The labeled lid_ranges of sources, targets and gap_junctions on the cell;
+    const std::unordered_multimap<cell_tag_type, lid_range>& detector_ranges() const;
+    const std::unordered_multimap<cell_tag_type, lid_range>& synapse_ranges() const;
+    const std::unordered_multimap<cell_tag_type, lid_range>& gap_junction_ranges() const;
 
 private:
     std::unique_ptr<cable_cell_impl, void (*)(cable_cell_impl*)> impl_;
diff --git a/arbor/include/arbor/cable_cell_param.hpp b/arbor/include/arbor/cable_cell_param.hpp
index 3c53621b4bdb3d7422fcc898544cc0593c340fc4..af7e6671ce1430359f5b978138edc25698b0d8cb 100644
--- a/arbor/include/arbor/cable_cell_param.hpp
+++ b/arbor/include/arbor/cable_cell_param.hpp
@@ -247,7 +247,7 @@ struct cable_cell_parameter_set {
 // are to be applied to a morphology in a cable_cell.
 class decor {
     std::vector<std::pair<region, paintable>> paintings_;
-    std::vector<std::pair<locset, placeable>> placements_;
+    std::vector<std::tuple<locset, placeable, cell_tag_type>> placements_;
     cable_cell_parameter_set defaults_;
 
 public:
@@ -256,7 +256,7 @@ public:
     const auto& defaults()   const {return defaults_;   }
 
     void paint(region, paintable);
-    unsigned place(locset, placeable);
+    void place(locset, placeable, cell_tag_type);
     void set_default(defaultable);
 };
 
diff --git a/arbor/include/arbor/common_types.hpp b/arbor/include/arbor/common_types.hpp
index fb7a91e8b83bf2daa64fc1453cf8fffec7f8d427..e9bacd12a9c459a1a8aea6d851863a7ce305fb75 100644
--- a/arbor/include/arbor/common_types.hpp
+++ b/arbor/include/arbor/common_types.hpp
@@ -10,6 +10,7 @@
 #include <functional>
 #include <limits>
 #include <iosfwd>
+#include <string>
 #include <type_traits>
 
 #include <arbor/util/lexcmp_def.hpp>
@@ -32,6 +33,9 @@ using cell_size_type = std::make_unsigned_t<cell_gid_type>;
 
 using cell_lid_type = std::uint32_t;
 
+// Local labels for items within a particular cell-local collection
+using cell_tag_type = std::string;
+
 // For counts of cell-local data.
 
 using cell_local_size_type = std::make_unsigned_t<cell_lid_type>;
@@ -51,7 +55,48 @@ struct cell_member_type {
     cell_lid_type index;
 };
 
+// Pair of indexes that describe range of local indices.
+
+struct lid_range {
+    cell_lid_type begin;
+    cell_lid_type end;
+    lid_range() {};
+    lid_range(cell_lid_type b, cell_lid_type e):
+        begin(b), end(e) {}
+};
+
+// Policy for selecting a cell_lid_type from a range of possible values.
+
+enum class lid_selection_policy {
+    round_robin,
+    assert_univalent // throw if the range of possible lids is wider than 1
+};
+
+// For referring to a labeled placement on an unspecified cell.
+// The placement may be associated with multiple locations, the policy
+// is used to select a specific location.
+
+struct cell_local_label_type {
+    cell_tag_type tag;
+    lid_selection_policy policy;
+
+    cell_local_label_type(cell_tag_type tag, lid_selection_policy policy=lid_selection_policy::assert_univalent):
+        tag(std::move(tag)), policy(policy) {}
+};
+
+// For referring to a labeled placement on a cell identified by gid.
+
+struct cell_global_label_type {
+    cell_gid_type gid;
+    cell_local_label_type label;
+
+    cell_global_label_type(cell_gid_type gid, cell_local_label_type label): gid(gid), label(std::move(label)) {}
+    cell_global_label_type(cell_gid_type gid, cell_tag_type tag): gid(gid), label(std::move(tag)) {}
+    cell_global_label_type(cell_gid_type gid, cell_tag_type tag, lid_selection_policy policy): gid(gid), label(std::move(tag), policy) {}
+};
+
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(cell_member_type,(a.gid,a.index),(b.gid,b.index))
+ARB_DEFINE_LEXICOGRAPHIC_ORDERING(lid_range,(a.begin, a.end),(b.begin,b.end))
 
 // For storing time values [ms]
 
@@ -91,6 +136,7 @@ enum class binning_kind {
     following, // => round times down to previous event if within binning interval.
 };
 
+std::ostream& operator<<(std::ostream& o, lid_selection_policy m);
 std::ostream& operator<<(std::ostream& o, cell_member_type m);
 std::ostream& operator<<(std::ostream& o, cell_kind k);
 std::ostream& operator<<(std::ostream& o, backend_kind k);
diff --git a/arbor/include/arbor/event_generator.hpp b/arbor/include/arbor/event_generator.hpp
index 95a524522ef695bd736c36d0d341de7e3483d08e..42e5ac40ef42511c29f284f5e9c20d837d23c32c 100644
--- a/arbor/include/arbor/event_generator.hpp
+++ b/arbor/include/arbor/event_generator.hpp
@@ -32,9 +32,11 @@ namespace arb {
 //     Provide a non-owning view on to the events in the time interval
 //     [to, from).
 //
-// `std::vector<cell_member_type> targets()`
+// `void resolve_label(resolution_function)`
 //
-//     Return a vector of all the targets of the generator.
+//     event_generators are constructed on cable_local_label_types comprising
+//     a label and a selection policy. These labels need to be resolved to a
+//     specific cell_lid_type. This is done using a resolution_function.
 //
 // The `event_seq` type is a pair of `spike_event` pointers that
 // provide a view onto an internally-maintained contiguous sequence
@@ -49,16 +51,21 @@ namespace arb {
 //
 // `event_generator` objects have value semantics, and use type erasure
 // to wrap implementation details. An `event_generator` can be constructed
-// from an onbject of an implementation class Impl that is copy-constructible
+// from an object of an implementation class Impl that is copy-constructable
 // and otherwise provides `reset` and `events` methods following the
 // API described above.
 //
 // Some pre-defined event generators are included:
 //  - `empty_generator`: produces no events
-//  - `schedule_generator`: events to a fixed target according to a time schedule
+//  - `schedule_generator`: produces events according to a time schedule.
+//    A target is selected using a label resolution function for every generated
+//    event.
+//  - `explicit_generator`: is constructed from a vector of {label, time, weight}
+//    objects. Explicit targets are generated from the labels using a resolution
+//    function before the first call to the `events` method.
 
 using event_seq = std::pair<const spike_event*, const spike_event*>;
-
+using resolution_function = std::function<cell_lid_type(const cell_local_label_type&)>;
 
 // The simplest possible generator that generates no events.
 // Declared ahead of event_generator so that it can be used as the default
@@ -68,9 +75,7 @@ struct empty_generator {
     event_seq events(time_type, time_type) {
         return {nullptr, nullptr};
     }
-    std::vector<cell_lid_type> targets() {
-        return {};
-    };
+    void resolve_label(resolution_function) {}
 };
 
 class event_generator {
@@ -102,15 +107,15 @@ public:
         return impl_->events(t0, t1);
     }
 
-    std::vector<cell_lid_type> targets() const {
-        return impl_->targets();
+    void resolve_label(resolution_function label_resolver) {
+        impl_->resolve_label(std::move(label_resolver));
     }
 
 private:
     struct interface {
         virtual void reset() = 0;
+        virtual void resolve_label(resolution_function) = 0;
         virtual event_seq events(time_type, time_type) = 0;
-        virtual std::vector<cell_lid_type> targets() = 0;
         virtual std::unique_ptr<interface> clone() = 0;
         virtual ~interface() {}
     };
@@ -126,14 +131,14 @@ private:
             return wrapped.events(t0, t1);
         }
 
-        std::vector<cell_lid_type> targets() override {
-            return wrapped.targets();
-        }
-
         void reset() override {
             wrapped.reset();
         }
 
+        void resolve_label(resolution_function label_resolver) override {
+            wrapped.resolve_label(std::move(label_resolver));
+        }
+
         std::unique_ptr<interface> clone() override {
             return std::unique_ptr<interface>(new wrap<Impl>(wrapped));
         }
@@ -148,10 +153,14 @@ private:
 // a provided time schedule.
 
 struct schedule_generator {
-    schedule_generator(cell_lid_type target, float weight, schedule sched):
-        target_(target), weight_(weight), sched_(std::move(sched))
+    schedule_generator(cell_local_label_type target, float weight, schedule sched):
+        target_(std::move(target)), weight_(weight), sched_(std::move(sched))
     {}
 
+    void resolve_label(resolution_function label_resolver) {
+        label_resolver_ = std::move(label_resolver);
+    }
+
     void reset() {
         sched_.reset();
     }
@@ -163,19 +172,16 @@ struct schedule_generator {
         events_.reserve(ts.second-ts.first);
 
         for (auto i = ts.first; i!=ts.second; ++i) {
-            events_.push_back(spike_event{target_, *i, weight_});
+            events_.push_back(spike_event{label_resolver_(target_), *i, weight_});
         }
 
         return {events_.data(), events_.data()+events_.size()};
     }
 
-    std::vector<cell_lid_type> targets() {
-        return {target_};
-    }
-
 private:
     pse_vector events_;
-    cell_lid_type target_;
+    cell_local_label_type target_;
+    resolution_function label_resolver_;
     float weight_;
     schedule sched_;
 };
@@ -183,43 +189,50 @@ private:
 // Generate events at integer multiples of dt that lie between tstart and tstop.
 
 inline event_generator regular_generator(
-    cell_lid_type target,
+    cell_local_label_type target,
     float weight,
     time_type tstart,
     time_type dt,
     time_type tstop=terminal_time)
 {
-    return schedule_generator(target, weight, regular_schedule(tstart, dt, tstop));
+    return schedule_generator(std::move(target), weight, regular_schedule(tstart, dt, tstop));
 }
 
 template <typename RNG>
 inline event_generator poisson_generator(
-    cell_lid_type target,
+    cell_local_label_type target,
     float weight,
     time_type tstart,
     time_type rate_kHz,
     const RNG& rng)
 {
-    return schedule_generator(target, weight, poisson_schedule(tstart, rate_kHz, rng));
+    return schedule_generator(std::move(target), weight, poisson_schedule(tstart, rate_kHz, rng));
 }
 
 
 // Generate events from a predefined sorted event sequence.
 
 struct explicit_generator {
+    struct labeled_synapse_event {
+        cell_local_label_type label;
+        time_type time;
+        float weight;
+    };
+
+    using lse_vector = std::vector<labeled_synapse_event>;
+
     explicit_generator() = default;
     explicit_generator(const explicit_generator&) = default;
     explicit_generator(explicit_generator&&) = default;
 
-    template <typename Seq>
-    explicit_generator(const Seq& events):
-        start_index_(0)
-    {
-        using std::begin;
-        using std::end;
+    explicit_generator(const lse_vector& events):
+        input_events_(events), start_index_(0) {}
 
-        events_ = pse_vector(begin(events), end(events));
-        arb_assert(std::is_sorted(events_.begin(), events_.end()));
+    void resolve_label(resolution_function label_resolver) {
+        for (const auto& e: input_events_) {
+            events_.push_back({label_resolver(e.label), e.time, e.weight});
+        }
+        std::sort(events_.begin(), events_.end());
     }
 
     void reset() {
@@ -237,13 +250,8 @@ struct explicit_generator {
         return {lb, ub};
     }
 
-    std::vector<cell_lid_type> targets() {
-        std::vector<cell_lid_type> tgts;
-        std::transform(events_.begin(), events_.end(), std::back_inserter(tgts), [](auto&& e){ return e.target;});
-        return tgts;
-    }
-
 private:
+    lse_vector input_events_;
     pse_vector events_;
     std::size_t start_index_ = 0;
 };
diff --git a/arbor/include/arbor/lif_cell.hpp b/arbor/include/arbor/lif_cell.hpp
index 68d694caa9b41f52c4140122f79cb4eb01663302..35c2fa98809bf6c0c2c0c4987132fc09b2c129af 100644
--- a/arbor/include/arbor/lif_cell.hpp
+++ b/arbor/include/arbor/lif_cell.hpp
@@ -1,9 +1,14 @@
 #pragma once
 
+#include <arbor/common_types.hpp>
+
 namespace arb {
 
 // Model parameters of leaky integrate and fire neuron model.
 struct lif_cell {
+    cell_tag_type source; // Label of source.
+    cell_tag_type target; // Label of target.
+
     // Neuronal parameters.
     double tau_m = 10;    // Membrane potential decaying constant [ms].
     double V_th = 10;     // Firing threshold [mV].
@@ -12,6 +17,9 @@ struct lif_cell {
     double V_m = E_L;     // Initial value of the Membrane potential [mV].
     double V_reset = E_L; // Reset potential [mV].
     double t_ref = 2;     // Refractory period [ms].
+
+    lif_cell() = delete;
+    lif_cell(cell_tag_type source, cell_tag_type  target): source(std::move(source)), target(std::move(target)) {}
 };
 
 } // namespace arb
diff --git a/arbor/include/arbor/recipe.hpp b/arbor/include/arbor/recipe.hpp
index 63fe1a215d446acb1201644fc7cbb468adc53ea3..76be9ddc3c048b091c8eacbd4041b763c7e35907 100644
--- a/arbor/include/arbor/recipe.hpp
+++ b/arbor/include/arbor/recipe.hpp
@@ -41,24 +41,24 @@ struct cell_connection {
     // Connection end-points are represented by pairs
     // (cell index, source/target index on cell).
 
-    cell_member_type source;
-    cell_lid_type dest;
+    cell_global_label_type source;
+    cell_local_label_type dest;
 
     float weight;
     float delay;
 
-    cell_connection(cell_member_type src, cell_lid_type dst, float w, float d):
-        source(src), dest(dst), weight(w), delay(d)
-    {}
+    cell_connection(cell_global_label_type src, cell_local_label_type dst, float w, float d):
+        source(std::move(src)), dest(std::move(dst)), weight(w), delay(d) {}
 };
 
 struct gap_junction_connection {
-    cell_member_type peer;
-    cell_lid_type local;
+    cell_global_label_type peer;
+    cell_local_label_type local;
+
     double ggap;
 
-    gap_junction_connection(cell_member_type peer, cell_lid_type local, double g):
-        peer(peer), local(local), ggap(g) {}
+    gap_junction_connection(cell_global_label_type peer, cell_local_label_type local, double g):
+        peer(std::move(peer)), local(std::move(local)), ggap(g) {}
 };
 
 class recipe {
@@ -69,11 +69,6 @@ public:
     virtual util::unique_any get_cell_description(cell_gid_type gid) const = 0;
     virtual cell_kind get_cell_kind(cell_gid_type) const = 0;
 
-    virtual cell_size_type num_sources(cell_gid_type) const { return 0; }
-    virtual cell_size_type num_targets(cell_gid_type) const { return 0; }
-    virtual cell_size_type num_gap_junction_sites(cell_gid_type gid)  const {
-        return gap_junctions_on(gid).size();
-    }
     virtual std::vector<event_generator> event_generators(cell_gid_type) const {
         return {};
     }
diff --git a/arbor/include/arbor/spike_source_cell.hpp b/arbor/include/arbor/spike_source_cell.hpp
index 0e9bbf17dc4ab8fb1af70d15b915123ea178a20a..9d04282730a1b3c888ce5b2a986c383d7c6f8f7e 100644
--- a/arbor/include/arbor/spike_source_cell.hpp
+++ b/arbor/include/arbor/spike_source_cell.hpp
@@ -1,5 +1,6 @@
 #pragma once
 
+#include <arbor/common_types.hpp>
 #include <arbor/schedule.hpp>
 
 namespace arb {
@@ -8,7 +9,11 @@ namespace arb {
 // recipe::cell_kind(gid) returning cell_kind::spike_source
 
 struct spike_source_cell {
+    cell_tag_type source; // Label of source.
     schedule seq;
+
+    spike_source_cell() = delete;
+    spike_source_cell(cell_tag_type source, schedule seq): source(std::move(source)), seq(std::move(seq)) {};
 };
 
 } // namespace arb
diff --git a/arbor/include/arbor/symmetric_recipe.hpp b/arbor/include/arbor/symmetric_recipe.hpp
index 81c8336e77e48ef2fbbdfa6c6cce195cd8bfc9d4..7fdf873221a1e2bf9af162b210ad18e44a416395 100644
--- a/arbor/include/arbor/symmetric_recipe.hpp
+++ b/arbor/include/arbor/symmetric_recipe.hpp
@@ -31,10 +31,6 @@ public:
 
     cell_kind get_cell_kind(cell_gid_type i) const override;
 
-    cell_size_type num_sources(cell_gid_type i) const override;
-
-    cell_size_type num_targets(cell_gid_type i) const override;
-
     std::vector<event_generator> event_generators(cell_gid_type i) const override;
 
     std::vector<cell_connection> connections_on(cell_gid_type i) const override;
diff --git a/arbor/label_resolution.cpp b/arbor/label_resolution.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..43c9a688f2031acf80a52b5a4db4a121f84262e4
--- /dev/null
+++ b/arbor/label_resolution.cpp
@@ -0,0 +1,167 @@
+#include <iterator>
+#include <vector>
+
+#include <arbor/assert.hpp>
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/util/expected.hpp>
+
+#include "label_resolution.hpp"
+#include "util/partition.hpp"
+#include "util/rangeutil.hpp"
+#include "util/span.hpp"
+
+namespace arb {
+
+// cell_label_range methods
+cell_label_range::cell_label_range(std::vector<cell_size_type> size_vec,
+                                   std::vector<cell_tag_type> label_vec,
+                                   std::vector<lid_range> range_vec):
+    sizes_(std::move(size_vec)), labels_(std::move(label_vec)), ranges_(std::move(range_vec))
+{
+    arb_assert(check_invariant());
+};
+
+void cell_label_range::add_cell() {
+    sizes_.push_back(0);
+}
+
+void cell_label_range::add_label(cell_tag_type label, lid_range range) {
+    if (sizes_.empty()) throw arbor_internal_error("adding label to cell_label_range without cell");
+    ++sizes_.back();
+    labels_.push_back(std::move(label));
+    ranges_.push_back(std::move(range));
+}
+
+void cell_label_range::append(cell_label_range other) {
+    using std::make_move_iterator;
+    sizes_.insert(sizes_.end(), make_move_iterator(other.sizes_.begin()), make_move_iterator(other.sizes_.end()));
+    labels_.insert(labels_.end(), make_move_iterator(other.labels_.begin()), make_move_iterator(other.labels_.end()));
+    ranges_.insert(ranges_.end(), make_move_iterator(other.ranges_.begin()), make_move_iterator(other.ranges_.end()));
+}
+
+bool cell_label_range::check_invariant() const {
+    const cell_size_type count = std::accumulate(sizes_.begin(), sizes_.end(), cell_size_type(0));
+    return count==labels_.size() && count==ranges_.size();
+}
+
+// cell_labels_and_gids methods
+cell_labels_and_gids::cell_labels_and_gids(cell_label_range lr, std::vector<cell_gid_type> gid):
+    label_range(std::move(lr)), gids(std::move(gid))
+{
+    if (label_range.sizes().size()!=gids.size()) throw arbor_internal_error("cell_label_range and gid count mismatch");
+}
+
+void cell_labels_and_gids::append(cell_labels_and_gids other) {
+    label_range.append(other.label_range);
+    gids.insert(gids.end(), make_move_iterator(other.gids.begin()), make_move_iterator(other.gids.end()));
+}
+
+bool cell_labels_and_gids::check_invariant() const {
+    return label_range.check_invariant() && label_range.sizes().size()==gids.size();
+}
+
+// label_resolution_map methods
+cell_size_type label_resolution_map::range_set::size() const {
+    return ranges_partition.back();
+}
+
+lid_hopefully label_resolution_map::range_set::at(unsigned idx) const {
+    if (size() < 1) return util::unexpected("no valid lids");
+    auto part = util::partition_view(ranges_partition);
+    // Index of the range containing idx.
+    auto ridx = part.index(idx);
+
+    // First element of the range containing idx.
+    const auto& start = ranges.at(ridx).begin;
+
+    // Offset into the range containing idx.
+    const auto& range_part = part.at(ridx);
+    auto offset = idx - range_part.first;
+
+    return start + offset;
+}
+
+const label_resolution_map::range_set& label_resolution_map::at(const cell_gid_type& gid, const cell_tag_type& tag) const {
+    return map.at(gid).at(tag);
+}
+
+std::size_t label_resolution_map::count(const cell_gid_type& gid, const cell_tag_type& tag) const {
+    if (!map.count(gid)) return 0u;
+    return map.at(gid).count(tag);
+}
+
+label_resolution_map::label_resolution_map(const cell_labels_and_gids& clg) {
+    arb_assert(clg.label_range.check_invariant());
+    const auto& gids = clg.gids;
+    const auto& labels = clg.label_range.labels();
+    const auto& ranges = clg.label_range.ranges();
+    const auto& sizes = clg.label_range.sizes();
+
+    std::vector<cell_size_type> label_divs;
+    auto partn = util::make_partition(label_divs, sizes);
+    for (auto i: util::count_along(partn)) {
+        auto gid = gids[i];
+
+        std::unordered_map<cell_tag_type, range_set> m;
+        for (auto label_idx: util::make_span(partn[i])) {
+            const auto range = ranges[label_idx];
+            auto size = int(range.end - range.begin);
+            if (size < 0) {
+                throw arb::arbor_internal_error("label_resolution_map: invalid lid_range");
+            }
+            auto& range_set = m[labels[label_idx]];
+            range_set.ranges.push_back(range);
+            range_set.ranges_partition.push_back(range_set.ranges_partition.back() + size);
+        }
+        if (!map.insert({gid, std::move(m)}).second) {
+            throw arb::arbor_internal_error("label_resolution_map: duplicate gid");
+        }
+    }
+}
+// variant state methods
+lid_hopefully round_robin_state::update(const label_resolution_map::range_set& range_set) {
+    auto lid = range_set.at(state);
+    if (lid) state = (state+1) % range_set.size();
+    return lid;
+}
+
+lid_hopefully assert_univalent_state::update(const label_resolution_map::range_set& range_set) {
+    if (range_set.size() != 1) {
+        return util::unexpected("range is not univalent");
+    }
+    // Get the lid of the only element.
+    return range_set.at(0);
+}
+
+// resolver methods
+resolver::state_variant resolver::construct_state(lid_selection_policy pol) {
+    switch (pol) {
+    case lid_selection_policy::round_robin:
+        return round_robin_state();
+    case lid_selection_policy::assert_univalent:
+       return assert_univalent_state();
+    default: return assert_univalent_state();
+    }
+}
+
+cell_lid_type resolver::resolve(const cell_global_label_type& iden) {
+    if (!label_map_->count(iden.gid, iden.label.tag)) {
+        throw arb::bad_connection_label(iden.gid, iden.label.tag, "label does not exist");
+    }
+    const auto& range_set = label_map_->at(iden.gid, iden.label.tag);
+
+    // Construct state if if doesn't exist
+    if (!state_map_[iden.gid][iden.label.tag].count(iden.label.policy)) {
+        state_map_[iden.gid][iden.label.tag][iden.label.policy] = construct_state(iden.label.policy);
+    }
+
+    auto lid = std::visit([range_set](auto& state) { return state.update(range_set); }, state_map_[iden.gid][iden.label.tag][iden.label.policy]);
+    if (!lid) {
+        throw arb::bad_connection_label(iden.gid, iden.label.tag, lid.error());
+    }
+    return lid.value();
+}
+
+} // namespace arb
+
diff --git a/arbor/label_resolution.hpp b/arbor/label_resolution.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..5912672e13e2642c98693cdf7fe73978df3cb89c
--- /dev/null
+++ b/arbor/label_resolution.hpp
@@ -0,0 +1,114 @@
+#pragma once
+
+#include <unordered_map>
+#include <vector>
+
+#include <arbor/arbexcept.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/util/expected.hpp>
+
+#include "util/partition.hpp"
+
+namespace arb {
+
+using lid_hopefully = arb::util::expected<cell_lid_type, std::string>;
+
+// class containing the data required for {cell, label} to lid resolution.
+// `sizes` is a partitioning vector for associating a cell with a set of
+// (label, range) pairs in `labels`, `ranges`.
+// gids of the cells are unknown.
+class cell_label_range {
+public:
+    cell_label_range() = default;
+    cell_label_range(cell_label_range&&) = default;
+    cell_label_range(const cell_label_range&) = default;
+    cell_label_range& operator=(const cell_label_range&) = default;
+    cell_label_range& operator=(cell_label_range&&) = default;
+
+    cell_label_range(std::vector<cell_size_type> size_vec, std::vector<cell_tag_type> label_vec, std::vector<lid_range> range_vec);
+
+    void add_cell();
+
+    void add_label(cell_tag_type label, lid_range range);
+
+    void append(cell_label_range other);
+
+    bool check_invariant() const;
+
+    const auto& sizes() const { return sizes_; }
+    const auto& labels() const { return labels_; }
+    const auto& ranges() const { return ranges_; }
+
+
+private:
+    // The number of labels associated with each cell.
+    std::vector<cell_size_type> sizes_;
+
+    // The labels corresponding to each cell, partitioned according to sizes_.
+    std::vector<cell_tag_type> labels_;
+
+    // The lid_range corresponding to each label.
+    std::vector<lid_range> ranges_;
+};
+
+// Struct for associating each cell of `cell_label_range` with a gid.
+struct cell_labels_and_gids {
+    cell_labels_and_gids() = default;
+    cell_labels_and_gids(cell_label_range lr, std::vector<cell_gid_type> gids);
+
+    void append(cell_labels_and_gids other);
+
+    bool check_invariant() const;
+
+    cell_label_range label_range;
+    std::vector<cell_gid_type> gids;
+};
+
+// Class constructed from `cell_labels_and_ranges`:
+// Represents the information in the object in a more
+// structured manner for lid resolution in `resolver`
+class label_resolution_map {
+public:
+    struct range_set {
+        std::vector<lid_range> ranges;
+        std::vector<unsigned> ranges_partition = {0};
+        cell_size_type size() const;
+        lid_hopefully at(unsigned idx) const;
+    };
+
+    label_resolution_map() = delete;
+    explicit label_resolution_map(const cell_labels_and_gids&);
+
+    const range_set& at(const cell_gid_type& gid, const cell_tag_type& tag) const;
+    std::size_t count(const cell_gid_type& gid, const cell_tag_type& tag) const;
+
+private:
+    std::unordered_map<cell_gid_type, std::unordered_map<cell_tag_type, range_set>> map;
+};
+
+struct round_robin_state {
+    cell_size_type state = 0;
+    round_robin_state() : state(0) {};
+    round_robin_state(cell_lid_type state) : state(state) {};
+    lid_hopefully update(const label_resolution_map::range_set& range);
+};
+
+struct assert_univalent_state {
+    lid_hopefully update(const label_resolution_map::range_set& range);
+};
+
+// Struct used for resolving the lid of a (gid, label, lid_selection_policy) input.
+// Requires a `label_resolution_map` which stores the constant mapping of (gid, label) pairs to lid sets.
+struct resolver {
+    resolver(const label_resolution_map* label_map): label_map_(label_map) {}
+    cell_lid_type resolve(const cell_global_label_type& iden);
+
+private:
+    using state_variant = std::variant<round_robin_state, assert_univalent_state>;
+
+    state_variant construct_state(lid_selection_policy pol);
+
+    const label_resolution_map* label_map_;
+    std::unordered_map<cell_gid_type, std::unordered_map<cell_tag_type, std::unordered_map <lid_selection_policy, state_variant>>> state_map_;
+};
+} // namespace arb
diff --git a/arbor/lif_cell_group.cpp b/arbor/lif_cell_group.cpp
index d3f84f8ae7ccd2d1ddd2ffa2e8afec0b884ddaf2..99182f3e4d6877666cd814f500c28e42cde64157 100644
--- a/arbor/lif_cell_group.cpp
+++ b/arbor/lif_cell_group.cpp
@@ -1,7 +1,7 @@
 #include <arbor/arbexcept.hpp>
 
-#include <lif_cell_group.hpp>
-
+#include "label_resolution.hpp"
+#include "lif_cell_group.hpp"
 #include "profile/profiler_macro.hpp"
 #include "util/rangeutil.hpp"
 #include "util/span.hpp"
@@ -9,7 +9,7 @@
 using namespace arb;
 
 // Constructor containing gid of first cell in a group and a container of all cells.
-lif_cell_group::lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec):
+lif_cell_group::lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets):
     gids_(gids)
 {
     for (auto gid: gids_) {
@@ -26,6 +26,13 @@ lif_cell_group::lif_cell_group(const std::vector<cell_gid_type>& gids, const rec
     for (auto lid: util::make_span(gids_.size())) {
         cells_.push_back(util::any_cast<lif_cell>(rec.get_cell_description(gids_[lid])));
     }
+
+    for (const auto& c: cells_) {
+        cg_sources.add_cell();
+        cg_targets.add_cell();
+        cg_sources.add_label(c.source, {0, 1});
+        cg_targets.add_label(c.target, {0, 1});
+    }
 }
 
 cell_kind lif_cell_group::get_cell_kind() const {
@@ -112,4 +119,4 @@ void lif_cell_group::advance_cell(time_type tfinal, time_type dt, cell_gid_type
 
     // This is the last time a cell was updated.
     last_time_updated_[lid] = t;
-}
+}
\ No newline at end of file
diff --git a/arbor/lif_cell_group.hpp b/arbor/lif_cell_group.hpp
index 034f3e3452756ee69fa0487c89939fe2f39321dc..feda74c97808f5fcbb90eca20b959bf30406da7e 100644
--- a/arbor/lif_cell_group.hpp
+++ b/arbor/lif_cell_group.hpp
@@ -9,6 +9,7 @@
 #include <arbor/spike.hpp>
 
 #include "cell_group.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
@@ -19,7 +20,7 @@ public:
     lif_cell_group() = default;
 
     // Constructor containing gid of first cell in a group and a container of all cells.
-    lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec);
+    lif_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets);
 
     virtual cell_kind get_cell_kind() const override;
     virtual void reset() override;
diff --git a/arbor/mc_cell_group.cpp b/arbor/mc_cell_group.cpp
index 3a0a27ed0a26ee0ba564a2a23bbe41d573f415d0..78d2d791c120450eead105f7b643ee25c8ffeadd 100644
--- a/arbor/mc_cell_group.cpp
+++ b/arbor/mc_cell_group.cpp
@@ -15,6 +15,7 @@
 #include "cell_group.hpp"
 #include "event_binner.hpp"
 #include "fvm_lowered_cell.hpp"
+#include "label_resolution.hpp"
 #include "mc_cell_group.hpp"
 #include "profile/profiler_macro.hpp"
 #include "sampler_map.hpp"
@@ -29,7 +30,11 @@ namespace arb {
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(arb::target_handle,(a.mech_id,a.mech_index,a.intdom_index),(b.mech_id,b.mech_index,b.intdom_index))
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(arb::deliverable_event,(a.time,a.handle,a.weight),(b.time,b.handle,b.weight))
 
-mc_cell_group::mc_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, fvm_lowered_cell_ptr lowered):
+mc_cell_group::mc_cell_group(const std::vector<cell_gid_type>& gids,
+                             const recipe& rec,
+                             cell_label_range& cg_sources,
+                             cell_label_range& cg_targets,
+                             fvm_lowered_cell_ptr lowered):
     gids_(gids), lowered_(std::move(lowered))
 {
     // Default to no binning of events
@@ -40,20 +45,25 @@ mc_cell_group::mc_cell_group(const std::vector<cell_gid_type>& gids, const recip
         gid_index_map_[gids_[i]] = i;
     }
 
-    // Create lookup structure for target ids.
-    util::make_partition(target_handle_divisions_,
-            util::transform_view(gids_, [&rec](cell_gid_type i) { return rec.num_targets(i); }));
-    std::size_t n_targets = target_handle_divisions_.back();
+    // Construct cell implementation, retrieving handles and maps.
+    auto fvm_info = lowered_->initialize(gids_, rec);
+
+    // Propagate source and target ranges to the simulator object
+    cg_sources = std::move(fvm_info.source_data);
+    cg_targets = std::move(fvm_info.target_data);
 
-    // Pre-allocate space to store handles.
-    target_handles_.reserve(n_targets);
+    // Store consistent data from fvm_lowered_cell
+    target_handles_ = std::move(fvm_info.target_handles);
+    cell_to_intdom_ = std::move(fvm_info.cell_to_intdom);
+    probe_map_ = std::move(fvm_info.probe_map);
 
-    // Construct cell implementation, retrieving handles and maps. 
-    lowered_->initialize(gids_, rec, cell_to_intdom_, target_handles_, probe_map_);
+    // Create lookup structure for target ids.
+    util::make_partition(target_handle_divisions_,
+        util::transform_view(gids_, [&](cell_gid_type i) { return fvm_info.num_targets[i]; }));
 
     // Create a list of the global identifiers for the spike sources
     for (auto source_gid: gids_) {
-        for (cell_lid_type lid = 0; lid<rec.num_sources(source_gid); ++lid) {
+        for (cell_lid_type lid = 0; lid<fvm_info.num_sources[source_gid]; ++lid) {
             spike_sources_.push_back({source_gid, lid});
         }
     }
@@ -582,5 +592,4 @@ std::vector<probe_metadata> mc_cell_group::get_probe_metadata(cell_member_type p
 
     return result;
 }
-
 } // namespace arb
diff --git a/arbor/mc_cell_group.hpp b/arbor/mc_cell_group.hpp
index f93e4ebb582d73a80a18681c9efc434ac0486405..173aae9ae6681cfd4d08b90979d52de88a51595b 100644
--- a/arbor/mc_cell_group.hpp
+++ b/arbor/mc_cell_group.hpp
@@ -18,6 +18,7 @@
 #include "event_binner.hpp"
 #include "event_queue.hpp"
 #include "fvm_lowered_cell.hpp"
+#include "label_resolution.hpp"
 #include "sampler_map.hpp"
 
 namespace arb {
@@ -26,7 +27,11 @@ class mc_cell_group: public cell_group {
 public:
     mc_cell_group() = default;
 
-    mc_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, fvm_lowered_cell_ptr lowered);
+    mc_cell_group(const std::vector<cell_gid_type>& gids,
+                  const recipe& rec,
+                  cell_label_range& cg_sources,
+                  cell_label_range& cg_targets,
+                  fvm_lowered_cell_ptr lowered);
 
     cell_kind get_cell_kind() const override {
         return cell_kind::cable;
diff --git a/arbor/simulation.cpp b/arbor/simulation.cpp
index 231c7d9a1cf37f968c09873812d461532c6a64b4..7284a3c41c537959f76bdf1406490269d7276304 100644
--- a/arbor/simulation.cpp
+++ b/arbor/simulation.cpp
@@ -183,10 +183,36 @@ simulation_state::simulation_state(
         const domain_decomposition& decomp,
         execution_context ctx
     ):
-    communicator_(rec, decomp, ctx),
     task_system_(ctx.thread_pool),
     local_spikes_({thread_private_spike_store(ctx.thread_pool), thread_private_spike_store(ctx.thread_pool)})
 {
+    // Generate the cell groups in parallel, with one task per cell group.
+    cell_groups_.resize(decomp.groups.size());
+    std::vector<cell_labels_and_gids> cg_sources(cell_groups_.size());
+    std::vector<cell_labels_and_gids> cg_targets(cell_groups_.size());
+    foreach_group_index(
+        [&](cell_group_ptr& group, int i) {
+          const auto& group_info = decomp.groups[i];
+          cell_label_range sources, targets;
+          auto factory = cell_kind_implementation(group_info.kind, group_info.backend, ctx);
+          group = factory(group_info.gids, rec, sources, targets);
+
+          cg_sources[i] = cell_labels_and_gids(std::move(sources), group_info.gids);
+          cg_targets[i] = cell_labels_and_gids(std::move(targets), group_info.gids);
+        });
+
+    cell_labels_and_gids local_sources, local_targets;
+    for(const auto& i: util::make_span(cell_groups_.size())) {
+        local_sources.append(cg_sources.at(i));
+        local_targets.append(cg_targets.at(i));
+    }
+    auto global_sources = ctx.distributed->gather_cell_labels_and_gids(local_sources);
+
+    auto source_resolution_map = label_resolution_map(std::move(global_sources));
+    auto target_resolution_map = label_resolution_map(std::move(local_targets));
+
+    communicator_ = arb::communicator(rec, decomp, source_resolution_map, target_resolution_map, ctx);
+
     const auto num_local_cells = communicator_.num_local_cells();
 
     // Use half minimum delay of the network for max integration interval.
@@ -198,20 +224,21 @@ simulation_state::simulation_state(
     event_generators_.resize(num_local_cells);
     cell_size_type lidx = 0;
     cell_size_type grpidx = 0;
+
+    auto target_resolution_map_ptr = std::make_shared<label_resolution_map>(std::move(target_resolution_map));
     for (const auto& group_info: decomp.groups) {
         for (auto gid: group_info.gids) {
             // Store mapping of gid to local cell index.
             gid_to_local_[gid] = gid_local_info{lidx, grpidx};
 
-            // Check validity of event_generator targets
+            // Resolve event_generator targets.
+            // Each event generator gets their own resolver state.
             auto event_gens = rec.event_generators(gid);
-            auto num_targets = rec.num_targets(gid);
-            for (const auto& g: event_gens) {
-                for (const auto& t: g.targets()) {
-                    if (t >= num_targets) {
-                        throw arb::bad_event_generator_target_lid(gid, t, num_targets);
-                    }
-                }
+            for (auto& g: event_gens) {
+                g.resolve_label([target_resolution_map_ptr, event_resolver=resolver(target_resolution_map_ptr.get()), gid]
+                    (const cell_local_label_type& label) mutable {
+                        return event_resolver.resolve({gid, label});
+                    });
             }
 
             // Set up the event generators for cell gid.
@@ -222,15 +249,6 @@ simulation_state::simulation_state(
         ++grpidx;
     }
 
-    // Generate the cell groups in parallel, with one task per cell group.
-    cell_groups_.resize(decomp.groups.size());
-    foreach_group_index(
-        [&](cell_group_ptr& group, int i) {
-            const auto& group_info = decomp.groups[i];
-            auto factory = cell_kind_implementation(group_info.kind, group_info.backend, ctx);
-            group = factory(group_info.gids, rec);
-        });
-
     // Create event lane buffers.
     // One buffer is consumed by cell group updates while the other is filled with events for
     // the following epoch. In each buffer there is one lane for each local cell.
diff --git a/arbor/spike_source_cell_group.cpp b/arbor/spike_source_cell_group.cpp
index 856eec81a7b7c3d6da1fe3d157a6e110c70be81d..0b61bdb98ae1287ede4d14a2736e1c8b82492aaa 100644
--- a/arbor/spike_source_cell_group.cpp
+++ b/arbor/spike_source_cell_group.cpp
@@ -6,13 +6,18 @@
 #include <arbor/schedule.hpp>
 
 #include "cell_group.hpp"
+#include "label_resolution.hpp"
 #include "profile/profiler_macro.hpp"
 #include "spike_source_cell_group.hpp"
 #include "util/span.hpp"
 
 namespace arb {
 
-spike_source_cell_group::spike_source_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec):
+spike_source_cell_group::spike_source_cell_group(
+    const std::vector<cell_gid_type>& gids,
+    const recipe& rec,
+    cell_label_range& cg_sources,
+    cell_label_range& cg_targets):
     gids_(gids)
 {
     for (auto gid: gids_) {
@@ -23,9 +28,12 @@ spike_source_cell_group::spike_source_cell_group(const std::vector<cell_gid_type
 
     time_sequences_.reserve(gids_.size());
     for (auto gid: gids_) {
+        cg_sources.add_cell();
+        cg_targets.add_cell();
         try {
             auto cell = util::any_cast<spike_source_cell>(rec.get_cell_description(gid));
             time_sequences_.push_back(std::move(cell.seq));
+            cg_sources.add_label(cell.source, {0, 1});
         }
         catch (std::bad_any_cast& e) {
             throw bad_cell_description(cell_kind::spike_source, gid);
diff --git a/arbor/spike_source_cell_group.hpp b/arbor/spike_source_cell_group.hpp
index 3d36a376092b576b3698c984b1d4b2b02e833ece..a20772258c25f4138d321ecef2981a4c0cb0c1e2 100644
--- a/arbor/spike_source_cell_group.hpp
+++ b/arbor/spike_source_cell_group.hpp
@@ -10,12 +10,13 @@
 
 #include "cell_group.hpp"
 #include "epoch.hpp"
+#include "label_resolution.hpp"
 
 namespace arb {
 
 class spike_source_cell_group: public cell_group {
 public:
-    spike_source_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec);
+    spike_source_cell_group(const std::vector<cell_gid_type>& gids, const recipe& rec, cell_label_range& cg_sources, cell_label_range& cg_targets);
 
     cell_kind get_cell_kind() const override;
 
diff --git a/arbor/symmetric_recipe.cpp b/arbor/symmetric_recipe.cpp
index ccf9dec5083e79e41a3c0c7ea78db0268d0c0de1..4e7f48d5a54e59efc491e479978e3b3fa56df807 100644
--- a/arbor/symmetric_recipe.cpp
+++ b/arbor/symmetric_recipe.cpp
@@ -14,14 +14,6 @@ cell_kind symmetric_recipe::get_cell_kind(cell_gid_type i) const {
     return tiled_recipe_->get_cell_kind(i % tiled_recipe_->num_cells());
 }
 
-cell_size_type symmetric_recipe::num_sources(cell_gid_type i) const {
-    return tiled_recipe_->num_sources(i % tiled_recipe_->num_cells());
-}
-
-cell_size_type symmetric_recipe::num_targets(cell_gid_type i) const {
-    return tiled_recipe_->num_targets(i % tiled_recipe_->num_cells());
-}
-
 // Only function that calls the underlying tile's function on the same gid.
 // This is because applying transformations to event generators is not straightforward.
 std::vector<event_generator> symmetric_recipe::event_generators(cell_gid_type i) const {
diff --git a/arborio/cableio.cpp b/arborio/cableio.cpp
index c883bfef66138307e5f22a918ad86fa59189373e..998dff37682fc0ee4b8afe954443127da810fd4b 100644
--- a/arborio/cableio.cpp
+++ b/arborio/cableio.cpp
@@ -105,7 +105,7 @@ s_expr mksexp(const decor& d) {
     }
     for (const auto& p: d.placements()) {
         decorations.push_back(std::visit([&](auto& x)
-            { return slist("place"_symbol, round_trip(p.first), mksexp(x)); }, p.second));
+            { return slist("place"_symbol, round_trip(std::get<0>(p)), mksexp(x), s_expr(std::get<2>(p))); }, std::get<1>(p)));
     }
     return {"decor"_symbol, slist_range(decorations)};
 }
@@ -252,10 +252,10 @@ arb::ion_reversal_potential_method make_ion_reversal_potential_method(const std:
 #undef ARBIO_DEFINE_DOUBLE_ARG
 
 // Define makers for placeable pairs, paintable pairs, defaultables and decors
-using place_pair = std::pair<arb::locset, arb::placeable>;
+using place_tuple = std::tuple<arb::locset, arb::placeable, std::string>;
 using paint_pair = std::pair<arb::region, arb::paintable>;
-place_pair make_place(locset where, placeable what) {
-    return place_pair{where, what};
+place_tuple make_place(locset where, placeable what, std::string name) {
+    return place_tuple{where, what, name};
 }
 paint_pair make_paint(region where, paintable what) {
     return paint_pair{where, what};
@@ -263,11 +263,11 @@ paint_pair make_paint(region where, paintable what) {
 defaultable make_default(defaultable what) {
     return what;
 }
-decor make_decor(const std::vector<std::variant<place_pair, paint_pair, defaultable>>& args) {
+decor make_decor(const std::vector<std::variant<place_tuple, paint_pair, defaultable>>& args) {
     decor d;
     for(const auto& a: args) {
         auto decor_visitor = arb::util::overload(
-            [&](const place_pair & p) { d.place(p.first, p.second); },
+            [&](const place_tuple & p) { d.place(std::get<0>(p), std::get<1>(p), std::get<2>(p)); },
             [&](const paint_pair & p) { d.paint(p.first, p.second); },
             [&](const defaultable & p){ d.set_default(p); });
         std::visit(decor_visitor, a);
@@ -769,28 +769,28 @@ eval_map named_evals{
     {"mechanism", make_mech_call("'mechanism' with a name argument, and 0 or more parameter settings"
                                       "(name:string (param:string val:real))")},
 
-    {"place", make_call<locset, gap_junction_site>(make_place, "'place' with 2 arguments (locset gap-junction-site)")},
-    {"place", make_call<locset, i_clamp>(make_place, "'place' with 2 arguments (locset current-clamp)")},
-    {"place", make_call<locset, threshold_detector>(make_place, "'place' with 2 arguments (locset threshold-detector)")},
-    {"place", make_call<locset, mechanism_desc>(make_place, "'place' with 2 arguments (locset mechanism)")},
-
-    {"paint", make_call<region, init_membrane_potential>(make_paint, "'paint' with 2 arguments (region membrane-potential)")},
-    {"paint", make_call<region, temperature_K>(make_paint, "'paint' with 2 arguments (region temperature-kelvin)")},
-    {"paint", make_call<region, membrane_capacitance>(make_paint, "'paint' with 2 arguments (region membrane-capacitance)")},
-    {"paint", make_call<region, axial_resistivity>(make_paint, "'paint' with 2 arguments (region axial-resistivity)")},
-    {"paint", make_call<region, init_int_concentration>(make_paint, "'paint' with 2 arguments (region ion-internal-concentration)")},
-    {"paint", make_call<region, init_ext_concentration>(make_paint, "'paint' with 2 arguments (region ion-external-concentration)")},
-    {"paint", make_call<region, init_reversal_potential>(make_paint, "'paint' with 2 arguments (region ion-reversal-potential)")},
-    {"paint", make_call<region, mechanism_desc>(make_paint, "'paint' with 2 arguments (region mechanism)")},
-
-    {"default", make_call<init_membrane_potential>(make_default, "'default' with 1 argument (membrane-potential)")},
-    {"default", make_call<temperature_K>(make_default, "'default' with 1 argument (temperature-kelvin)")},
-    {"default", make_call<membrane_capacitance>(make_default, "'default' with 1 argument (membrane-capacitance)")},
-    {"default", make_call<axial_resistivity>(make_default, "'default' with 1 argument (axial-resistivity)")},
-    {"default", make_call<init_int_concentration>(make_default, "'default' with 1 argument (ion-internal-concentration)")},
-    {"default", make_call<init_ext_concentration>(make_default, "'default' with 1 argument (ion-external-concentration)")},
-    {"default", make_call<init_reversal_potential>(make_default, "'default' with 1 argument (ion-reversal-potential)")},
-    {"default", make_call<ion_reversal_potential_method>(make_default, "'default' with 1 argument (ion-reversal-potential-method)")},
+    {"place", make_call<locset, gap_junction_site, std::string>(make_place, "'place' with 3 arguments (ls:locset gj:gap-junction-site name:string)")},
+    {"place", make_call<locset, i_clamp, std::string>(make_place, "'place' with 3 arguments (ls:locset c:current-clamp name:string)")},
+    {"place", make_call<locset, threshold_detector, std::string>(make_place, "'place' with 3 arguments (ls:locset t:threshold-detector name:string)")},
+    {"place", make_call<locset, mechanism_desc, std::string>(make_place, "'place' with 3 arguments (ls:locset mech:mechanism name:string)")},
+
+    {"paint", make_call<region, init_membrane_potential>(make_paint, "'paint' with 2 arguments (reg:region v:membrane-potential)")},
+    {"paint", make_call<region, temperature_K>(make_paint, "'paint' with 2 arguments (reg:region v:temperature-kelvin)")},
+    {"paint", make_call<region, membrane_capacitance>(make_paint, "'paint' with 2 arguments (reg:region v:membrane-capacitance)")},
+    {"paint", make_call<region, axial_resistivity>(make_paint, "'paint' with 2 arguments (reg:region v:axial-resistivity)")},
+    {"paint", make_call<region, init_int_concentration>(make_paint, "'paint' with 2 arguments (reg:region v:ion-internal-concentration)")},
+    {"paint", make_call<region, init_ext_concentration>(make_paint, "'paint' with 2 arguments (reg:region v:ion-external-concentration)")},
+    {"paint", make_call<region, init_reversal_potential>(make_paint, "'paint' with 2 arguments (reg:region v:ion-reversal-potential)")},
+    {"paint", make_call<region, mechanism_desc>(make_paint, "'paint' with 2 arguments (reg:region v:mechanism)")},
+
+    {"default", make_call<init_membrane_potential>(make_default, "'default' with 1 argument (v:membrane-potential)")},
+    {"default", make_call<temperature_K>(make_default, "'default' with 1 argument (v:temperature-kelvin)")},
+    {"default", make_call<membrane_capacitance>(make_default, "'default' with 1 argument (v:membrane-capacitance)")},
+    {"default", make_call<axial_resistivity>(make_default, "'default' with 1 argument (v:axial-resistivity)")},
+    {"default", make_call<init_int_concentration>(make_default, "'default' with 1 argument (v:ion-internal-concentration)")},
+    {"default", make_call<init_ext_concentration>(make_default, "'default' with 1 argument (v:ion-external-concentration)")},
+    {"default", make_call<init_reversal_potential>(make_default, "'default' with 1 argument (v:ion-reversal-potential)")},
+    {"default", make_call<ion_reversal_potential_method>(make_default, "'default' with 1 argument (v:ion-reversal-potential-method)")},
 
     {"locset-def", make_call<std::string, locset>(make_locset_pair,
                        "'locset-def' with 2 arguments (name:string ls:locset)")},
@@ -804,7 +804,7 @@ eval_map named_evals{
     {"branch",  make_branch_call(
                     "'branch' with 2 integers and 1 or more segment arguments (id:int parent:int s0:segment s1:segment ..)")},
 
-    {"decor", make_arg_vec_call<place_pair, paint_pair, defaultable>(make_decor,
+    {"decor", make_arg_vec_call<place_tuple, paint_pair, defaultable>(make_decor,
                   "'decor' with 1 or more `paint`, `place` or `default` arguments")},
     {"label-dict", make_arg_vec_call<locset_pair, region_pair>(make_label_dict,
                        "'label-dict' with 1 or more `locset-def` or `region-def` arguments")},
diff --git a/doc/concepts/cell.rst b/doc/concepts/cell.rst
index 174d628173b91e6b6974de450d6213f0395bf3ba..eaef2c438812835df9a82ab4aa9603b6045238c7 100644
--- a/doc/concepts/cell.rst
+++ b/doc/concepts/cell.rst
@@ -7,17 +7,21 @@ The basic unit of abstraction in an Arbor model is a cell.
 A cell represents the smallest model that can be simulated.
 Cells interact with each other via spike exchange and gap junctions.
 
-.. table:: Identifiers used to uniquely refer to cells and objects like synapses on cells.
-
-    ========================  ================================  ===========================================================
-    Identifier                Type                              Description
-    ========================  ================================  ===========================================================
-    .. generic:: gid          integral                          The unique global identifier of a cell.
-    .. generic:: index        integral                          The index of an item in a cell-local collection.
-                                                                For example the 7th synapse on a cell.
-    .. generic:: cell_member  tuple (:gen:`gid`, :gen:`index`)  The global identification of a cell-local item with `index`
-                                                                into a cell-local collection on the cell identified by `gid`.
-    ========================  ================================  ===========================================================
+.. table:: Identifiers used to refer to cells and objects like synapses on cells.
+
+    =============================  ===========================================  ===========================================================
+    Identifier                     Type                                         Description
+    =============================  ===========================================  ===========================================================
+    .. generic:: gid               integral                                     The unique global identifier of a cell.
+    .. generic:: tag               string                                       The label of a group of items in a cell-local collection.
+                                                                                For example the synapses "syns_0" on a cell.
+    .. generic:: selection_policy  enum                                         The policy for selecting a single item out of a group
+                                                                                identified by its label.
+    .. generic:: local_label       tuple (:gen:`tag`, :gen:`selection_policy`)  The local identification of an cell-local item from a
+                                                                                cell-local collection on an unspecified cell.
+    .. generic:: global_label      tuple (:gen:`gid`, :gen:`local_lable`)       The global identification of a cell-local item from a
+                                                                                cell-local collection on the cell identified by `gid`.
+    =============================  ===========================================  ===========================================================
 
 Cell interactions via :ref:`connections <modelconnections>` and :ref:`gap junctions <modelgapjunctions>` occur
 between **source**, **target** and **gap junction site** locations on a cell. Connections are formed from sources
@@ -25,18 +29,22 @@ to targets. Gap junctions are formed between two gap junction sites. An example
 :ref:`cable cell<modelcablecell>` is a :ref:`threshold detector <cablecell-threshold-detectors>` (spike detector);
 an example of a target on a cable cell is a :ref:`synapse <cablecell-synapses>`.
 
-Each cell has a global identifier :gen:`gid`, and each **source**, **target** and **gap junction site** has a
-global identifier :gen:`cell_member`. These are used to refer to them in :ref:`recipes <modelrecipe>`.
+**Sources**, **targets** and **gap junction sites** are placed on sets of one or more locations on a cell.
+The number of locations in each set (and hence the number of sources/targets/gap junctions), depends on the cell
+description. For example, a user may choose to place a synapse at the end of every branch of a cell: the number of
+synapses in this case depends on the underlying morphology.
 
-A cell can have multiple sources, targets and gap junction site objects. Each object is ordered relative to other
-objects of the same type on that cell. The unique :gen:`cell_member` (:gen:`gid`, :gen:`index`) identifies an object
-according to the :gen:`gid` of the cell it is placed on, and its :gen:`index` on the cell enumerated according to the
-order of insertion on the cell relative to other objects of the same type.
+A set of one or more items of the same type (source/target/gap junction) are grouped under a label which can
+be when used when forming connections in a network. However, connections are one-to-one, so a :gen:`selection_policy`
+is needed to select an item of the group, for both ends of a connection or gap junction.
 
-The :gen:`gid` of a cell is used to determine its cell :ref:`kind <modelcellkind>` and
-:ref:`description <modelcelldesc>` in the :ref:`recipe <modelrecipe>`. The :gen:`cell_member` of a source,
-target or gap junction site is used to form :ref:`connections <modelconnections>` and
-:ref:`gap junctions <modelgapjunctions>` in the recipe.
+The combination of :gen:`tag` and :gen:`selection_policy` forms a :gen:`local_label`. When the global identifier of
+the cell :gen:`gid` is added, a :gen:`global_label` is formed, capable of globally identifying a source, target or
+gap junction site in the network. These :gen:`global_labels` are used to form connections and gap junctions in the
+:ref:`recipe <modelrecipe>`.
+
+The :gen:`gid` of a cell is also used to determine its cell :ref:`kind <modelcellkind>` and
+:ref:`description <modelcelldesc>` in the :ref:`recipe <modelrecipe>`.
 
 .. _modelcellkind:
 
diff --git a/doc/concepts/decor.rst b/doc/concepts/decor.rst
index ad4b7b7863b38b62369d4ed8c420766e6ea51d29..e1ec4bb287476da4a4ce2b0519ad9ef297bd5e4d 100644
--- a/doc/concepts/decor.rst
+++ b/doc/concepts/decor.rst
@@ -261,21 +261,24 @@ Placed dynamics
 ---------------
 
 Placed dynamics are discrete countable items that affect or record the dynamics of a cell,
-and are assigned to specific locations.
+and are assigned to :term:`locsets <locset>`. Because locsets can contain multiple locations
+on the cell, and the exact number of these locations can not be known until the model is built,
+each placed dynamic is given a string label, used to refer to the group of items on the underlying
+locset.
 
 .. _cablecell-synapses:
 
 1. Connection sites
 ~~~~~~~~~~~~~~~~~~~
 
-Connections (synapses) are instances of NMODL POINT mechanisms. See also :ref:`modelconnections`.
+Connections (synapses) are instances of NMODL POINT mechanisms. See also :term:`connection`.
 
 .. _cablecell-gj-sites:
 
 2. Gap junction sites
 ~~~~~~~~~~~~~~~~~~~~~
 
-See :ref:`modelgapjunctions`.
+See :term:`gap junction`.
 
 .. _cablecell-threshold-detectors:
 
@@ -306,17 +309,17 @@ constant stimuli and constant amplitude stimuli restricted to a fixed time inter
 .. code-block:: Python
 
     # Constant stimulus, amplitude 10 nA.
-    decor.place('(root)', arbor.iclamp(10))
+    decor.place('(root)', arbor.iclamp(10), "iclamp0")
 
     # Constant amplitude 10 nA stimulus at 20 Hz, with initial phase of π/4 radians.
-    decor.place('(root)', arbor.iclamp(10, frequency=0.020, phasce=math.pi/4))
+    decor.place('(root)', arbor.iclamp(10, frequency=0.020, phasce=math.pi/4), "iclamp1")
 
     # Stimulus at 1 kHz, amplitude 10 nA, for 40 ms starting at t = 30 ms.
-    decor.place('(root)', arbor.iclamp(30, 40, 10, frequency=1))
+    decor.place('(root)', arbor.iclamp(30, 40, 10, frequency=1), "iclamp2")
 
     # Piecewise linear stimulus with amplitude ranging from 0 nA to 10 nA,
     # starting at t = 30 ms and stopping at t = 50 ms.
-    decor.place('(root)', arbor.iclamp([(30, 0), (37, 10), (43, 8), (50, 0)])
+    decor.place('(root)', arbor.iclamp([(30, 0), (37, 10), (43, 8), (50, 0)], "iclamp3")
 
 
 .. _cablecell-probes:
diff --git a/doc/concepts/interconnectivity.rst b/doc/concepts/interconnectivity.rst
index 73f9ca9cfac70f72a32d1005e725017fb54de761..cdc2c8eabf17047eaba942fe5ffb38a5dc8f935a 100644
--- a/doc/concepts/interconnectivity.rst
+++ b/doc/concepts/interconnectivity.rst
@@ -3,10 +3,10 @@
 Interconnectivity
 =================
 
-Networks can be regarded as graph, where the nodes are cells and the edges
+Networks can be regarded as graphs, where the nodes are locations on cells and the edges
 describe the communications between them. In Arbor, two sorts of edges are modelled: a
 :term:`connection` abstracts the propagation of action potentials (:term:`spikes <spike>`) through the network,
-while a :term:`gap junction` is used to describe a direct electrical connection between two cells.
+while a :term:`gap junction` is used to describe a direct electrical connection between two locations on two cells.
 Connections only capture the propagation delay and attenuation associated with spike
 connectivity: the biophysical modelling of the chemical synapses themselves is the
 responsibility of the target cell model.
@@ -21,25 +21,31 @@ A recipe lets you define which sites are connected to which.
 
    connection
       Connections implement chemical synapses between **source** and **target** cells and are characterized
-      by having a transmission delay. On a cell, sources and targets are separately indexed.
+      by having a transmission delay.
 
       Connections in Arbor are defined in two steps:
 
-      1. Create **source** and **target** on two separate cells as part of their
+      1. Create labeled **source** and **target** on two separate cells as part of their
          :ref:`cell descriptions <modelcelldesc>` in the :ref:`recipe <modelrecipe>`. Sources typically
          generate spikes. Targets are typically synapses with associated biophysical model descriptions.
-      2. Declare the connection in the recipe: with the source and target identified using :gen:`cell_member`,
-         a connection delay and a connection weight. The connection should be declared on the target cell.
+         Each labeled group of sources or targets may contain multiple items on possibly multiple locations
+         on the cell.
+      2. Declare the connection in the recipe *on the target cell*:  from a source identified using
+         a :gen:`global_label`; a target identified using a :gen:`local_label` (:gen:`gid` of target is
+         the argument of the recipe method); a connection delay and a connection weight.
 
    spike
    action potential
       Spikes travel over :term:`connections <connection>`. In a synapse, they generate an event.
 
    event
-      In a synapse :term:`spikes <spike>` generate events, which constitute stimulation of the synapse mechanism and the transmission of a signal. A synapse may receive events directly from an :term:`event generator`.
+      In a synapse :term:`spikes <spike>` generate events, which constitute stimulation of the synapse
+      mechanism and the transmission of a signal. A synapse may receive events directly from an
+      :term:`event generator`.
 
    event generator
-      Externally stimulate a synapse. Event can be delivered on a schedule, one time, etc. See :py:class:`arbor.event_generator` for options.
+      Externally stimulate a synapse. Events can be delivered on a schedule.
+      See :py:class:`arbor.event_generator` for details.
 
 .. _modelgapjunctions:
 
@@ -51,11 +57,14 @@ A recipe lets you define which sites are connected to which.
 
       Similarly to `Connections`, Gap Junctions in Arbor are defined in two steps:
 
-      1. Create a **gap junction site** on two separate cells as part of their
+      1. Create labeled **gap junction sites** on two separate cells as part of their
          :ref:`cell descriptions <modelcelldesc>` in the :ref:`recipe <modelrecipe>`.
-      2. Declare the Gap Junction connections in the recipe: between a peer and local **gap junction site**
-         identified using :gen:`cell_member` and a conductance in μS. Two of these connections are needed,
-         on each of the peer and local cells.
+         Each labeled group of gap junctions may contain multiple items on possibly multiple locations
+         on the cell.
+      2. Declare the Gap Junction connections in the recipe *on the local cell*: from a peer **gap junction site**
+         identified using a :gen:`global_label`; to a local **gap junction site** identified using a
+         :gen:`local_label` (:gen:`gid` of the site is implicitly known); and a conductance in μS.
+         Two of these connections are needed, on each of the peer and local cells.
 
    .. Note::
       Only cable cells support gap junctions as of now.
diff --git a/doc/concepts/lif_cell.rst b/doc/concepts/lif_cell.rst
index e611f0ca0b19d4f315cedf4c46b14c4e58604795..c965c967b03998fefb433f3339d429e2a1969100 100644
--- a/doc/concepts/lif_cell.rst
+++ b/doc/concepts/lif_cell.rst
@@ -13,8 +13,9 @@ The description of a LIF cell is used to control the leaky integrate-and-fire dy
 * Firing threshold.
 * Refractory period.
 
-The morphology of a LIF cell is automatically modelled as a single :term:`compartment <control volume>`; each cell has one built-in
-**source** and one built-in **target** which do not need to be explicitly added in the cell description.
+The morphology of a LIF cell is automatically modelled as a single :term:`compartment <control volume>`;
+each cell has one built-in **source** and one built-in **target** which need to be given labels when the
+cell is created. The labels are used to form connections to and from the cell.
 LIF cells do not support adding additional **sources** or **targets** to the description. They do not support
 **gap junctions**. They do not support adding density or point mechanisms.
 
diff --git a/doc/concepts/recipe.rst b/doc/concepts/recipe.rst
index 581580662f53b027afabd9768430e2fa20564975..ca1b4d45caa6dc77a47e81c51f5fbf8b16033bda 100644
--- a/doc/concepts/recipe.rst
+++ b/doc/concepts/recipe.rst
@@ -10,9 +10,6 @@ building phase to provide information about individual cells in the model, such
 * The **kind** of each cell.
 * The **description** of each cell, e.g. with morphology, dynamics, synapses, detectors,
   stimuli etc.
-* The number of **spike targets** on each cell.
-* The number of **spike sources** on each cell.
-* The number of **gap junction sites** on each cell.
 * Incoming **network connections** from other cells terminating on a cell.
 * **Gap junction connections** on each cell.
 * **Probes** on each cell.
@@ -26,42 +23,31 @@ which helps make Arbor fast and easily distributable over many nodes.
 To better illustrate the content of a recipe, let's consider the following network of
 three cells:
 
-- ``Cell 0``: Is a single soma, with ``hh`` (Hodgkin-huxley) dynamics. In the middle
-  of the soma, a spike detector is attached, it generates a spiking event when the
-  voltage goes above 10 mV. In the same spot on the soma, a current clamp is also
-  attached, with the intention of triggering some spikes. All of the preceding info:
-  the morphology, dynamics, spike detector and current clamp are what is referred to in
-  Arbor as the **description** of the cell.
+- ``Cell 0``: Is a single soma, with ``hh`` (Hodgkin-huxley) dynamics. A spike detector
+  labelled "detector_0" is attached to the middle of the soma. The detector will generate a
+  spike event when the voltage goes above 10 mV. At the same spot on the soma, a current clamp
+  is also attached, with the intention of triggering some spikes. All of the preceding info —
+  the morphology, dynamics, spike detector and current clamp — constitute what is termed the
+  **description** of the cell.
   ``Cell 0`` should be modelled as a :ref:`cable cell<modelcablecell>`,
   (because cable cells allow complex dynamics such as ``hh``). This is referred to as
   the **kind** of the cell.
-  It's quite expensive to build cable cells, so we don't want to do this too often.
-  But when the simulation is first set up, it needs to know how cells interact with
-  one another in order to distribute the simulation over the available computational
-  resources. This is why the number of **targets**, **sources** and **gap junction sites**
-  is needed separately from the cell description: with them, the simulation can tell
-  that ``cell 0`` has 1 **spike source** (the detector), 0 **spike targets**, and 0
-  **gap junction sites**, without having to build the cell.
 - ``Cell 1``: Is a soma and a single dendrite, with ``passive`` dynamics everywhere.
-  It has a single synapse at the end of the dendrite and a gap junction site in the
-  middle of the soma. This is the **description** of the cell.
-  It's also a cable cell, which is its **cell kind**. It has 0 **spike sources**, 1
-  **spike target** (the synapse) and 1 **gap junction site**.
+  It has a single synapse at the end of the dendrite labeled "syanpse_1" and a gap
+  junction site in the middle of the soma labeled "gap_junction_1".
+  This is the **description** of the cell. It's also a cable cell, which is its **cell kind**.
 - ``Cell 2``: Is a soma and a single dendrite, with ``passive`` dynamics everywhere.
-  It has a gap junction site in the middle of the soma. This is the **description**
-  of the cell. It's also a cable cell, which is its **cell kind**. It has 0
-  **spike sources**, 0 **spike targets** and 1 **gap junction site**.
+  It has a gap junction site in the middle of the soma labeled "gap_junction_2".
+  This is the **description** of the cell. It's also a cable cell, which is its **cell kind**.
 
-The total **number of cells** in the model is 3. The **kind**, **description** and
-number of **spike sources**, **spike targets** and **gap junction sites** on each cell
+The total **number of cells** in the model is 3. The **kind**, and **description** of each cell
 is known and can be registered in the recipe. Next is the cell interaction.
 
-The model is designed such that ``cell 0`` has a spike source, ``cell 1`` has
-a spike target and gap junction site, and ``cell 2`` has a gap junction site. A
-**network connection** can be formed from ``cell 0`` to ``cell 1``; and a
-**gap junction connection** from ``cell 1`` to ``cell 2``. If ``cell 0`` spikes,
-a spike should be observed on ``cell 2`` after some delay. To monitor
-the voltage on ``cell 2`` and record the spike, a **probe** can be set up
+The model is designed such that each cell has labeled source, target and gap junction sites.
+A **network connection** can be formed from ``detector_0`` to ``synpase_1``; and a
+**gap junction connection** between ``gap_junction_1`` and ``gap_junction_2``.
+If ``detector_0`` spikes, a spike should be observed on ``gap_junction_2`` after some delay.
+To monitor the voltage on ``gap_junction_2`` and record the spike, a **probe** can be set up
 on ``cell 2``. All this information is also registered via the recipe.
 
 There are additional docs on :ref:`cell kinds <modelcellkind>`;
@@ -78,11 +64,11 @@ Are recipes always necessary?
 Yes. However, we provide a python :class:`single_cell_model <py_single_cell_model>`
 that abstracts away the details of a recipe for simulations of  single, stand-alone
 :ref:`cable cells<modelcablecell>`, which absolves the users from having to create the
-recipe themselves. This is possible because the number of cells, spike targets, spike sources
-and gap junction sites is fixed and known, as well as the fact that there can be no connections
-or gap junctions on a single cell. The single cell model is able to fill out the details of the
-recipe under the hood, and the user need only provide the cell description, and any probes they
-wish to place on the cell.
+recipe themselves. This is possible because the number of cells is fixed and known,
+and it is guaranteed that there can be no connections or gap junctions in a model of a
+single cell. The single cell model is able to fill out the details of the recipe under
+the hood, and the user need only provide the cell description, and any probes they wish
+to place on the cell.
 
 Why recipes?
 ------------
diff --git a/doc/concepts/spike_source_cell.rst b/doc/concepts/spike_source_cell.rst
index 0cf3955bfc46d63b26767cd67017f1f725967e15..87e8ff1e28123472acb7376835229af2ec8ba912 100644
--- a/doc/concepts/spike_source_cell.rst
+++ b/doc/concepts/spike_source_cell.rst
@@ -9,8 +9,7 @@ They are typically used as stimuli in a network of more complex cells.
 A spike source cell:
 
 * has its morphology is automatically modelled as a single :term:`compartment <control volume>`;
-* has one built-in **source**, which does not need to
-  be explicitly added in the cell description;
+* has one built-in **source**, which needs to be given a label to be used when forming connections from the cell;
 * has no **targets**;
 * does not support adding additional **sources** or **targets**;
 * does not support **gap junctions**;
diff --git a/doc/cpp/cable_cell.rst b/doc/cpp/cable_cell.rst
index ae46477f4b8fc1e9d91e1932397f63a4b2f4e7fc..aa2253a01e68c0e6630ab00ef06234cd3fbfb4ae 100644
--- a/doc/cpp/cable_cell.rst
+++ b/doc/cpp/cable_cell.rst
@@ -93,9 +93,10 @@ Density mechanisms are associated with a cable cell object with:
 .. cpp:function:: void cable_cell::paint(const region&, mechanism_desc)
 
 Point mechanisms, which are associated with connection end points on a
-cable cell, are attached to a cell with:
+cable cell, are placed on a set of locations given by a locset. The group
+of generated items requires a label. They are attached to a cell with:
 
-.. cpp:function:: void cable_cell::place(const locset&, mechanism_desc)
+.. cpp:function:: void cable_cell::place(const locset&, mechanism_desc, cell_tag_type label)
 
 .. todo::
 
diff --git a/doc/cpp/cell.rst b/doc/cpp/cell.rst
index b49f5245b6b0fe575b23e5ed78f5b9412e87e2a4..56e34c7c4be32a767845c64011de1d52ad01ca7f 100644
--- a/doc/cpp/cell.rst
+++ b/doc/cpp/cell.rst
@@ -14,7 +14,8 @@ cells and members of cell-local collections.
 .. Note::
     Arbor uses ``std::unit32_t`` for :cpp:type:`cell_gid_type`,
     :cpp:type:`cell_size_type`, :cpp:type:`cell_lid_type`, and
-    :cpp:type:`cell_local_size_type` at the time of writing, however
+    :cpp:type:`cell_local_size_type`; and uses ``std::string`` for
+    :cpp:type:`cell_tag_type` at the time of writing. However,
     this could change, e.g. to handle models that cell gid that don't
     fit into a 32 bit unsigned integer.
     It is thus recommended that these type aliases be used whenever identifying
@@ -35,15 +36,61 @@ cells and members of cell-local collections.
 .. cpp:type::  cell_lid_type
 
     For indexes into cell-local data.
-    Local indices for items within a particular cell-local collection should be
-    zero-based and numbered contiguously.
 
+.. cpp:type::  cell_tag_type
+
+    For labels of cell-local data.
+    Local labels are used for groups of items within a particular cell-local collection.
+    Each label is associated with a range of :cpp:type:`cell_lid_type` indexing the individual
+    items on the cell. The range is generated when the model is built and is not directly
+    available to the user.
+
+.. cpp:enum::  lid_selection_policy
+
+   For selecting an individual item from a group of items sharing the
+   same :cpp:type:`cell_tag_type` label.
+
+   .. cpp:enumerator:: round_robin
+
+      Iterate over the items of the group in a round-robin fashion.
+
+   .. cpp:enumerator:: assert_univalent
+
+      Assert that ony one item is available in the group. Throws an exception if the assertion
+      fails.
+
+.. cpp:class::  cell_local_label_type
+
+   For local identification of an item on an unspecified cell.
+   This is used for selecting the target of a connection or the local site of a gap junction
+   connection. The cell ``gid`` is implicitly known from the recipe.
+
+   .. cpp:member:: cell_tag_type  tag
+
+      Identifier of a group of items in a cell-local collection.
+
+   .. cpp:member:: lid_selection_policy   policy
+
+      Policy used for selecting a single item of the tagged group.
+
+.. cpp:class::  cell_global_label_type
+
+   For global identification of an item on a cell.
+   This is used for selecting the source of a connection or the peer site of a gap junction
+   connection.
+
+   .. cpp:member:: cell_gid_type   gid
+
+      Global identifier of the cell associated with the item.
+
+   .. cpp:member:: cell_local_label_type label
+
+      Identifier of a single item on the cell.
 
 .. cpp:type::  cell_local_size_type
 
     An unsigned integer for for counts of cell-local data.
 
-
 .. cpp:class:: cell_member_type
 
     For global identification of an item of cell local data.
@@ -54,9 +101,9 @@ cells and members of cell-local collections.
         * identify an item within a cell-local collection by the member
           :cpp:member:`index`.
 
-    An example is uniquely identifying a synapse in the model.
-    Each synapse has a post-synaptic cell (:cpp:member:`gid`), and an index
-    (:cpp:member:`index`) into the set of synapses on the post-synaptic cell.
+    An example is uniquely identifying a probe description in the model.
+    Each probe has a cell id (:cpp:member:`gid`), and an index
+    (:cpp:member:`index`) into the set of probes on the cell.
 
     Lexicographically ordered by :cpp:member:`gid`,
     then :cpp:member:`index`.
@@ -69,7 +116,6 @@ cells and members of cell-local collections.
 
         The index of the item in a cell-local collection.
 
-
 .. cpp:enum-class:: cell_kind
 
     Enumeration used to identify the cell type/kind, used by the model to
diff --git a/doc/cpp/interconnectivity.rst b/doc/cpp/interconnectivity.rst
index 3ac0dc43cee374f2db241c78e5bce6b0789d8667..947498f4c0377384baf5dd52de468034a82981b0 100644
--- a/doc/cpp/interconnectivity.rst
+++ b/doc/cpp/interconnectivity.rst
@@ -1,5 +1,7 @@
 .. _cppinterconnectivity:
 
+.. cpp:namespace:: arb
+
 Interconnectivity
 #################
 
@@ -13,13 +15,16 @@ Interconnectivity
     :cpp:class:`cell_connection` is bound to the destination cell which means that the gid
     is implicitly known.
 
-    .. cpp:member:: cell_member_type source
+    .. cpp:member:: cell_global_label_type source
 
-        Source end point, represented by the pair (cell gid, source index on the cell)
+        Source end point, represented by a :cpp:type:`cell_global_label_type` which packages
+        a cell gid, label of a group of sources on the cell, and source selection policy.
 
-    .. cpp:member:: cell_lid_type dest
+    .. cpp:member:: cell_local_label_type dest
 
-        Destination target index on the cell, target cell's gid is implicitly known.
+        Destination end point on the cell, represented by a :cpp:type:`cell_local_label_type`
+        which packages a label of a group of targets on the cell and a selection policy.
+        The target cell's gid is implicitly known.
 
     .. cpp:member:: float weight
 
@@ -47,13 +52,16 @@ Interconnectivity
        :cpp:member:`local` site, and ``c0`` is the :cpp:member:`peer` site. If :cpp:member:`ggap` is equal
        in both connections, a symmetric gap-junction is formed, other wise the gap-junction is asymmetric.
 
-    .. cpp:member:: cell_member_type peer
+    .. cpp:member:: cell_global_label_type peer
 
-        Peer gap junction site, represented by the pair (cell gid, gap junction site index on the cell)
+        Peer gap junction site, represented by a :cpp:type:`cell_local_label_type` which packages a cell gid,
+        a label of a group of gap junction sites on the cell, and a site selection policy.
 
-    .. cpp:member:: cell_lid_type local
+    .. cpp:member:: cell_local_label_type local
 
-        Local gap junction site index on the cell, the gid of the local site's cell is implicitly known.
+        Local gap junction site on the cell, represented by a :cpp:type:`cell_local_label_type`
+        which packages a label of a group of gap junction sites on the cell and a selection policy.
+        The gid of the local site's cell is implicitly known.
 
     .. cpp:member:: float ggap
 
diff --git a/doc/cpp/recipe.rst b/doc/cpp/recipe.rst
index 5660894e4f66f67976cc93924b4aad0040feb75a..5eb4f589d736b7e2603f00b5991d77209e8937a2 100644
--- a/doc/cpp/recipe.rst
+++ b/doc/cpp/recipe.rst
@@ -74,8 +74,8 @@ Recipe
     .. cpp:function:: virtual std::vector<cell_connection> connections_on(cell_gid_type gid) const
 
         Returns a list of all the **incoming** connections for `gid` .
-        Each connection ``con`` should have a valid synapse id ``con.dest`` on the post-synaptic target `gid`,
-        and a valid source id ``con.source.index`` on the pre-synaptic source ``con.source.gid``.
+        Each connection ``con`` should have a valid synapse label ``con.dest`` on the post-synaptic target `gid`,
+        and a valid source label ``con.source.label`` on the pre-synaptic source ``con.source.gid``.
         See :cpp:type:`cell_connection`.
 
         By default returns an empty list.
@@ -83,8 +83,8 @@ Recipe
     .. cpp:function:: virtual std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const
 
         Returns a list of all the gap junctions connected to `gid`.
-        Each gap junction ``gj`` should have a valid gap junction site id ``gj.local`` on ``gid``,
-        and a valid gap junction site id ``gj.peer.index`` on ``gj.peer.gid``.
+        Each gap junction ``gj`` should have a valid gap junction site label ``gj.local`` on ``gid``,
+        and a valid gap junction site label ``gj.peer.label`` on ``gj.peer.gid``.
         See :cpp:type:`gap_junction_connection`.
 
         By default returns an empty list.
@@ -95,28 +95,6 @@ Recipe
 
         By default returns an empty list.
 
-    .. cpp:function:: virtual cell_size_type num_sources(cell_gid_type gid) const
-
-        Returns the number of spike sources on `gid`. This corresponds to the number
-        of spike detectors on a multi-compartment cell. Typically there is one detector
-        at the soma of the cell, however it is possible to attache multiple detectors
-        at arbitrary locations.
-
-        By default returns 0.
-
-    .. cpp:function:: virtual cell_size_type num_targets(cell_gid_type gid) const
-
-        The number of post-synaptic sites on `gid`, which corresponds to the number
-        of synapses.
-
-        By default returns 0.
-
-    .. cpp:function:: virtual cell_size_type num_gap_junction_sites(cell_gid_type gid) const
-
-        Returns the number of gap junction sites on `gid`.
-
-        By default returns 0.
-
     .. cpp:function:: virtual std::vector<probe_info> get_probes(cell_gid_type gid) const
 
         Intended for use by cell group implementations to set up sampling data
diff --git a/doc/fileformat/cable_cell.rst b/doc/fileformat/cable_cell.rst
index 17f564502673381aee08359aa4a5df56597d8a06..95aebc30468754604aa1c3ba4e76d3ea9fa2502a 100644
--- a/doc/fileformat/cable_cell.rst
+++ b/doc/fileformat/cable_cell.rst
@@ -201,18 +201,22 @@ The various properties and dynamics of the decor are described as follows:
    This expression sets the membrane capacitance of the region tagged ``1`` to 0.02 F/m².
 
 
-.. label:: (place ls:locset prop:placeable)
+.. label:: (place ls:locset prop:placeable label:string)
 
-   This places the property ``prop`` on locset ``ls``.
-   For example:
+   This places the property ``prop`` on locset ``ls`` and labels the group of items on the
+   locset with ``label``. For example:
 
    .. code:: lisp
 
-      (place (locset "mylocset") (threshold-detector 10))
+      (place (locset "mylocset") (threshold-detector 10) "mydetectors")
+
+   This expression places 10 mV threshold detectors on the locset labeled ``mylocset``,
+   and labels the detectors "mydetectors". The definition of ``mylocset`` should be provided
+   in a label dictionary associated with the decor.
 
-   This expression places a 10 mV threshold detector on the locset labeled ``mylocset``.
-   (The definition of ``mylocset`` should be provided in a label dictionary associated
-   with the decor).
+   The number of detectors placed depends on the number of locations in the "mylocset" locset.
+   The placed detectors can be referred to (in the recipe for example) using the label
+   "mydetectors".
 
 .. label:: (default prop:defaultable)
 
@@ -241,8 +245,8 @@ Any number of paint, place and default expressions can be used to create a decor
         (paint (region "soma") (membrane-potential -50.000000))
         (paint (all) (mechanism "pas"))
         (paint (tag 4) (mechanism "Ih" ("gbar" 0.001)))
-        (place (locset "root") (mechanism "expsyn"))
-        (place (terminal) (gap-junction-site)))
+        (place (locset "root") (mechanism "expsyn") "root_synapse")
+        (place (terminal) (gap-junction-site) "terminal_gj"))
 
 Morphology
 ----------
@@ -334,8 +338,8 @@ expressions.
           (paint (region "my_soma") (temperature-kelvin 270))
           (paint (region "my_region") (membrane-potential -50.000000))
           (paint (tag 4) (mechanism "Ih" ("gbar" 0.001)))
-          (place (locset "root") (mechanism "expsyn"))
-          (place (location 1 0.2) (gap-junction-site)))
+          (place (locset "root") (mechanism "expsyn") "root_synapse")
+          (place (location 1 0.2) (gap-junction-site) "terminal_gj"))
         (morphology
           (branch 0 -1
             (segment 0 (point 0 0 0 2) (point 4 0 0 2) 1)
@@ -401,7 +405,7 @@ Decoration
      (meta-data (version "0.1-dev"))
      (decor
        (default (membrane-potential -55.000000))
-       (place (locset "root") (mechanism "expsyn"))
+       (place (locset "root") (mechanism "expsyn") "root_synapse")
        (paint (region "my_soma") (temperature-kelvin 270))))
 
 Morphology
@@ -430,7 +434,7 @@ Cable-cell
          (locset-def "root" (root)))
        (decor
          (default (membrane-potential -55.000000))
-         (place (locset "root") (mechanism "expsyn"))
+         (place (locset "root") (mechanism "expsyn") "root_synapse")
          (paint (region "my_soma") (temperature-kelvin 270)))
        (morphology
           (branch 0 -1
diff --git a/doc/python/benchmark_cell.rst b/doc/python/benchmark_cell.rst
index aad447f2bd55459fd6b31960a62146a284c412b5..2e16e136da1474c5a6d9ddc8642aeb637ae6cc97 100644
--- a/doc/python/benchmark_cell.rst
+++ b/doc/python/benchmark_cell.rst
@@ -9,7 +9,11 @@ Benchmark cells
 
     A benchmarking cell, used by Arbor developers to test communication performance.
 
-    .. function:: benchmark_cell(schedule, realtime_ratio)
+    .. function:: benchmark_cell(source, target, schedule, realtime_ratio)
+
+        Construct a benchmark cell with a single built-in source with label ``source``; and a
+        single built-in target with label ``target``. The labels can be used for forming connections from/to
+        the cell in the :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
 
         A benchmark cell generates spikes at a user-defined sequence of time points:
 
@@ -19,6 +23,10 @@ Benchmark cells
 
         and the time taken to integrate a cell can be tuned by setting the parameter ``realtime_ratio``.
 
+        :param source: label of the source on the cell.
+
+        :param target: label of the target on the cell.
+
         :param schedule: User-defined sequence of time points (choose from :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`, or :class:`arbor.poisson_schedule`).
 
         :param realtime_ratio: Time taken to integrate a cell, for example if ``realtime_ratio`` = 2, a cell will take 2 seconds of CPU time to simulate 1 second.
diff --git a/doc/python/cell.rst b/doc/python/cell.rst
index e89d472f3f6749e1cb229f2bb436d5a975fb3093..7f19f603edc97b61e66ca42c2ee769f6fdaabb06 100644
--- a/doc/python/cell.rst
+++ b/doc/python/cell.rst
@@ -9,19 +9,105 @@ The types defined below are used as identifiers for cells and members of cell-lo
 
 .. module:: arbor
 
+.. class:: selection_policy
+
+   Enumeration used for selecting an individual item from a group of items sharing the
+   same label.
+
+   .. attribute:: round_robin
+
+      Iterate over the items of the group in a round-robin fashion.
+
+   .. attribute:: univalent
+
+      Assert that only one item is available in the group. Throws an exception if the assertion
+      fails.
+
+.. class:: cell_local_label
+
+   For local identification of an item on an unspecified cell.
+
+   A local string label :attr:`tag` is used to identify a group of items within a particular
+   cell-local collection. Each label is associated with a set of items distributed over various
+   locations on the cell. The exact number of items associated to a label can only be known when the
+   model is built and is therefore not directly available to the user.
+
+   Because each label can be mapped to any of the items in its group, a :attr:`selection_policy`
+   is needed to select one of the items of the group. If the policy is not supplied, the default
+   :attr:`selection_policy.univalent` is selected.
+
+   :class:`cell_local_label` is used for selecting the target of a connection or the
+   local site of a gap junction connection. The cell ``gid`` of the item is implicitly known in the
+   recipe.
+
+   .. attribute:: tag
+
+      Identifier of a group of items in a cell-local collection.
+
+   .. attribute:: selection_policy
+
+      Policy used for selecting a single item of the tagged group.
+
+   An example of a cell member construction reads as follows:
+
+   .. container:: example-code
+
+       .. code-block:: python
+
+           import arbor
+
+           # Create the policy
+           policy = arbor.selection_policy.univalent
+
+           # Create the local label referring to the group of items labeled "syn0".
+           # The group is expected to only contain 1 item.
+           local_label = arbor.cell_local_label("syn0", policy)
+
+.. class:: cell_global_label
+
+   For global identification of an item on a cell.
+   This is used for selecting the source of a connection or the peer site of a gap junction connection.
+   The :attr:`label` expects a :class:`cell_local_label` type.
+
+   .. attribute:: gid
+
+      Global identifier of the cell associated with the item.
+
+   .. attribute:: label
+
+      Identifier of a single item on the cell.
+
+   .. container:: example-code
+
+       .. code-block:: python
+
+           import arbor
+
+           # Create the policy
+           policy = arbor.selection_policy.univalent
+
+           # Creat the local label referring to the group of items labeled "syn0".
+           # The group is expected to only contain 1 item.
+           local_label = arbor.cell_local_label("syn0", policy)
+
+           # Create the global label referring to the group of items labeled "syn0"
+           # on cell 5
+           global_label = arbor.cell_global_label(5, local_label)
 .. class:: cell_member
 
     .. function:: cell_member(gid, index)
 
-        Construct a ``cell_member`` object with parameters :attr:`gid` and :attr:`index` for global identification of a cell-local item.
+        Construct a ``cell_member`` object with parameters :attr:`gid` and :attr:`index` for
+        global identification of a cell-local item.
 
         Items of type :class:`cell_member` must:
 
         * be associated with a unique cell, identified by the member :attr:`gid`;
         * identify an item within a cell-local collection by the member :attr:`index`.
 
-        An example is uniquely identifying a synapse in the model.
-        Each synapse has a post-synaptic cell (with :attr:`gid`), and an :attr:`index` into the set of synapses on the post-synaptic cell.
+        An example is uniquely identifying a probe description in the model.
+        Each probe description has a cell (with :attr:`gid`), and an :attr:`index` into
+        the set of probe descriptions on the cell.
 
         Lexicographically ordered by :attr:`gid`, then :attr:`index`.
 
diff --git a/doc/python/decor.rst b/doc/python/decor.rst
index 4cebdb7c4d96af1fbe188ffa70a7cde9c4256186..8c0104a34d31393371f0cd3d80a07404f552afc0 100644
--- a/doc/python/decor.rst
+++ b/doc/python/decor.rst
@@ -125,60 +125,61 @@ Cable cell decoration
         :param str region: description of the region.
         :param str mechanism: the name of the mechanism.
 
-    .. method:: place(locations, const arb::mechanism_desc& d)
+    .. method:: place(locations, mech_name, label)
 
-        Place one instance of synapse described by ``mechanism`` to each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the
-        placed items on the cable cell. For instance: the ``index`` returned when a synapse mechanism is placed,
-        can be used when creating a :py:class:`arbor.connection`
+        Place one instance of the synapse named ``mech_name`` to each location in ``locations`` and label the
+        group of synapses with ``label``. The label can be used to form connections to one of the synapses
+        in the :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
 
         :param str locations: description of the locset.
         :param str mechanism: the name of the mechanism.
-        :rtype: int
+        :param str label: the label of the group of synapses on the locset.
 
-    .. method:: place(locations, mechanism)
+    .. method:: place(locations, mechanism, label)
         :noindex:
 
-        Place one instance of synapse described by ``mechanism`` to each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Place one instance of the synapse described by ``mechanism`` to each location in ``locations`` and label the
+        group of synapses with ``label``. The label can be used to form connections to one of the synapses
+        in the :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
 
         :param str locations: description of the locset.
         :param mechanism: the mechanism.
         :type mechanism: :py:class:`mechanism`
-        :rtype: int
+        :param str label: the label of the group of synapses on the locset.
 
-    .. method:: place(locations, site)
+    .. method:: place(locations, site, label)
         :noindex:
 
-        Place one gap junction site at each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Place one gap junction site at each location in ``locations`` and label the group of gap junction sites with
+        ``label``. The label can be used to form connections to/from one of the gap junction sites in the
+        :py:class:`arbor.recipe` by creating a :py:class:`arbor.gap_junction_connection`.
 
         :param str locations: description of the locset.
         :param site: indicates a gap junction site..
         :type site: :py:class:`gap_junction_site`
-        :rtype: int
+        :param str label: the label of the group of gap junction sites on the locset.
 
-    .. method:: place(locations, stim)
+    .. method:: place(locations, stim, label)
         :noindex:
 
-        Add a current stimulus at each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Add a current stimulus at each location in ``locations`` and label the group of stimuli with ``label``.
 
         :param str locations: description of the locset.
         :param stim: the current stim.
         :type stim: :py:class:`iclamp`
-        :rtype: int
+        :param str label: the label of the group of stimuli on the locset.
 
-    .. method:: place(locations, d)
+    .. method:: place(locations, d, label)
         :noindex:
 
-        Add a voltage spike detector at each location in ``locations``.
-        Returns a unique identifier that can be used to query the local indexes (see :gen:`index`) assigned to the placed items on the cable cell.
+        Add a voltage spike detector at each location in ``locations`` and label the group of detectors with ``label``.
+        The label can be used to form connections from one of the detectors in the :py:class:`arbor.recipe` by creating
+        a :py:class:`arbor.connection`.
 
         :param str locations: description of the locset.
         :param d: description of the detector.
         :type d: :py:class:`threshold_detector`
-        :rtype: int
+        :param str label: the label of the group of detectors on the locset.
 
     .. method:: discretization(policy)
 
diff --git a/doc/python/interconnectivity.rst b/doc/python/interconnectivity.rst
index 944825a2a981abe120a2efefc41bfe0a839180a4..df1445d3f4f6211d56aee024e56158f9d2e4c365 100644
--- a/doc/python/interconnectivity.rst
+++ b/doc/python/interconnectivity.rst
@@ -7,11 +7,11 @@ Interconnectivity
 
 .. class:: connection
 
-    Describes a connection between two cells, defined by source and destination end points (that is pre-synaptic and post-synaptic respectively),
-    a connection weight and a delay time.
+    Describes a connection between two cells, defined by source and destination end points (that is pre-synaptic and
+    post-synaptic respectively), a connection weight and a delay time.
 
-    The :attr:`dest` does not include the gid of a cell, this is because a :class:`arbor.connection` is bound to the destination cell which means that the gid
-    is implicitly known.
+    The :attr:`dest` does not include the gid of a cell, this is because a :class:`arbor.connection` is bound to the
+    destination cell which means that the gid is implicitly known.
 
     .. function:: connection(source, destination, weight, delay)
 
@@ -19,12 +19,16 @@ Interconnectivity
 
     .. attribute:: source
 
-        The source end point of the connection (type: :class:`arbor.cell_member`, which can be initialized with a (gid, index) tuple).
+        The source end point of the connection (type: :class:`arbor.cell_global_label`, which can be initialized with a
+        (gid, label) or a (gid, (label, policy)) tuple. If the policy is not indicated, the default
+        :attr:`arbor.selection_policy.univalent` is used).
 
     .. attribute:: dest
 
-        The destination end point of the connection (type: :class:`arbor.cell_member.index` representing the index of the destination on the cell).
-        The gid of the cell is implicitly known.
+        The destination end point of the connection (type: :class:`arbor.cell_local_label` representing the label of the
+        destination on the cell, which can be initialized with just a label, in which case the default
+        :attr:`arbor.selection_policy.univalent` is used, or a (label, policy) tuple). The gid of the cell is
+        implicitly known.
 
     .. attribute:: weight
 
@@ -45,20 +49,20 @@ Interconnectivity
             import arbor
 
             def connections_on(gid):
-               # construct a connection from the 0th source index of cell 2 (2,0)
-               # to the 1st target index of cell gid (gid,1) with weight 0.01 and delay of 10 ms.
-               src  = arbor.cell_member(2,0)
-               dest = 1 # gid of the destination is is determined by the argument to `connections_on`
+               # construct a connection from the "detector" source label on cell 2
+               # to the "syn" target label on cell gid with weight 0.01 and delay of 10 ms.
+               src  = arbor.cell_global_label(2, "detector")
+               dest = arbor.cell_local_label("syn") # gid of the destination is is determined by the argument to `connections_on`
                w    = 0.01
                d    = 10
                return [arbor.connection(src, dest, w, d)]
 
 .. class:: gap_junction_connection
 
-    Describes a gap junction between two gap junction sites. Gap junction sites are identified by :class:`arbor.cell_member`.
+    Describes a gap junction between two gap junction sites.
 
-    The :attr:`local` site does not include the gid of a cell, this is because a :class:`arbor.gap_junction_connection` is bound to
-    the destination cell which means that the gid is implicitly known.
+    The :attr:`local` site does not include the gid of a cell, this is because a :class:`arbor.gap_junction_connection`
+    is bound to the destination cell which means that the gid is implicitly known.
 
     .. note::
 
@@ -74,13 +78,16 @@ Interconnectivity
 
     .. attribute:: peer
 
-        The gap junction site: the remote half of the gap junction connection (type: :class:`arbor.cell_member`,
-        which can be initialized with a (gid, index) tuple).
+        The gap junction site: the remote half of the gap junction connection (type: :class:`arbor.cell_global_label`,
+        which can be initialized with a (gid, label) or a (gid, label, policy) tuple. If the policy is not indicated,
+        the default :attr:`arbor.selection_policy.univalent` is used).
 
     .. attribute:: local
 
-        The gap junction site: the local half of the gap junction connection (type: :class:`arbor.cell_member.index`, representing
-        the index of the local site on the cell). The gid of the cell is implicitly known.
+        The gap junction site: the local half of the gap junction connection (type: :class:`arbor.cell_local_label`
+        representing the label of the destination on the cell, which can be initialized with just a label, in which case
+        the default :attr:`arbor.selection_policy.univalent` is used, or a (label, policy) tuple). The gid of the
+        cell is implicitly known.
 
     .. attribute:: ggap
 
@@ -88,7 +95,8 @@ Interconnectivity
 
 .. class:: spike_detector
 
-    A spike detector, generates a spike when voltage crosses a threshold. Can be used as source endpoint for an :class:`arbor.connection`.
+    A spike detector, generates a spike when voltage crosses a threshold. Can be used as source endpoint for an
+    :class:`arbor.connection`.
 
     .. attribute:: threshold
 
diff --git a/doc/python/lif_cell.rst b/doc/python/lif_cell.rst
index dfd6d469020fb61e939282165e023840cdab1721..d8623077df2f5084877d5a44a9825318f5ee4c5d 100644
--- a/doc/python/lif_cell.rst
+++ b/doc/python/lif_cell.rst
@@ -10,6 +10,21 @@ LIF cells
     A benchmarking cell (leaky integrate-and-fire), used by Arbor developers to test communication performance,
     with neuronal parameters:
 
+    .. function:: lif_cell(source, target)
+
+        Constructor: assigns the label ``source`` to the single built-in source on the cell; and assigns the
+        label ``target`` to the single built-in target on the cell.
+
+    .. attribute:: source
+
+        The label of the single built-in source on the cell. Used for forming connections from the cell in the
+        :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
+
+    .. attribute:: target
+
+        The label of the single built-in target on the cell. Used for forming connections to the cell in the
+        :py:class:`arbor.recipe` by creating a :py:class:`arbor.connection`.
+
     .. attribute:: tau_m
 
         Membrane potential decaying constant [ms].
diff --git a/doc/python/recipe.rst b/doc/python/recipe.rst
index ecdea890931c35597328758123c4c953228bc361..30a383b4ca13ec7a1570fc7cf442b092c5a5025f 100644
--- a/doc/python/recipe.rst
+++ b/doc/python/recipe.rst
@@ -20,7 +20,7 @@ Recipe
     All recipes derive from this abstract base class.
 
     Recipes provide a cell-centric interface for describing a model.
-    This means that model properties, such as connections, are queried using the global identifier (:attr:`arbor.cell_member.gid`) of a cell.
+    This means that model properties, such as connections, are queried using the global identifier ``gid`` of a cell.
     In the description below, the term ``gid`` is used as shorthand for the cell with global identifier.
 
     **Required Member Functions**
@@ -49,8 +49,8 @@ Recipe
     .. function:: connections_on(gid)
 
         Returns a list of all the **incoming** connections to ``gid``.
-        Each connection should have a valid synapse id ``connection.dest`` on the post-synaptic target ``gid``,
-        and a valid source id ``connection.source.index`` on the pre-synaptic source ``connection.source.gid``.
+        Each connection should have a valid synapse label ``connection.dest`` on the post-synaptic target ``gid``,
+        and a valid source label ``connection.source.label`` on the pre-synaptic source ``connection.source.gid``.
         See :class:`connection`.
 
         By default returns an empty list.
@@ -58,8 +58,8 @@ Recipe
     .. function:: gap_junctions_on(gid)
 
         Returns a list of all the gap junctions connected to ``gid``.
-        Each gap junction ``gj`` should have a valid gap junction site id ``gj.local`` on ``gid``,
-        and a valid gap junction site id ``gj.peer.index`` on ``gj.peer.gid``.
+        Each gap junction ``gj`` should have a valid gap junction site label ``gj.local`` on ``gid``,
+        and a valid gap junction site label ``gj.peer.label`` on ``gj.peer.gid``.
         See :class:`gap_junction_connection`.
 
         By default returns an empty list.
@@ -70,30 +70,12 @@ Recipe
 
         By default returns an empty list.
 
-    .. function:: num_sources(gid)
-
-        The number of spike sources on ``gid``.
-
-        By default returns 0.
-
-    .. function:: num_targets(gid)
-
-        The number of post-synaptic sites on ``gid``, which corresponds to the number of synapses.
-
-        By default returns 0.
-
-    .. function:: num_gap_junction_sites(gid)
-
-        Returns the number of gap junction sites on ``gid``.
-
-        By default returns 0.
-
     .. function:: probes(gid)
 
         Returns a list specifying the probe addresses describing probes on the cell ``gid``.
         Each address in the list is an opaque object of type :class:`probe` produced by
         cell kind-specific probe address functions. Each probe address in the list
-        has a corresponding probe id of type :class:`cell_member_type`: an id ``(gid, i)``
+        has a corresponding probe id of type :class:`cell_member`: an id ``(gid, i)``
         refers to the probes described by the ith entry in the list returned by ``get_probes(gid)``.
 
         By default returns an empty list.
@@ -126,11 +108,13 @@ Event generator and schedules
 
     .. function:: event_generator(target, weight, schedule)
 
-        Construct an event generator for a :attr:`target` synapse with :attr:`weight` of the events to deliver based on a schedule (i.e., :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`, :class:`arbor.poisson_schedule`).
+        Construct an event generator for a :attr:`target` synapse with :attr:`weight` of the events to
+        deliver based on a schedule (i.e., :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`,
+        :class:`arbor.poisson_schedule`).
 
     .. attribute:: target
 
-        The target synapse of type :class:`arbor.cell_member.index`.
+        The target synapse of type :class:`arbor.cell_local_label`.
 
     .. attribute:: weight
 
@@ -221,7 +205,7 @@ An example of an event generator reads as follows:
         # define a Poisson schedule with start time 1 ms, expected frequency of 5 Hz,
         # and the target cell's gid as seed
         def event_generators(gid):
-            target = 0   # index of the synapse on target cell gid
+            target = arbor.cell_local_label("syn", arbor.selection_policy.round_robin) # label of the synapse on target cell gid
             seed   = gid
             tstart = 1
             freq   = 0.005
@@ -261,14 +245,10 @@ helpers in cell_parameters and make_cable_cell for building cells are used.
 
             # The cell_description method returns a cell.
             def cell_description(self, gid):
+                # Cell should have a synapse labeled "syn"
+                # and a detector labeled "detector"
                 return make_cable_cell(gid, self.params)
 
-            def num_targets(self, gid):
-                return 1
-
-            def num_sources(self, gid):
-                return 1
-
             # The kind method returns the type of cell with gid.
             # Note: this must agree with the type returned by cell_description.
             def cell_kind(self, gid):
@@ -279,13 +259,13 @@ helpers in cell_parameters and make_cable_cell for building cells are used.
                 src = (gid-1)%self.ncells
                 w = 0.01
                 d = 10
-                return [arbor.connection(arbor.cell_member(src,0), 0, w, d)]
+                return [arbor.connection((src,"detector"), "syn", w, d)]
 
             # Attach a generator to the first cell in the ring.
             def event_generators(self, gid):
                 if gid==0:
                     sched = arbor.explicit_schedule([1])
-                    return [arbor.event_generator(0, 0.1, sched)]
+                    return [arbor.event_generator("syn", 0.1, sched)]
                 return []
 
             def get_probes(self, id):
diff --git a/doc/python/spike_source_cell.rst b/doc/python/spike_source_cell.rst
index 339f5ec4e50be133911f2fcbe5f677d55bf7efc8..a146e6a960916887170a71bb7a540402930225b7 100644
--- a/doc/python/spike_source_cell.rst
+++ b/doc/python/spike_source_cell.rst
@@ -10,12 +10,16 @@ Spike source cells
     A spike source cell, that generates a user-defined sequence of spikes
     that act as inputs for other cells in the network.
 
-    .. function:: spike_source_cell(schedule)
+    .. function:: spike_source_cell(source, schedule)
+
+        Construct a spike source cell that generates spikes and give it the label ``source``. The label
+        can be used for forming connections from the cell in the :py:class:`arbor.recipe` by creating a
+        :py:class:`arbor.connection`
 
-        Construct a spike source cell that generates spikes
 
         - at regular intervals (using an :class:`arbor.regular_schedule`)
         - at a sequence of user-defined times (using an :class:`arbor.explicit_schedule`)
         - at times defined by a Poisson sequence (using an :class:`arbor.poisson_schedule`)
 
+        :param source: label of the source on the cell.
         :param schedule: User-defined sequence of time points (choose from :class:`arbor.regular_schedule`, :class:`arbor.explicit_schedule`, or :class:`arbor.poisson_schedule`).
diff --git a/doc/tutorial/network_ring.rst b/doc/tutorial/network_ring.rst
index 8401e839647817cd17ed0a81ba36acca98d2e3d6..f395d695ed7d09c94061c8b43747905a2b3b4034 100644
--- a/doc/tutorial/network_ring.rst
+++ b/doc/tutorial/network_ring.rst
@@ -62,12 +62,19 @@ These locations will form the endpoints of the connections between the cells.
    # Mark the root of the tree.
    labels['root'] = '(root)'
 
-After we've created a basic :py:class:`arbor.decor`, step **(3)** places a synapse with an exponential decay (``'expsyn'``) is on the ``'synapse_site'``.
+After we've created a basic :py:class:`arbor.decor`, step **(3)** places a synapse with an exponential decay (``'expsyn'``) on the ``'synapse_site'``.
+The synapse is given the label ``'syn'``, which is later used to form :py:class:`arbor.connection` objects terminating *at* the cell.
 Note that mechanisms can be initialized with their name; ``'expsyn'`` is short for ``arbor.mechanism('expsyn')``.
 
-Step **(4)** places a spike detector at the ``'root'``. :py:class:`spike_detectors <arbor.spike_detector>` will send spikes into an
-:class:`arbor.connection`, whereas the :ref:`expsyn mechanism <mechanisms_builtins>` can receive spikes from an
-:class:`arbor.connection`.
+Step **(4)** places a spike detector at the ``'root'``. The detector is given the label ``'detector'``, which is later used to form
+:py:class:`arbor.connection` objects originating *from* the cell.
+
+.. Note::
+
+   The number of synapses placed on the cell in this case is 1, because the ``'synapse_sites'`` locset is an explicit location.
+   Had the chosen locset contained multiple locations, an equal number of synapses would have been placed, all given the same label ``'syn'``.
+
+   The same explanation applies to the number of detectors on this cell.
 
 .. code-block:: python
 
@@ -77,11 +84,11 @@ Step **(4)** places a spike detector at the ``'root'``. :py:class:`spike_detecto
    decor.paint('"soma"', 'hh')
    decor.paint('"dend"', 'pas')
 
-   # (3) Attach a single synapse.
-   decor.place('"synapse_site"', 'expsyn')
+   # (3) Attach a single synapse, label it 'syn'
+   decor.place('"synapse_site"', 'expsyn', 'syn')
 
    # (4) Attach a spike detector with threshold of -10 mV.
-   decor.place('"root"', arbor.spike_detector(-10))
+   decor.place('"root"', arbor.spike_detector(-10), 'detector')
 
    cell = arbor.cable_cell(tree, labels, decor)
 
@@ -97,20 +104,24 @@ are connecting the cells **(8)**, returning a configurable number of cells **(6)
 (``make_cable_cell()`` returns the cell above).
 
 Step **(8)** creates an :py:class:`arbor.connection` between consecutive cells. If a cell has gid ``gid``, the
-previous cell has a gid ``(gid-1)%self.ncells``. The connection has a weight of 0.1 μS and a delay of 5 ms. The first two arguments
-to :py:class:`arbor.connection` are the **source** and **target** of the connection, and these are defined by the
-cell index ``gid`` and the source or target index. (:term:`Remember <connection>` that sources and targets are
-separately indexed.)
-
-The source endpoint has type :class:`arbor.cell_member`, and can be initialized with a ``(gid,index)`` tuple.
-The gid of the target end-point is implicitly known from the argument of :py:func:`arbor.recipe.connections_on`,
-Therefore, we only need to identify the index of the target on the cell using the :class:`arbor.cell_member.index` type.
-The cells have one synapse (step **3**), so the target endpoint has the 0th index. The cell has one
-spike generator (step **4**), so its source index is also 0.
-
-Lastly, we must inform the recipe how many sources and targets we have on each cell (``gid``).
-:func:`arbor.cable_cell.num_targets` and :func:`arbor.cable_cell.num_sources` must be set to 1: each cell has one
-source and one target endpoint.
+previous cell has a gid ``(gid-1)%self.ncells``. The connection has a weight of 0.1 μS and a delay of 5 ms.
+The first two arguments to :py:class:`arbor.connection` are the **source** and **target** of the connection.
+
+The **source** is a :py:class:`arbor.cell_global_label` object containing a cell index ``gid``, the source label
+corresponding to a valid detector label on the cell and an optional selection policy (for choosing a single detector
+out of potentially many detectors grouped under the same label - remember, in this case the number of detectors labeled
+'detector' is 1).
+The :py:class:`arbor.cell_global_label` can be initialized with a ``(gid, label)`` tuple, in which case the selection
+policy is the default :py:attr:`arbor.selection_policy.univalent`; or a ``(gid, (label, policy))`` tuple.
+
+The **target** is a :py:class:`arbor.cell_local_label` object containing a cell index ``gid``, the target label
+corresponding to a valid synapse label on the cell and an optional selection policy (for choosing a single synapse
+out of potentially many synapses grouped under the same label - remember, in this case the number of synapses labeled
+'syn' is 1).
+The :py:class:`arbor.cell_local_label` can be initialized with a ``label`` string, in which case the selection
+policy is the default :py:attr:`arbor.selection_policy.univalent`; or a ``(label, policy)`` tuple. The ``gid``
+of the target cell doesn't need to be explicitly added to the connection, it is the argument to the
+:py:func:`arbor.recipe.connections_on` method.
 
 Step **(9)** attaches an :py:class:`arbor.event_generator` on the 0th target (synapse) on the 0th cell; this means it
 is connected to the ``"synapse_site"`` on cell 0. This initiates the signal cascade through the network. The
@@ -153,19 +164,13 @@ Step **(11)** instantiates the recipe with 4 cells.
          src = (gid-1)%self.ncells
          w = 0.01
          d = 5
-         return [arbor.connection((src,0), (gid,0), w, d)]
-
-      def num_targets(self, gid):
-         return 1
-
-      def num_sources(self, gid):
-         return 1
+         return [arbor.connection((src,'detector'), 'syn', w, d)]
 
       # (9) Attach a generator to the first cell in the ring.
       def event_generators(self, gid):
          if gid==0:
                sched = arbor.explicit_schedule([1])
-               return [arbor.event_generator((0,0), 0.1, sched)]
+               return [arbor.event_generator('syn', 0.1, sched)]
          return []
 
       # (10) Place a probe at the root of each cell.
diff --git a/doc/tutorial/single_cell_detailed.rst b/doc/tutorial/single_cell_detailed.rst
index ba0b0f8c4ed62e6e79de037db8a51568dae6261b..52207ef06bca47b9788408eb2c883f64dfad844e 100644
--- a/doc/tutorial/single_cell_detailed.rst
+++ b/doc/tutorial/single_cell_detailed.rst
@@ -323,16 +323,25 @@ constructed in order to change the default values of its 'gbar' parameter.
 
 The decor object is also used to *place* stimuli and spike detectors on the cell using :meth:`arbor.decor.place`.
 We place 3 current clamps of 2 nA on the "root" locset defined earlier, starting at time = 10, 30, 50 ms and
-lasting 1ms each. As well as spike detectors on the "axon_terminal" locset for voltages above -10 mV:
+lasting 1ms each. As well as spike detectors on the "axon_terminal" locset for voltages above -10 mV.
+Every placement gets a label. The labels of detectors and synapses are used to form connection from and to them
+in the recipe.
 
 .. code-block:: python
 
    # Place stimuli and spike detectors on certain locsets
 
-   decor.place('"root"', arbor.iclamp(10, 1, current=2))
-   decor.place('"root"', arbor.iclamp(30, 1, current=2))
-   decor.place('"root"', arbor.iclamp(50, 1, current=2))
-   decor.place('"axon_terminal"', arbor.spike_detector(-10))
+   decor.place('"root"', arbor.iclamp(10, 1, current=2), 'iclamp0')
+   decor.place('"root"', arbor.iclamp(30, 1, current=2), 'iclamp1')
+   decor.place('"root"', arbor.iclamp(50, 1, current=2), 'iclamp2')
+   decor.place('"axon_terminal"', arbor.spike_detector(-10), 'detector')
+
+.. Note::
+
+   The number of individual locations in the ``'axon_terminal'`` locset depends on the underlying morphology and the
+   number of axon branches in the morphology. The number of detectors that get added on the cell is equal to the number
+   of locations in the locset, and the label ``'detector'`` refers to all of them. If we want to refer to a single
+   detector from the group (to form a network connection for example), we need a :py:class:`arbor.selection_policy`.
 
 Finally, there's one last property that impacts the behavior of a model: the discretisation.
 Cells in Arbor are simulated as discrete components called control volumes (CV). The size of
diff --git a/doc/tutorial/single_cell_detailed_recipe.rst b/doc/tutorial/single_cell_detailed_recipe.rst
index f12580b00fabbbe1836a4241b606b1e35d887259..a05849cf5b7f49791ae7043619ad7203faa139c8 100644
--- a/doc/tutorial/single_cell_detailed_recipe.rst
+++ b/doc/tutorial/single_cell_detailed_recipe.rst
@@ -93,10 +93,10 @@ We can immediately paste the cell description code from the
 
    # Place stimuli and spike detectors.
 
-   decor.place('"root"', arbor.iclamp(10, 1, current=2))
-   decor.place('"root"', arbor.iclamp(30, 1, current=2))
-   decor.place('"root"', arbor.iclamp(50, 1, current=2))
-   decor.place('"axon_terminal"', arbor.spike_detector(-10))
+   decor.place('"root"', arbor.iclamp(10, 1, current=2), 'iclamp0')
+   decor.place('"root"', arbor.iclamp(30, 1, current=2), 'iclamp1')
+   decor.place('"root"', arbor.iclamp(50, 1, current=2), 'iclamp2')
+   decor.place('"axon_terminal"', arbor.spike_detector(-10), 'detector')
 
    # Set cv_policy
 
@@ -153,39 +153,31 @@ examine the recipe in detail: how to create one, and why it is needed.
        def num_cells(self):
            return 1
 
-       # (4) Override the num_sources method
-       def num_sources(self, gid):
-           return 1
-
-       # (5) Override the num_targets method
-       def num_targets(self, gid):
-           return 0
-
-       # (6) Override the num_targets method
+       # (4) Override the cell_kind method
        def cell_kind(self, gid):
            return arbor.cell_kind.cable
 
-       # (7) Override the cell_description method
+       # (5) Override the cell_description method
        def cell_description(self, gid):
            return self.the_cell
 
-       # (8) Override the probes method
+       # (6) Override the probes method
        def probes(self, gid):
            return self.the_probes
 
-       # (9) Override the connections_on method
+       # (7) Override the connections_on method
        def connections_on(self, gid):
            return []
 
-       # (10) Override the gap_junction_on method
+       # (8) Override the gap_junction_on method
        def gap_junction_on(self, gid):
            return []
 
-       # (11) Override the event_generators method
+       # (9) Override the event_generators method
        def event_generators(self, gid):
            return []
 
-       # (12) Overrode the global_properties method
+       # (10) Overrode the global_properties method
        def global_properties(self, gid):
           return self.the_props
 
@@ -221,57 +213,38 @@ what we did in the :ref:`previous example <tutorialsinglecellswc-gprop>`. One la
 Step **(3)** overrides the :meth:`arbor.recipe.num_cells` method. It takes no arguments. We simply return 1,
 as we are only simulating one cell in this example.
 
-Step **(4)** overrides the :meth:`arbor.recipe.num_sources` method. It takes one argument: ``gid``.
-Given this global ID of a cell, the method will return the number of spike *sources* on the cell. We have defined
-our cell with one spike detector, on one location on the morphology, so we return 1.
-
-Step **(5)** overrides the :meth:`arbor.recipe.num_targets` method. It takes one argument: ``gid``.
-Given the gid, this method returns the number of *targets* on the cell. These are typically synapses on the cell
-that are capable of receiving events from other cells. We have defined our cell with 0 synapses, so we return 0.
-
-Step **(6)** overrides the :meth:`arbor.recipe.cell_kind` method. It takes one argument: ``gid``.
+Step **(4)** overrides the :meth:`arbor.recipe.cell_kind` method. It takes one argument: ``gid``.
 Given the gid, this method returns the kind of the cell. Our defined cell is a
 :class:`arbor.cell_kind.cable`, so we simply return that.
 
-Step **(7)** overrides the :meth:`arbor.recipe.cell_description` method. It takes one argument: ``gid``.
+Step **(5)** overrides the :meth:`arbor.recipe.cell_description` method. It takes one argument: ``gid``.
 Given the gid, this method returns the cell description which is the cell object passed to the constructor
 of the recipe. We return ``self.the_cell``.
 
-Step **(8)** overrides the :meth:`arbor.recipe.get_probes` method. It takes one argument: ``gid``.
+Step **(6)** overrides the :meth:`arbor.recipe.get_probes` method. It takes one argument: ``gid``.
 Given the gid, this method returns all the probes on the cell. The probes can be of many different kinds
 measuring different quantities on different locations of the cell. We pass these probes explicitly to the recipe
 and they are stored in ``self.the_probes``, so we return that variable.
 
-Step **(9)** overrides the :meth:`arbor.recipe.connections_on` method. It takes one argument: ``gid``.
+Step **(7)** overrides the :meth:`arbor.recipe.connections_on` method. It takes one argument: ``gid``.
 Given the gid, this method returns all the connections ending on that cell. These are typically synapse
 connections from other cell *sources* to specific *targets* on the cell with id ``gid``. Since we are
 simulating a single cell, and self-connections are not possible, we return an empty list.
 
-Step **(10)** overrides the :meth:`arbor.recipe.gap_junctions_on` method. It takes one argument: ``gid``.
+Step **(8)** overrides the :meth:`arbor.recipe.gap_junctions_on` method. It takes one argument: ``gid``.
 Given the gid, this method returns all the gap junctions on that cell. Gap junctions require 2 separate cells.
 Since we are simulating a single cell, we return an empty list.
 
-Step **(11)** overrides the :meth:`arbor.recipe.event_generators` method. It takes one argument: ``gid``.
+Step **(9)** overrides the :meth:`arbor.recipe.event_generators` method. It takes one argument: ``gid``.
 Given the gid, this method returns *event generators* on that cell. These generators trigger events (or
 spikes) on specific *targets* on the cell. They can be used to simulate spikes from other cells, to kick-start
 a simulation for example. Our cell uses a current clamp as a stimulus, and has no targets, so we return
 an empty list.
 
-Step **(12)** overrides the :meth:`arbor.recipe.global_properties` method. It takes one argument: ``kind``.
+Step **(10)** overrides the :meth:`arbor.recipe.global_properties` method. It takes one argument: ``kind``.
 This method returns the default global properties of the model which apply to all cells in the network of
 that kind. We return ``self.the_props`` which we defined in step **(1)**.
 
-.. Note::
-
-   You may wonder why the methods:  :meth:`arbor.recipe.num_sources`, :meth:`arbor.recipe.num_targets`,
-   and :meth:`arbor.recipe.cell_kind` are required, since they can be inferred by examining the cell description.
-   The recipe was designed to allow building simulations efficiently in a distributed system with minimum
-   communication. Some parts of the model initialization require only the cell kind, or the number of
-   sources and targets, not the full cell description which can be quite expensive to build. Providing these
-   descriptions separately saves time and resources for the user.
-
-   More information on the recipe can be found :ref:`here <modelrecipe>`.
-
 Now we can instantiate a ``single_recipe`` object using the ``cell`` and ``probe`` we created in the
 previous section:
 
diff --git a/doc/tutorial/single_cell_model.rst b/doc/tutorial/single_cell_model.rst
index a5ce9dd5148bec27f61a2aba6efbfe8f07f25452..d659d0dc2eec6f7b2db3cea346b2e2eb038e70de 100644
--- a/doc/tutorial/single_cell_model.rst
+++ b/doc/tutorial/single_cell_model.rst
@@ -52,8 +52,8 @@ Our *single-segment HH cell* has a simple morphology and dynamics, constructed a
     decor = arbor.decor()
     decor.set_property(Vm=-40)
     decor.paint('"soma"', 'hh')
-    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-    decor.place('"midpoint"', arbor.spike_detector(-10))
+    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), 'iclamp')
+    decor.place('"midpoint"', arbor.spike_detector(-10), 'detector')
 
     # (4) Create cell
     cell = arbor.cable_cell(tree, labels, decor)
diff --git a/doc/tutorial/single_cell_recipe.rst b/doc/tutorial/single_cell_recipe.rst
index 41963f59f6db225de2668a1e4180d6b6e57efc56..0012e2f099eb21b1e28583b1e2ff6150bd363ab6 100644
--- a/doc/tutorial/single_cell_recipe.rst
+++ b/doc/tutorial/single_cell_recipe.rst
@@ -37,8 +37,8 @@ We can immediately paste the cell description code from the
     decor = arbor.decor()
     decor.set_property(Vm=-40)
     decor.paint('"soma"', 'hh')
-    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-    decor.place('"midpoint"', arbor.spike_detector(-10))
+    decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), 'iclamp')
+    decor.place('"midpoint"', arbor.spike_detector(-10), 'detector')
     cell = arbor.cable_cell(tree, labels, decor)
 
 The recipe
@@ -73,24 +73,20 @@ It returns `0` by default and models without cells are quite boring!
             # (4.2) Override the num_cells method
             return 1
 
-        def num_sources(self, gid):
-            # (4.3) Override the num_sources method
-            return 1
-
         def cell_kind(self, gid):
-            # (4.4) Override the cell_kind method
+            # (4.3) Override the cell_kind method
             return arbor.cell_kind.cable
 
         def cell_description(self, gid):
-            # (4.5) Override the cell_description method
+            # (4.4) Override the cell_description method
             return self.the_cell
 
         def probes(self, gid):
-            # (4.6) Override the probes method
+            # (4.5) Override the probes method
             return self.the_probes
 
         def global_properties(self, kind):
-            # (4.7) Override the global_properties method
+            # (4.6) Override the global_properties method
             return self.the_props
 
     # (5) Instantiate recipe with a voltage probe located on "midpoint".
@@ -109,21 +105,19 @@ extend with Arbor's own :meth:`arbor.default_catalogue`.
 
 Step **(4.2)** defines that this model has one cell.
 
-Step **(4.3)** defines that this model has one source.
-
-Step **(4.4)** returns :class:`arbor.cell_kind.cable`, the :class:`arbor.cell_kind`
+Step **(4.3)** returns :class:`arbor.cell_kind.cable`, the :class:`arbor.cell_kind`
 associated with the cable cell defined above. If you mix multiple cell kinds and
 descriptions in one recipe, make sure a particular ``gid`` returns matching cell kinds
 and descriptions.
 
-Step **(4.5)** returns the cell description passed in on class initialisation. If we
+Step **(4.4)** returns the cell description passed in on class initialisation. If we
 were modelling multiple cells of different kinds, we would need to make sure that the
 cell returned by :meth:`arbor.recipe.cell_description` has the same cell kind as
 returned by :meth:`arbor.recipe.cell_kind` for every :gen:`gid`.
 
-Step **(4.6)** returns the probes passed in at class initialisation.
+Step **(4.5)** returns the probes passed in at class initialisation.
 
-Step **(4.7)** returns the properties that will be applied to all cells of that kind in the model.
+Step **(4.6)** returns the properties that will be applied to all cells of that kind in the model.
 
 More methods can be overridden if your model requires that, see :class:`arbor.recipe` for options.
 
diff --git a/example/bench/bench.cpp b/example/bench/bench.cpp
index 43df04e0e828b30f92ceb4079cb77a09aaf079e3..9006af75562143446b8005f68985248f93cc591c 100644
--- a/example/bench/bench.cpp
+++ b/example/bench/bench.cpp
@@ -87,33 +87,21 @@ public:
 
     arb::util::unique_any get_cell_description(arb::cell_gid_type gid) const override {
         std::mt19937_64 rng(gid);
-        arb::benchmark_cell cell;
-        cell.realtime_ratio = params_.cell.realtime_ratio;
 
         // The time_sequence of the cell produces the series of time points at
         // which it will spike. We use a poisson_schedule with a random sequence
         // seeded with the gid. In this way, a cell's random stream depends only
         // on its gid, and will hence give reproducable results when run with
         // different MPI ranks and threads.
-        cell.time_sequence = arb::poisson_schedule(1e-3*params_.cell.spike_freq_hz, rng);
-        return std::move(cell);
+        auto sched = arb::poisson_schedule(1e-3*params_.cell.spike_freq_hz, rng);
+
+        return arb::benchmark_cell("src", "tgt", sched, params_.cell.realtime_ratio);
     }
 
     arb::cell_kind get_cell_kind(arb::cell_gid_type gid) const override {
         return arb::cell_kind::benchmark;
     }
 
-    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        // Only one target, to which all incoming connections connect.
-        // This could be parameterized, in which case the connections
-        // generated in connections_on should end on random cell-local targets.
-        return 1;
-    }
-
-    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        return 1;
-    }
-
     std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
         const auto n = params_.network.fan_in;
         std::vector<arb::cell_connection> cons;
@@ -133,8 +121,7 @@ public:
             // Draw random source and adjust to avoid self-connections if neccesary.
             arb::cell_gid_type src = dist(rng);
             if (src>=gid) ++src;
-            // Note: target is {gid, 0}, i.e. the first (and only) target on the cell.
-            arb::cell_connection con({src, 0}, 0, 1.f, params_.network.min_delay);
+            arb::cell_connection con({src, "src"}, {"tgt"}, 1.f, params_.network.min_delay);
             cons.push_back(con);
         }
 
@@ -244,7 +231,7 @@ bench_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/brunel/brunel.cpp b/example/brunel/brunel.cpp
index 354db0b2e0a17a66e426b96365a394e24cd6a413..40029aa0e16a4126f9215362539fda817d353078 100644
--- a/example/brunel/brunel.cpp
+++ b/example/brunel/brunel.cpp
@@ -119,18 +119,18 @@ public:
         std::vector<cell_connection> connections;
         // Add incoming excitatory connections.
         for (auto i: sample_subset(gid, 0, ncells_exc_, in_degree_exc_)) {
-            connections.push_back({{cell_gid_type(i), 0}, 0, weight_exc_, delay_});
+            connections.push_back({{cell_gid_type(i), "src"}, {"tgt"}, weight_exc_, delay_});
         }
 
         // Add incoming inhibitory connections.
         for (auto i: sample_subset(gid, ncells_exc_, ncells_exc_ + ncells_inh_, in_degree_inh_)) {
-            connections.push_back({{cell_gid_type(i), 0}, 0, weight_inh_, delay_});
+            connections.push_back({{cell_gid_type(i), "src"}, {"tgt"}, weight_inh_, delay_});
         }
         return connections;
     }
 
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        auto cell = lif_cell();
+        auto cell = lif_cell("src", "tgt");
         cell.tau_m = 10;
         cell.V_th = 10;
         cell.C_m = 20;
@@ -145,15 +145,7 @@ public:
         std::mt19937_64 G;
         G.seed(gid + seed_);
         time_type t0 = 0;
-        return {poisson_generator(0, weight_ext_, t0, lambda_, G)};
-    }
-
-    cell_size_type num_sources(cell_gid_type) const override {
-         return 1;
-    }
-
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        return {poisson_generator({"tgt"}, weight_ext_, t0, lambda_, G)};
     }
 
 private:
diff --git a/example/dryrun/branch_cell.hpp b/example/dryrun/branch_cell.hpp
index 15344fc81964420e01072e6350e3c5776c3dd30c..77f71c1417f58fd5616a496a148f93597027807d 100644
--- a/example/dryrun/branch_cell.hpp
+++ b/example/dryrun/branch_cell.hpp
@@ -117,14 +117,14 @@ arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& param
     decor.set_default(arb::axial_resistivity{100}); // [Ω·cm]
 
     // Add spike threshold detector at the soma.
-    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
 
     // Add a synapse to the mid point of the first dendrite.
-    decor.place(arb::mlocation{0, 0.5}, "expsyn");
+    decor.place(arb::mlocation{0, 0.5}, "expsyn", "synapse");
 
     // Add additional synapses that will not be connected to anything.
     for (unsigned i=1u; i<params.synapses; ++i) {
-        decor.place(arb::mlocation{1, 0.5}, "expsyn");
+        decor.place(arb::mlocation{1, 0.5}, "expsyn", "dummy_synapses");
     }
 
     // Make a CV between every sample in the sample tree.
diff --git a/example/dryrun/dryrun.cpp b/example/dryrun/dryrun.cpp
index a743ac753983a5dcff0aa913fd886b5ac2419f73..bdfa32e7058baf3e0030651ed8a198ab35ff7bbf 100644
--- a/example/dryrun/dryrun.cpp
+++ b/example/dryrun/dryrun.cpp
@@ -89,16 +89,6 @@ public:
         return gprop;
     }
 
-    // Each cell has one spike detector (at the soma).
-    cell_size_type num_sources(cell_gid_type gid) const override {
-        return 1;
-    }
-
-    // The cell has one target synapse
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        return 1;
-    }
-
     // Each cell has one incoming connection, from any cell in the network spanning all ranks:
     // src gid in {0, ..., num_cells_*num_tiles_ - 1}.
     std::vector<arb::cell_connection> connections_on(cell_gid_type gid) const override {
@@ -108,7 +98,7 @@ public:
         auto src = source_distribution(src_gen);
         if (src>=gid) ++src;
 
-        return {arb::cell_connection({src, 0}, 0, event_weight_, min_delay_)};
+        return {arb::cell_connection({src, "detector"}, {"synapse"}, event_weight_, min_delay_)};
     }
 
     // Return an event generator on every 20th gid. This function needs to generate events
@@ -117,7 +107,7 @@ public:
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
         std::vector<arb::event_generator> gens;
         if (gid%20 == 0) {
-            gens.push_back(arb::explicit_generator(arb::pse_vector{{0, 1.0, event_weight_}}));
+            gens.push_back(arb::explicit_generator({{{"synapse"}, 1.0, event_weight_}}));
         }
         return gens;
     }
@@ -280,7 +270,7 @@ run_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index 58f37a4d427fe6a88a7e16849287e6cbe7cafff1..97206b037c9974c8ed340b141d05f8791b31b766 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -77,21 +77,11 @@ public:
         return cell_kind::cable;
     }
 
-    // Each cell has one spike detector (at the soma).
-    cell_size_type num_sources(cell_gid_type gid) const override {
-        return 1;
-    }
-
-    // The cell has one target synapse, which will be connected to a cell in another cable.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        return 1;
-    }
-
     std::vector<arb::cell_connection> connections_on(cell_gid_type gid) const override {
         if(gid % params_.n_cells_per_cable || (int)gid - 1 < 0) {
             return{};
         }
-        return {arb::cell_connection({gid - 1, 0}, 0, params_.event_weight, params_.event_min_delay)};
+        return {arb::cell_connection({gid - 1, "detector"}, {"syn"}, params_.event_weight, params_.event_min_delay)};
     }
 
     std::vector<arb::probe_info> get_probes(cell_gid_type gid) const override {
@@ -108,6 +98,7 @@ public:
     }
 
     std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
+        using policy = arb::lid_selection_policy;
         std::vector<arb::gap_junction_connection> conns;
 
         int cable_begin = (gid/params_.n_cells_per_cable) * params_.n_cells_per_cable;
@@ -121,10 +112,12 @@ public:
         // Gap junction conductance in μS
 
         if (next_cell < cable_end) {
-            conns.push_back(arb::gap_junction_connection({(cell_gid_type)next_cell, 0}, 1, 0.015));
+            conns.push_back(arb::gap_junction_connection({(cell_gid_type)next_cell, "local_0", policy::assert_univalent},
+                                                         {"local_1", policy::assert_univalent}, 0.015));
         }
         if (prev_cell >= cable_begin) {
-            conns.push_back(arb::gap_junction_connection({(cell_gid_type)prev_cell, 1}, 0, 0.015));
+            conns.push_back(arb::gap_junction_connection({(cell_gid_type)prev_cell, "local_1", policy::assert_univalent},
+                                                         {"local_0", policy::assert_univalent}, 0.015));
         }
 
         return conns;
@@ -312,20 +305,20 @@ arb::cable_cell gj_cell(cell_gid_type gid, unsigned ncell, double stim_duration)
     decor.paint("(all)", pas);
 
     // Add a spike detector to the soma.
-    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
 
     // Add two gap junction sites.
-    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{});
-    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{});
+    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{}, "local_0");
+    decor.place(arb::mlocation{0, 1}, arb::gap_junction_site{}, "local_1");
 
     // Attach a stimulus to the second cell.
     if (!gid) {
         auto stim = arb::i_clamp::box(0, stim_duration, 0.4);
-        decor.place(arb::mlocation{0, 0.5}, stim);
+        decor.place(arb::mlocation{0, 0.5}, stim, "stim");
     }
 
     // Add a synapse to the mid point of the first dendrite.
-    decor.place(arb::mlocation{0, 0.5}, "expsyn");
+    decor.place(arb::mlocation{0, 0.5}, "expsyn", "syn");
 
     // Create the cell and set its electrical properties.
     return arb::cable_cell(tree, {}, decor);
@@ -340,7 +333,7 @@ gap_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/generators/generators.cpp b/example/generators/generators.cpp
index a2747ea3850ac5d4623c2f41d09080e0240a1686..96db4f046836b962574309a1743299f25f15308c 100644
--- a/example/generators/generators.cpp
+++ b/example/generators/generators.cpp
@@ -61,7 +61,7 @@ public:
         // Add one synapse at the soma.
         // This synapse will be the target for all events, from both
         // event_generators.
-        decor.place(arb::mlocation{0, 0.5}, "expsyn");
+        decor.place(arb::mlocation{0, 0.5}, "expsyn", "syn");
 
         return arb::cable_cell(tree, labels, decor);
     }
@@ -77,12 +77,6 @@ public:
         return gprop;
     }
 
-    // The cell has one target synapse, which receives both inhibitory and exchitatory inputs.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        assert(gid==0); // There is only one cell in the model
-        return 1;
-    }
-
     // Return two generators attached to the one cell.
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
         assert(gid==0); // There is only one cell in the model
@@ -103,7 +97,7 @@ public:
 
         // Add excitatory generator
         gens.push_back(
-            arb::poisson_generator(0,              // Target synapse index on cell `gid`
+            arb::poisson_generator({"syn"},               // Target synapse index on cell `gid`
                                    w_e,                   // Weight of events to deliver
                                    t0,                    // Events start being delivered from this time
                                    lambda_e,              // Expected frequency (kHz)
@@ -111,7 +105,7 @@ public:
 
         // Add inhibitory generator
         gens.emplace_back(
-            arb::poisson_generator(0, w_i, t0, lambda_i,  RNG(86543891)));
+            arb::poisson_generator({"syn"}, w_i, t0, lambda_i,  RNG(86543891)));
 
         return gens;
     }
diff --git a/example/generators/readme.md b/example/generators/readme.md
index fd32e77d83879ee2f1594959f637f008386f193c..843d5664732c9c8262ead9d8ac252a110ac73832 100644
--- a/example/generators/readme.md
+++ b/example/generators/readme.md
@@ -36,10 +36,6 @@ Every user-defined recipe must provide implementations for the following three m
 * `recipe::get_cell_description(gid)`: return a description of the cell with `gid`.
 * `recipe::get_cell_kind(gid)`:  return an `arb::cell_kind` enum value for the cell type.
 
-In addition to these three, we also need to implement
-`recipe::num_targets(gid)` to return 1, to indicate that there is one target
-(i.e. synapse) on the cell.
-
 This single cell model has no connections for spike communication, so the
 default `recipe::connections_on(gid)` method can use the default implementation,
 which returns an empty list of connections for a cell.
@@ -52,28 +48,26 @@ compartment cell with one synapse, and a `arb::cell_kind::cable` respectively:
     // Return an arb::cell that describes a single compartment cell with
     // passive dynamics, and a single expsyn synapse.
     arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
-        arb::cell c;
+        arb::segment_tree tree;
+        double r = 18.8/2.0; // convert 18.8 μm diameter to radius
+        tree.append(arb::mnpos, {0,0,-r,r}, {0,0,r,r}, 1);
+
+        arb::label_dict labels;
+        labels.set("soma", arb::reg::tagged(1));
 
-        c.add_soma(18.8/2.0);
-        c.soma()->add_mechanism("pas");
+        arb::decor decor;
+        decor.paint("\"soma\"", "pas");
 
-        // Add one synapse at the soma.
+        // Add one synapse labeled "syn" at the soma.
         // This synapse will be the target for all events, from both event_generators.
-        auto syn_spec = arb::mechanism_spec("expsyn");
-        c.add_synapse({0, 0.5}, syn_spec);
+        decor.place(arb::mlocation{0, 0.5}, "expsyn", "syn");
 
-        return std::move(c); // move into unique_any wrapper
+        return arb::cable_cell(tree, labels, decor);
     }
 
     cell_kind get_cell_kind(cell_gid_type gid) const override {
         return cell_kind::cable;
     }
-
-    // There is one synapse, i.e. target, on the cell.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        EXPECTS(gid==0); // There is only one cell in the model
-        return 1;
-    }
 ```
 
 ### Event Generators
@@ -105,18 +99,16 @@ The implementation of this with hard-coded frequencies and weights is:
         std::vector<arb::event_generator> gens;
 
         // Add excitatory generator
-        gens.emplace_back(
-	    arb::poisson_generator(
-                 cell_member_type{0,0}, // Target synapse (gid, local_id).
-                 w_e,                   // Weight of events to deliver
-                 t0,                    // Events start being delivered from this time
-                 lambda_e,              // Expected frequency (events per ms)
-                 RNG(29562872)));       // Random number generator to use
-
-        // Add inhibitory generator
-        gens.emplace_back(
-            arb::poisson_generator(cell_member_type{0,0}, w_i, t0, lambda_i, RNG(86543891)));
-
+         gens.push_back(
+            arb::poisson_generator(
+                {"syn"},               // Target synapse index on cell `gid`
+                w_e,                   // Weight of events to deliver
+                t0,                    // Events start being delivered from this time
+                lambda_e,              // Expected frequency (kHz)
+                RNG(29562872)));       // Random number generator to use
+
+                // Add inhibitory generator
+        gens.emplace_back(arb::poisson_generator({"syn"}, w_i, t0, lambda_i,  RNG(86543891)));
         return gens;
     }
 ```
diff --git a/example/lfp/lfp.cpp b/example/lfp/lfp.cpp
index 10c463ad2c2410988ed001c54eb62ac3da092acb..61ddb27bd040169d2c2648bc92e5d3660bdb3c50 100644
--- a/example/lfp/lfp.cpp
+++ b/example/lfp/lfp.cpp
@@ -33,7 +33,6 @@ struct lfp_demo_recipe: public arb::recipe {
     }
 
     arb::cell_size_type num_cells() const override { return 1; }
-    arb::cell_size_type num_targets(cell_gid_type) const override { return 1; }
 
     std::vector<arb::probe_info> get_probes(cell_gid_type) const override {
         // Four probes:
@@ -95,7 +94,7 @@ private:
 
         // Add exponential synapse at centre of soma.
         synapse_location_ = ls::on_components(0.5, reg::tagged(1));
-        dec.place(synapse_location_, mechanism_desc("expsyn").set("e", 0).set("tau", 2));
+        dec.place(synapse_location_, mechanism_desc("expsyn").set("e", 0).set("tau", 2), "syn");
 
         cell_ = cable_cell(tree, {}, dec);
     }
@@ -187,7 +186,7 @@ int main(int argc, char** argv) {
     auto context = arb::make_context();
 
     // Weight 0.005 μS, onset at t = 0 ms, mean frequency 0.1 kHz.
-    auto events = arb::poisson_generator(0, .005, 0., 0.1, std::minstd_rand{});
+    auto events = arb::poisson_generator({"syn"}, .005, 0., 0.1, std::minstd_rand{});
     lfp_demo_recipe R(events);
 
     const double t_stop = 100;    // [ms]
diff --git a/example/probe-demo/probe-demo.cpp b/example/probe-demo/probe-demo.cpp
index d0ced90109efb40788038d834949c3ef547b751e..6f67587abd141f067e0bc366693ce3847251d2b4 100644
--- a/example/probe-demo/probe-demo.cpp
+++ b/example/probe-demo/probe-demo.cpp
@@ -98,7 +98,6 @@ struct cable_recipe: public arb::recipe {
     }
 
     arb::cell_size_type num_cells() const override { return 1; }
-    arb::cell_size_type num_targets(arb::cell_gid_type) const override { return 0; }
 
     std::vector<arb::probe_info> get_probes(arb::cell_gid_type) const override {
         return {probe_addr}; // (use default tag value 0)
@@ -121,7 +120,7 @@ struct cable_recipe: public arb::recipe {
 
         arb::decor decor;
         decor.paint(arb::reg::all(), "hh"); // HH mechanism over whole cell.
-        decor.place(arb::mlocation{0, 0.}, arb::i_clamp{1.}); // Inject a 1 nA current indefinitely.
+        decor.place(arb::mlocation{0, 0.}, arb::i_clamp{1.}, "iclamp"); // Inject a 1 nA current indefinitely.
 
         return arb::cable_cell(tree, {}, decor);
     }
diff --git a/example/ring/branch_cell.hpp b/example/ring/branch_cell.hpp
index 15344fc81964420e01072e6350e3c5776c3dd30c..1a243a4d1ca4f00af69c25e66da04c81492eb1e8 100644
--- a/example/ring/branch_cell.hpp
+++ b/example/ring/branch_cell.hpp
@@ -117,14 +117,15 @@ arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& param
     decor.set_default(arb::axial_resistivity{100}); // [Ω·cm]
 
     // Add spike threshold detector at the soma.
-    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10});
+    decor.place(arb::mlocation{0,0}, arb::threshold_detector{10}, "detector");
 
     // Add a synapse to the mid point of the first dendrite.
-    decor.place(arb::mlocation{0, 0.5}, "expsyn");
+    decor.place(arb::mlocation{0, 0.5}, "expsyn", "primary_syn");
 
     // Add additional synapses that will not be connected to anything.
-    for (unsigned i=1u; i<params.synapses; ++i) {
-        decor.place(arb::mlocation{1, 0.5}, "expsyn");
+
+    if (params.synapses > 1) {
+        decor.place(arb::ls::uniform("dend"_lab, 0, params.synapses - 2, gid), "expsyn", "extra_syns");
     }
 
     // Make a CV between every sample in the sample tree.
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index c3091d5a8670a8d08a048a1d742c63e2ed216a21..3e34d80b01086f188a5e9e0c6321b5127eaedebc 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -84,20 +84,12 @@ public:
         return cell_kind::cable;
     }
 
-    // Each cell has one spike detector (at the soma).
-    cell_size_type num_sources(cell_gid_type gid) const override {
-        return 1;
-    }
-
-    // The cell has one target synapse, which will be connected to cell gid-1.
-    cell_size_type num_targets(cell_gid_type gid) const override {
-        return 1;
-    }
-
     // Each cell has one incoming connection, from cell with gid-1.
     std::vector<arb::cell_connection> connections_on(cell_gid_type gid) const override {
+        std::vector<arb::cell_connection> cons;
         cell_gid_type src = gid? gid-1: num_cells_-1;
-        return {arb::cell_connection({src, 0}, 0, event_weight_, min_delay_)};
+        cons.push_back(arb::cell_connection({src, "detector"}, {"primary_syn"}, event_weight_, min_delay_));
+        return cons;
     }
 
     // Return one event generator on gid 0. This generates a single event that will
@@ -105,7 +97,7 @@ public:
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
         std::vector<arb::event_generator> gens;
         if (!gid) {
-            gens.push_back(arb::explicit_generator(arb::pse_vector{{0, 1.0, event_weight_}}));
+            gens.push_back(arb::explicit_generator({{{"primary_syn"}, 1.0, event_weight_}}));
         }
         return gens;
     }
@@ -274,7 +266,7 @@ ring_params read_options(int argc, char** argv) {
         return params;
     }
     if (argc>2) {
-        throw std::runtime_error("More than command line one option not permitted.");
+        throw std::runtime_error("More than one command line option is not permitted.");
     }
 
     std::string fname = argv[1];
diff --git a/example/single/single.cpp b/example/single/single.cpp
index db95d817c9a71895ad73043b4bc081fd613ca819..ea6cf74b9a9213abd77e793513f968f944e210e3 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -36,7 +36,6 @@ struct single_recipe: public arb::recipe {
     }
 
     arb::cell_size_type num_cells() const override { return 1; }
-    arb::cell_size_type num_targets(arb::cell_gid_type) const override { return 1; }
 
     std::vector<arb::probe_info> get_probes(arb::cell_gid_type) const override {
         arb::mlocation mid_soma = {0, 0.5};
@@ -72,7 +71,7 @@ struct single_recipe: public arb::recipe {
 
         arb::cell_lid_type last_branch = morpho.num_branches()-1;
         arb::mlocation end_last_branch = { last_branch, 1. };
-        decor.place(end_last_branch, "exp2syn");
+        decor.place(end_last_branch, "exp2syn", "synapse");
 
         return arb::cable_cell(morpho, dict, decor);
     }
diff --git a/python/cells.cpp b/python/cells.cpp
index 447a725ad74f7b03d301b3a360a7e14f39c41741..7105d48c2396c5b081f323e9a5c4f16f9f0af9ec 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -138,17 +138,23 @@ void register_cells(pybind11::module& m) {
 
     spike_source_cell
         .def(pybind11::init<>(
-            [](const regular_schedule_shim& sched){
-                return arb::spike_source_cell{sched.schedule()};}),
-            "schedule"_a, "Construct a spike source cell that generates spikes at regular intervals.")
+            [](arb::cell_tag_type source_label, const regular_schedule_shim& sched){
+                return arb::spike_source_cell{std::move(source_label), sched.schedule()};}),
+            "source_label"_a, "schedule"_a,
+            "Construct a spike source cell with a single source labeled 'source_label'.\n"
+            "The cell generates spikes on 'source_label' at regular intervals.")
         .def(pybind11::init<>(
-            [](const explicit_schedule_shim& sched){
-                return arb::spike_source_cell{sched.schedule()};}),
-            "schedule"_a, "Construct a spike source cell that generates spikes at a sequence of user-defined times.")
+            [](arb::cell_tag_type source_label, const explicit_schedule_shim& sched){
+                return arb::spike_source_cell{std::move(source_label), sched.schedule()};}),
+            "source_label"_a, "schedule"_a,
+            "Construct a spike source cell with a single source labeled 'source_label'.\n"
+            "The cell generates spikes on 'source_label' at a sequence of user-defined times.")
         .def(pybind11::init<>(
-            [](const poisson_schedule_shim& sched){
-                return arb::spike_source_cell{sched.schedule()};}),
-            "schedule"_a, "Construct a spike source cell that generates spikes at times defined by a Poisson sequence.")
+            [](arb::cell_tag_type source_label, const poisson_schedule_shim& sched){
+                return arb::spike_source_cell{std::move(source_label), sched.schedule()};}),
+            "source_label"_a, "schedule"_a,
+            "Construct a spike source cell with a single source labeled 'source_label'.\n"
+            "The cell generates spikes on 'source_label' at times defined by a Poisson sequence.")
         .def("__repr__", [](const arb::spike_source_cell&){return "<arbor.spike_source_cell>";})
         .def("__str__",  [](const arb::spike_source_cell&){return "<arbor.spike_source_cell>";});
 
@@ -163,20 +169,23 @@ void register_cells(pybind11::module& m) {
 
     benchmark_cell
         .def(pybind11::init<>(
-            [](const regular_schedule_shim& sched, double ratio){
-                return arb::benchmark_cell{sched.schedule(), ratio};}),
-            "schedule"_a, "realtime_ratio"_a=1.0,
-            "Construct a benchmark cell that generates spikes at regular intervals.")
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label, const regular_schedule_shim& sched, double ratio){
+                return arb::benchmark_cell{std::move(source_label), std::move(target_label), sched.schedule(), ratio};}),
+            "source_label"_a, "target_label"_a,"schedule"_a, "realtime_ratio"_a=1.0,
+            "Construct a benchmark cell that generates spikes on 'source_label' at regular intervals.\n"
+            "The cell has one source labeled 'source_label', and one target labeled 'target_label'.")
         .def(pybind11::init<>(
-            [](const explicit_schedule_shim& sched, double ratio){
-                return arb::benchmark_cell{sched.schedule(), ratio};}),
-            "schedule"_a, "realtime_ratio"_a=1.0,
-            "Construct a benchmark cell that generates spikes at a sequence of user-defined times.")
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label, const explicit_schedule_shim& sched, double ratio){
+                return arb::benchmark_cell{std::move(source_label), std::move(target_label),sched.schedule(), ratio};}),
+            "source_label"_a, "target_label"_a, "schedule"_a, "realtime_ratio"_a=1.0,
+            "Construct a benchmark cell that generates spikes on 'source_label' at a sequence of user-defined times.\n"
+            "The cell has one source labeled 'source_label', and one target labeled 'target_label'.")
         .def(pybind11::init<>(
-            [](const poisson_schedule_shim& sched, double ratio){
-                return arb::benchmark_cell{sched.schedule(), ratio};}),
-            "schedule"_a, "realtime_ratio"_a=1.0,
-            "Construct a benchmark cell that generates spikes at times defined by a Poisson sequence.")
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label, const poisson_schedule_shim& sched, double ratio){
+                return arb::benchmark_cell{std::move(source_label), std::move(target_label), sched.schedule(), ratio};}),
+            "source_label"_a, "target_label"_a, "schedule"_a, "realtime_ratio"_a=1.0,
+            "Construct a benchmark cell that generates spikeson 'source_label' at times defined by a Poisson sequence.\n"
+            "The cell has one source labeled 'source_label', and one target labeled 'target_label'.")
         .def("__repr__", [](const arb::benchmark_cell&){return "<arbor.benchmark_cell>";})
         .def("__str__",  [](const arb::benchmark_cell&){return "<arbor.benchmark_cell>";});
 
@@ -186,7 +195,11 @@ void register_cells(pybind11::module& m) {
         "A leaky integrate-and-fire cell.");
 
     lif_cell
-        .def(pybind11::init<>())
+        .def(pybind11::init<>(
+            [](arb::cell_tag_type source_label, arb::cell_tag_type target_label){
+                return arb::lif_cell(std::move(source_label), std::move(target_label));}),
+            "source_label"_a, "target_label"_a,
+            "Construct a lif cell with one source labeled 'source_label', and one target labeled 'target_label'.")
         .def_readwrite("tau_m", &arb::lif_cell::tau_m,
             "Membrane potential decaying constant [ms].")
         .def_readwrite("V_th", &arb::lif_cell::V_th,
@@ -201,6 +214,10 @@ void register_cells(pybind11::module& m) {
             "Refractory period [ms].")
         .def_readwrite("V_reset", &arb::lif_cell::V_reset,
             "Reset potential [mV].")
+        .def_readwrite("source", &arb::lif_cell::source,
+            "Label of the single build-in source on the cell.")
+        .def_readwrite("target", &arb::lif_cell::target,
+            "Label of the single build-in target on the cell.")
         .def("__repr__", &lif_str)
         .def("__str__",  &lif_str);
 
@@ -514,37 +531,42 @@ void register_cells(pybind11::module& m) {
             "Set ion species properties conditions on a region.")
         // Place synapses
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::mechanism_desc& d) -> int {
-                return dec.place(locset, d); },
-            "locations"_a, "mechanism"_a,
-            "Place one instance of synapse described by 'mechanism' to each location in 'locations'.")
+            [](arb::decor& dec, const char* locset, const arb::mechanism_desc& d, const char* label_name) {
+                return dec.place(locset, d, label_name); },
+            "locations"_a, "mechanism"_a, "label"_a,
+            "Place one instance of the synapse described by 'mechanism' on each location in 'locations'. "
+            "The group of synapses has the label 'label', used for forming connections between cells.")
         .def("place",
-            [](arb::decor& dec, const char* locset, const char* mech_name) -> int {
-                return dec.place(locset, mech_name);
+            [](arb::decor& dec, const char* locset, const char* mech_name, const char* label_name) {
+                return dec.place(locset, mech_name, label_name);
             },
-            "locations"_a, "mechanism"_a,
-            "Place one instance of synapse described by 'mechanism' to each location in 'locations'.")
+            "locations"_a, "mechanism"_a, "label"_a,
+            "Place one instance of the synapse described by 'mechanism' on each location in 'locations'."
+            "The group of synapses has the label 'label', used for forming connections between cells.")
         // Place gap junctions.
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::gap_junction_site& site) -> int {
-                return dec.place(locset, site);
+            [](arb::decor& dec, const char* locset, const arb::gap_junction_site& site, const char* label_name) {
+                return dec.place(locset, site, label_name);
             },
-            "locations"_a, "gapjunction"_a,
-            "Place one gap junction site at each location in 'locations'.")
+            "locations"_a, "gapjunction"_a, "label"_a,
+            "Place one gap junction site labeled 'label' on each location in 'locations'."
+            "The group of gap junctions has the label 'label', used for forming connections between cells.")
         // Place current clamp stimulus.
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::i_clamp& stim) -> int {
-                return dec.place(locset, stim);
+            [](arb::decor& dec, const char* locset, const arb::i_clamp& stim, const char* label_name) {
+                return dec.place(locset, stim, label_name);
             },
-            "locations"_a, "iclamp"_a,
-            "Add a current stimulus at each location in locations.")
+            "locations"_a, "iclamp"_a, "label"_a,
+            "Add a current stimulus at each location in locations."
+            "The group of current stimuli has the label 'label'.")
         // Place spike detector.
         .def("place",
-            [](arb::decor& dec, const char* locset, const arb::threshold_detector& d) -> int {
-                return dec.place(locset, d);
+            [](arb::decor& dec, const char* locset, const arb::threshold_detector& d, const char* label_name) {
+                return dec.place(locset, d, label_name);
             },
-            "locations"_a, "detector"_a,
-            "Add a voltage spike detector at each location in locations.")
+            "locations"_a, "detector"_a, "label"_a,
+            "Add a voltage spike detector at each location in locations."
+            "The group of spike detectors has the label 'label', used for forming connections between cells.")
         .def("discretization",
             [](arb::decor& dec, const arb::cv_policy& p) { dec.set_default(p); },
             pybind11::arg_v("policy", "A cv_policy used to discretise the cell into compartments for simulation"));
@@ -577,14 +599,6 @@ void register_cells(pybind11::module& m) {
         .def("cables",
             [](arb::cable_cell& c, const char* label) {return c.concrete_region(label).cables();},
             "label"_a, "The cable segments of the cell morphology for a region label.")
-        // Get lid range associated with a placement.
-        .def("placed_lid_range",
-            [](arb::cable_cell& c, int idx) -> pybind11::tuple {
-                auto range = c.placed_lid_range(idx);
-                return pybind11::make_tuple(range.begin, range.end);
-            },
-            "index"_a,
-            "The range of lids assigned to the items from a placement, for the lids assigned to synapses.")
         // Stringification
         .def("__repr__", [](const arb::cable_cell&){return "<arbor.cable_cell>";})
         .def("__str__",  [](const arb::cable_cell&){return "<arbor.cable_cell>";});
diff --git a/python/event_generator.cpp b/python/event_generator.cpp
index ad7d0bbd27d4772200aa6a866241bfd65e91f0fe..c9608b49e3689723bf050fd9226723fb78311824 100644
--- a/python/event_generator.cpp
+++ b/python/event_generator.cpp
@@ -17,11 +17,11 @@ void register_event_generators(pybind11::module& m) {
 
     event_generator
         .def(pybind11::init<>(
-            [](arb::cell_lid_type target, double weight, const schedule_shim_base& sched) {
-                return event_generator_shim(target, weight, sched.schedule()); }),
+            [](arb::cell_local_label_type target, double weight, const schedule_shim_base& sched) {
+                return event_generator_shim(std::move(target), weight, sched.schedule()); }),
             "target"_a, "weight"_a, "sched"_a,
             "Construct an event generator with arguments:\n"
-            "  target: The target synapse (gid, local_id).\n"
+            "  target: The target synapse label and selection policy.\n"
             "  weight: The weight of events to deliver.\n"
             "  sched:  A schedule of the events.")
         .def_readwrite("target", &event_generator_shim::target,
diff --git a/python/event_generator.hpp b/python/event_generator.hpp
index 1d0d61c3ac50dea1499ef6f981e959d32b4b1534..4bca919a9350e29a395ac55210bb7178ad45d7a7 100644
--- a/python/event_generator.hpp
+++ b/python/event_generator.hpp
@@ -6,12 +6,12 @@
 namespace pyarb {
 
 struct event_generator_shim {
-    arb::cell_lid_type target;
+    arb::cell_local_label_type target;
     double weight;
     arb::schedule time_sched;
 
-    event_generator_shim(arb::cell_lid_type event_target, double event_weight, arb::schedule sched):
-        target(event_target),
+    event_generator_shim(arb::cell_local_label_type event_target, double event_weight, arb::schedule sched):
+        target(std::move(event_target)),
         weight(event_weight),
         time_sched(std::move(sched))
     {}
diff --git a/python/example/brunel.py b/python/example/brunel.py
index 3bd0af9e470e8ae3032686332ce330a941ba5f97..30dc11e241ebd2850124db4072618e6bf667b151 100755
--- a/python/example/brunel.py
+++ b/python/example/brunel.py
@@ -64,14 +64,14 @@ class brunel_recipe (arbor.recipe):
         connections=[]
         # Add incoming excitatory connections.
         for i in sample_subset(gid, 0, self.ncells_exc_, self.in_degree_exc_):
-            connections.append(arbor.connection((i,0), 0, self.weight_exc_, self.delay_))
+            connections.append(arbor.connection((i,"src"), "tgt", self.weight_exc_, self.delay_))
         # Add incoming inhibitory connections.
         for i in sample_subset(gid, self.ncells_exc_, self.ncells_exc_ + self.ncells_inh_, self.in_degree_inh_):
-            connections.append(arbor.connection((i,0), 0, self.weight_inh_, self.delay_))
+            connections.append(arbor.connection((i,"src"), "tgt", self.weight_inh_, self.delay_))
         return connections
 
     def cell_description(self, gid):
-        cell = arbor.lif_cell()
+        cell = arbor.lif_cell("src", "tgt")
         cell.tau_m = 10
         cell.V_th = 10
         cell.C_m = 20
@@ -83,15 +83,8 @@ class brunel_recipe (arbor.recipe):
 
     def event_generators(self, gid):
         t0 = 0
-        idx = 0
         sched = arbor.poisson_schedule(t0, self.lambda_, gid + self.seed_)
-        return [arbor.event_generator(idx, self.weight_ext_, sched)]
-
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
+        return [arbor.event_generator("tgt", self.weight_ext_, sched)]
 
 if __name__ == "__main__":
 
diff --git a/python/example/dynamic-catalogue.py b/python/example/dynamic-catalogue.py
index d8da71925fbeca6c7e9706fc63760efdce2b374f..48248e72580a0b7abdb60969b16638962cdd798b 100644
--- a/python/example/dynamic-catalogue.py
+++ b/python/example/dynamic-catalogue.py
@@ -25,12 +25,6 @@ class recipe(arb.recipe):
     def num_cells(self):
         return 1
 
-    def num_targets(self, gid):
-        return 0
-
-    def num_sources(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return arb.cell_kind.cable
 
diff --git a/python/example/mpi.py b/python/example/mpi.py
index 14e7e409eb3083e03fadcfedba91a3379ac7a9b0..a7096ede95ab22ee2b1b021531f42d92ddf7c288 100644
--- a/python/example/mpi.py
+++ b/python/example/mpi.py
@@ -92,12 +92,6 @@ class ring_recipe (arbor.recipe):
         d = 5
         return [arbor.connection((src,0), (gid,0), w, d)]
 
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
-
     # (9) Attach a generator to the first cell in the ring.
     def event_generators(self, gid):
         if gid==0:
diff --git a/python/example/network_ring.py b/python/example/network_ring.py
index 236570658edea5b6aa288ccb2fa653ef2f76b6f5..1754ab38100cf7e4c19f3c006ab449640f517f06 100755
--- a/python/example/network_ring.py
+++ b/python/example/network_ring.py
@@ -48,10 +48,10 @@ def make_cable_cell(gid):
     decor.paint('"dend"', 'pas')
 
     # (4) Attach a single synapse.
-    decor.place('"synapse_site"', 'expsyn')
+    decor.place('"synapse_site"', 'expsyn', "syn")
 
     # Attach a spike detector with threshold of -10 mV.
-    decor.place('"root"', arbor.spike_detector(-10))
+    decor.place('"root"', arbor.spike_detector(-10), "detector")
 
     cell = arbor.cable_cell(tree, labels, decor)
 
@@ -88,19 +88,13 @@ class ring_recipe (arbor.recipe):
         src = (gid-1)%self.ncells
         w = 0.01
         d = 5
-        return [arbor.connection((src,0), 0, w, d)]
-
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
+        return [arbor.connection((src,"detector"), "syn", w, d)]
 
     # (9) Attach a generator to the first cell in the ring.
     def event_generators(self, gid):
         if gid==0:
             sched = arbor.explicit_schedule([1])
-            return [arbor.event_generator(0, 0.1, sched)]
+            return [arbor.event_generator("syn", 0.1, sched)]
         return []
 
     # (10) Place a probe at the root of each cell.
diff --git a/python/example/single_cell_cable.py b/python/example/single_cell_cable.py
index bf3d0ac5b602847890e22e7cc3558678a97f20e3..753c7169f40bb5712bbccf0f8dccd10f1129f82f 100755
--- a/python/example/single_cell_cable.py
+++ b/python/example/single_cell_cable.py
@@ -86,9 +86,7 @@ class Cable(arbor.recipe):
         decor.paint('"cable"',
                     arbor.mechanism(f'pas/e={self.Vm}', {'g': self.g}))
 
-        decor.place('"start"', arbor.iclamp(args.stimulus_start,
-                                            args.stimulus_duration,
-                                            args.stimulus_amplitude))
+        decor.place('"start"', arbor.iclamp(args.stimulus_start, args.stimulus_duration, args.stimulus_amplitude), "iclamp")
 
         policy = arbor.cv_policy_max_extent(self.cv_policy_max_extent)
         decor.discretization(policy)
diff --git a/python/example/single_cell_detailed.py b/python/example/single_cell_detailed.py
index 71e1346ab1040c9bcbef8ab278128c5464f0627f..fb3cd6fac4ae3bf95729c64d7c3d815263869600 100755
--- a/python/example/single_cell_detailed.py
+++ b/python/example/single_cell_detailed.py
@@ -60,10 +60,10 @@ decor.paint('"dend"',  mech('Ih', {'gbar': 0.001}))
 
 # Place stimuli and spike detectors.
 
-decor.place('"root"', arbor.iclamp(10, 1, current=2))
-decor.place('"root"', arbor.iclamp(30, 1, current=2))
-decor.place('"root"', arbor.iclamp(50, 1, current=2))
-decor.place('"axon_terminal"', arbor.spike_detector(-10))
+decor.place('"root"', arbor.iclamp(10, 1, current=2), "iclamp0")
+decor.place('"root"', arbor.iclamp(30, 1, current=2), "iclamp1")
+decor.place('"root"', arbor.iclamp(50, 1, current=2), "iclamp2")
+decor.place('"axon_terminal"', arbor.spike_detector(-10), "detector")
 
 # Set cv_policy
 
diff --git a/python/example/single_cell_detailed_recipe.py b/python/example/single_cell_detailed_recipe.py
index 2e1ecac9d40ed53d0800371ea18c472164b2a894..83ff1f056ccb6decc08926f52e822241aa6aa0d6 100644
--- a/python/example/single_cell_detailed_recipe.py
+++ b/python/example/single_cell_detailed_recipe.py
@@ -64,10 +64,10 @@ decor.paint('"dend"',  mech('Ih', {'gbar': 0.001}))
 
 # Place stimuli and spike detectors.
 
-decor.place('"root"', arbor.iclamp(10, 1, current=2))
-decor.place('"root"', arbor.iclamp(30, 1, current=2))
-decor.place('"root"', arbor.iclamp(50, 1, current=2))
-decor.place('"axon_terminal"', arbor.spike_detector(-10))
+decor.place('"root"', arbor.iclamp(10, 1, current=2), "iclamp0")
+decor.place('"root"', arbor.iclamp(30, 1, current=2), "iclamp1")
+decor.place('"root"', arbor.iclamp(50, 1, current=2), "iclamp2")
+decor.place('"axon_terminal"', arbor.spike_detector(-10), "detector")
 
 # Set cv_policy
 
@@ -109,12 +109,6 @@ class single_recipe (arbor.recipe):
     def num_cells(self):
         return 1
 
-    def num_sources(self, gid):
-        return 1
-
-    def num_targets(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
@@ -177,4 +171,4 @@ for d, m in sim.samples(handle):
 df = pandas.DataFrame()
 for i in range(len(data)):
     df = df.append(pandas.DataFrame({'t/ms': data[i][:, 0], 'U/mV': data[i][:, 1], 'Location': str(meta[i]), 'Variable':'voltage'}))
-seaborn.relplot(data=df, kind="line", x="t/ms", y="U/mV",hue="Location",col="Variable",ci=None).savefig('single_cell_recipe_result.svg')
\ No newline at end of file
+seaborn.relplot(data=df, kind="line", x="t/ms", y="U/mV",hue="Location",col="Variable",ci=None).savefig('single_cell_recipe_result.svg')
diff --git a/python/example/single_cell_model.py b/python/example/single_cell_model.py
index 39248517f9c05b13f2a7ed10fdd3384e8e4bdc9a..d0e83d1851ae94019dc3a46d4d92b9f5a3a09314 100755
--- a/python/example/single_cell_model.py
+++ b/python/example/single_cell_model.py
@@ -15,8 +15,8 @@ labels = arbor.label_dict({'soma':   '(tag 1)',
 decor = arbor.decor()
 decor.set_property(Vm=-40)
 decor.paint('"soma"', 'hh')
-decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-decor.place('"midpoint"', arbor.spike_detector(-10))
+decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), "iclamp")
+decor.place('"midpoint"', arbor.spike_detector(-10), "detector")
 
 # (4) Create cell and the single cell model based on it
 cell = arbor.cable_cell(tree, labels, decor)
diff --git a/python/example/single_cell_nml.py b/python/example/single_cell_nml.py
index 838cb92590987e4947fe504532702df4edb37a05..7c5920bcb828c8d4dee65cba35f46e0563f98114 100755
--- a/python/example/single_cell_nml.py
+++ b/python/example/single_cell_nml.py
@@ -57,12 +57,12 @@ decor.paint('"dend"', 'pas')
 # Increase resistivity on dendrites.
 decor.paint('"dend"', rL=500)
 # Attach stimuli that inject 4 nA current for 1 ms, starting at 3 and 8 ms.
-decor.place('"root"', arbor.iclamp(10, 1, current=5))
-decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(8, 1, current=4))
+decor.place('"root"', arbor.iclamp(10, 1, current=5), "iclamp0")
+decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5), "iclamp1")
+decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5), "iclamp2")
+decor.place('"stim_site"', arbor.iclamp(8, 1, current=4), "iclamp3")
 # Detect spikes at the soma with a voltage threshold of -10 mV.
-decor.place('"axon_end"', arbor.spike_detector(-10))
+decor.place('"axon_end"', arbor.spike_detector(-10), "detector")
 
 # Create the policy used to discretise the cell into CVs.
 # Use a single CV for the soma, and CVs of maximum length 1 μm elsewhere.
diff --git a/python/example/single_cell_recipe.py b/python/example/single_cell_recipe.py
index 43a28f86aa5e8db41c5b7fb5b1eff8b92a67eaff..f3d51e1dc304b90e889399102a85c7430830aff8 100644
--- a/python/example/single_cell_recipe.py
+++ b/python/example/single_cell_recipe.py
@@ -17,8 +17,8 @@ labels = arbor.label_dict({'soma':   '(tag 1)',
 decor = arbor.decor()
 decor.set_property(Vm=-40)
 decor.paint('"soma"', 'hh')
-decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8))
-decor.place('"midpoint"', arbor.spike_detector(-10))
+decor.place('"midpoint"', arbor.iclamp( 10, 2, 0.8), "iclamp")
+decor.place('"midpoint"', arbor.spike_detector(-10), "detector")
 cell = arbor.cable_cell(tree, labels, decor)
 
 # (4) Define a recipe for a single cell and set of probes upon it.
@@ -39,9 +39,6 @@ class single_recipe (arbor.recipe):
     def num_cells(self):
         return 1
 
-    def num_sources(self, gid):
-        return 1
-
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
diff --git a/python/example/single_cell_stdp.py b/python/example/single_cell_stdp.py
index 2c783520e31034888c499437560114677a391f55..b2db196e07abe9fcd956a69523fd596fe2ffd3e8 100755
--- a/python/example/single_cell_stdp.py
+++ b/python/example/single_cell_stdp.py
@@ -19,12 +19,6 @@ class single_recipe(arbor.recipe):
     def num_cells(self):
         return 1
 
-    def num_sources(self, gid):
-        return 1
-
-    def num_targets(self, gid):
-        return 2
-
     def cell_kind(self, gid):
         return arbor.cell_kind.cable
 
@@ -40,13 +34,13 @@ class single_recipe(arbor.recipe):
         decor.set_property(Vm=-40)
         decor.paint('(all)', 'hh')
 
-        decor.place('"center"', arbor.spike_detector(-10))
-        decor.place('"center"', 'expsyn')
+        decor.place('"center"', arbor.spike_detector(-10), "detector")
+        decor.place('"center"', 'expsyn', "synapse")
 
         mech_syn = arbor.mechanism('expsyn_stdp')
         mech_syn.set("max_weight", 1.)
 
-        decor.place('"center"', mech_syn)
+        decor.place('"center"', mech_syn, "stpd_synapse")
 
         cell = arbor.cable_cell(tree, labels, decor)
 
@@ -59,10 +53,10 @@ class single_recipe(arbor.recipe):
         stimulus_times = numpy.linspace(50, 500, self.n_pairs)
 
         # strong enough stimulus
-        spike = arbor.event_generator(0, 1., arbor.explicit_schedule(stimulus_times))
+        spike = arbor.event_generator("synapse", 1., arbor.explicit_schedule(stimulus_times))
 
         # zero weight -> just modify synaptic weight via stdp
-        stdp = arbor.event_generator(1, 0., arbor.explicit_schedule(stimulus_times - self.dT))
+        stdp = arbor.event_generator("stpd_synapse", 0., arbor.explicit_schedule(stimulus_times - self.dT))
 
         return [spike, stdp]
 
diff --git a/python/example/single_cell_swc.py b/python/example/single_cell_swc.py
index 7203919ed096b692225182311561f7f37ea40be2..f229b623a503d3072ea5606a79a6a0d808f528d6 100755
--- a/python/example/single_cell_swc.py
+++ b/python/example/single_cell_swc.py
@@ -49,12 +49,12 @@ decor.paint('"dend"', 'pas')
 # Increase resistivity on dendrites.
 decor.paint('"dend"', rL=500)
 # Attach stimuli that inject 4 nA current for 1 ms, starting at 3 and 8 ms.
-decor.place('"root"', arbor.iclamp(10, 1, current=5))
-decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5))
-decor.place('"stim_site"', arbor.iclamp(8, 1, current=4))
+decor.place('"root"', arbor.iclamp(10, 1, current=5), "iclamp0")
+decor.place('"stim_site"', arbor.iclamp(3, 1, current=0.5), "iclamp1")
+decor.place('"stim_site"', arbor.iclamp(10, 1, current=0.5), "iclamp2")
+decor.place('"stim_site"', arbor.iclamp(8, 1, current=4), "iclamp3")
 # Detect spikes at the soma with a voltage threshold of -10 mV.
-decor.place('"axon_end"', arbor.spike_detector(-10))
+decor.place('"axon_end"', arbor.spike_detector(-10), "detector")
 
 # Create the policy used to discretise the cell into CVs.
 # Use a single CV for the soma, and CVs of maximum length 1 μm elsewhere.
diff --git a/python/identifiers.cpp b/python/identifiers.cpp
index c64ed997b062a608fb6fbd165d8cd6b7c7e1917b..71e583a6242e9a2f7f2562c347f1cb868cfbacbf 100644
--- a/python/identifiers.cpp
+++ b/python/identifiers.cpp
@@ -15,6 +15,90 @@ namespace py = pybind11;
 void register_identifiers(py::module& m) {
     using namespace py::literals;
 
+    py::enum_<arb::lid_selection_policy>(m, "selection_policy",
+        "Enumeration used to identify a selection policy, used by the model for selecting one of possibly multiple locations on the cell associated with a labeled item.")
+        .value("round_robin", arb::lid_selection_policy::round_robin,
+               "iterate round-robin over all possible locations.")
+        .value("univalent", arb::lid_selection_policy::assert_univalent,
+               "Assert that there is only one possible location associated with a labeled item on the cell. The model throws an exception if the assertion fails.");
+
+    py::class_<arb::cell_local_label_type> cell_local_label_type(m, "cell_local_label",
+        "For local identification of an item.\n\n"
+        "cell_local_label identifies:\n"
+        "(1) a labeled group of one or more items on one or more locations on the cell.\n"
+        "(2) a policy for selecting one of the items.\n");
+
+    cell_local_label_type
+        .def(py::init(
+            [](arb::cell_tag_type label) {
+              return arb::cell_local_label_type{std::move(label)};
+            }),
+             "label"_a,
+             "Construct a cell_local_label identifier from a label argument identifying a group of one or more items on a cell.\n"
+             "The default round_robin policy is used for selecting one of possibly multiple items associated with the label.")
+        .def(py::init(
+            [](arb::cell_tag_type label, arb::lid_selection_policy policy) {
+              return arb::cell_local_label_type{std::move(label), policy};
+            }),
+             "label"_a, "policy"_a,
+             "Construct a cell_local_label identifier with arguments:\n"
+             "  label:  The identifier of a group of one or more items on a cell.\n"
+             "  policy: The policy for selecting one of possibly multiple items associated with the label.\n")
+        .def(py::init([](py::tuple t) {
+               if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
+               return arb::cell_local_label_type{t[0].cast<arb::cell_tag_type>(), t[1].cast<arb::lid_selection_policy>()};
+             }),
+             "Construct a cell_local_label identifier with tuple argument (label, policy):\n"
+             "  label:  The identifier of a group of one or more items on a cell.\n"
+             "  policy: The policy for selecting one of possibly multiple items associated with the label.\n")
+        .def_readwrite("label",  &arb::cell_local_label_type::tag,
+             "The identifier of a a group of one or more items on a cell.")
+        .def_readwrite("policy", &arb::cell_local_label_type::policy,
+            "The policy for selecting one of possibly multiple items associated with the label.")
+        .def("__str__", [](arb::cell_local_label_type m) {return pprintf("<arbor.cell_local_label: label {}, policy {}>", m.tag, m.policy);})
+        .def("__repr__",[](arb::cell_local_label_type m) {return pprintf("<arbor.cell_local_label: label {}, policy {}>", m.tag, m.policy);});
+
+    py::implicitly_convertible<py::tuple, arb::cell_local_label_type>();
+    py::implicitly_convertible<py::str, arb::cell_local_label_type>();
+
+    py::class_<arb::cell_global_label_type> cell_global_label_type(m, "cell_global_label",
+        "For global identification of an item.\n\n"
+        "cell_global_label members:\n"
+        "(1) a unique cell identified by its gid.\n"
+        "(2) a cell_local_label, referring to a labeled group of items on the cell and a policy for selecting a single item out of the group.\n");
+
+    cell_global_label_type
+        .def(py::init(
+            [](arb::cell_gid_type gid, arb::cell_tag_type label) {
+              return arb::cell_global_label_type{gid, std::move(label)};
+            }),
+             "gid"_a, "label"_a,
+             "Construct a cell_global_label identifier from a gid and a label argument identifying an item on the cell.\n"
+             "The default round_robin policy is used for selecting one of possibly multiple items on the cell associated with the label.")
+        .def(py::init(
+            [](arb::cell_gid_type gid, arb::cell_local_label_type label) {
+              return arb::cell_global_label_type{gid, label};
+            }),
+             "gid"_a, "label"_a,
+             "Construct a cell_global_label identifier with arguments:\n"
+             "  gid:   The global identifier of the cell.\n"
+             "  label: The cell_local_label representing the label and selection policy of an item on the cell.\n")
+        .def(py::init([](py::tuple t) {
+               if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
+               return arb::cell_global_label_type{t[0].cast<arb::cell_gid_type>(), t[1].cast<arb::cell_local_label_type>()};
+             }),
+             "Construct a cell_global_label identifier with tuple argument (gid, label):\n"
+             "  gid:   The global identifier of the cell.\n"
+             "  label: The cell_local_label representing the label and selection policy of an item on the cell.\n")
+        .def_readwrite("gid",  &arb::cell_global_label_type::gid,
+             "The global identifier of the cell.")
+        .def_readwrite("label", &arb::cell_global_label_type::label,
+             "The cell_local_label representing the label and selection policy of an item on the cell.")
+        .def("__str__", [](arb::cell_global_label_type m) {return pprintf("<arbor.cell_global_label: gid {}, label ({}, {})>", m.gid, m.label.tag, m.label.policy);})
+        .def("__repr__",[](arb::cell_global_label_type m) {return pprintf("<arbor.cell_global_label: gid {}, label ({}, {})>", m.gid, m.label.tag, m.label.policy);});
+
+    py::implicitly_convertible<py::tuple, arb::cell_global_label_type>();
+
     py::class_<arb::cell_member_type> cell_member(m, "cell_member",
         "For global identification of a cell-local item.\n\n"
         "Items of cell_member must:\n"
@@ -31,7 +115,7 @@ void register_identifiers(py::module& m) {
             "  gid:     The global identifier of the cell.\n"
             "  index:   The cell-local index of the item.\n")
         .def(py::init([](py::tuple t) {
-                if (py::len(t)!=2) throw std::runtime_error("tuple length != 4");
+                if (py::len(t)!=2) throw std::runtime_error("tuple length != 2");
                 return arb::cell_member_type{t[0].cast<arb::cell_gid_type>(), t[1].cast<arb::cell_lid_type>()};
             }),
             "Construct a cell member identifier with tuple argument (gid, index):\n"
diff --git a/python/recipe.cpp b/python/recipe.cpp
index febd5cdbd7d836f1d87cc7a8fdc91c0a883a790e..b5d23897d6b866d2496a22ae9de51fc7aa5334d0 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -118,13 +118,13 @@ std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid
 }
 
 std::string con_to_string(const arb::cell_connection& c) {
-    return util::pprintf("<arbor.connection: source ({},{}), destination {}, delay {}, weight {}>",
-         c.source.gid, c.source.index, c.dest, c.delay, c.weight);
+    return util::pprintf("<arbor.connection: source ({}, \"{}\", {}), destination (\"{}\", {}), delay {}, weight {}>",
+         c.source.gid, c.source.label.tag, c.source.label.policy, c.dest.tag, c.dest.policy, c.delay, c.weight);
 }
 
 std::string gj_to_string(const arb::gap_junction_connection& gc) {
-    return util::pprintf("<arbor.gap_junction_connection: peer ({},{}), local {}, ggap {}>",
-         gc.peer.gid, gc.peer.index, gc.local, gc.ggap);
+    return util::pprintf("<arbor.gap_junction_connection: peer ({}, \"{}\", {}), local (\"{}\", {}), ggap {}>",
+         gc.peer.gid, gc.peer.label.tag, gc.peer.label.policy, gc.local.tag, gc.local.policy, gc.ggap);
 }
 
 void register_recipe(pybind11::module& m) {
@@ -135,7 +135,7 @@ void register_recipe(pybind11::module& m) {
         "Describes a connection between two cells:\n"
         "  Defined by source and destination end points (that is pre-synaptic and post-synaptic respectively), a connection weight and a delay time.");
     cell_connection
-        .def(pybind11::init<arb::cell_member_type, arb::cell_lid_type, float, float>(),
+        .def(pybind11::init<arb::cell_global_label_type, arb::cell_local_label_type, float, float>(),
             "source"_a, "dest"_a, "weight"_a, "delay"_a,
             "Construct a connection with arguments:\n"
             "  source:      The source end point of the connection.\n"
@@ -143,9 +143,9 @@ void register_recipe(pybind11::module& m) {
             "  weight:      The weight delivered to the target synapse (unit defined by the type of synapse target).\n"
             "  delay:       The delay of the connection [ms].")
         .def_readwrite("source", &arb::cell_connection::source,
-            "The source of the connection.")
+            "The source gid and label of the connection.")
         .def_readwrite("dest", &arb::cell_connection::dest,
-            "The destination of the connection.")
+            "The destination label of the connection.")
         .def_readwrite("weight", &arb::cell_connection::weight,
             "The weight of the connection.")
         .def_readwrite("delay", &arb::cell_connection::delay,
@@ -157,16 +157,16 @@ void register_recipe(pybind11::module& m) {
     pybind11::class_<arb::gap_junction_connection> gap_junction_connection(m, "gap_junction_connection",
         "Describes a gap junction between two gap junction sites.");
     gap_junction_connection
-        .def(pybind11::init<arb::cell_member_type, arb::cell_lid_type, double>(),
+        .def(pybind11::init<arb::cell_global_label_type, arb::cell_local_label_type, double>(),
             "peer"_a, "local"_a, "ggap"_a,
             "Construct a gap junction connection with arguments:\n"
-            "  peer:  One half of the gap junction connection.\n"
-            "  local: Other half of the gap junction connection.\n"
+            "  peer:  remote half of the gap junction connection.\n"
+            "  local: local half of the gap junction connection.\n"
             "  ggap:  Gap junction conductance [μS].")
         .def_readwrite("peer", &arb::gap_junction_connection::peer,
-            "Peer half of the gap junction connection.")
+            "Remote gid and label of the gap junction connection.")
         .def_readwrite("local", &arb::gap_junction_connection::local,
-            "Local half of the gap junction connection.")
+            "Local label of the gap junction connection.")
         .def_readwrite("ggap", &arb::gap_junction_connection::ggap,
             "Gap junction conductance [μS].")
         .def("__str__",  &gj_to_string)
@@ -187,15 +187,6 @@ void register_recipe(pybind11::module& m) {
         .def("cell_kind", &py_recipe::cell_kind,
             "gid"_a,
             "The kind of cell with global identifier gid.")
-        .def("num_sources", &py_recipe::num_sources,
-            "gid"_a,
-            "The number of spike sources on gid, 0 by default.")
-        .def("num_targets", &py_recipe::num_targets,
-            "gid"_a,
-            "The number of post-synaptic sites on gid, 0 by default.")
-        .def("num_gap_junction_sites", &py_recipe::num_gap_junction_sites,
-            "gid"_a,
-            "The number of gap junction sites on gid, 0 by default.")
         .def("event_generators", &py_recipe::event_generators,
             "gid"_a,
             "A list of all the event generators that are attached to gid, [] by default.")
diff --git a/python/recipe.hpp b/python/recipe.hpp
index b00a608ce52f303a68612d004184c579b810eee3..9956b7240e4d050acb928ee4a7ac6aa988847a52 100644
--- a/python/recipe.hpp
+++ b/python/recipe.hpp
@@ -33,15 +33,6 @@ public:
     virtual pybind11::object cell_description(arb::cell_gid_type gid) const = 0;
     virtual arb::cell_kind cell_kind(arb::cell_gid_type gid) const = 0;
 
-    virtual arb::cell_size_type num_sources(arb::cell_gid_type) const {
-        return 0;
-    }
-    virtual arb::cell_size_type num_targets(arb::cell_gid_type) const {
-        return 0;
-    }
-    virtual arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const {
-        return gap_junctions_on(gid).size();
-    }
     virtual std::vector<pybind11::object> event_generators(arb::cell_gid_type gid) const {
         return {};
     }
@@ -74,18 +65,6 @@ public:
         PYBIND11_OVERLOAD_PURE(arb::cell_kind, py_recipe, cell_kind, gid);
     }
 
-    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_sources, gid);
-    }
-
-    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_targets, gid);
-    }
-
-    arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
-        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_gap_junction_sites, gid);
-    }
-
     std::vector<pybind11::object> event_generators(arb::cell_gid_type gid) const override {
         PYBIND11_OVERLOAD(std::vector<pybind11::object>, py_recipe, event_generators, gid);
     }
@@ -136,18 +115,6 @@ public:
         return try_catch_pyexception([&](){ return impl_->cell_kind(gid); }, msg);
     }
 
-    arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        return try_catch_pyexception([&](){ return impl_->num_sources(gid); }, msg);
-    }
-
-    arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        return try_catch_pyexception([&](){ return impl_->num_targets(gid); }, msg);
-    }
-
-    arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
-        return try_catch_pyexception([&](){ return impl_->num_gap_junction_sites(gid); }, msg);
-    }
-
     std::vector<arb::event_generator> event_generators(arb::cell_gid_type gid) const override;
 
     std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
diff --git a/python/single_cell_model.cpp b/python/single_cell_model.cpp
index 22b17ada403bf9c7919e114a60635205f3fbb9ad..64b416898a2f4431536e6d35bb8ebbb4b74d54f2 100644
--- a/python/single_cell_model.cpp
+++ b/python/single_cell_model.cpp
@@ -92,15 +92,7 @@ struct single_cell_recipe: arb::recipe {
         return arb::cell_kind::cable;
     }
 
-    virtual arb::cell_size_type num_sources(arb::cell_gid_type) const override {
-        return cell_.detectors().size();
-    }
-
-    // synapses, connections and event generators
-
-    virtual arb::cell_size_type num_targets(arb::cell_gid_type) const override {
-        return cell_.synapses().size();
-    }
+    // connections and event generators
 
     virtual std::vector<arb::cell_connection> connections_on(arb::cell_gid_type) const override {
         return {}; // no connections on a single cell model
@@ -123,10 +115,6 @@ struct single_cell_recipe: arb::recipe {
 
     // gap junctions
 
-    virtual arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid)  const override {
-        return 0; // No gap junctions on a single cell model.
-    }
-
     virtual std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type) const override {
         return {}; // No gap junctions on a single cell model.
     }
diff --git a/python/test/unit/test_cable_probes.py b/python/test/unit/test_cable_probes.py
index 8a39d3cfff1ad3a45224e39663b424b5dc2c01db..33d2c254c1e5d5ec30faab29c80b94f07ca5cb1c 100644
--- a/python/test/unit/test_cable_probes.py
+++ b/python/test/unit/test_cable_probes.py
@@ -27,9 +27,9 @@ class cc_recipe(A.recipe):
 
         dec = A.decor()
 
-        dec.place('(location 0 0.08)', "expsyn")
-        dec.place('(location 0 0.09)', "exp2syn")
-        dec.place('(location 0 0.1)', A.iclamp(20.))
+        dec.place('(location 0 0.08)', "expsyn", "syn0")
+        dec.place('(location 0 0.09)', "exp2syn", "syn1")
+        dec.place('(location 0 0.1)', A.iclamp(20.), "iclamp")
         dec.paint('(all)', "hh")
 
         self.cell = A.cable_cell(st, A.label_dict(), dec)
@@ -41,12 +41,6 @@ class cc_recipe(A.recipe):
     def num_cells(self):
         return 1
 
-    def num_targets(self, gid):
-        return 2
-
-    def num_sources(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return A.cell_kind.cable
 
diff --git a/python/test/unit/test_catalogues.py b/python/test/unit/test_catalogues.py
index eac665c1d0d3ce3052ddf58e7d8c4eca53745747..f81f3f41b2901841c55e8f3c90255df2c1f5fcd1 100644
--- a/python/test/unit/test_catalogues.py
+++ b/python/test/unit/test_catalogues.py
@@ -39,12 +39,6 @@ class recipe(arb.recipe):
     def num_cells(self):
         return 1
 
-    def num_targets(self, gid):
-        return 0
-
-    def num_sources(self, gid):
-        return 0
-
     def cell_kind(self, gid):
         return arb.cell_kind.cable
 
diff --git a/python/test/unit/test_event_generators.py b/python/test/unit/test_event_generators.py
index a8b837388ad1495871dfad4185b89a303e314ae3..c45e591f386057dabde675fdab52e9dffd35ea03 100644
--- a/python/test/unit/test_event_generators.py
+++ b/python/test/unit/test_event_generators.py
@@ -22,21 +22,26 @@ all tests for event generators (regular, explicit, poisson)
 class EventGenerator(unittest.TestCase):
 
     def test_event_generator_regular_schedule(self):
+        cm = arb.cell_local_label("tgt0")
         rs = arb.regular_schedule(2.0, 1., 100.)
-        rg = arb.event_generator(3, 3.14, rs)
-        self.assertEqual(rg.target, 3)
+        rg = arb.event_generator(cm, 3.14, rs)
+        self.assertEqual(rg.target.label, "tgt0")
+        self.assertEqual(rg.target.policy, arb.selection_policy.univalent)
         self.assertAlmostEqual(rg.weight, 3.14)
 
     def test_event_generator_explicit_schedule(self):
+        cm = arb.cell_local_label("tgt1", arb.selection_policy.round_robin)
         es = arb.explicit_schedule([0,1,2,3,4.4])
-        eg = arb.event_generator(42, -0.01, es)
-        self.assertEqual(eg.target, 42)
+        eg = arb.event_generator(cm, -0.01, es)
+        self.assertEqual(eg.target.label, "tgt1")
+        self.assertEqual(eg.target.policy, arb.selection_policy.round_robin)
         self.assertAlmostEqual(eg.weight, -0.01)
 
     def test_event_generator_poisson_schedule(self):
         ps = arb.poisson_schedule(0., 10., 0)
-        pg = arb.event_generator(2, 42., ps)
-        self.assertEqual(pg.target, 2)
+        pg = arb.event_generator("tgt2", 42., ps)
+        self.assertEqual(pg.target.label, "tgt2")
+        self.assertEqual(pg.target.policy, arb.selection_policy.univalent)
         self.assertEqual(pg.weight, 42.)
 
 def suite():
diff --git a/python/test/unit/test_simulator.py b/python/test/unit/test_simulator.py
new file mode 100644
index 0000000000000000000000000000000000000000..bee420b3f82bfb926b520be9d4d34d2b585b5827
--- /dev/null
+++ b/python/test/unit/test_simulator.py
@@ -0,0 +1,252 @@
+# -*- coding: utf-8 -*-
+#
+# test_simulator.py
+
+import unittest
+import numpy as np
+import arbor as A
+
+# to be able to run .py file from child directory
+import sys, os
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
+
+try:
+    import options
+except ModuleNotFoundError:
+    from test import options
+
+"""
+all tests for the simulator wrapper
+"""
+
+# Test recipe cc2 comprises two cable cells and some probes.
+
+class cc2_recipe(A.recipe):
+    def __init__(self):
+        A.recipe.__init__(self)
+        st = A.segment_tree()
+        i = st.append(A.mnpos, (0, 0, 0, 10), (1, 0, 0, 10), 1)
+        st.append(i, (1, 3, 0, 5), 1)
+        st.append(i, (1, -4, 0, 3), 1)
+        self.the_morphology = A.morphology(st)
+        self.the_cat = A.default_catalogue()
+        self.the_props = A.neuron_cable_properties()
+        self.the_props.register(self.the_cat)
+
+    def num_cells(self):
+        return 2
+
+    def cell_kind(self, gid):
+        return A.cell_kind.cable
+
+    def connections_on(self, gid):
+        return []
+
+    def event_generators(self, gid):
+        return []
+
+    def global_properties(self, kind):
+        return self.the_props
+
+    def probes(self, gid):
+        # Cell 0 has three voltage probes:
+        #     0, 0: end of branch 1
+        #     0, 1: end of branch 2
+        #     0, 2: all terminal points
+        # Values sampled from (0, 0) and (0, 1) should correspond
+        # to the values sampled from (0, 2).
+
+        # Cell 1 has whole cell probes:
+        #     0, 0: all membrane voltages
+        #     0, 1: all expsyn state variable 'g'
+
+        if gid==0:
+            return [A.cable_probe_membrane_voltage('(location 1 1)'),
+                    A.cable_probe_membrane_voltage('(location 2 1)'),
+                    A.cable_probe_membrane_voltage('(terminal)')]
+        elif gid==1:
+            return [A.cable_probe_membrane_voltage_cell(),
+                    A.cable_probe_point_state_cell('expsyn', 'g')]
+        else:
+            return []
+
+    def cell_description(self, gid):
+        c = A.cable_cell(self.the_morphology, A.label_dict())
+        c.set_properties(Vm=0.0, cm=0.01, rL=30, tempK=300)
+        c.paint('(all)', "pas")
+        c.place('(location 0 0)', A.iclamp(current=10 if gid==0 else 20))
+        c.place('(sum (on-branches 0.3) (location 0 0.6))', "expsyn")
+        return c
+
+# Test recipe lif2 comprises two independent LIF cells driven by a regular, rapid
+# sequence of incoming spikes. The cells have differing refactory periods.
+
+class lif2_recipe(A.recipe):
+    def __init__(self):
+        A.recipe.__init__(self)
+
+    def num_cells(self):
+        return 2
+
+    def cell_kind(self, gid):
+        return A.cell_kind.lif
+
+    def connections_on(self, gid):
+        return []
+
+    def event_generators(self, gid):
+        sched_dt = 0.25
+        weight = 400
+        return [A.event_generator((gid,0), weight, A.regular_schedule(sched_dt)) for gid in range(0, self.num_cells())]
+
+    def probes(self, gid):
+        return []
+
+    def cell_description(self, gid):
+        c = A.lif_cell()
+        if gid==0:
+            c.t_ref = 2
+        if gid==1:
+            c.t_ref = 4
+        return c
+
+class Simulator(unittest.TestCase):
+    def init_sim(self, recipe):
+        context = A.context()
+        dd = A.partition_load_balance(recipe, context)
+        return A.simulation(recipe, dd, context)
+
+    def test_simple_run(self):
+        sim = self.init_sim(cc2_recipe())
+        sim.run(1.0, 0.01)
+
+    def test_probe_meta(self):
+        sim = self.init_sim(cc2_recipe())
+
+        self.assertEqual([A.location(1, 1)], sim.probe_metadata((0, 0)))
+        self.assertEqual([A.location(2, 1)], sim.probe_metadata((0, 1)))
+        self.assertEqual([A.location(1, 1), A.location(2, 1)], sorted(sim.probe_metadata((0, 2)), key=lambda x:(x.branch, x.pos)))
+
+        # Default CV policy is one per branch, which also gives a tivial CV over the branch point.
+        # Expect metadata cables to be one for each full branch, plus three length-zero cables corresponding to the branch point.
+        self.assertEqual([A.cable(0, 0, 1), A.cable(0, 1, 1), A.cable(1, 0, 0), A.cable(1, 0, 1), A.cable(2, 0, 0), A.cable(2, 0, 1)],
+                sorted(sim.probe_metadata((1,0))[0], key=lambda x:(x.branch, x.prox, x.dist)))
+
+        # Four expsyn synapses; the two on branch zero should be coalesced, giving a multiplicity of 2.
+        # Expect entries to be in target index order.
+        m11 = sim.probe_metadata((1,1))[0]
+        self.assertEqual(4, len(m11))
+        self.assertEqual([0, 1, 2, 3], [x.target for x in m11])
+        self.assertEqual([2, 2, 1, 1], [x.multiplicity for x in m11])
+        self.assertEqual([A.location(0, 0.3), A.location(0, 0.6), A.location(1, 0.3), A.location(2, 0.3)], [x.location for x in m11])
+
+    def test_probe_scalar_recorders(self):
+        sim = self.init_sim(cc2_recipe())
+        ts = [0, 0.1, 0.3, 0.7]
+        h = sim.sample((0, 0), A.explicit_schedule(ts))
+        dt = 0.01
+        sim.run(10., dt)
+        s, meta = sim.samples(h)[0]
+        self.assertEqual(A.location(1, 1), meta)
+        for i, t in enumerate(s[:,0]):
+            self.assertLess(abs(t-ts[i]), dt)
+
+        sim.remove_sampler(h)
+        sim.reset()
+        h = sim.sample(A.cell_member(0, 0), A.explicit_schedule(ts), A.sampling_policy.exact)
+        sim.run(10., dt)
+        s, meta = sim.samples(h)[0]
+        for i, t in enumerate(s[:,0]):
+            self.assertEqual(t, ts[i])
+
+
+    def test_probe_multi_scalar_recorders(self):
+        sim = self.init_sim(cc2_recipe())
+        ts = [0, 0.1, 0.3, 0.7]
+        h0 = sim.sample((0, 0), A.explicit_schedule(ts))
+        h1 = sim.sample((0, 1), A.explicit_schedule(ts))
+        h2 = sim.sample((0, 2), A.explicit_schedule(ts))
+
+        dt = 0.01
+        sim.run(10., dt)
+
+        r0 = sim.samples(h0)
+        self.assertEqual(1, len(r0))
+        s0, meta0 = r0[0]
+
+        r1 = sim.samples(h1)
+        self.assertEqual(1, len(r1))
+        s1, meta1 = r1[0]
+
+        r2 = sim.samples(h2)
+        self.assertEqual(2, len(r2))
+        s20, meta20 = r2[0]
+        s21, meta21 = r2[1]
+
+        # Probe id (0, 2) has probes over the two locations that correspond to probes (0, 0) and (0, 1).
+
+        # (order is not guaranteed to line up though)
+        if meta20==meta0:
+            self.assertEqual(meta1, meta21)
+            self.assertTrue((s0[:,1]==s20[:,1]).all())
+            self.assertTrue((s1[:,1]==s21[:,1]).all())
+        else:
+            self.assertEqual(meta1, meta20)
+            self.assertTrue((s1[:,1]==s20[:,1]).all())
+            self.assertEqual(meta0, meta21)
+            self.assertTrue((s0[:,1]==s21[:,1]).all())
+
+    def test_probe_vector_recorders(self):
+        sim = self.init_sim(cc2_recipe())
+        ts = [0, 0.1, 0.3, 0.7]
+        h0 = sim.sample((1, 0), A.explicit_schedule(ts), A.sampling_policy.exact)
+        h1 = sim.sample((1, 1), A.explicit_schedule(ts), A.sampling_policy.exact)
+        sim.run(10., 0.01)
+
+        # probe (1, 0) is the whole cell voltage; expect time + 6 sample values per row in returned data (see test_probe_meta above).
+
+        s0, meta0 = sim.samples(h0)[0]
+        self.assertEqual(6, len(meta0))
+        self.assertEqual((len(ts), 7), s0.shape)
+        for i, t in enumerate(s0[:,0]):
+            self.assertEqual(t, ts[i])
+
+        # probe (1, 1) is the 'g' state for all expsyn synapses.
+        # With the default descretization, expect two synapses with multiplicity 2 and two with multiplicity 1.
+
+        s1, meta1 = sim.samples(h1)[0]
+        self.assertEqual(4, len(meta1))
+        self.assertEqual((len(ts), 5), s1.shape)
+        for i, t in enumerate(s1[:,0]):
+            self.assertEqual(t, ts[i])
+
+        meta1_mult = {(m.location.branch, m.location.pos): m.multiplicity for m in meta1}
+        self.assertEqual(2, meta1_mult[(0, 0.3)])
+        self.assertEqual(2, meta1_mult[(0, 0.6)])
+        self.assertEqual(1, meta1_mult[(1, 0.3)])
+        self.assertEqual(1, meta1_mult[(2, 0.3)])
+
+    def test_spikes(self):
+        sim = self.init_sim(lif2_recipe())
+        sim.record(A.spike_recording.all)
+        sim.run(21, 0.01)
+
+        spikes = sim.spikes().tolist()
+        s0 = sorted([t for s, t in spikes if s==(0, 0)])
+        s1 = sorted([t for s, t in spikes if s==(1, 0)])
+
+        self.assertEqual([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20], s0)
+        self.assertEqual([0, 4, 8, 12, 16, 20], s1)
+
+def suite():
+    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
+    suite = unittest.makeSuite(Simulator, ('test'))
+    return suite
+
+def run():
+    v = options.parse_arguments().verbosity
+    runner = unittest.TextTestRunner(verbosity = v)
+    runner.run(suite())
+
+if __name__ == "__main__":
+    run()
diff --git a/python/test/unit/test_spikes.py b/python/test/unit/test_spikes.py
index 86e7464e756868d22f1c69ab81fdf6c6cb2cc5e2..8211f5de9d10acd5334b7b08f9e93779a0931d62 100644
--- a/python/test/unit/test_spikes.py
+++ b/python/test/unit/test_spikes.py
@@ -32,12 +32,6 @@ class art_spiker_recipe(A.recipe):
     def num_cells(self):
         return 3
 
-    def num_targets(self, gid):
-        return 0
-
-    def num_sources(self, gid):
-        return 1
-
     def cell_kind(self, gid):
         return A.cell_kind.spike_source
 
@@ -54,7 +48,7 @@ class art_spiker_recipe(A.recipe):
         return []
 
     def cell_description(self, gid):
-        return A.spike_source_cell(A.explicit_schedule(self.trains[gid]))
+        return A.spike_source_cell("src", A.explicit_schedule(self.trains[gid]))
 
 
 class Spikes(unittest.TestCase):
diff --git a/python/test/unit_distributed/test_domain_decompositions.py b/python/test/unit_distributed/test_domain_decompositions.py
index 7a52288ed2645018836a8998da4066be3c7ca653..439fd4259ec6a691f94f8b57262bdf7a505f9847 100644
--- a/python/test/unit_distributed/test_domain_decompositions.py
+++ b/python/test/unit_distributed/test_domain_decompositions.py
@@ -49,7 +49,11 @@ class hetero_recipe (arb.recipe):
         return self.ncells
 
     def cell_description(self, gid):
-        return []
+        tree = arb.segment_tree()
+        tree.append(arb.mnpos, arb.mpoint(-3, 0, 0, 3), arb.mpoint(3, 0, 0, 3), tag=1)
+        decor = arb.decor()
+        decor.place('(location 0 0.5)', arb.gap_junction_site(), "gj")
+        return arb.cable_cell(tree, arb.label_dict(), decor)
 
     def cell_kind(self, gid):
         if (gid%2):
@@ -57,12 +61,6 @@ class hetero_recipe (arb.recipe):
         else:
             return arb.cell_kind.cable
 
-    def num_sources(self, gid):
-        return 0
-
-    def num_targets(self, gid):
-        return 0
-
     def connections_on(self, gid):
         return []
 
@@ -79,22 +77,22 @@ class gj_switch:
         return getattr(self, 'case_' + str(arg), lambda: default)()
 
     def case_1(self):
-        return [arb.gap_junction_connection(arb.cell_member(7 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((7 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_2(self):
-        return [arb.gap_junction_connection(arb.cell_member(6 + self.shift_, 0), 0, 0.1),
-                arb.gap_junction_connection(arb.cell_member(9 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((6 + self.shift_, "gj"), "gj", 0.1),
+                arb.gap_junction_connection((9 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_6(self):
-        return [arb.gap_junction_connection(arb.cell_member(2 + self.shift_, 0), 0, 0.1),
-                arb.gap_junction_connection(arb.cell_member(7 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((2 + self.shift_, "gj"), "gj", 0.1),
+                arb.gap_junction_connection((7 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_7(self):
-        return [arb.gap_junction_connection(arb.cell_member(6 + self.shift_, 0), 0, 0.1),
-                arb.gap_junction_connection(arb.cell_member(1 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((6 + self.shift_, "gj"), "gj", 0.1),
+                arb.gap_junction_connection((1 + self.shift_, "gj"), "gj", 0.1)]
 
     def case_9(self):
-        return [arb.gap_junction_connection(arb.cell_member(2 + self.shift_, 0), 0, 0.1)]
+        return [arb.gap_junction_connection((2 + self.shift_, "gj"), "gj", 0.1)]
 
 class gj_symmetric (arb.recipe):
     def __init__(self, num_ranks):
@@ -127,7 +125,11 @@ class gj_non_symmetric (arb.recipe):
         return self.size*self.groups
 
     def cell_description(self, gid):
-        return []
+        tree = arb.segment_tree()
+        tree.append(arb.mnpos, arb.mpoint(-3, 0, 0, 3), arb.mpoint(3, 0, 0, 3), tag=1)
+        decor = arb.decor()
+        decor.place('(location 0 0.5)', arb.gap_junction_site(), "gj")
+        return arb.cable_cell(tree, arb.label_dict(), decor)
 
     def cell_kind(self, gid):
         return arb.cell_kind.cable
@@ -137,9 +139,9 @@ class gj_non_symmetric (arb.recipe):
         id = gid%self.size
 
         if (id == group and group != (self.groups - 1)):
-            return [arb.gap_junction_connection(arb.cell_member(gid + self.size, 0), 0, 0.1)]
+            return [arb.gap_junction_connection((gid + self.size, "gj"), "gj", 0.1)]
         elif (id == group - 1):
-            return [arb.gap_junction_connection(arb.cell_member(gid - self.size, 0), 0, 0.1)]
+            return [arb.gap_junction_connection((gid - self.size, "gj"), "gj", 0.1)]
         else:
             return []
 
diff --git a/python/test/unit_distributed/test_simulator.py b/python/test/unit_distributed/test_simulator.py
index 399ffdc4af905de25fe9583e6d70e7524d0609c6..0dcf1374a0d534242d4e13f8393cf68f012189ec 100644
--- a/python/test/unit_distributed/test_simulator.py
+++ b/python/test/unit_distributed/test_simulator.py
@@ -31,12 +31,6 @@ class lifN_recipe(A.recipe):
     def num_cells(self):
         return self.n_cell
 
-    def num_targets(self, gid):
-        return 1
-
-    def num_sources(self, gid):
-        return 1
-
     def cell_kind(self, gid):
         return A.cell_kind.lif
 
@@ -46,7 +40,7 @@ class lifN_recipe(A.recipe):
     def event_generators(self, gid):
         sched_dt = 0.25
         weight = 400
-        return [A.event_generator(0, weight, A.regular_schedule(sched_dt)) for gid in range(0, self.num_cells())]
+        return [A.event_generator("tgt", weight, A.regular_schedule(sched_dt)) for gid in range(0, self.num_cells())]
 
     def probes(self, gid):
         return []
@@ -55,7 +49,7 @@ class lifN_recipe(A.recipe):
         return self.props
 
     def cell_description(self, gid):
-        c = A.lif_cell()
+        c = A.lif_cell("src", "tgt")
         if gid%2==0:
             c.t_ref = 2
         else:
diff --git a/test/common_cells.cpp b/test/common_cells.cpp
index 7a5deb48f7c52a3a6690087085adff174db51d65..94422df0879c4a70918c3e5577061e8fb7fd69df 100644
--- a/test/common_cells.cpp
+++ b/test/common_cells.cpp
@@ -132,7 +132,7 @@ msize_t soma_cell_builder::add_branch(
     return bid;
 }
 
-cable_cell_description  soma_cell_builder::make_cell() const {
+cable_cell_description soma_cell_builder::make_cell() const {
     // Test that a valid tree was generated, that is, every branch has
     // either 0 children, or at least 2 children.
     for (auto i: branch_distal_id) {
@@ -181,7 +181,7 @@ cable_cell_description make_cell_soma_only(bool with_stim) {
     auto c = builder.make_cell();
     c.decorations.paint("soma"_lab, "hh");
     if (with_stim) {
-        c.decorations.place(builder.location({0,0.5}), i_clamp{10., 100., 0.1});
+        c.decorations.place(builder.location({0,0.5}), i_clamp{10., 100., 0.1}, "cc");
     }
 
     return {c.morph, c.labels, c.decorations};
@@ -217,7 +217,7 @@ cable_cell_description make_cell_ball_and_stick(bool with_stim) {
     c.decorations.paint("soma"_lab, "hh");
     c.decorations.paint("dend"_lab, "pas");
     if (with_stim) {
-        c.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3});
+        c.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3}, "cc");
     }
 
     return {c.morph, c.labels, c.decorations};
@@ -256,8 +256,8 @@ cable_cell_description make_cell_ball_and_3stick(bool with_stim) {
     c.decorations.paint("soma"_lab, "hh");
     c.decorations.paint("dend"_lab, "pas");
     if (with_stim) {
-        c.decorations.place(builder.location({2,1}), i_clamp{5.,  80., 0.45});
-        c.decorations.place(builder.location({3,1}), i_clamp{40., 10.,-0.2});
+        c.decorations.place(builder.location({2,1}), i_clamp{5.,  80., 0.45}, "cc0");
+        c.decorations.place(builder.location({3,1}), i_clamp{40., 10.,-0.2}, "cc1");
     }
 
     return {c.morph, c.labels, c.decorations};
diff --git a/test/simple_recipes.hpp b/test/simple_recipes.hpp
index 5344a1361bde35598945793894f24c37b15c4a5a..5ff33ff6b3b2a10a92e784e9c15e745a284c2f39 100644
--- a/test/simple_recipes.hpp
+++ b/test/simple_recipes.hpp
@@ -116,14 +116,6 @@ public:
     cell_size_type num_cells() const override { return cells_.size(); }
     cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
 
-    cell_size_type num_sources(cell_gid_type i) const override {
-        return cells_.at(i).detectors().size();
-    }
-
-    cell_size_type num_targets(cell_gid_type i) const override {
-        return util::sum_by(cells_.at(i).synapses(), [](auto& syn) {return syn.second.size();});
-    }
-
     util::unique_any get_cell_description(cell_gid_type i) const override {
         return util::make_unique_any<cable_cell>(cells_[i]);
     }
diff --git a/test/unit-distributed/test_communicator.cpp b/test/unit-distributed/test_communicator.cpp
index 5c1f904a8a6b1fe3c221c7622def90401ee1968a..e7e081d56857307434b3986fae88193e62c6cc06 100644
--- a/test/unit-distributed/test_communicator.cpp
+++ b/test/unit-distributed/test_communicator.cpp
@@ -1,16 +1,19 @@
 #include "../gtest.h"
 #include "test.hpp"
 
-#include <stdexcept>
+#include <tuple>
 #include <vector>
 
 #include <arbor/domain_decomposition.hpp>
+#include <arbor/lif_cell.hpp>
 #include <arbor/load_balance.hpp>
 #include <arbor/spike_event.hpp>
-#include <threading/threading.hpp>
 
 #include "communication/communicator.hpp"
 #include "execution_context.hpp"
+#include "fvm_lowered_cell.hpp"
+#include "lif_cell_group.hpp"
+#include "mc_cell_group.hpp"
 #include "util/filter.hpp"
 #include "util/rangeutil.hpp"
 #include "util/span.hpp"
@@ -184,41 +187,52 @@ namespace {
     // Even gid are rss, and odd gid are cable cells.
     class ring_recipe: public recipe {
     public:
-        ring_recipe(cell_size_type s):
-            size_(s),
-            ranks_(g_context->distributed->size())
-        {}
+        ring_recipe(cell_size_type s): size_(s) {}
 
         cell_size_type num_cells() const override {
             return size_;
         }
 
-        util::unique_any get_cell_description(cell_gid_type) const override {
-            return {};
+        util::unique_any get_cell_description(cell_gid_type gid) const override {
+            if (gid%2) {
+                arb::segment_tree tree;
+                tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+                arb::decor decor;
+                decor.set_default(arb::cv_policy_fixed_per_branch(10));
+                decor.place(arb::mlocation{0, 0.5}, arb::threshold_detector{10}, "src");
+                decor.place(arb::mlocation{0, 0.5}, "expsyn", "tgt");
+                return arb::cable_cell(arb::morphology(tree), {}, decor);
+            }
+            return arb::lif_cell("src", "tgt");
         }
 
         cell_kind get_cell_kind(cell_gid_type gid) const override {
-            return gid%2? cell_kind::cable: cell_kind::spike_source;
+            return gid%2? cell_kind::cable: cell_kind::lif;
         }
 
-        cell_size_type num_sources(cell_gid_type) const override { return 1; }
-        cell_size_type num_targets(cell_gid_type) const override { return 1; }
-
         std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
             // a single connection from the preceding cell, i.e. a ring
             // weight is the destination gid
             // delay is 1
-            cell_member_type src = {gid==0? size_-1: gid-1, 0};
-            cell_lid_type dst = 0;
+            cell_global_label_type src = {gid==0? size_-1: gid-1, "src"};
+            cell_local_label_type dst = {"tgt"};
             return {cell_connection(
                         src, dst,   // end points
                         float(gid), // weight
                         1.0f)};     // delay
         }
 
+        std::any get_global_properties(arb::cell_kind kind) const override {
+            if (kind == arb::cell_kind::cable) {
+                arb::cable_cell_global_properties gprop;
+                gprop.default_parameters = arb::neuron_parameter_defaults;
+                return gprop;
+            }
+            return {};
+        }
+
     private:
         cell_size_type size_;
-        cell_size_type ranks_;
     };
 
     cell_gid_type source_of(cell_gid_type gid, cell_size_type num_cells) {
@@ -247,32 +261,32 @@ namespace {
     // Even gid are rss, and odd gid are cable cells.
     class all2all_recipe: public recipe {
     public:
-        all2all_recipe(cell_size_type s):
-            size_(s),
-            ranks_(g_context->distributed->size())
-        {}
+        all2all_recipe(cell_size_type s): size_(s) {}
 
         cell_size_type num_cells() const override {
             return size_;
         }
 
-        util::unique_any get_cell_description(cell_gid_type) const override {
-            return {};
+        util::unique_any get_cell_description(cell_gid_type gid) const override {
+            arb::segment_tree tree;
+            tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+            arb::decor decor;
+            decor.set_default(arb::cv_policy_fixed_per_branch(10));
+            decor.place(arb::mlocation{0, 0.5}, arb::threshold_detector{10}, "src");
+            decor.place(arb::ls::uniform(arb::reg::all(), 0, size_, gid), "expsyn", "tgt");
+            return arb::cable_cell(arb::morphology(tree), {}, decor);
         }
         cell_kind get_cell_kind(cell_gid_type gid) const override {
-            return gid%2? cell_kind::cable: cell_kind::spike_source;
+            return cell_kind::cable;
         }
 
-        cell_size_type num_sources(cell_gid_type) const override { return 1; }
-        cell_size_type num_targets(cell_gid_type) const override { return size_; }
-
         std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
             std::vector<cell_connection> cons;
             cons.reserve(size_);
             for (auto sid: util::make_span(0, size_)) {
                 cell_connection con(
-                        {sid, 0},       // source
-                        sid,     // destination
+                        {sid, {"src", arb::lid_selection_policy::round_robin}}, // source
+                        {"tgt", arb::lid_selection_policy::round_robin},        // destination
                         float(gid+sid), // weight
                         1.0f);          // delay
                 cons.push_back(con);
@@ -280,9 +294,14 @@ namespace {
             return cons;
         }
 
+        std::any get_global_properties(arb::cell_kind) const override {
+            arb::cable_cell_global_properties gprop;
+            gprop.default_parameters = arb::neuron_parameter_defaults;
+            return gprop;
+        }
+
     private:
         cell_size_type size_;
-        cell_size_type ranks_;
     };
 
     spike_event expected_event_all2all(cell_gid_type gid, cell_gid_type sid) {
@@ -312,6 +331,105 @@ namespace {
         }
         return map;
     }
+
+    class mini_recipe: public recipe {
+    public:
+        mini_recipe(cell_size_type s): nranks_(s), ncells_(nranks_*3) {}
+
+        cell_size_type num_cells() const override {
+            return ncells_;
+        }
+
+        util::unique_any get_cell_description(cell_gid_type gid) const override {
+            arb::segment_tree tree;
+            tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+            arb::decor decor;
+            if (gid%3 != 1) {
+                decor.place(arb::ls::uniform(arb::reg::all(), 0, 1, gid), "expsyn", "synapses_0");
+                decor.place(arb::ls::uniform(arb::reg::all(), 2, 2, gid), "expsyn", "synapses_1");
+            }
+            else {
+                decor.place(arb::ls::uniform(arb::reg::all(), 0, 2, gid), arb::threshold_detector{10}, "detectors_0");
+                decor.place(arb::ls::uniform(arb::reg::all(), 3, 3, gid), arb::threshold_detector{10}, "detectors_1");
+            }
+            return arb::cable_cell(arb::morphology(tree), {}, decor);
+        }
+
+        cell_kind get_cell_kind(cell_gid_type gid) const override {
+            return gid%3 != 1? cell_kind::cable: cell_kind::lif;
+        }
+
+        std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
+            // Cells with gid%3 == 1 are senders, the others are receivers.
+            // The following connections are formed; used to test out lid resolutions:
+            // 7 from detectors_0 (round-robin) to synapses_0 (round-robin)
+            // 1 from detectors_0 (round-robin) to synapses_1 (univalent)
+            // 2 from detectors_1 (round-robin) to synapses_0 (round-robin)
+            // 1 from detectors_1 (univalent)   to synapses_1 (round-robin)
+            // These Should generate the following {src_gid, src_lid} -> {tgt_gid, tgt_lid} mappings (unsorted; 1 rank with 3 cells total):
+            // cell 1 - > cell 0:
+            //   {1, 0} -> {0, 0}
+            //   {1, 1} -> {0, 1}
+            //   {1, 2} -> {0, 0}
+            //   {1, 0} -> {0, 1}
+            //   {1, 1} -> {0, 0}
+            //   {1, 2} -> {0, 1}
+            //   {1, 0} -> {0, 0}
+            //   {1, 1} -> {0, 2}
+            //   {1, 3} -> {0, 1}
+            //   {1, 3} -> {0, 0}
+            //   {1, 3} -> {0, 2}
+
+            // cell 1 - > cell 2:
+            //   {1, 0} -> {2, 0}
+            //   {1, 1} -> {2, 1}
+            //   {1, 2} -> {2, 0}
+            //   {1, 0} -> {2, 1}
+            //   {1, 1} -> {2, 0}
+            //   {1, 2} -> {2, 1}
+            //   {1, 0} -> {2, 0}
+            //   {1, 1} -> {2, 2}
+            //   {1, 3} -> {2, 1}
+            //   {1, 3} -> {2, 0}
+            //   {1, 3} -> {2, 2}
+            std::vector<cell_connection> cons;
+            using pol = lid_selection_policy;
+            if (gid%3 != 1) {
+                for (auto sid: util::make_span(0, ncells_)) {
+                    if (sid%3 == 1) {
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+
+                        cons.push_back({{sid, "detectors_0", pol::round_robin}, {"synapses_1", pol::assert_univalent}, 1.0, 1.0});
+
+                        cons.push_back({{sid, "detectors_1", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+                        cons.push_back({{sid, "detectors_1", pol::round_robin}, {"synapses_0", pol::round_robin}, 1.0, 1.0});
+
+                        cons.push_back({{sid, "detectors_1", pol::assert_univalent}, {"synapses_1", pol::round_robin}, 1.0, 1.0});
+                    }
+                }
+            }
+            return cons;
+        }
+
+        std::any get_global_properties(arb::cell_kind kind) const override {
+            if (kind == arb::cell_kind::cable) {
+                arb::cable_cell_global_properties gprop;
+                gprop.default_parameters = arb::neuron_parameter_defaults;
+                return gprop;
+            }
+            return {};
+        }
+
+    private:
+        cell_size_type nranks_;
+        cell_size_type ncells_;
+    };
 }
 
 template <typename F>
@@ -389,7 +507,31 @@ TEST(communicator, ring)
     // use a node decomposition that reflects the resources available
     // on the node that the test is running on, including gpus.
     const auto D = partition_load_balance(R, g_context);
-    auto C = communicator(R, D, *g_context);
+
+    // set up source and target label->lid resolvers
+    // from mc_cell_group and lif_cell_group
+    std::vector<cell_gid_type> mc_gids, lif_gids;
+    for (auto g: D.groups) {
+        if (g.kind == cell_kind::cable) {
+            mc_gids.insert(mc_gids.end(), g.gids.begin(), g.gids.end());
+        }
+        else if (g.kind == cell_kind::lif) {
+            lif_gids.insert(lif_gids.end(), g.gids.begin(), g.gids.end());
+        }
+    }
+    cell_label_range mc_srcs, mc_tgts, lif_srcs, lif_tgts;
+    auto mc_group = mc_cell_group(mc_gids, R, mc_srcs, mc_tgts, make_fvm_lowered_cell(backend_kind::multicore, *g_context));
+    auto lif_group = lif_cell_group(lif_gids, R, lif_srcs, lif_tgts);
+
+    auto local_sources = cell_labels_and_gids(mc_srcs, mc_gids);
+    auto local_targets = cell_labels_and_gids(mc_tgts, mc_gids);
+    local_sources.append({lif_srcs, lif_gids});
+    local_targets.append({lif_tgts, lif_gids});
+
+    auto global_sources = g_context->distributed->gather_cell_labels_and_gids(local_sources);
+
+    // construct the communicator
+    auto C = communicator(R, D, label_resolution_map(global_sources), label_resolution_map(local_targets), *g_context);
 
     // every cell fires
     EXPECT_TRUE(test_ring(D, C, [](cell_gid_type g){return true;}));
@@ -484,7 +626,30 @@ TEST(communicator, all2all)
     // use a node decomposition that reflects the resources available
     // on the node that the test is running on, including gpus.
     const auto D = partition_load_balance(R, g_context);
-    auto C = communicator(R, D, *g_context);
+
+    // set up source and target label->lid resolvers
+    // from mc_cell_group
+    std::vector<cell_gid_type> mc_gids;
+    for (auto g: D.groups) {
+        mc_gids.insert(mc_gids.end(), g.gids.begin(), g.gids.end());
+    }
+    cell_label_range local_sources, local_targets;
+    auto mc_group = mc_cell_group(mc_gids, R, local_sources, local_targets, make_fvm_lowered_cell(backend_kind::multicore, *g_context));
+    auto global_sources = g_context->distributed->gather_cell_labels_and_gids({local_sources, mc_gids});
+
+    // construct the communicator
+    auto C = communicator(R, D, label_resolution_map(global_sources), label_resolution_map({local_targets, mc_gids}), *g_context);
+    auto connections = C.connections();
+
+    for (auto i: util::make_span(0, n_global)) {
+        for (unsigned j = 0; j < n_local; ++j) {
+            auto c = connections[i*n_local+j];
+            EXPECT_EQ(i, c.source().gid);
+            EXPECT_EQ(0u, c.source().index);
+            EXPECT_EQ(i, c.destination());
+            EXPECT_LT(c.index_on_domain(), n_local);
+        }
+    }
 
     // every cell fires
     EXPECT_TRUE(test_all2all(D, C, [](cell_gid_type g){return true;}));
@@ -495,3 +660,50 @@ TEST(communicator, all2all)
     // odd-numbered cells fire
     EXPECT_TRUE(test_all2all(D, C, [](cell_gid_type g){return g%2==1;}));
 }
+
+TEST(communicator, mini_network)
+{
+    using util::make_span;
+
+    // construct a homogeneous network of 10*n_domain identical cells in a ring
+    unsigned N = g_context->distributed->size();
+
+    auto R = mini_recipe(N);
+    // use a node decomposition that reflects the resources available
+    // on the node that the test is running on, including gpus.
+    const auto D = partition_load_balance(R, g_context);
+
+    // set up source and target label->lid resolvers
+    // from mc_cell_group
+    std::vector<cell_gid_type> gids;
+    for (auto g: D.groups) {
+        gids.insert(gids.end(), g.gids.begin(), g.gids.end());
+    }
+    cell_label_range local_sources, local_targets;
+    auto mc_group = mc_cell_group(gids, R, local_sources, local_targets, make_fvm_lowered_cell(backend_kind::multicore, *g_context));
+    auto global_sources = g_context->distributed->gather_cell_labels_and_gids({local_sources, gids});
+
+    // construct the communicator
+    auto C = communicator(R, D, label_resolution_map(global_sources), label_resolution_map({local_targets, gids}), *g_context);
+
+    // sort connections by source then target
+    auto connections = C.connections();
+    util::sort(connections, [](const connection& lhs, const connection& rhs) {
+      return std::forward_as_tuple(lhs.source(), lhs.index_on_domain(), lhs.destination()) < std::forward_as_tuple(rhs.source(), rhs.index_on_domain(), rhs.destination());
+    });
+
+    // Expect one set of 22 connections from every rank: these have been sorted.
+    std::vector<cell_lid_type> ex_source_lids =  {0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3};
+    std::vector<std::vector<cell_lid_type>> ex_target_lids = {{0, 0, 1, 0, 0, 1, 0, 1, 2, 0, 1, 2, 0, 1, 0, 1, 0, 1, 2, 0, 1, 2},
+                                                              {0, 1, 1, 0, 1, 1, 0, 1, 2, 0, 1, 2, 0, 1, 0, 1, 0, 1, 2, 0, 1, 2}};
+
+    for (auto i: util::make_span(0, N)) {
+        std::vector<cell_gid_type> ex_source_gids(22u, i*3 + 1);
+        for (unsigned j = 0; j < 22u; ++j) {
+            auto c = connections[i*22 + j];
+            EXPECT_EQ(ex_source_gids[j], c.source().gid);
+            EXPECT_EQ(ex_source_lids[j], c.source().index);
+            EXPECT_EQ(ex_target_lids[i%2][j], c.destination());
+        }
+    }
+}
diff --git a/test/unit-distributed/test_domain_decomposition.cpp b/test/unit-distributed/test_domain_decomposition.cpp
index e905f5645da48db7745b21a3a517721ea40df6cb..92a7a45374bb850fa03d9f09aaf18d88bf481a47 100644
--- a/test/unit-distributed/test_domain_decomposition.cpp
+++ b/test/unit-distributed/test_domain_decomposition.cpp
@@ -51,9 +51,6 @@ namespace {
                 cell_kind::cable;
         }
 
-        cell_size_type num_sources(cell_gid_type) const override { return 0; }
-        cell_size_type num_targets(cell_gid_type) const override { return 0; }
-
         std::vector<cell_connection> connections_on(cell_gid_type) const override {
             return {};
         }
@@ -85,20 +82,20 @@ namespace {
         std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
             unsigned shift = (gid/size_)*size_;
             switch (gid % size_) {
-                case 1 :  return { gap_junction_connection({7 + shift, 0}, 0, 0.1)};
+                case 1 :  return { gap_junction_connection({7 + shift, "gj"}, {"gj"}, 0.1)};
                 case 2 :  return {
-                    gap_junction_connection({6 + shift, 0}, 0, 0.1),
-                    gap_junction_connection({9 + shift, 0}, 0, 0.1)
+                    gap_junction_connection({6 + shift, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({9 + shift, "gj"}, {"gj"}, 0.1)
                 };
                 case 6 :  return {
-                    gap_junction_connection({2 + shift, 0}, 0, 0.1),
-                    gap_junction_connection({7 + shift, 0}, 0, 0.1)
+                    gap_junction_connection({2 + shift, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({7 + shift, "gj"}, {"gj"}, 0.1)
                 };
                 case 7 :  return {
-                    gap_junction_connection({6 + shift, 0}, 0, 0.1),
-                    gap_junction_connection({1 + shift, 0}, 0, 0.1)
+                    gap_junction_connection({6 + shift, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({1 + shift, "gj"}, {"gj"}, 0.1)
                 };
-                case 9 :  return { gap_junction_connection({2 + shift, 0}, 0, 0.1)};
+                case 9 :  return { gap_junction_connection({2 + shift, "gj"}, {"gj"}, 0.1)};
                 default : return {};
             }
         }
@@ -127,10 +124,10 @@ namespace {
             unsigned group = gid/groups_;
             unsigned id = gid%size_;
             if (id == group && group != (groups_ - 1)) {
-                return {gap_junction_connection({gid + size_, 0}, 0, 0.1)};
+                return {gap_junction_connection({gid + size_, "gj"}, {"gj"}, 0.1)};
             }
             else if (id == group - 1) {
-                return {gap_junction_connection({gid - size_, 0}, 0, 0.1)};
+                return {gap_junction_connection({gid - size_, "gj"}, {"gj"}, 0.1)};
             }
             else {
                 return {};
diff --git a/test/unit-distributed/test_mpi.cpp b/test/unit-distributed/test_mpi.cpp
index 9dfa81d1fb45b1af9c5cc0015d60a1abb320a217..95ae3a8a649e05ff23944ace249f21321ab24390 100644
--- a/test/unit-distributed/test_mpi.cpp
+++ b/test/unit-distributed/test_mpi.cpp
@@ -123,6 +123,40 @@ TEST(mpi, gather_string) {
     }
 }
 
+TEST(mpi, gather_string_vec) {
+    int id = mpi::rank(MPI_COMM_WORLD);
+    int size = mpi::size(MPI_COMM_WORLD);
+
+    // Make a vector of strings of variable length:
+    // rank strings
+    //  0   a
+    //  1   b; bb
+    //  2   c; cc; ccc
+    //  3   d; dd; ddd; dddd
+    //   ...
+    // 25   z; zz; ...; zzzz...zzz   (26 times z)
+    // 26   a; aa; ...; aaaa...aaaa  (27 times a)
+    auto make_string = [](int length, int id) {
+      return std::string(length, 'a'+char(id%26));};
+
+    std::vector<std::string> string_vec(id+1);
+    for (int i = 0; i < id+1; ++i) {
+        string_vec[i] = make_string(i+1, id);
+    }
+
+    auto gathered = mpi::gather_all(string_vec, MPI_COMM_WORLD);
+
+    int expected_size = size*(size+1)/2;
+    ASSERT_TRUE(expected_size==(int)gathered.size());
+
+    int idx = 0;
+    for (int i = 0; i < size; ++i) {
+        for (int j = 0; j < i+1; ++j) {
+            EXPECT_EQ(make_string(j+1, i), gathered[idx++]);
+        }
+    }
+}
+
 TEST(mpi, gather) {
     int id = mpi::rank(MPI_COMM_WORLD);
     int size = mpi::size(MPI_COMM_WORLD);
diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt
index 745e52a273ecdfad5620d939f6feb1c03bb145dd..ff57993631e632f496c632fdb1a58439f3208184 100644
--- a/test/unit/CMakeLists.txt
+++ b/test/unit/CMakeLists.txt
@@ -110,6 +110,7 @@ set(unit_sources
     test_index.cpp
     test_kinetic_linear.cpp
     test_lexcmp.cpp
+    test_label_resolution.cpp
     test_lif_cell_group.cpp
     test_local_context.cpp
     test_maputil.cpp
diff --git a/test/unit/test_cable_cell.cpp b/test/unit/test_cable_cell.cpp
index d57a0caa59135b938f152a1f1f0d3ea29c0d62fb..882f40824a6fdfbc3258b40eeb054872c7496b8c 100644
--- a/test/unit/test_cable_cell.cpp
+++ b/test/unit/test_cable_cell.cpp
@@ -30,27 +30,45 @@ TEST(cable_cell, lid_ranges) {
 
     // Place synapses and threshold detectors in interleaved order.
     // Note: there are 2 terminal points.
-    auto idx1 = decorations.place("term"_lab, "expsyn");
-    auto idx2 = decorations.place("term"_lab, "expsyn");
-    auto idx3 = decorations.place("term"_lab, threshold_detector{-10});
-    auto idx4 = decorations.place(empty_sites, "expsyn");
-    auto idx5 = decorations.place("term"_lab, threshold_detector{-20});
-    auto idx6 = decorations.place(three_sites, "expsyn");
+    decorations.place("term"_lab, "expsyn", "t0");
+    decorations.place("term"_lab, "expsyn", "t1");
+    decorations.place("term"_lab, threshold_detector{-10}, "s0");
+    decorations.place(empty_sites, "expsyn", "t2");
+    decorations.place("term"_lab, threshold_detector{-20}, "s1");
+    decorations.place(three_sites, "expsyn", "t3");
+    decorations.place("term"_lab, "exp2syn", "t3");
 
     cable_cell cell(morph, dict, decorations);
 
     // Get the assigned lid ranges for each placement
-    auto r1 = cell.placed_lid_range(idx1);
-    auto r2 = cell.placed_lid_range(idx2);
-    auto r3 = cell.placed_lid_range(idx3);
-    auto r4 = cell.placed_lid_range(idx4);
-    auto r5 = cell.placed_lid_range(idx5);
-    auto r6 = cell.placed_lid_range(idx6);
-
-    EXPECT_EQ(idx1, 0u); EXPECT_EQ(r1.begin, 0u); EXPECT_EQ(r1.end, 2u);
-    EXPECT_EQ(idx2, 1u); EXPECT_EQ(r2.begin, 2u); EXPECT_EQ(r2.end, 4u);
-    EXPECT_EQ(idx3, 2u); EXPECT_EQ(r3.begin, 0u); EXPECT_EQ(r3.end, 2u);
-    EXPECT_EQ(idx4, 3u); EXPECT_EQ(r4.begin, 4u); EXPECT_EQ(r4.end, 4u);
-    EXPECT_EQ(idx5, 4u); EXPECT_EQ(r5.begin, 2u); EXPECT_EQ(r5.end, 4u);
-    EXPECT_EQ(idx6, 5u); EXPECT_EQ(r6.begin, 4u); EXPECT_EQ(r6.end, 7u);
+    const auto& src_ranges = cell.detector_ranges();
+    const auto& tgt_ranges = cell.synapse_ranges();
+
+    EXPECT_EQ(1u, tgt_ranges.count("t0"));
+    EXPECT_EQ(1u, tgt_ranges.count("t1"));
+    EXPECT_EQ(1u, src_ranges.count("s0"));
+    EXPECT_EQ(1u, tgt_ranges.count("t2"));
+    EXPECT_EQ(1u, src_ranges.count("s1"));
+    EXPECT_EQ(2u, tgt_ranges.count("t3"));
+
+    auto r1 = tgt_ranges.equal_range("t0").first->second;
+    auto r2 = tgt_ranges.equal_range("t1").first->second;
+    auto r3 = src_ranges.equal_range("s0").first->second;
+    auto r4 = tgt_ranges.equal_range("t2").first->second;
+    auto r5 = src_ranges.equal_range("s1").first->second;
+
+    auto r6_range = tgt_ranges.equal_range("t3");
+    auto r6_0 = r6_range.first;
+    auto r6_1 = std::next(r6_range.first);
+    if (r6_0->second.begin != 4u) {
+        std::swap(r6_0, r6_1);
+    }
+
+    EXPECT_EQ(r1.begin, 0u); EXPECT_EQ(r1.end, 2u);
+    EXPECT_EQ(r2.begin, 2u); EXPECT_EQ(r2.end, 4u);
+    EXPECT_EQ(r3.begin, 0u); EXPECT_EQ(r3.end, 2u);
+    EXPECT_EQ(r4.begin, 4u); EXPECT_EQ(r4.end, 4u);
+    EXPECT_EQ(r5.begin, 2u); EXPECT_EQ(r5.end, 4u);
+    EXPECT_EQ(r6_0->second.begin, 4u); EXPECT_EQ(r6_0->second.end, 7u);
+    EXPECT_EQ(r6_1->second.begin, 7u); EXPECT_EQ(r6_1->second.end, 9u);
 }
diff --git a/test/unit/test_domain_decomposition.cpp b/test/unit/test_domain_decomposition.cpp
index 2db3367993446af5375cfc57a11d5764efca2695..223f894f893c7499d44be7f2894e0406266c8d3c 100644
--- a/test/unit/test_domain_decomposition.cpp
+++ b/test/unit/test_domain_decomposition.cpp
@@ -68,7 +68,7 @@ namespace {
 
         arb::util::unique_any get_cell_description(cell_gid_type) const override {
             auto c = arb::make_cell_soma_only(false);
-            c.decorations.place(mlocation{0,1}, gap_junction_site{});
+            c.decorations.place(mlocation{0,1}, gap_junction_site{}, "gj");
             return {arb::cable_cell(c)};
         }
 
@@ -78,38 +78,38 @@ namespace {
         std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
             switch (gid) {
                 case 0 :  return {
-                    gap_junction_connection({13, 0}, 0, 0.1)
+                    gap_junction_connection({13, "gj"}, {"gj"}, 0.1)
                 };
                 case 2 :  return {
-                    gap_junction_connection({7, 0}, 0, 0.1),
-                    gap_junction_connection({11, 0}, 0, 0.1)
+                    gap_junction_connection({7,  "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({11, "gj"}, {"gj"}, 0.1)
                 };
                 case 3 :  return {
-                    gap_junction_connection({4, 0}, 0, 0.1),
-                    gap_junction_connection({8, 0}, 0, 0.1)
+                    gap_junction_connection({4, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({8, "gj"}, {"gj"}, 0.1)
                 };
                 case 4 :  return {
-                    gap_junction_connection({3, 0}, 0, 0.1),
-                    gap_junction_connection({8, 0}, 0, 0.1),
-                    gap_junction_connection({9, 0}, 0, 0.1)
+                    gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({8, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({9, "gj"}, {"gj"}, 0.1)
                 };
                 case 7 :  return {
-                    gap_junction_connection({2, 0}, 0, 0.1),
-                    gap_junction_connection({11, 0}, 0, 0.1)
+                    gap_junction_connection({2,  "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({11, "gj"}, {"gj"}, 0.1)
                 };;
                 case 8 :  return {
-                    gap_junction_connection({3, 0}, 0, 0.1),
-                    gap_junction_connection({4, 0}, 0, 0.1)
+                    gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({4, "gj"}, {"gj"}, 0.1)
                 };;
                 case 9 :  return {
-                    gap_junction_connection({4, 0}, 0, 0.1)
+                    gap_junction_connection({4, "gj"}, {"gj"}, 0.1)
                 };
                 case 11 : return {
-                    gap_junction_connection({2, 0}, 0, 0.1),
-                    gap_junction_connection({7, 0}, 0, 0.1)
+                    gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                    gap_junction_connection({7, "gj"}, {"gj"}, 0.1)
                 };
                 case 13 : return {
-                    gap_junction_connection({0, 0}, 0, 0.1)
+                    gap_junction_connection({0, "gj"}, {"gj"}, 0.1)
                 };
                 default : return {};
             }
diff --git a/test/unit/test_event_delivery.cpp b/test/unit/test_event_delivery.cpp
index 1d4f9cd95c163e3cebb9b3092481b74aa8aa5556..9ffcb0868e8226f253b7569babe68ce37f2d5804 100644
--- a/test/unit/test_event_delivery.cpp
+++ b/test/unit/test_event_delivery.cpp
@@ -35,16 +35,13 @@ struct test_recipe: public n_cable_cell_recipe {
         labels.set("soma", arb::reg::tagged(1));
 
         decor decorations;
-        decorations.place(mlocation{0, 0.5}, "expsyn");
-        decorations.place(mlocation{0, 0.5}, threshold_detector{-64});
-        decorations.place(mlocation{0, 0.5}, gap_junction_site{});
+        decorations.place(mlocation{0, 0.5}, "expsyn", "synapse");
+        decorations.place(mlocation{0, 0.5}, threshold_detector{-64}, "detector");
+        decorations.place(mlocation{0, 0.5}, gap_junction_site{}, "gapjunction");
         cable_cell c(st, labels, decorations);
 
         return c;
     }
-
-    cell_size_type num_sources(cell_gid_type) const override { return 1; }
-    cell_size_type num_targets(cell_gid_type) const override { return 1; }
 };
 
 using gid_vector = std::vector<cell_gid_type>;
@@ -107,13 +104,13 @@ struct test_recipe_gj: public test_recipe {
     explicit test_recipe_gj(int n, cell_gj_pairs gj_pairs):
         test_recipe(n), gj_pairs_(std::move(gj_pairs)) {}
 
-    cell_size_type num_gap_junction_sites(cell_gid_type) const override { return 1; }
-
     std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type i) const override {
         std::vector<gap_junction_connection> gjs;
         for (auto p: gj_pairs_) {
-            if (p.first == i) gjs.push_back({{p.second, 0u}, 0u, 0.});
-            if (p.second == i) gjs.push_back({{p.first, 0u}, 0u, 0.});
+            if (p.first == i) gjs.push_back({{p.second, "gapjunction", lid_selection_policy::assert_univalent},
+                                             {"gapjunction", lid_selection_policy::assert_univalent}, 0.});
+            if (p.second == i) gjs.push_back({{p.first, "gapjunction", lid_selection_policy::assert_univalent},
+                                                    {"gapjunction", lid_selection_policy::assert_univalent}, 0.});
         }
         return gjs;
     }
diff --git a/test/unit/test_event_generators.cpp b/test/unit/test_event_generators.cpp
index 0f17feb47434835ae274689526a789257e7824d4..cdb97b8257cb3772fc273b6f4bcfc14e503f6787 100644
--- a/test/unit/test_event_generators.cpp
+++ b/test/unit/test_event_generators.cpp
@@ -18,7 +18,8 @@ namespace{
 }
 
 TEST(event_generators, assign_and_copy) {
-    event_generator gen = regular_generator(2, 5., 0.5, 0.75);
+    event_generator gen = regular_generator({"l2"}, 5., 0.5, 0.75);
+    gen.resolve_label([](const cell_local_label_type&) {return 2;});
     spike_event expected{2, 0.75, 5.};
 
     auto first = [](const event_seq& seq) {
@@ -54,16 +55,18 @@ TEST(event_generators, regular) {
     // events regularly spaced 0.5 ms apart.
     time_type t0 = 2.0;
     time_type dt = 0.5;
-    cell_lid_type target = 3;
+    cell_tag_type label = "label";
+    cell_lid_type lid = 3;
     float weight = 3.14;
 
-    event_generator gen = regular_generator(target, weight, t0, dt);
+    event_generator gen = regular_generator(label, weight, t0, dt);
+    gen.resolve_label([lid](const cell_local_label_type&) {return lid;});
 
     // Helper for building a set of expected events.
     auto expected = [&] (std::vector<time_type> times) {
         pse_vector events;
         for (auto t: times) {
-            events.push_back({target, t, weight});
+            events.push_back({lid, t, weight});
         }
         return events;
     };
@@ -80,42 +83,53 @@ TEST(event_generators, regular) {
 }
 
 TEST(event_generators, seq) {
-    pse_vector in = {
-        {0, 0.1, 1.0},
-        {0, 1.0, 2.0},
-        {0, 1.0, 3.0},
-        {0, 1.5, 4.0},
-        {0, 2.3, 5.0},
-        {0, 3.0, 6.0},
-        {0, 3.5, 7.0},
-    };
-
-    auto events = [&in] (int b, int e) {
-        return pse_vector(in.begin()+b, in.begin()+e);
+    explicit_generator::lse_vector in = {
+        {{"l0"}, 0.1, 1.0},
+        {{"l0"}, 1.0, 2.0},
+        {{"l2"}, 1.0, 3.0},
+        {{"l1"}, 1.5, 4.0},
+        {{"l2"}, 2.3, 5.0},
+        {{"l0"}, 3.0, 6.0},
+        {{"l0"}, 3.5, 7.0},
     };
+    std::unordered_map<cell_tag_type, cell_lid_type> lid_map = {{"l0", 0},{"l1", 1}, {"l2", 2}};
+    pse_vector expected;
+    std::transform(in.begin(), in.end(), std::back_inserter(expected),
+        [lid_map](const auto& item) {return spike_event{lid_map.at(item.label.tag), item.time, item.weight};});
 
     event_generator gen = explicit_generator(in);
-    EXPECT_EQ(in, as_vector(gen.events(0, 100.)));
+    gen.resolve_label([lid_map](const cell_local_label_type& item) {return lid_map.at(item.tag);});
+
+    EXPECT_EQ(expected, as_vector(gen.events(0, 100.)));
     gen.reset();
-    EXPECT_EQ(in, as_vector(gen.events(0, 100.)));
+    EXPECT_EQ(expected, as_vector(gen.events(0, 100.)));
     gen.reset();
 
     // Check reported sub-intervals against a smaller set of events.
     in = {
-        {0, 1.5, 4.0},
-        {0, 2.3, 5.0},
-        {0, 3.0, 6.0},
-        {0, 3.5, 7.0},
+        {{"l0"}, 1.5, 4.0},
+        {{"l0"}, 2.3, 5.0},
+        {{"l0"}, 3.0, 6.0},
+        {{"l0"}, 3.5, 7.0},
     };
+    expected.clear();
+    std::transform(in.begin(), in.end(), std::back_inserter(expected),
+        [lid_map](const auto& item) {return spike_event{lid_map.at(item.label.tag), item.time, item.weight};});
+
     gen = explicit_generator(in);
+    gen.resolve_label([lid_map](const cell_local_label_type& item) {return lid_map.at(item.tag);});
 
     auto draw = [](event_generator& gen, time_type t0, time_type t1) {
         gen.reset();
         return as_vector(gen.events(t0, t1));
     };
 
+    auto events = [&expected] (int b, int e) {
+      return pse_vector(expected.begin()+b, expected.begin()+e);
+    };
+
     // a range that includes all the events
-    EXPECT_EQ(in, draw(gen, 0, 4));
+    EXPECT_EQ(expected, draw(gen, 0, 4));
 
     // a strict subset including the first event
     EXPECT_EQ(events(0, 2), draw(gen, 0, 3));
@@ -143,10 +157,12 @@ TEST(event_generators, poisson) {
     time_type t0 = 0;
     time_type t1 = 10;
     time_type lambda = 10; // expect 10 events per ms
-    cell_lid_type target = 2;
+    cell_tag_type label = "label";
+    cell_lid_type lid = 2;
     float weight = 42;
 
-    event_generator gen = poisson_generator(target, weight, t0, lambda, G);
+    event_generator gen = poisson_generator(label, weight, t0, lambda, G);
+    gen.resolve_label([lid](const cell_local_label_type&) {return lid;});
 
     pse_vector int1 = as_vector(gen.events(0, t1));
     // Test that the output is sorted
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index 6132b3b1346996d2c00dc3f35fe6b41fbd64022b..ed621761bc989a88e1f020f058706a6f5b0b1cab 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -56,7 +56,7 @@ namespace {
             auto description = builder.make_cell();
             description.decorations.paint("\"soma\"", "hh");
             description.decorations.paint("\"dend\"", "pas");
-            description.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3});
+            description.decorations.place(builder.location({1,1}), i_clamp{5, 80, 0.3}, "clamp");
 
             s.builders.push_back(std::move(builder));
             descriptions.push_back(description);
@@ -108,8 +108,8 @@ namespace {
             desc.decorations.paint(c2, membrane_capacitance{0.013});
             desc.decorations.paint(c3, membrane_capacitance{0.018});
 
-            desc.decorations.place(b.location({2,1}), i_clamp{5.,  80., 0.45});
-            desc.decorations.place(b.location({3,1}), i_clamp{40., 10.,-0.2});
+            desc.decorations.place(b.location({2,1}), i_clamp{5.,  80., 0.45}, "clamo0");
+            desc.decorations.place(b.location({3,1}), i_clamp{40., 10.,-0.2}, "clamp1");
 
             desc.decorations.set_default(axial_resistivity{90});
 
@@ -133,10 +133,10 @@ TEST(fvm_layout, mech_index) {
     auto& builders = system.builders;
 
     // Add four synapses of two varieties across the cells.
-    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn");
-    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn");
-    descriptions[1].decorations.place(builders[1].location({2, 0.4}), "exp2syn");
-    descriptions[1].decorations.place(builders[1].location({3, 0.4}), "expsyn");
+    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn", "syn0");
+    descriptions[0].decorations.place(builders[0].location({1, 0.4}), "expsyn", "syn1");
+    descriptions[1].decorations.place(builders[1].location({2, 0.4}), "exp2syn", "syn3");
+    descriptions[1].decorations.place(builders[1].location({3, 0.4}), "expsyn", "syn4");
 
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
@@ -253,10 +253,10 @@ TEST(fvm_layout, coalescing_synapses) {
     {
         auto desc = builder.make_cell();
 
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "expsyn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "expsyn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "expsyn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -270,10 +270,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "exp2syn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "exp2syn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "exp2syn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "exp2syn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -290,10 +290,10 @@ TEST(fvm_layout, coalescing_synapses) {
     {
         auto desc = builder.make_cell();
 
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "expsyn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "expsyn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "expsyn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -307,10 +307,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.5}), "exp2syn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.9}), "exp2syn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.5}), "exp2syn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.9}), "exp2syn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -328,10 +328,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.3}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
-        desc.decorations.place(builder.location({1, 0.7}), "expsyn");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn0");
+        desc.decorations.place(builder.location({1, 0.3}), "expsyn", "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn2");
+        desc.decorations.place(builder.location({1, 0.7}), "expsyn", "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -345,10 +345,10 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0.1, 0.2));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0.1, 0.2));
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2), "syn0");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 0.2), "syn1");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0.1, 0.2), "syn2");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0.1, 0.2), "syn3");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -368,14 +368,14 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2));
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3), "syn0");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3), "syn1");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 0, 3), "syn2");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc("expsyn", 1, 3), "syn3");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2), "syn4");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2), "syn5");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 0, 2), "syn6");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn", 1, 2), "syn7");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -396,16 +396,16 @@ TEST(fvm_layout, coalescing_synapses) {
         auto desc = builder.make_cell();
 
         // Add synapses of two varieties.
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 4, 1));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  5, 1));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 1, 3));
-        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1));
-        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2));
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2), "syn0");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 4, 1), "syn1");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2), "syn2");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  5, 1), "syn3");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc_2("exp2syn", 1, 3), "syn4");
+        desc.decorations.place(builder.location({1, 0.3}), syn_desc("expsyn",  1, 2), "syn5");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2), "syn6");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1), "syn7");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 1), "syn8");
+        desc.decorations.place(builder.location({1, 0.7}), syn_desc_2("exp2syn", 2, 2), "syn9");
 
         cable_cell cell(desc);
         fvm_cv_discretization D = fvm_cv_discretize({cell}, neuron_parameter_defaults);
@@ -445,14 +445,14 @@ TEST(fvm_layout, synapse_targets) {
         return mechanism_desc(name).set("e", syn_e.at(idx));
     };
 
-    descriptions[0].decorations.place(builders[0].location({1, 0.9}), syn_desc("expsyn", 0));
-    descriptions[0].decorations.place(builders[0].location({0, 0.5}), syn_desc("expsyn", 1));
-    descriptions[0].decorations.place(builders[0].location({1, 0.4}), syn_desc("expsyn", 2));
+    descriptions[0].decorations.place(builders[0].location({1, 0.9}), syn_desc("expsyn", 0), "syn0");
+    descriptions[0].decorations.place(builders[0].location({0, 0.5}), syn_desc("expsyn", 1), "syn1");
+    descriptions[0].decorations.place(builders[0].location({1, 0.4}), syn_desc("expsyn", 2), "syn2");
 
-    descriptions[1].decorations.place(builders[1].location({2, 0.4}), syn_desc("exp2syn", 3));
-    descriptions[1].decorations.place(builders[1].location({1, 0.4}), syn_desc("exp2syn", 4));
-    descriptions[1].decorations.place(builders[1].location({3, 0.4}), syn_desc("expsyn", 5));
-    descriptions[1].decorations.place(builders[1].location({3, 0.7}), syn_desc("exp2syn", 6));
+    descriptions[1].decorations.place(builders[1].location({2, 0.4}), syn_desc("exp2syn", 3), "syn3");
+    descriptions[1].decorations.place(builders[1].location({1, 0.4}), syn_desc("exp2syn", 4), "syn4");
+    descriptions[1].decorations.place(builders[1].location({3, 0.4}), syn_desc("expsyn", 5), "syn5");
+    descriptions[1].decorations.place(builders[1].location({3, 0.7}), syn_desc("exp2syn", 6), "syn6");
 
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index 0634c25f41050d955185cd9470816a72548f2318..d8f04096b0a9e9e379a38a405bbb52c5c371f71e 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -1,4 +1,5 @@
 #include <cmath>
+#include <numeric>
 #include <string>
 #include <vector>
 
@@ -97,7 +98,7 @@ public:
 
     arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
         auto c = soma_cell_builder(20).make_cell();
-        c.decorations.place(mlocation{0, 1}, gap_junction_site{});
+        c.decorations.place(mlocation{0, 1}, gap_junction_site{}, "gj");
         return {cable_cell{c}};
     }
 
@@ -107,21 +108,21 @@ public:
     std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
         switch (gid) {
             case 0 :
-                return {gap_junction_connection({5, 0}, 0, 0.1)};
+                return {gap_junction_connection({5, "gj"}, {"gj"}, 0.1)};
             case 2 :
                 return {
-                        gap_junction_connection({3, 0}, 0, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
                 };
             case 3 :
                 return {
-                        gap_junction_connection({7, 0}, 0, 0.1),
-                        gap_junction_connection({2, 0}, 0, 0.1)
+                        gap_junction_connection({7, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1)
                 };
             case 5 :
-                return {gap_junction_connection({0, 0}, 0, 0.1)};
+                return {gap_junction_connection({0, "gj"}, {"gj"}, 0.1)};
             case 7 :
                 return {
-                        gap_junction_connection({3, 0}, 0, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
                 };
             default :
                 return {};
@@ -162,7 +163,7 @@ public:
 
     arb::util::unique_any get_cell_description(cell_gid_type) const override {
         auto c = soma_cell_builder(20).make_cell();
-        c.decorations.place(mlocation{0,1}, gap_junction_site{});
+        c.decorations.place(mlocation{0,1}, gap_junction_site{}, "gj");
         return {cable_cell{c}};
     }
 
@@ -173,27 +174,27 @@ public:
         switch (gid) {
             case 0 :
                 return {
-                        gap_junction_connection({2, 0}, 0, 0.1),
-                        gap_junction_connection({3, 0}, 0, 0.1),
-                        gap_junction_connection({5, 0}, 0, 0.1)
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({5, "gj"}, {"gj"}, 0.1)
                 };
             case 2 :
                 return {
-                        gap_junction_connection({0, 0}, 0, 0.1),
-                        gap_junction_connection({3, 0}, 0, 0.1),
-                        gap_junction_connection({5, 0}, 0, 0.1)
+                        gap_junction_connection({0, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({5, "gj"}, {"gj"}, 0.1)
                 };
             case 3 :
                 return {
-                        gap_junction_connection({0, 0}, 0, 0.1),
-                        gap_junction_connection({2, 0}, 0, 0.1),
-                        gap_junction_connection({5, 0}, 0, 0.1)
+                        gap_junction_connection({0, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({5, "gj"}, {"gj"}, 0.1)
                 };
             case 5 :
                 return {
-                        gap_junction_connection({2, 0}, 0, 0.1),
-                        gap_junction_connection({3, 0}, 0, 0.1),
-                        gap_junction_connection({0, 0}, 0, 0.1)
+                        gap_junction_connection({2, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({3, "gj"}, {"gj"}, 0.1),
+                        gap_junction_connection({0, "gj"}, {"gj"}, 0.1)
                 };
             default :
                 return {};
@@ -224,12 +225,8 @@ TEST(fvm_lowered, matrix_init)
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 10, "dend"); // 10 compartments
     cable_cell cell = builder.make_cell();
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, cable1d_recipe(cell), cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, cable1d_recipe(cell));
 
     auto& J = fvcell.*private_matrix_ptr;
     auto& S = fvcell.*private_state_ptr;
@@ -268,23 +265,19 @@ TEST(fvm_lowered, target_handles) {
     };
 
     // (in increasing target order)
-    descriptions[0].decorations.place(mlocation{0, 0.7}, "expsyn");
-    descriptions[0].decorations.place(mlocation{0, 0.3}, "expsyn");
-    descriptions[1].decorations.place(mlocation{2, 0.2}, "exp2syn");
-    descriptions[1].decorations.place(mlocation{2, 0.8}, "expsyn");
+    descriptions[0].decorations.place(mlocation{0, 0.7}, "expsyn", "syn0");
+    descriptions[0].decorations.place(mlocation{0, 0.3}, "expsyn", "syn1");
+    descriptions[1].decorations.place(mlocation{2, 0.2}, "exp2syn", "syn2");
+    descriptions[1].decorations.place(mlocation{2, 0.8}, "expsyn", "syn3");
 
-    descriptions[1].decorations.place(mlocation{0, 0}, threshold_detector{3.3});
+    descriptions[1].decorations.place(mlocation{0, 0}, threshold_detector{3.3}, "detector");
 
     cable_cell cells[] = {descriptions[0], descriptions[1]};
 
     EXPECT_EQ(cells[0].morphology().num_branches(), 1u);
     EXPECT_EQ(cells[1].morphology().num_branches(), 3u);
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
-    auto test_target_handles = [&](fvm_cell& cell) {
+    auto test_target_handles = [&](fvm_cell& cell, const std::vector<target_handle>& targets) {
         mechanism* expsyn = find_mechanism(cell, "expsyn");
         ASSERT_TRUE(expsyn);
         mechanism* exp2syn = find_mechanism(cell, "exp2syn");
@@ -313,12 +306,12 @@ TEST(fvm_lowered, target_handles) {
     };
 
     fvm_cell fvcell0(context);
-    fvcell0.initialize({0, 1}, cable1d_recipe(cells, true), cell_to_intdom, targets, probe_map);
-    test_target_handles(fvcell0);
+    auto fvm_info0 = fvcell0.initialize({0, 1}, cable1d_recipe(cells, true));
+    test_target_handles(fvcell0, fvm_info0.target_handles);
 
     fvm_cell fvcell1(context);
-    fvcell1.initialize({0, 1}, cable1d_recipe(cells, false), cell_to_intdom, targets, probe_map);
-    test_target_handles(fvcell1);
+    auto fvm_info1 = fvcell1.initialize({0, 1}, cable1d_recipe(cells, false));
+    test_target_handles(fvcell1, fvm_info1.target_handles);
 
 }
 
@@ -345,9 +338,9 @@ TEST(fvm_lowered, stimulus) {
     auto desc = make_cell_ball_and_stick(false);
 
     // At end of stick
-    desc.decorations.place(mlocation{0,1},   i_clamp::box(5., 80., 0.3));
+    desc.decorations.place(mlocation{0,1},   i_clamp::box(5., 80., 0.3), "clamp0");
     // On the soma CV, which is over the approximate interval: (cable 0 0 0.1)
-    desc.decorations.place(mlocation{0,0.05}, i_clamp::box(1., 2.,  0.1));
+    desc.decorations.place(mlocation{0,0.05}, i_clamp::box(1., 2.,  0.1), "clamp1");
 
     std::vector<cable_cell> cells{desc};
 
@@ -357,19 +350,14 @@ TEST(fvm_lowered, stimulus) {
     // The implementation of the stimulus is tested by creating a lowered cell, then
     // testing that the correct currents are injected at the correct control volumes
     // as during the stimulus windows.
-    std::vector<fvm_index_type> cell_to_intdom(cells.size(), 0);
-
     cable_cell_global_properties gprop;
     gprop.default_parameters = neuron_parameter_defaults;
 
     fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
     const auto& A = D.cv_area;
 
-    std::vector<target_handle> targets;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, cable1d_recipe(cells));
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& J = state.current_density;
@@ -422,7 +410,7 @@ TEST(fvm_lowered, ac_stimulus) {
     const double max_time = 8; // (ms)
 
     // Envelope is linear ramp from 0 to max_time.
-    dec.place(mlocation{0, 0}, i_clamp({{0, 0}, {max_time, max_amplitude}, {max_time, 0}}, freq, phase));
+    dec.place(mlocation{0, 0}, i_clamp({{0, 0}, {max_time, max_amplitude}, {max_time, 0}}, freq, phase), "clamp");
     std::vector<cable_cell> cells = {cable_cell(tree, {}, dec)};
 
     cable_cell_global_properties gprop;
@@ -431,12 +419,8 @@ TEST(fvm_lowered, ac_stimulus) {
     fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
     const auto& A = D.cv_area;
 
-    std::vector<target_handle> targets;
-    probe_association_map probe_map;
-    std::vector<fvm_index_type> cell_to_intdom(cells.size(), 0);
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, cable1d_recipe(cells));
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& J = state.current_density;
@@ -516,13 +500,9 @@ TEST(fvm_lowered, derived_mechs) {
     {
         // Test initialization and global parameter values.
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
-        fvcell.initialize({0, 1, 2}, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize({0, 1, 2}, rec);
 
         // Both mechanisms will have the same internal name, "test_kin1".
 
@@ -588,10 +568,6 @@ TEST(fvm_lowered, read_valence) {
         resources.num_threads = arbenv::thread_concurrency();
     }
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     {
         std::vector<cable_cell> cells(1);
 
@@ -603,7 +579,7 @@ TEST(fvm_lowered, read_valence) {
 
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
-        fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize({0}, rec);
 
         // test_ca_read_valence initialization should write ca ion valence
         // to state variable 'record_zca':
@@ -631,7 +607,7 @@ TEST(fvm_lowered, read_valence) {
 
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
-        fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize({0}, rec);
 
         auto cr_mech_ptr = dynamic_cast<multicore::mechanism*>(find_mechanism(fvcell, 0));
         auto cr_opt_record_z_ptr = util::value_by_key((cr_mech_ptr->*private_field_table_ptr)(), "record_z"s);
@@ -744,12 +720,8 @@ TEST(fvm_lowered, ionic_currents) {
     cable1d_recipe rec({cable_cell{c}});
     rec.catalogue() = make_unit_test_catalogue();
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, rec);
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& ion = state.ion_data.at("ca"s);
@@ -784,17 +756,13 @@ TEST(fvm_lowered, point_ionic_current) {
     double soma_area_m2 = 4*math::pi<double>*r*r*1e-12; // [m²]
 
     // Event weight is translated by point_ica_current into a current contribution in nA.
-    c.decorations.place(mlocation{0u, 0.5}, "point_ica_current");
+    c.decorations.place(mlocation{0u, 0.5}, "point_ica_current", "syn");
 
     cable1d_recipe rec({cable_cell{c}});
     rec.catalogue() = make_unit_test_catalogue();
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, rec);
 
     // Only one target, corresponding to our point process on soma.
     double ica_nA = 12.3;
@@ -870,12 +838,8 @@ TEST(fvm_lowered, weighted_write_ion) {
     rec.catalogue() = make_unit_test_catalogue();
     rec.add_ion("ca", 2, con_int, con_ext, 0.0);
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell fvcell(context);
-    fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    fvcell.initialize({0}, rec);
 
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& ion = state.ion_data.at("ca"s);
@@ -931,32 +895,37 @@ TEST(fvm_lowered, gj_coords_simple) {
 
     class gap_recipe: public recipe {
     public:
-        gap_recipe() {}
+        gap_recipe(std::vector<cable_cell> cells) : cells_(cells) {
+            gprop_.default_parameters = neuron_parameter_defaults;
+        }
 
         cell_size_type num_cells() const override { return n_; }
         cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
         util::unique_any get_cell_description(cell_gid_type gid) const override {
-            return {};
+            return cells_[gid];
         }
         std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
             std::vector<gap_junction_connection> conns;
-            conns.push_back(gap_junction_connection({(gid+1)%2, 0}, 0, 0.5));
+            conns.push_back(gap_junction_connection({(gid+1)%2, "gj", lid_selection_policy::assert_univalent}, {"gj", lid_selection_policy::assert_univalent}, 0.5));
             return conns;
         }
-
+        std::any get_global_properties(cell_kind) const override {
+            return gprop_;
+        }
     protected:
+        arb::cable_cell_global_properties gprop_;
+        std::vector<cable_cell> cells_;
         cell_size_type n_ = 2;
     };
 
     fvm_cell fvcell(context);
 
-    gap_recipe rec;
     std::vector<cable_cell> cells;
     {
         soma_cell_builder b(2.1);
         b.add_branch(0, 10, 0.3, 0.2, 5, "dend");
         auto c = b.make_cell();
-        c.decorations.place(b.location({1, 0.8}), gap_junction_site{});
+        c.decorations.place(b.location({1, 0.8}), gap_junction_site{}, "gj");
         cells.push_back(c);
     }
 
@@ -964,14 +933,17 @@ TEST(fvm_lowered, gj_coords_simple) {
         soma_cell_builder b(2.4);
         b.add_branch(0, 10, 0.3, 0.2, 2, "dend");
         auto c = b.make_cell();
-        c.decorations.place(b.location({1, 1}), gap_junction_site{});
+        c.decorations.place(b.location({1, 1}), gap_junction_site{}, "gj");
         cells.push_back(c);
     }
+    gap_recipe rec(cells);
 
     fvm_cv_discretization D = fvm_cv_discretize(cells, neuron_parameter_defaults, context);
 
     std::vector<cell_gid_type> gids = {0, 1};
-    auto GJ = fvcell.fvm_gap_junctions(cells, gids, rec, D);
+    auto fvm_info = fvcell.initialize(gids, rec);
+
+    auto GJ = fvcell.fvm_gap_junctions(cells, gids, fvm_info.gap_junction_data, rec, D);
 
     auto weight = [&](fvm_value_type g, fvm_index_type i){
         return g * 1e3 / D.cv_area[i];
@@ -996,41 +968,47 @@ TEST(fvm_lowered, gj_coords_complex) {
 
     class gap_recipe: public recipe {
     public:
-        gap_recipe() {}
+        gap_recipe(std::vector<cable_cell> cells) : cells_(cells) {
+            gprop_.default_parameters = neuron_parameter_defaults;
+        }
 
         cell_size_type num_cells() const override { return n_; }
         cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
         util::unique_any get_cell_description(cell_gid_type gid) const override {
-            return {};
+            return cells_[gid];
         }
         std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
             std::vector<gap_junction_connection> conns;
             switch (gid) {
             case 0:
                 return {
-                    gap_junction_connection({2, 0}, 1, 0.01),
-                    gap_junction_connection({1, 0}, 0, 0.03),
-                    gap_junction_connection({1, 1}, 0, 0.04)
+                    gap_junction_connection({2, "gj0", lid_selection_policy::assert_univalent}, {"gj1", lid_selection_policy::assert_univalent}, 0.01),
+                    gap_junction_connection({1, "gj0", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.03),
+                    gap_junction_connection({1, "gj1", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.04)
                 };
             case 1:
                 return {
-                    gap_junction_connection({0, 0}, 0, 0.03),
-                    gap_junction_connection({0, 0}, 1, 0.04),
-                    gap_junction_connection({2, 1}, 2, 0.02),
-                    gap_junction_connection({2, 2}, 3, 0.01)
+                    gap_junction_connection({0, "gj0", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.03),
+                    gap_junction_connection({0, "gj0", lid_selection_policy::assert_univalent}, {"gj1", lid_selection_policy::assert_univalent}, 0.04),
+                    gap_junction_connection({2, "gj1", lid_selection_policy::assert_univalent}, {"gj2", lid_selection_policy::assert_univalent}, 0.02),
+                    gap_junction_connection({2, "gj2", lid_selection_policy::assert_univalent}, {"gj3", lid_selection_policy::assert_univalent}, 0.01)
                 };
             case 2:
                 return {
-                    gap_junction_connection({0, 1}, 0, 0.01),
-                    gap_junction_connection({1, 2}, 1, 0.02),
-                    gap_junction_connection({1, 3}, 2, 0.01)
+                    gap_junction_connection({0, "gj1", lid_selection_policy::assert_univalent}, {"gj0", lid_selection_policy::assert_univalent}, 0.01),
+                    gap_junction_connection({1, "gj2", lid_selection_policy::assert_univalent}, {"gj1", lid_selection_policy::assert_univalent}, 0.02),
+                    gap_junction_connection({1, "gj3", lid_selection_policy::assert_univalent}, {"gj2", lid_selection_policy::assert_univalent}, 0.01)
                 };
             default : return {};
             }
             return conns;
         }
-
+        std::any get_global_properties(cell_kind) const override {
+            return gprop_;
+        }
     protected:
+        arb::cable_cell_global_properties gprop_;
+        std::vector<cable_cell> cells_;
         cell_size_type n_ = 3;
     };
 
@@ -1041,8 +1019,8 @@ TEST(fvm_lowered, gj_coords_complex) {
     auto c0 = b0.make_cell();
     mlocation c0_gj[2] = {b0.location({1, 1}), b0.location({1, 0.5})};
 
-    c0.decorations.place(c0_gj[0], gap_junction_site{});
-    c0.decorations.place(c0_gj[1], gap_junction_site{});
+    c0.decorations.place(c0_gj[0], gap_junction_site{}, "gj0");
+    c0.decorations.place(c0_gj[1], gap_junction_site{}, "gj1");
 
     soma_cell_builder b1(1.4);
     b1.add_branch(0, 12, 0.3, 0.5, 6, "dend");
@@ -1052,10 +1030,10 @@ TEST(fvm_lowered, gj_coords_complex) {
     auto c1 = b1.make_cell();
     mlocation c1_gj[4] = {b1.location({2, 1}), b1.location({1, 1}), b1.location({1, 0.45}), b1.location({1, 0.1})};
 
-    c1.decorations.place(c1_gj[0], gap_junction_site{});
-    c1.decorations.place(c1_gj[1], gap_junction_site{});
-    c1.decorations.place(c1_gj[2], gap_junction_site{});
-    c1.decorations.place(c1_gj[3], gap_junction_site{});
+    c1.decorations.place(c1_gj[0], gap_junction_site{}, "gj0");
+    c1.decorations.place(c1_gj[1], gap_junction_site{}, "gj1");
+    c1.decorations.place(c1_gj[2], gap_junction_site{}, "gj2");
+    c1.decorations.place(c1_gj[3], gap_junction_site{}, "gj3");
 
 
     soma_cell_builder b2(2.9);
@@ -1068,19 +1046,18 @@ TEST(fvm_lowered, gj_coords_complex) {
     auto c2 = b2.make_cell();
     mlocation c2_gj[3] = {b2.location({1, 0.5}), b2.location({4, 1}), b2.location({2, 1})};
 
-    c2.decorations.place(c2_gj[0], gap_junction_site{});
-    c2.decorations.place(c2_gj[1], gap_junction_site{});
-    c2.decorations.place(c2_gj[2], gap_junction_site{});
+    c2.decorations.place(c2_gj[0], gap_junction_site{}, "gj0");
+    c2.decorations.place(c2_gj[1], gap_junction_site{}, "gj1");
+    c2.decorations.place(c2_gj[2], gap_junction_site{}, "gj2");
 
     std::vector<cable_cell> cells{c0, c1, c2};
-
-    std::vector<fvm_index_type> cell_to_intdom;
-
     std::vector<cell_gid_type> gids = {0, 1, 2};
 
-    gap_recipe rec;
+    gap_recipe rec(cells);
     fvm_cell fvcell(context);
-    fvcell.fvm_intdom(rec, gids, cell_to_intdom);
+
+    auto fvm_info = fvcell.initialize(gids, rec);
+    fvcell.fvm_intdom(rec, gids, fvm_info.cell_to_intdom);
     fvm_cv_discretization D = fvm_cv_discretize(cells, neuron_parameter_defaults, context);
 
     using namespace cv_prefer;
@@ -1093,7 +1070,7 @@ TEST(fvm_lowered, gj_coords_complex) {
     int c2_gj_cv[3];
     for (int i = 0; i<3; ++i) c2_gj_cv[i] = D.geometry.location_cv(2, c2_gj[i], cv_nonempty);
 
-    std::vector<fvm_gap_junction> GJ = fvcell.fvm_gap_junctions(cells, gids, rec, D);
+    std::vector<fvm_gap_junction> GJ = fvcell.fvm_gap_junctions(cells, gids, fvm_info.gap_junction_data, rec, D);
     EXPECT_EQ(10u, GJ.size());
 
     auto weight = [&](fvm_value_type g, fvm_index_type i){
@@ -1140,12 +1117,16 @@ TEST(fvm_lowered, cell_group_gj) {
 
     class gap_recipe: public recipe {
     public:
-        gap_recipe() {}
+        gap_recipe(const std::vector<cable_cell>& cg0, const std::vector<cable_cell>& cg1) {
+            cells_ = cg0;
+            cells_.insert(cells_.end(), cg1.begin(), cg1.end());
+            gprop_.default_parameters = neuron_parameter_defaults;
+        }
 
         cell_size_type num_cells() const override { return n_; }
         cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable; }
         util::unique_any get_cell_description(cell_gid_type gid) const override {
-            return {};
+            return cells_[gid];
         }
         std::vector<arb::gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override{
             std::vector<gap_junction_connection> conns;
@@ -1153,17 +1134,23 @@ TEST(fvm_lowered, cell_group_gj) {
                 // connect 5 of the first 10 cells in a ring; connect 5 of the second 10 cells in a ring
                 auto next_cell = gid == 8 ? 0 : (gid == 18 ? 10 : gid + 2);
                 auto prev_cell = gid == 0 ? 8 : (gid == 10 ? 18 : gid - 2);
-                conns.push_back(gap_junction_connection({next_cell, 0}, 0, 0.03));
-                conns.push_back(gap_junction_connection({prev_cell, 0}, 0, 0.03));
+                conns.push_back(gap_junction_connection({next_cell, "gj", lid_selection_policy::assert_univalent},
+                                                             {"gj", lid_selection_policy::assert_univalent}, 0.03));
+                conns.push_back(gap_junction_connection({prev_cell, "gj", lid_selection_policy::assert_univalent},
+                                                             {"gj", lid_selection_policy::assert_univalent}, 0.03));
             }
             return conns;
         }
+        std::any get_global_properties(cell_kind) const override {
+            return gprop_;
+        }
 
     protected:
+        arb::cable_cell_global_properties gprop_;
+        std::vector<cable_cell> cells_;
         cell_size_type n_ = 20;
     };
 
-    gap_recipe rec;
     std::vector<cable_cell> cell_group0;
     std::vector<cable_cell> cell_group1;
 
@@ -1171,7 +1158,7 @@ TEST(fvm_lowered, cell_group_gj) {
     for (unsigned i = 0; i < 20; i++) {
         cable_cell_description c = soma_cell_builder(2.1).make_cell();
         if (i % 2 == 0) {
-            c.decorations.place(mlocation{0, 1}, gap_junction_site{});
+            c.decorations.place(mlocation{0, 1}, gap_junction_site{}, "gj");
         }
         if (i < 10) {
             cell_group0.push_back(c);
@@ -1184,18 +1171,22 @@ TEST(fvm_lowered, cell_group_gj) {
     std::vector<cell_gid_type> gids_cg0 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
     std::vector<cell_gid_type> gids_cg1 = {10, 11, 12, 13, 14, 15, 16, 17, 18, 19};
 
-    std::vector<fvm_index_type> cell_to_intdom0, cell_to_intdom1;
+    gap_recipe rec(cell_group0, cell_group1);
 
-    fvm_cell fvcell(context);
+    fvm_cell fvcell0(context);
+    fvm_cell fvcell1(context);
+
+    auto fvm_info_0 = fvcell0.initialize(gids_cg0, rec);
+    auto fvm_info_1 = fvcell1.initialize(gids_cg1, rec);
 
-    auto num_dom0 = fvcell.fvm_intdom(rec, gids_cg0, cell_to_intdom0);
-    auto num_dom1 = fvcell.fvm_intdom(rec, gids_cg1, cell_to_intdom1);
+    auto num_dom0 = fvcell0.fvm_intdom(rec, gids_cg0, fvm_info_0.cell_to_intdom);
+    auto num_dom1 = fvcell1.fvm_intdom(rec, gids_cg1, fvm_info_1.cell_to_intdom);
 
     fvm_cv_discretization D0 = fvm_cv_discretize(cell_group0, neuron_parameter_defaults, context);
     fvm_cv_discretization D1 = fvm_cv_discretize(cell_group1, neuron_parameter_defaults, context);
 
-    auto GJ0 = fvcell.fvm_gap_junctions(cell_group0, gids_cg0, rec, D0);
-    auto GJ1 = fvcell.fvm_gap_junctions(cell_group1, gids_cg1, rec, D1);
+    auto GJ0 = fvcell0.fvm_gap_junctions(cell_group0, gids_cg0, fvm_info_0.gap_junction_data, rec, D0);
+    auto GJ1 = fvcell1.fvm_gap_junctions(cell_group1, gids_cg1, fvm_info_1.gap_junction_data, rec, D1);
 
     EXPECT_EQ(10u, GJ0.size());
     EXPECT_EQ(10u, GJ1.size());
@@ -1211,8 +1202,8 @@ TEST(fvm_lowered, cell_group_gj) {
     EXPECT_EQ(6u, num_dom0);
     EXPECT_EQ(6u, num_dom1);
 
-    EXPECT_EQ(expected_doms, cell_to_intdom0);
-    EXPECT_EQ(expected_doms, cell_to_intdom1);
+    EXPECT_EQ(expected_doms, fvm_info_0.cell_to_intdom);
+    EXPECT_EQ(expected_doms, fvm_info_1.cell_to_intdom);
 
 }
 
@@ -1293,27 +1284,17 @@ TEST(fvm_lowered, post_events_shared_state) {
             auto ndetectors = detectors_per_cell_[gid];
             auto offset = 1.0 / ndetectors;
             for (unsigned i = 0; i < ndetectors; ++i) {
-                decor.place(arb::mlocation{0, offset * i}, arb::threshold_detector{10});
+                decor.place(arb::mlocation{0, offset * i}, arb::threshold_detector{10}, "detector"+std::to_string(i));
             }
-            decor.place(arb::mlocation{0, 0.5}, synapse_);
+            decor.place(arb::mlocation{0, 0.5}, synapse_, "syanpse");
 
-            return arb::cable_cell(arb::morphology(tree), {}, decor);;
+            return arb::cable_cell(arb::morphology(tree), {}, decor);
         }
 
         cell_kind get_cell_kind(cell_gid_type gid) const override {
             return cell_kind::cable;
         }
 
-        // Each cell has one spike detector (at the soma).
-        cell_size_type num_sources(cell_gid_type gid) const override {
-            return detectors_per_cell_[gid];
-        }
-
-        // The cell has one target synapse, which will be connected to cell gid-1.
-        cell_size_type num_targets(cell_gid_type gid) const override {
-            return 1;
-        }
-
         std::any get_global_properties(arb::cell_kind) const override {
             arb::cable_cell_global_properties gprop;
             gprop.default_parameters = arb::neuron_parameter_defaults;
@@ -1329,10 +1310,7 @@ TEST(fvm_lowered, post_events_shared_state) {
         mechanism_catalogue cat_;
     };
 
-    std::vector<target_handle> targets;
-    probe_association_map probe_map;
-
-    std::vector<unsigned> gids                 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+    std::vector<unsigned> gids = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
     const unsigned ncell = gids.size();
     const unsigned cv_per_cell = 10;
 
@@ -1344,10 +1322,9 @@ TEST(fvm_lowered, post_events_shared_state) {
 
     for (const auto& detectors_per_cell: detectors_per_cell_vec) {
         detector_recipe rec(cv_per_cell, detectors_per_cell, "post_events_syn");
-        std::vector<fvm_index_type> cell_to_intdom;
 
         fvm_cell fvcell(context);
-        fvcell.initialize(gids, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize(gids, rec);
 
         auto& S = fvcell.*private_state_ptr;
 
@@ -1366,10 +1343,9 @@ TEST(fvm_lowered, post_events_shared_state) {
     }
     for (const auto& detectors_per_cell: detectors_per_cell_vec) {
         detector_recipe rec(cv_per_cell, detectors_per_cell, "expsyn");
-        std::vector<fvm_index_type> cell_to_intdom;
 
         fvm_cell fvcell(context);
-        fvcell.initialize(gids, rec, cell_to_intdom, targets, probe_map);
+        fvcell.initialize(gids, rec);
 
         auto& S = fvcell.*private_state_ptr;
 
@@ -1377,5 +1353,194 @@ TEST(fvm_lowered, post_events_shared_state) {
         EXPECT_EQ(0u, S->src_to_spike.size());
         EXPECT_EQ(0u, S->time_since_spike.size());
     }
-
 }
+
+TEST(fvm_lowered, label_data) {
+    arb::proc_allocation resources;
+    if (auto nt = arbenv::get_env_num_threads()) {
+        resources.num_threads = nt;
+    } else {
+        resources.num_threads = arbenv::thread_concurrency();
+    }
+    arb::execution_context context(resources);
+
+    class decorated_recipe: public arb::recipe {
+    public:
+        decorated_recipe(unsigned ncell): ncell_(ncell) {
+            // Cells will have one of 2 decorations:
+            //   (1)  1 synapse label with 4 locations; 1 synapse label with 1 location
+            //        1 detector label with 1 location
+            //        0 gap junctions
+            //        if (duplicate) add a duplicate label, check fvm initialize throws
+            //   (2)  0 synapse labels
+            //        1 detector label with 3 locations; 1 detector label with 2 locations
+            //        1 gap-junction label with 2 locations; 1 gap-junction label with 1 location
+            using arb::reg::all;
+            using arb::ls::uniform;
+            arb::segment_tree tree;
+            tree.append(arb::mnpos, {0, 0, 0.0, 1.0}, {0, 0, 200, 1.0}, 1);
+            {
+                arb::decor decor;
+                decor.set_default(arb::cv_policy_fixed_per_branch(10));
+                decor.place(uniform(all(), 0, 3, 42), "expsyn", "4_synapses");
+                decor.place(uniform(all(), 4, 4, 42), "expsyn", "1_synapse");
+                decor.place(uniform(all(), 5, 5, 42), arb::threshold_detector{10}, "1_detector");
+
+                cells_.push_back(arb::cable_cell(arb::morphology(tree), {}, decor));
+            }
+            {
+                arb::decor decor;
+                decor.set_default(arb::cv_policy_fixed_per_branch(10));
+                decor.place(uniform(all(), 0, 2, 24), arb::threshold_detector{10}, "3_detectors");
+                decor.place(uniform(all(), 3, 4, 24), arb::threshold_detector{10}, "2_detectors");
+                decor.place(uniform(all(), 5, 6, 24), arb::gap_junction_site(), "2_gap_junctions");
+                decor.place(uniform(all(), 7, 7, 24), arb::gap_junction_site(), "1_gap_junction");
+
+                cells_.push_back(arb::cable_cell(arb::morphology(tree), {}, decor));
+            }
+        }
+
+        cell_size_type num_cells() const override {
+            return ncell_;
+        }
+
+        arb::util::unique_any get_cell_description(cell_gid_type gid) const override {
+            if (gid %3 == 0) {
+                return cells_[0];
+            }
+            return cells_[1];
+        }
+
+        cell_kind get_cell_kind(cell_gid_type gid) const override {
+            return cell_kind::cable;
+        }
+
+        std::any get_global_properties(arb::cell_kind) const override {
+            arb::cable_cell_global_properties gprop;
+            gprop.default_parameters = arb::neuron_parameter_defaults;
+            return gprop;
+        }
+
+    private:
+        unsigned ncell_;
+        std::vector<cable_cell> cells_;
+    };
+
+    std::vector<unsigned> gids = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+    const unsigned ncell = gids.size();
+
+    // Check correct synapse, deterctor and gj data
+    decorated_recipe rec(ncell);
+    std::vector<fvm_index_type> cell_to_intdom;
+    std::vector<target_handle> targets;
+    probe_association_map probe_map;
+
+    fvm_cell fvcell(context);
+    auto fvm_info = fvcell.initialize(gids, rec);
+
+    for (auto gid: gids) {
+        if (gid%3 == 0) {
+            EXPECT_EQ(5u, fvm_info.num_targets[gid]);
+            EXPECT_EQ(1u, fvm_info.num_sources[gid]);
+        }
+        else {
+            EXPECT_EQ(0u, fvm_info.num_targets[gid]);
+            EXPECT_EQ(5u, fvm_info.num_sources[gid]);
+        }
+    }
+
+    // synapses
+    {
+        auto clg = cell_labels_and_gids(fvm_info.target_data, gids);
+        std::vector<cell_size_type> expected_sizes = {2, 0, 0, 2, 0, 0, 2, 0, 0, 2};
+        std::vector<std::pair<cell_tag_type, lid_range>> expected_labeled_ranges, actual_labeled_ranges;
+        expected_labeled_ranges = {
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}},
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}},
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}},
+            {"1_synapse",  {4, 5}}, {"4_synapses", {0, 4}}
+        };
+
+        EXPECT_EQ(clg.gids, gids);
+        EXPECT_EQ(clg.label_range.sizes(), expected_sizes);
+        EXPECT_EQ(clg.label_range.labels().size(), expected_labeled_ranges.size());
+        EXPECT_EQ(clg.label_range.ranges().size(), expected_labeled_ranges.size());
+
+        for (unsigned i = 0; i < expected_labeled_ranges.size(); ++i) {
+            actual_labeled_ranges.push_back({clg.label_range.labels()[i], clg.label_range.ranges()[i]});
+        }
+
+        std::vector<cell_size_type> size_partition;
+        auto part = util::make_partition(size_partition, expected_sizes);
+        for (const auto& r: part) {
+            util::sort(util::subrange_view(actual_labeled_ranges, r));
+        }
+        EXPECT_EQ(actual_labeled_ranges, expected_labeled_ranges);
+    }
+
+    // detectors
+    {
+        auto clg = cell_labels_and_gids(fvm_info.source_data, gids);
+        std::vector<cell_size_type> expected_sizes = {1, 2, 2, 1, 2, 2, 1, 2, 2, 1};
+        std::vector<std::pair<cell_tag_type, lid_range>> expected_labeled_ranges, actual_labeled_ranges;
+        expected_labeled_ranges = {
+            {"1_detector",  {0, 1}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"1_detector",  {0, 1}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"1_detector",  {0, 1}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"2_detectors", {3, 5}}, {"3_detectors", {0, 3}},
+            {"1_detector",  {0, 1}}
+        };
+
+        EXPECT_EQ(clg.gids, gids);
+        EXPECT_EQ(clg.label_range.sizes(), expected_sizes);
+        EXPECT_EQ(clg.label_range.labels().size(), expected_labeled_ranges.size());
+        EXPECT_EQ(clg.label_range.ranges().size(), expected_labeled_ranges.size());
+
+        for (unsigned i = 0; i < expected_labeled_ranges.size(); ++i) {
+            actual_labeled_ranges.push_back({clg.label_range.labels()[i], clg.label_range.ranges()[i]});
+        }
+
+        std::vector<cell_size_type> size_partition;
+        auto part = util::make_partition(size_partition, expected_sizes);
+        for (const auto& r: part) {
+            util::sort(util::subrange_view(actual_labeled_ranges, r));
+        }
+        EXPECT_EQ(actual_labeled_ranges, expected_labeled_ranges);
+    }
+
+    // gap_junctions
+    {
+        auto clg = cell_labels_and_gids(fvm_info.gap_junction_data, gids);
+        std::vector<cell_size_type> expected_sizes = {0, 2, 2, 0, 2, 2, 0, 2, 2, 0};
+        std::vector<std::pair<cell_tag_type, lid_range>> expected_labeled_ranges, actual_labeled_ranges;
+        expected_labeled_ranges = {
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+            {"1_gap_junction",  {2, 3}}, {"2_gap_junctions", {0, 2}},
+        };
+
+        EXPECT_EQ(clg.gids, gids);
+        EXPECT_EQ(clg.label_range.sizes(), expected_sizes);
+        EXPECT_EQ(clg.label_range.labels().size(), expected_labeled_ranges.size());
+        EXPECT_EQ(clg.label_range.ranges().size(), expected_labeled_ranges.size());
+
+        for (unsigned i = 0; i < expected_labeled_ranges.size(); ++i) {
+            actual_labeled_ranges.push_back({clg.label_range.labels()[i], clg.label_range.ranges()[i]});
+        }
+
+        std::vector<cell_size_type> size_partition;
+        auto part = util::make_partition(size_partition, expected_sizes);
+        for (const auto& r: part) {
+            util::sort(util::subrange_view(actual_labeled_ranges, r));
+        }
+        EXPECT_EQ(actual_labeled_ranges, expected_labeled_ranges);
+    }
+}
\ No newline at end of file
diff --git a/test/unit/test_label_resolution.cpp b/test/unit/test_label_resolution.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c5ad758207a449a64caa67eebeaaa563aefb91c8
--- /dev/null
+++ b/test/unit/test_label_resolution.cpp
@@ -0,0 +1,318 @@
+#include "../gtest.h"
+
+#include <vector>
+
+#include <arbor/arbexcept.hpp>
+
+#include "label_resolution.hpp"
+
+using namespace arb;
+
+TEST(test_cell_label_range, build) {
+    using ivec = std::vector<cell_size_type>;
+    using svec = std::vector<cell_tag_type>;
+    using lvec = std::vector<lid_range>;
+
+    // Test add_cell and add_label
+    auto b0 = cell_label_range();
+    EXPECT_THROW(b0.add_label("l0", {0u, 1u}), arb::arbor_internal_error);
+    EXPECT_TRUE(b0.sizes().empty());
+    EXPECT_TRUE(b0.labels().empty());
+    EXPECT_TRUE(b0.ranges().empty());
+    EXPECT_TRUE(b0.check_invariant());
+
+    auto b1 = cell_label_range();
+    b1.add_cell();
+    b1.add_cell();
+    b1.add_cell();
+    EXPECT_EQ((ivec{0u, 0u, 0u}), b1.sizes());
+    EXPECT_TRUE(b1.labels().empty());
+    EXPECT_TRUE(b1.ranges().empty());
+    EXPECT_TRUE(b1.check_invariant());
+
+    auto b2 = cell_label_range();
+    b2.add_cell();
+    b2.add_label("l0", {0u, 1u});
+    b2.add_label("l0", {3u, 13u});
+    b2.add_label("l1", {0u, 5u});
+    b2.add_cell();
+    b2.add_cell();
+    b2.add_label("l2", {6u, 8u});
+    b2.add_label("l3", {1u, 0u});
+    b2.add_label("l4", {7u, 2u});
+    b2.add_label("l4", {7u, 2u});
+    b2.add_label("l2", {7u, 2u});
+    EXPECT_EQ((ivec{3u, 0u, 5u}), b2.sizes());
+    EXPECT_EQ((svec{"l0", "l0", "l1", "l2", "l3", "l4", "l4", "l2"}), b2.labels());
+    EXPECT_EQ((lvec{{0u, 1u}, {3u, 13u}, {0u, 5u}, {6u, 8u}, {1u, 0u}, {7u, 2u}, {7u, 2u}, {7u, 2u}}), b2.ranges());
+    EXPECT_TRUE(b2.check_invariant());
+
+    auto b3 = cell_label_range();
+    b3.add_cell();
+    b3.add_label("r0", {0u, 9u});
+    b3.add_label("r1", {10u, 10u});
+    b3.add_cell();
+    EXPECT_EQ((ivec{2u, 0u}), b3.sizes());
+    EXPECT_EQ((svec{"r0", "r1"}), b3.labels());
+    EXPECT_EQ((lvec{{0u, 9u}, {10u, 10u}}), b3.ranges());
+    EXPECT_TRUE(b3.check_invariant());
+
+    // Test appending
+    b0.append(b1);
+    EXPECT_EQ((ivec{0u, 0u, 0u}), b0.sizes());
+    EXPECT_TRUE(b0.labels().empty());
+    EXPECT_TRUE(b0.ranges().empty());
+    EXPECT_TRUE(b0.check_invariant());
+
+    b0.append(b2);
+    EXPECT_EQ((ivec{0u, 0u, 0u, 3u, 0u, 5u}), b0.sizes());
+    EXPECT_EQ((svec{"l0", "l0", "l1", "l2", "l3", "l4", "l4", "l2"}), b0.labels());
+    EXPECT_EQ((lvec{{0u, 1u}, {3u, 13u}, {0u, 5u}, {6u, 8u}, {1u, 0u}, {7u, 2u}, {7u, 2u}, {7u, 2u}}), b0.ranges());
+    EXPECT_TRUE(b0.check_invariant());
+
+    b0.append(b3);
+    EXPECT_EQ((ivec{0u, 0u, 0u, 3u, 0u, 5u, 2u, 0u}), b0.sizes());
+    EXPECT_EQ((svec{"l0", "l0", "l1", "l2", "l3", "l4", "l4", "l2", "r0", "r1"}), b0.labels());
+    EXPECT_EQ((lvec{{0u, 1u}, {3u, 13u}, {0u, 5u}, {6u, 8u}, {1u, 0u}, {7u, 2u}, {7u, 2u}, {7u, 2u}, {0u, 9u}, {10u, 10u}}), b0.ranges());
+    EXPECT_TRUE(b0.check_invariant());
+}
+
+TEST(test_cell_labels_and_gids, build) {
+    // Test add_cell and add_label
+    auto b0 = cell_label_range();
+    EXPECT_THROW(cell_labels_and_gids(b0, {1u}), arb::arbor_internal_error);
+    auto c0 = cell_labels_and_gids(b0, {});
+
+    auto b1 = cell_label_range();
+    b1.add_cell();
+    auto c1 = cell_labels_and_gids(b1, {0});
+    c0.append(c1);
+    EXPECT_TRUE(c0.check_invariant());
+
+    auto b2 = cell_label_range();
+    b2.add_cell();
+    b2.add_cell();
+    b2.add_cell();
+    auto c2 = cell_labels_and_gids(b2, {4, 6, 0});
+    c0.append(c2);
+    EXPECT_TRUE(c0.check_invariant());
+
+    auto b3 = cell_label_range();
+    b3.add_cell();
+    b3.add_cell();
+    auto c3 = cell_labels_and_gids(b3, {1, 1});
+    c0.append(c3);
+    EXPECT_TRUE(c0.check_invariant());
+
+    c0.gids = {};
+    EXPECT_FALSE(c0.check_invariant());
+
+    c0.gids = {0, 4, 6, 0, 1, 1};
+    EXPECT_TRUE(c0.check_invariant());
+
+    c0.label_range = {};
+    EXPECT_FALSE(c0.check_invariant());
+}
+
+TEST(test_label_resolution, policies) {
+    using vec = std::vector<cell_lid_type>;
+    {
+        std::vector<cell_gid_type> gids = {0, 1, 2, 3, 4};
+        std::vector<cell_size_type> sizes = {1, 0, 1, 2, 3};
+        std::vector<cell_tag_type> labels = {"l0_0", "l2_0", "l3_0", "l3_1", "l4_0", "l4_1", "l4_1"};
+        std::vector<lid_range> ranges = {{0, 1}, {0, 3}, {1, 2}, {4, 10}, {5, 6}, {8, 11}, {12, 14}};
+
+        auto res_map = label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids));
+
+        // Check resolver map correctness
+        // gid 0
+        EXPECT_EQ(1u, res_map.count(0, "l0_0"));
+        auto rset = res_map.at(0, "l0_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 1), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 1u}), rset.ranges_partition);
+
+        // gid 1
+        EXPECT_EQ(0u, res_map.count(1, "l0_0"));
+
+        // gid 2
+        EXPECT_EQ(1u, res_map.count(2, "l2_0"));
+        rset = res_map.at(2, "l2_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 3), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 3u}), rset.ranges_partition);
+
+        // gid 3
+        EXPECT_EQ(1u, res_map.count(3, "l3_0"));
+        rset = res_map.at(3, "l3_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(1, 2), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 1u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(3, "l3_1"));
+        rset = res_map.at(3, "l3_1");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(4, 10), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 6u}), rset.ranges_partition);
+
+        // gid 4
+        EXPECT_EQ(1u, res_map.count(4, "l4_0"));
+        rset = res_map.at(4, "l4_0");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(5, 6), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 1u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(4, "l4_1"));
+        rset = res_map.at(4, "l4_1");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(8, 11), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(12, 14), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 3u, 5u}), rset.ranges_partition);
+
+        // Check lid resolution
+        auto lid_resolver = arb::resolver(&res_map);
+        // Non-existent gid or label
+        EXPECT_THROW(lid_resolver.resolve({9, "l9_0"}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({0, "l1_0"}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({1, "l1_0"}), arb::bad_connection_label);
+
+        // Univalent
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::assert_univalent}));
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::assert_univalent}));
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::assert_univalent})); // Repeated request
+
+        EXPECT_THROW(lid_resolver.resolve({2, "l2_0", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({3, "l3_1", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_THROW(lid_resolver.resolve({4, "l4_1", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+
+        // Round robin
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(2u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u, lid_resolver.resolve({4, "l4_0", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(8u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(9u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(10u, lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(12u, lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(13u, lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(8u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(9u,  lid_resolver.resolve({4, "l4_1", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(4u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+
+        // Reset
+        lid_resolver = arb::resolver(&res_map);
+        EXPECT_EQ(4u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(6u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(7u, lid_resolver.resolve({3, "l3_1", lid_selection_policy::round_robin}));
+
+        // Test exception
+        gids.push_back(5);
+        sizes.push_back(1);
+        labels.emplace_back("l5_0");
+        ranges.emplace_back(0, 0);
+        res_map = label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids));
+
+        lid_resolver = arb::resolver(&res_map);
+        EXPECT_THROW(lid_resolver.resolve({5, "l5_0"}), arb::bad_connection_label);
+
+        ranges.back() = {4, 2};
+        EXPECT_THROW(label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids)), arb::arbor_internal_error);
+
+    }
+    // multivalent labels
+    {
+        std::vector<cell_gid_type> gids = {0, 1, 2};
+        std::vector<cell_size_type> sizes = {3, 0, 6};
+        std::vector<cell_tag_type> labels = {"l0_0", "l0_1", "l0_0", "l2_0", "l2_0", "l2_2", "l2_1", "l2_1", "l2_2"};
+        std::vector<lid_range> ranges = {{0, 1}, {0, 3}, {1, 3}, {4, 6}, {9, 12}, {5, 5}, {1, 2}, {0, 1}, {22, 23}};
+
+        auto res_map = label_resolution_map(cell_labels_and_gids({sizes, labels, ranges}, gids));
+
+        // Check resolver map correctness
+        // gid 0
+        EXPECT_EQ(1u, res_map.count(0, "l0_1"));
+        auto rset = res_map.at(0, "l0_1");
+        EXPECT_EQ(1u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 3), rset.ranges.front());
+        EXPECT_EQ((vec{0u, 3u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(0, "l0_0"));
+        rset = res_map.at(0, "l0_0");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(0, 1), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(1, 3), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 1u, 3u}), rset.ranges_partition);
+
+        // gid 1
+        EXPECT_EQ(0u, res_map.count(1, "l0_1"));
+
+        // gid 2
+        EXPECT_EQ(1u, res_map.count(2, "l2_0"));
+        rset = res_map.at(2, "l2_0");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(4, 6), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(9, 12), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 2u, 5u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(2, "l2_1"));
+        rset = res_map.at(2, "l2_1");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(1, 2), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(0, 1), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 1u, 2u}), rset.ranges_partition);
+
+        EXPECT_EQ(1u, res_map.count(2, "l2_2"));
+        rset = res_map.at(2, "l2_2");
+        EXPECT_EQ(2u, rset.ranges.size());
+        EXPECT_EQ(lid_range(5, 5), rset.ranges.at(0));
+        EXPECT_EQ(lid_range(22, 23), rset.ranges.at(1));
+        EXPECT_EQ((vec{0u, 0u, 1u}), rset.ranges_partition);
+
+        // Check lid resolution
+        auto lid_resolver = arb::resolver(&res_map);
+        // gid 0
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(2u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_1", lid_selection_policy::round_robin}));
+
+        EXPECT_THROW(lid_resolver.resolve({0, "l0_0", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(2u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({0, "l0_0", lid_selection_policy::round_robin}));
+
+        // gid 2
+        EXPECT_THROW(lid_resolver.resolve({2, "l2_0", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_EQ(4u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(9u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(10u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(11u, lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(4u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+        EXPECT_EQ(5u,  lid_resolver.resolve({2, "l2_0", lid_selection_policy::round_robin}));
+
+        EXPECT_THROW(lid_resolver.resolve({2, "l2_1", lid_selection_policy::assert_univalent}), arb::bad_connection_label);
+        EXPECT_EQ(1u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(1u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+        EXPECT_EQ(0u, lid_resolver.resolve({2, "l2_1", lid_selection_policy::round_robin}));
+
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::assert_univalent}));
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::round_robin}));
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::round_robin}));
+        EXPECT_EQ(22u, lid_resolver.resolve({2, "l2_2", lid_selection_policy::assert_univalent}));
+
+    }
+}
+
diff --git a/test/unit/test_lif_cell_group.cpp b/test/unit/test_lif_cell_group.cpp
index 272245a140062851e4b3e2e5c7300d183a4df000..9ca765c40608093c049b57e86c4cf6609620702f 100644
--- a/test/unit/test_lif_cell_group.cpp
+++ b/test/unit/test_lif_cell_group.cpp
@@ -41,17 +41,15 @@ public:
         // In a ring, each cell has just one incoming connection.
         std::vector<cell_connection> connections;
         // gid-1 >= 0 since gid != 0
-        cell_member_type source{(gid - 1) % n_lif_cells_, 0};
-        cell_lid_type target{0};
-        cell_connection conn(source, target, weight_, delay_);
+        auto src_gid = (gid - 1) % n_lif_cells_;
+        cell_connection conn({src_gid, "src"}, {"tgt"}, weight_, delay_);
         connections.push_back(conn);
 
         // If first LIF cell, then add
         // the connection from the last LIF cell as well
         if (gid == 1) {
-            cell_member_type source{n_lif_cells_, 0};
-            cell_lid_type target{0};
-            cell_connection conn(source, target, weight_, delay_);
+            auto src_gid = n_lif_cells_;
+            cell_connection conn({src_gid, "src"}, {"tgt"}, weight_, delay_);
             connections.push_back(conn);
         }
 
@@ -62,17 +60,11 @@ public:
         // regularly spiking cell.
         if (gid == 0) {
             // Produces just a single spike at time 0ms.
-            return spike_source_cell{explicit_schedule({0.f})};
+            return spike_source_cell("src", explicit_schedule({0.f}));
         }
         // LIF cell.
-        return lif_cell();
-    }
-
-    cell_size_type num_sources(cell_gid_type) const override {
-        return 1;
-    }
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        auto cell = lif_cell("src", "tgt");
+        return cell;
     }
 
 private:
@@ -100,23 +92,15 @@ public:
             return {};
         }
         std::vector<cell_connection> connections;
-        cell_member_type source{gid - 1, 0};
-        cell_lid_type target{0};
-        cell_connection conn(source, target, weight_, delay_);
+        cell_connection conn({gid-1, "src"}, {"tgt"}, weight_, delay_);
         connections.push_back(conn);
 
         return connections;
     }
 
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        return lif_cell();
-    }
-
-    cell_size_type num_sources(cell_gid_type) const override {
-        return 1;
-    }
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        auto cell = lif_cell("src", "tgt");
+        return cell;
     }
 
 private:
@@ -139,13 +123,7 @@ public:
         return {};
     }
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        return lif_cell();
-    }
-    cell_size_type num_sources(cell_gid_type) const override {
-        return 1;
-    }
-    cell_size_type num_targets(cell_gid_type) const override {
-        return 1;
+        return lif_cell("src", "tgt");
     }
     std::vector<probe_info> get_probes(cell_gid_type gid) const override{
         return {arb::cable_probe_membrane_voltage{mlocation{0, 0}}};
diff --git a/test/unit/test_mc_cell_group.cpp b/test/unit/test_mc_cell_group.cpp
index 564b2179dc9b403360b554b2761d19c8ef244ac5..e72a3a5360e64699ce1117b4a73aaa6ab0eb289c 100644
--- a/test/unit/test_mc_cell_group.cpp
+++ b/test/unit/test_mc_cell_group.cpp
@@ -28,8 +28,8 @@ namespace {
         auto d = builder.make_cell();
         d.decorations.paint("soma"_lab, "hh");
         d.decorations.paint("dend"_lab, "pas");
-        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3));
-        d.decorations.place(builder.location({0, 0}), threshold_detector{0});
+        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3), "clamp0");
+        d.decorations.place(builder.location({0, 0}), threshold_detector{0}, "detector0");
         return d;
     }
 }
@@ -41,7 +41,8 @@ ACCESS_BIND(
 
 TEST(mc_cell_group, get_kind) {
     cable_cell cell = make_cell();
-    mc_cell_group group{{0}, cable1d_recipe({cell}), lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{{0}, cable1d_recipe({cell}), srcs, tgts, lowered_cell()};
 
     EXPECT_EQ(cell_kind::cable, group.get_cell_kind());
 }
@@ -53,7 +54,8 @@ TEST(mc_cell_group, test) {
     rec.nernst_ion("ca");
     rec.nernst_ion("k");
 
-    mc_cell_group group{{0}, rec, lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{{0}, rec, srcs, tgts, lowered_cell()};
     group.advance(epoch(0, 0., 50.), 0.01, {});
 
     // Model is expected to generate 4 spikes as a result of the
@@ -69,7 +71,7 @@ TEST(mc_cell_group, sources) {
     for (int i=0; i<20; ++i) {
         auto desc = make_cell();
         if (i==0 || i==3 || i==17) {
-            desc.decorations.place(mlocation{0, 0.3}, threshold_detector{2.3});
+            desc.decorations.place(mlocation{0, 0.3}, threshold_detector{2.3}, "detector1");
         }
         cells.emplace_back(desc);
 
@@ -82,7 +84,8 @@ TEST(mc_cell_group, sources) {
     rec.nernst_ion("ca");
     rec.nernst_ion("k");
 
-    mc_cell_group group{gids, rec, lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{gids, rec, srcs, tgts, lowered_cell()};
 
     // Expect group sources to be lexicographically sorted by source id
     // with gids in cell group's range and indices starting from zero.
diff --git a/test/unit/test_mc_cell_group_gpu.cpp b/test/unit/test_mc_cell_group_gpu.cpp
index f7c8fbb79678344a1e413f0ca5d8ccefc9abba6f..4640b02f6e4c35bcba5b8add9d648769d9b33e23 100644
--- a/test/unit/test_mc_cell_group_gpu.cpp
+++ b/test/unit/test_mc_cell_group_gpu.cpp
@@ -29,8 +29,8 @@ namespace {
         auto d = builder.make_cell();
         d.decorations.paint("soma"_lab, "hh");
         d.decorations.paint("dend"_lab, "pas");
-        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3));
-        d.decorations.place(builder.location({0, 0}), threshold_detector{0});
+        d.decorations.place(builder.location({1,1}), i_clamp::box(5, 80, 0.3), "clamp0");
+        d.decorations.place(builder.location({0, 0}), threshold_detector{0}, "detector0");
         return d;
     }
 }
@@ -43,7 +43,8 @@ TEST(mc_cell_group, gpu_test)
     rec.nernst_ion("ca");
     rec.nernst_ion("k");
 
-    mc_cell_group group{{0}, rec, lowered_cell()};
+    cell_label_range srcs, tgts;
+    mc_cell_group group{{0}, rec, srcs, tgts, lowered_cell()};
     group.advance(epoch(0, 0., 50.), 0.01, {});
 
     // The model is expected to generate 4 spikes as a result of the
diff --git a/test/unit/test_merge_events.cpp b/test/unit/test_merge_events.cpp
index d61363f4e1d8d9c5c1cd1d1574163cd54f74a7cc..f09cffc0126f9c97c51afe0b8466df1d4c361a74 100644
--- a/test/unit/test_merge_events.cpp
+++ b/test/unit/test_merge_events.cpp
@@ -156,9 +156,9 @@ TEST(merge_events, X)
         {0, 26, 4},
     };
 
-    std::vector<event_generator> generators = {
-        regular_generator(2, 42.f, t0, 5)
-    };
+    auto gen = regular_generator({"l0"}, 42.f, t0, 5);
+    gen.resolve_label([](const cell_local_label_type&) {return 2;});
+    std::vector<event_generator> generators = {gen};
 
     merge_events(t0, t1, lc, events, generators, lf);
 
@@ -182,24 +182,33 @@ TEST(merge_events, X)
 // Test the tournament tree for merging two small sequences 
 TEST(merge_events, tourney_seq)
 {
-    pse_vector evs1 = {
-        {0, 1, 1},
-        {0, 2, 2},
-        {0, 3, 3},
-        {0, 4, 4},
-        {0, 5, 5},
+    explicit_generator::lse_vector evs1 = {
+        {{"l0"}, 1, 1},
+        {{"l0"}, 2, 2},
+        {{"l0"}, 3, 3},
+        {{"l0"}, 4, 4},
+        {{"l0"}, 5, 5},
     };
 
-    pse_vector evs2 = {
-        {0, 1.5, 1},
-        {0, 2.5, 2},
-        {0, 3.5, 3},
-        {0, 4.5, 4},
-        {0, 5.5, 5},
+    explicit_generator::lse_vector evs2 = {
+        {{"l0"}, 1.5, 1},
+        {{"l0"}, 2.5, 2},
+        {{"l0"}, 3.5, 3},
+        {{"l0"}, 4.5, 4},
+        {{"l0"}, 5.5, 5},
     };
 
+    pse_vector expected;
+
+    auto gen_pse = [](const auto& item) {return spike_event{0, item.time, item.weight};};
+    std::transform(evs1.begin(), evs1.end(), std::back_inserter(expected), gen_pse);
+    std::transform(evs2.begin(), evs2.end(), std::back_inserter(expected), gen_pse);
+    util::sort(expected);
+
     event_generator g1 = explicit_generator(evs1);
     event_generator g2 = explicit_generator(evs2);
+    g1.resolve_label([](const cell_local_label_type&) {return 0;});
+    g2.resolve_label([](const cell_local_label_type&) {return 0;});
 
     std::vector<event_span> spans;
     spans.emplace_back(g1.events(0, terminal_time));
@@ -213,10 +222,6 @@ TEST(merge_events, tourney_seq)
     }
 
     EXPECT_TRUE(std::is_sorted(lf.begin(), lf.end()));
-    auto expected = evs1;
-    util::append(expected, evs2);
-    util::sort(expected);
-
     EXPECT_EQ(expected, lf);
 }
 
@@ -234,13 +239,15 @@ TEST(merge_events, tourney_poisson)
 
     std::vector<event_generator> generators;
     for (auto i=0u; i<ngen; ++i) {
-        cell_lid_type tgt = i;
+        cell_lid_type lid = i;
+        cell_tag_type label = "tgt"+std::to_string(i);
         float weight = i;
         // the first and last generators have the same seed to test that sorting
         // of events with the same time but different weights works properly.
         rndgen G(i%(ngen-1));
-        generators.emplace_back(
-                poisson_generator(tgt, weight, t0, lambda, G));
+        auto gen = poisson_generator(label, weight, t0, lambda, G);
+        gen.resolve_label([lid](const cell_local_label_type&) {return lid;});
+        generators.push_back(std::move(gen));
     }
 
     // manually generate the expected output
diff --git a/test/unit/test_probe.cpp b/test/unit/test_probe.cpp
index 1ac012537d1a338a80012bef95311dfe6392e363..8c465da24c7b36c22b8c8ab85ef43f9bb2c7dba0 100644
--- a/test/unit/test_probe.cpp
+++ b/test/unit/test_probe.cpp
@@ -111,7 +111,7 @@ void run_v_i_probe_test(const context& ctx) {
     bs.decorations.set_default(cv_policy_fixed_per_branch(1));
 
     auto stim = i_clamp::box(0, 100, 0.3);
-    bs.decorations.place(mlocation{1, 1}, stim);
+    bs.decorations.place(mlocation{1, 1}, stim, "clamp");
 
     cable1d_recipe rec((cable_cell(bs)));
 
@@ -123,13 +123,10 @@ void run_v_i_probe_test(const context& ctx) {
     rec.add_probe(0, 20, cable_probe_membrane_voltage{loc1});
     rec.add_probe(0, 30, cable_probe_total_ion_current_density{loc2});
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell lcell(*ctx);
-    lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    auto fvm_info = lcell.initialize({0}, rec);
 
+    const auto& probe_map = fvm_info.probe_map;
     EXPECT_EQ(3u, rec.get_probes(0).size());
     EXPECT_EQ(3u, probe_map.size());
 
@@ -226,16 +223,12 @@ void run_v_cell_probe_test(const context& ctx) {
         cable1d_recipe rec(cell, false);
         rec.add_probe(0, 0, cable_probe_membrane_voltage_cell{});
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         fvm_cell lcell(*ctx);
-        lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        auto fvm_info = lcell.initialize({0}, rec);
 
-        ASSERT_EQ(1u, probe_map.size());
+        ASSERT_EQ(1u, fvm_info.probe_map.size());
 
-        const fvm_probe_multi* h_ptr = std::get_if<fvm_probe_multi>(&probe_map.data_on({0, 0}).front().info);
+        const fvm_probe_multi* h_ptr = std::get_if<fvm_probe_multi>(&fvm_info.probe_map.data_on({0, 0}).front().info);
         ASSERT_TRUE(h_ptr);
         auto& h = *h_ptr;
 
@@ -280,8 +273,8 @@ void run_expsyn_g_probe_test(const context& ctx) {
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 1, "dend");
     builder.add_branch(0, 200, 1.0/2, 1.0/2, 1, "dend");
     auto bs = builder.make_cell();
-    bs.decorations.place(loc0, "expsyn");
-    bs.decorations.place(loc1, "expsyn");
+    bs.decorations.place(loc0, "expsyn", "syn0");
+    bs.decorations.place(loc1, "expsyn", "syn1");
     bs.decorations.set_default(cv_policy_fixed_per_branch(2));
 
     auto run_test = [&](bool coalesce_synapses) {
@@ -289,12 +282,10 @@ void run_expsyn_g_probe_test(const context& ctx) {
         rec.add_probe(0, 10, cable_probe_point_state{0u, "expsyn", "g"});
         rec.add_probe(0, 20, cable_probe_point_state{1u, "expsyn", "g"});
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         fvm_cell lcell(*ctx);
-        lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+        auto fvm_info = lcell.initialize({0}, rec);
+        const auto& probe_map = fvm_info.probe_map;
+        const auto& targets = fvm_info.target_handles;
 
         EXPECT_EQ(2u, rec.get_probes(0).size());
         EXPECT_EQ(2u, probe_map.size());
@@ -384,10 +375,11 @@ void run_expsyn_g_cell_probe_test(const context& ctx) {
     unsigned n_expsyn = 0;
     for (unsigned bid = 0; bid<3u; ++bid) {
         for (unsigned j = 0; j<10; ++j) {
+            auto idx = (bid*10+j)*2;
             mlocation expsyn_loc{bid, 0.1*j};
-            d.place(expsyn_loc, "expsyn");
+            d.place(expsyn_loc, "expsyn", "syn"+std::to_string(idx));
             expsyn_target_loc_map[2*n_expsyn] = expsyn_loc;
-            d.place(mlocation{bid, 0.1*j+0.05}, "exp2syn");
+            d.place(mlocation{bid, 0.1*j+0.05}, "exp2syn", "syn"+std::to_string(idx+1));
             ++n_expsyn;
         }
     }
@@ -400,12 +392,10 @@ void run_expsyn_g_cell_probe_test(const context& ctx) {
         rec.add_probe(0, 0, cable_probe_point_state_cell{"expsyn", "g"});
         rec.add_probe(1, 0, cable_probe_point_state_cell{"expsyn", "g"});
 
-        std::vector<target_handle> targets;
-        std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map probe_map;
-
         fvm_cell lcell(*ctx);
-        lcell.initialize({0, 1}, rec, cell_to_intdom, targets, probe_map);
+        auto fvm_info = lcell.initialize({0, 1}, rec);
+        const auto& probe_map = fvm_info.probe_map;
+        const auto& targets = fvm_info.target_handles;
 
         // Send an event to each expsyn synapse with a weight = target+100*cell_gid, and
         // integrate for a tiny time step.
@@ -558,12 +548,9 @@ void run_ion_density_probe_test(const context& ctx) {
     // Probe (0, 12): ca external on whole cell.
     rec.add_probe(0, 0, cable_probe_ion_ext_concentration_cell{"ca"});
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell lcell(*ctx);
-    lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+    auto fvm_info = lcell.initialize({0}, rec);
+    const auto& probe_map = fvm_info.probe_map;
 
     // Should be no sodium ion instantiated on CV 0, so probe (0, 6) should
     // have been silently discared. Similarly, write_ca2 is not instantiated on
@@ -736,12 +723,9 @@ void run_partial_density_probe_test(const context& ctx) {
         rec.add_probe(1, 0, cable_probe_density_state{mlocation{0, tp.pos}, "param_as_state", "s"});
     }
 
-    std::vector<target_handle> targets;
-    std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map probe_map;
-
     fvm_cell lcell(*ctx);
-    lcell.initialize({0, 1}, rec, cell_to_intdom, targets, probe_map);
+    auto fvm_info = lcell.initialize({0, 1}, rec);
+    const auto& probe_map = fvm_info.probe_map;
 
     // There should be 10 probes on each cell, but only 10 in total in the probe map,
     // as only those probes that are in the mechanism support should have an entry.
@@ -792,7 +776,7 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
     cv_policy policy = cv_policy_fixed_per_branch(n_cv);
     d.set_default(policy);
 
-    d.place(mlocation{0, 0}, i_clamp(0.3));
+    d.place(mlocation{0, 0}, i_clamp(0.3), "clamp");
 
     // The time constant will be membrane capacitance / membrane conductance.
     // For τ = 0.1 ms, set conductance to 0.01 S/cm² and membrance capacitance
@@ -989,8 +973,8 @@ void run_v_sampled_probe_test(const context& ctx) {
     // samples at the same point on each cell will give the same value at
     // 0.3 ms, but different at 0.6 ms.
 
-    d0.place(mlocation{1, 1}, i_clamp::box(0, 0.5, 1.));
-    d1.place(mlocation{1, 1}, i_clamp::box(0, 1.0, 1.));
+    d0.place(mlocation{1, 1}, i_clamp::box(0, 0.5, 1.), "clamp0");
+    d1.place(mlocation{1, 1}, i_clamp::box(0, 1.0, 1.), "clamp1");
     mlocation probe_loc{1, 0.2};
 
     std::vector<cable_cell> cells = {{bs.morph, bs.labels, d0}, {bs.morph, bs.labels, d1}};
@@ -1040,7 +1024,7 @@ void run_total_current_probe_test(const context& ctx) {
     // to 0.01 F/m².
 
     const double tau = 0.1;     // [ms]
-    d0.place(mlocation{0, 0}, i_clamp(0.3));
+    d0.place(mlocation{0, 0}, i_clamp(0.3), "clamp0");
 
     d0.paint(reg::all(), mechanism_desc("ca_linear").set("g", 0.01)); // [S/cm²]
     d0.set_default(membrane_capacitance{0.01}); // [F/m²]
@@ -1161,13 +1145,13 @@ void run_stimulus_probe_test(const context& ctx) {
 
     decor d0, d1;
     d0.set_default(policy);
-    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 10.));
-    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 20.));
+    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 10.), "clamp0");
+    d0.place(mlocation{0, 0.5}, i_clamp::box(0., stim_until, 20.), "clamp1");
     double expected_stim0 = 30;
 
     d1.set_default(policy);
-    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, 30.));
-    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, -10.));
+    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, 30.), "clamp0");
+    d1.place(mlocation{0, 1}, i_clamp::box(0., stim_until, -10.), "clamp1");
     double expected_stim1 = 20;
 
     std::vector<cable_cell> cells = {{m, {}, d0}, {m, {}, d1}};
@@ -1215,13 +1199,13 @@ void run_exact_sampling_probe_test(const context& ctx) {
             std::vector<cable_cell_description> cd;
             cd.assign(4, builder.make_cell());
 
-            cd[0].decorations.place(mlocation{1, 0.1}, "expsyn");
-            cd[1].decorations.place(mlocation{1, 0.1}, "exp2syn");
-            cd[2].decorations.place(mlocation{1, 0.9}, "expsyn");
-            cd[3].decorations.place(mlocation{1, 0.9}, "exp2syn");
+            cd[0].decorations.place(mlocation{1, 0.1}, "expsyn", "syn");
+            cd[1].decorations.place(mlocation{1, 0.1}, "exp2syn", "syn");
+            cd[2].decorations.place(mlocation{1, 0.9}, "expsyn", "syn");
+            cd[3].decorations.place(mlocation{1, 0.9}, "exp2syn", "syn");
 
-            cd[1].decorations.place(mlocation{1, 0.2}, gap_junction_site{});
-            cd[3].decorations.place(mlocation{1, 0.2}, gap_junction_site{});
+            cd[1].decorations.place(mlocation{1, 0.2}, gap_junction_site{}, "gj");
+            cd[3].decorations.place(mlocation{1, 0.2}, gap_junction_site{}, "gj");
 
             for (auto& d: cd) cells_.push_back(d);
         }
@@ -1240,18 +1224,12 @@ void run_exact_sampling_probe_test(const context& ctx) {
             return {cable_probe_membrane_voltage{mlocation{1, 0.5}}};
         }
 
-        cell_size_type num_targets(cell_gid_type) const override { return 1; }
-
-        cell_size_type num_gap_junction_sites(cell_gid_type gid) const override {
-            return gid==1 || gid==3;
-        }
-
         std::vector<gap_junction_connection> gap_junctions_on(cell_gid_type gid) const override {
             switch (gid) {
             case 1:
-                return {gap_junction_connection({3, 0}, 0, 1.)};
+                return {gap_junction_connection({3, "gj", lid_selection_policy::assert_univalent}, {"gj", lid_selection_policy::assert_univalent}, 1.)};
             case 3:
-                return {gap_junction_connection({1, 0}, 0, 1.)};
+                return {gap_junction_connection({1, "gj", lid_selection_policy::assert_univalent}, {"gj", lid_selection_policy::assert_univalent}, 1.)};
             default:
                 return {};
             }
@@ -1259,7 +1237,7 @@ void run_exact_sampling_probe_test(const context& ctx) {
 
         std::vector<event_generator> event_generators(cell_gid_type gid) const override {
             // Send a single event to cell i at 0.1*i milliseconds.
-            pse_vector spikes = {spike_event{0, 0.1*gid, 1.f}};
+            explicit_generator::lse_vector spikes = {{{"syn"}, 0.1*gid, 1.f}};
             return {explicit_generator(spikes)};
         }
 
diff --git a/test/unit/test_recipe.cpp b/test/unit/test_recipe.cpp
index cda30fb9b2d5c51c16ceab61ede15b92f7899479..0ecd2e332e83798386a5bc74903e88af1d655195 100644
--- a/test/unit/test_recipe.cpp
+++ b/test/unit/test_recipe.cpp
@@ -23,14 +23,10 @@ namespace {
     class custom_recipe: public recipe {
     public:
         custom_recipe(std::vector<cable_cell> cells,
-                      std::vector<cell_size_type> num_sources,
-                      std::vector<cell_size_type> num_targets,
                       std::vector<std::vector<cell_connection>> conns,
                       std::vector<std::vector<gap_junction_connection>> gjs,
                       std::vector<std::vector<arb::event_generator>> gens):
             num_cells_(cells.size()),
-            num_sources_(num_sources),
-            num_targets_(num_targets),
             connections_(conns),
             gap_junctions_(gjs),
             event_generators_(gens),
@@ -51,12 +47,6 @@ namespace {
         std::vector<cell_connection> connections_on(cell_gid_type gid) const override {
             return connections_.at(gid);
         }
-        cell_size_type num_sources(cell_gid_type gid) const override {
-            return num_sources_.at(gid);
-        }
-        cell_size_type num_targets(cell_gid_type gid) const override {
-            return num_targets_.at(gid);
-        }
         std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
             return event_generators_.at(gid);
         }
@@ -68,7 +58,6 @@ namespace {
 
     private:
         cell_size_type num_cells_;
-        std::vector<cell_size_type> num_sources_, num_targets_;
         std::vector<std::vector<cell_connection>> connections_;
         std::vector<std::vector<gap_junction_connection>> gap_junctions_;
         std::vector<std::vector<arb::event_generator>> event_generators_;
@@ -86,76 +75,23 @@ namespace {
 
         // Add a num_detectors detectors to the cell.
         for (auto i: util::make_span(num_detectors)) {
-            decorations.place(arb::mlocation{0,(double)i/num_detectors}, arb::threshold_detector{10});
+            decorations.place(arb::mlocation{0,(double)i/num_detectors}, arb::threshold_detector{10}, "detector"+std::to_string(i));
         }
 
         // Add a num_synapses synapses to the cell.
         for (auto i: util::make_span(num_synapses)) {
-            decorations.place(arb::mlocation{0,(double)i/num_synapses}, "expsyn");
+            decorations.place(arb::mlocation{0,(double)i/num_synapses}, "expsyn", "synapse"+std::to_string(i));
         }
 
         // Add a num_gj gap_junctions to the cell.
         for (auto i: util::make_span(num_gj)) {
-            decorations.place(arb::mlocation{0,(double)i/num_gj}, arb::gap_junction_site{});
+            decorations.place(arb::mlocation{0,(double)i/num_gj}, arb::gap_junction_site{}, "gapjunction"+std::to_string(i));
         }
 
         return arb::cable_cell(tree, {}, decorations);
     }
 }
 
-// test assumes one domain
-TEST(recipe, num_sources)
-{
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
-    auto cell = custom_cell(1, 0, 0);
-
-    {
-        auto recipe_0 = custom_recipe({cell}, {1}, {0}, {{}}, {{}}, {{}});
-        auto decomp_0 = partition_load_balance(recipe_0, context);
-
-        EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
-    }
-    {
-        auto recipe_1 = custom_recipe({cell}, {2}, {0}, {{}}, {{}}, {{}});
-        auto decomp_1 = partition_load_balance(recipe_1, context);
-
-        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_source_description);
-    }
-}
-
-TEST(recipe, num_targets)
-{
-    arb::proc_allocation resources;
-    if (auto nt = arbenv::get_env_num_threads()) {
-        resources.num_threads = nt;
-    }
-    else {
-        resources.num_threads = arbenv::thread_concurrency();
-    }
-    auto context = make_context(resources);
-    auto cell = custom_cell(0, 2, 0);
-
-    {
-        auto recipe_0 = custom_recipe({cell}, {0}, {2}, {{}}, {{}}, {{}});
-        auto decomp_0 = partition_load_balance(recipe_0, context);
-
-        EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
-    }
-    {
-        auto recipe_1 = custom_recipe({cell}, {0}, {3}, {{}}, {{}}, {{}});
-        auto decomp_1 = partition_load_balance(recipe_1, context);
-
-        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_target_description);
-    }
-}
-
 TEST(recipe, gap_junctions)
 {
     arb::proc_allocation resources;
@@ -170,33 +106,34 @@ TEST(recipe, gap_junctions)
     auto cell_0 = custom_cell(0, 0, 3);
     auto cell_1 = custom_cell(0, 0, 3);
 
+    using policy = lid_selection_policy;
     {
-        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, 1}, 0, 0.1},
-                                                           {{1, 2}, 1, 0.1},
-                                                           {{1, 0}, 2, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, "gapjunction1", policy::assert_univalent}, {"gapjunction0", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction2", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction0", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1}};
 
-        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, 0}, 1, 0.1},
-                                                           {{0, 1}, 2, 0.1},
-                                                           {{0, 2}, 0, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, "gapjunction0", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction1", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction2", policy::assert_univalent}, {"gapjunction0", policy::assert_univalent}, 0.1}};
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {0, 0}, {0, 0}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
         EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
     }
     {
-        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, 1}, 0, 0.1},
-                                                           {{1, 2}, 1, 0.1},
-                                                           {{1, 5}, 2, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_0 = {{{1, "gapjunction1", policy::assert_univalent}, {"gapjunction0", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction2", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{1, "gapjunction5", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1}};
 
-        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, 0}, 1, 0.1},
-                                                           {{0, 1}, 2, 0.1},
-                                                           {{0, 2}, 5, 0.1}};
+        std::vector<arb::gap_junction_connection> gjs_1 = {{{0, "gapjunction0", policy::assert_univalent}, {"gapjunction1", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction1", policy::assert_univalent}, {"gapjunction2", policy::assert_univalent}, 0.1},
+                                                           {{0, "gapjunction2", policy::assert_univalent}, {"gapjunction5", policy::assert_univalent}, 0.1}};
 
-        auto recipe_1 = custom_recipe({cell_0, cell_1}, {0, 0}, {0, 0}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
+        auto recipe_1 = custom_recipe({cell_0, cell_1}, {{}, {}}, {gjs_0, gjs_1}, {{}, {}});
         auto decomp_1 = partition_load_balance(recipe_1, context);
 
-        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_gj_connection_lid);
+        EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_connection_label);
 
     }
 }
@@ -216,60 +153,60 @@ TEST(recipe, connections)
     auto cell_1 = custom_cell(2, 1, 0);
     std::vector<arb::cell_connection> conns_0, conns_1;
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{1, 1}, 0, 0.1, 0.1},
-                   {{1, 0}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector0"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 0, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse0"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
         EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
     }
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{2, 1}, 0, 0.1, 0.1},
-                   {{1, 0}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{2, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector0"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 0, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse0"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_1 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_1 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_1 = partition_load_balance(recipe_1, context);
 
         EXPECT_THROW(simulation(recipe_1, decomp_1, context), arb::bad_connection_source_gid);
     }
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{1, 1}, 0, 0.1, 0.1},
-                   {{1, 3}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector3"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 0, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse0"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_2 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_2 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_2 = partition_load_balance(recipe_2, context);
 
-        EXPECT_THROW(simulation(recipe_2, decomp_2, context), arb::bad_connection_source_lid);
+        EXPECT_THROW(simulation(recipe_2, decomp_2, context), arb::bad_connection_label);
     }
     {
-        conns_0 = {{{1, 0}, 0, 0.1, 0.1},
-                   {{1, 1}, 0, 0.1, 0.1},
-                   {{1, 0}, 1, 0.2, 0.4}};
+        conns_0 = {{{1, "detector0"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector1"}, {"synapse0"}, 0.1, 0.1},
+                   {{1, "detector0"}, {"synapse1"}, 0.2, 0.4}};
 
-        conns_1 = {{{0, 0}, 0, 0.1, 0.2},
-                   {{0, 0}, 9, 0.3, 0.1},
-                   {{0, 0}, 0, 0.1, 0.8}};
+        conns_1 = {{{0, "detector0"}, {"synapse0"}, 0.1, 0.2},
+                   {{0, "detector0"}, {"synapse9"}, 0.3, 0.1},
+                   {{0, "detector0"}, {"synapse0"}, 0.1, 0.8}};
 
-        auto recipe_4 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
+        auto recipe_4 = custom_recipe({cell_0, cell_1}, {conns_0, conns_1}, {{}, {}},  {{}, {}});
         auto decomp_4 = partition_load_balance(recipe_4, context);
 
-        EXPECT_THROW(simulation(recipe_4, decomp_4, context), arb::bad_connection_target_lid);
+        EXPECT_THROW(simulation(recipe_4, decomp_4, context), arb::bad_connection_label);
     }
 }
 
@@ -287,22 +224,22 @@ TEST(recipe, event_generators) {
     auto cell_1 = custom_cell(2, 1, 0);
     std::vector<arb::event_generator> gens_0, gens_1;
     {
-        gens_0 = {arb::explicit_generator(arb::pse_vector{{0, 1.0, 0.1}, {1, 2.0, 0.1}})};
+        gens_0 = {arb::explicit_generator({{{"synapse0"}, 1.0, 0.1}, {{"synapse1"}, 2.0, 0.1}})};
 
-        gens_1 = {arb::explicit_generator(arb::pse_vector{{0, 1.0, 0.1}})};
+        gens_1 = {arb::explicit_generator({{{"synapse0"}, 1.0, 0.1}})};
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
         EXPECT_NO_THROW(simulation(recipe_0, decomp_0, context));
     }
     {
-        gens_0 = {arb::explicit_generator(arb::pse_vector{{0, 1.0, 0.1}, {3, 2.0, 0.1}})};
+        gens_0 = {arb::explicit_generator({{{"synapse0"}, 1.0, 0.1}, {{"synapse3"}, 2.0, 0.1}})};
         gens_1.clear();
 
-        auto recipe_0 = custom_recipe({cell_0, cell_1}, {1, 2}, {2, 1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
+        auto recipe_0 = custom_recipe({cell_0, cell_1}, {{}, {}}, {{}, {}},  {gens_0, gens_1});
         auto decomp_0 = partition_load_balance(recipe_0, context);
 
-        EXPECT_THROW(simulation(recipe_0, decomp_0, context), arb::bad_event_generator_target_lid);
+        EXPECT_THROW(simulation(recipe_0, decomp_0, context), arb::bad_connection_label);
     }
 }
diff --git a/test/unit/test_s_expr.cpp b/test/unit/test_s_expr.cpp
index 697e7b30fa8a2b962e8a13019142de9403c478ab..92ddcb1ffaa1539ddc0b9d8b0242dadf0c3898ea 100644
--- a/test/unit/test_s_expr.cpp
+++ b/test/unit/test_s_expr.cpp
@@ -313,7 +313,7 @@ std::optional<T> eval_cast_variant(const std::any& a) {
 }
 
 using branch = std::tuple<int, int, std::vector<arb::msegment>>;
-using place_pair = std::pair<arb::locset, arb::placeable>;
+using place_tuple = std::tuple<arb::locset, arb::placeable, std::string>;
 using paint_pair = std::pair<arb::region, arb::paintable>;
 using locset_pair = std::pair<std::string, locset>;
 using region_pair = std::pair<std::string, region>;
@@ -377,10 +377,10 @@ std::ostream& operator<<(std::ostream& o, const paint_pair& p) {
     std::visit([&](auto&& x) {o << x;}, p.second);
     return o << ")";
 }
-std::ostream& operator<<(std::ostream& o, const place_pair& p) {
-    o << "(place " << p.first << " ";
-    std::visit([&](auto&& x) {o << x;}, p.second);
-    return o << ")";
+std::ostream& operator<<(std::ostream& o, const place_tuple& p) {
+    o << "(place " << std::get<0>(p) << " ";
+    std::visit([&](auto&& x) {o << x;}, std::get<1>(p));
+    return o << " \"" << std::get<2>(p) << "\")";
 }
 std::ostream& operator<<(std::ostream& o, const defaultable& p) {
     o << "(default ";
@@ -530,16 +530,16 @@ TEST(decor_expressions, round_tripping) {
         "(default (ion-reversal-potential-method \"ca\" (mechanism \"nernst/ca\")))"
     };
     auto decorate_place_literals = {
-        "(place (location 3 0.2) (current-clamp (envelope (10 0.5) (110 0.5) (110 0)) 0.5 0.25))",
-        "(place (terminal) (threshold-detector -10))",
-        "(place (root) (gap-junction-site))",
-        "(place (locset \"my!ls\") (mechanism \"expsyn\"))"};
+        "(place (location 3 0.2) (current-clamp (envelope (10 0.5) (110 0.5) (110 0)) 0.5 0.25) \"clamp\")",
+        "(place (terminal) (threshold-detector -10) \"detector\")",
+        "(place (root) (gap-junction-site) \"gap_junction\")",
+        "(place (locset \"my!ls\") (mechanism \"expsyn\") \"synapse\")"};
 
     for (auto l: decorate_paint_literals) {
         EXPECT_EQ(l, round_trip<paint_pair>(l));
     }
     for (auto l: decorate_place_literals) {
-        EXPECT_EQ(l, round_trip<place_pair>(l));
+        EXPECT_EQ(l, round_trip<place_tuple>(l));
     }
     for (auto l: decorate_default_literals) {
         EXPECT_EQ(l, round_trip<defaultable>(l));
@@ -606,14 +606,17 @@ TEST(decor, round_tripping) {
                                 "      (ion-internal-concentration \"ca\" 0.500000))\n"
                                 "    (place \n"
                                 "      (location 0 0)\n"
-                                "      (gap-junction-site))\n"
+                                "      (gap-junction-site)\n"
+                                "      \"gap-junction\")\n"
                                 "    (place \n"
                                 "      (location 0 0)\n"
-                                "      (threshold-detector 10.000000))\n"
+                                "      (threshold-detector 10.000000)\n"
+                                "      \"detector\")\n"
                                 "    (place \n"
                                 "      (location 0 0.5)\n"
                                 "      (mechanism \"expsyn\" \n"
-                                "        (\"tau\" 1.500000)))))";
+                                "        (\"tau\" 1.500000))\n"
+                                "      \"synapse\")))";
 
     EXPECT_EQ(component_str, round_trip_component(component_str.c_str()));
 }
@@ -821,7 +824,8 @@ TEST(cable_cell, round_tripping) {
                                 "            (10.000000 0.500000)\n"
                                 "            (110.000000 0.500000)\n"
                                 "            (110.000000 0.000000))\n"
-                                "          0.000000 0.000000)))))";
+                                "          0.000000 0.000000)\n"
+                                "        \"iclamp\"))))";
 
     EXPECT_EQ(component_str, round_trip_component(component_str.c_str()));
 
@@ -845,8 +849,9 @@ TEST(cable_cell_literals, errors) {
                      "(paint (tag 1) (mechanims hh))",       // invalid painting
                      "(paint (terminal) (membrance-capacitance 0.2))", // can't paint a locset
                      "(paint (tag 3))",                      // too few arguments
-                     "(place (locset) (gap-junction-site))", // invalid locset
-                     "(place (gap-junction-site) (location 0 1))",      // swapped argument order
+                     "(place (locset) (gap-junction-site) \"gj\")",        // invalid locset
+                     "(place (gap-junction-site) (location 0 1), \"gj\")", // swapped argument order
+                     "(place (location 0 1) (mechanism \"expsyn\"))",      // missing label
                      "(region-def my_region (tag 3))",       // unquoted region name
                      "(locset-def \"my_ls\" (tag 3))",       // invalid locset
                      "(locset-def \"my_ls\")",               // too few arguments
@@ -890,7 +895,7 @@ TEST(doc_expressions, parse) {
                      "(ion-reversal-potential-method \"ca\" (mechanism \"nernst/ca\"))",
                      "(current-clamp (envelope (0 10) (50 10) (50 0)) 40 0.25)",
                      "(paint (tag 1) (membrane-capacitance 0.02))",
-                     "(place (locset \"mylocset\") (threshold-detector 10))",
+                     "(place (locset \"mylocset\") (threshold-detector 10) \"mydetectors\")",
                      "(default (membrane-potential -65))",
                      "(segment 3 (point 0 0 0 5) (point 0 0 10 2) 1)"})
     {
@@ -910,8 +915,8 @@ TEST(doc_expressions, parse) {
                      "  (paint (region \"soma\") (membrane-potential -50.000000))\n"
                      "  (paint (all) (mechanism \"pas\"))\n"
                      "  (paint (tag 4) (mechanism \"Ih\" (\"gbar\" 0.001)))\n"
-                     "  (place (locset \"root\") (mechanism \"expsyn\"))\n"
-                     "  (place (terminal) (gap-junction-site)))",
+                     "  (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
+                     "  (place (terminal) (gap-junction-site) \"terminal_gj\"))",
                      "(morphology\n"
                      "  (branch 0 -1\n"
                      "    (segment 0 (point 0 0 0 2) (point 4 0 0 2) 1)\n"
@@ -942,8 +947,8 @@ TEST(doc_expressions, parse) {
                      "    (paint (region \"my_soma\") (temperature-kelvin 270))\n"
                      "    (paint (region \"my_region\") (membrane-potential -50.000000))\n"
                      "    (paint (tag 4) (mechanism \"Ih\" (\"gbar\" 0.001)))\n"
-                     "    (place (locset \"root\") (mechanism \"expsyn\"))\n"
-                     "    (place (location 1 0.2) (gap-junction-site)))\n"
+                     "    (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
+                     "    (place (location 1 0.2) (gap-junction-site) \"terminal_gj\"))\n"
                      "  (morphology\n"
                      "    (branch 0 -1\n"
                      "      (segment 0 (point 0 0 0 2) (point 4 0 0 2) 1)\n"
@@ -980,7 +985,7 @@ TEST(doc_expressions, parse) {
                             "  (meta-data (version \"" + arborio::acc_version() +"\"))\n"
                             "  (decor\n"
                             "    (default (membrane-potential -55.000000))\n"
-                            "    (place (locset \"root\") (mechanism \"expsyn\"))\n"
+                            "    (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
                             "    (paint (region \"my_soma\") (temperature-kelvin 270))))",
                             "(arbor-component\n"
                             "  (meta-data (version \"" + arborio::acc_version() +"\"))\n"
@@ -997,7 +1002,7 @@ TEST(doc_expressions, parse) {
                             "      (locset-def \"root\" (root)))\n"
                             "    (decor\n"
                             "      (default (membrane-potential -55.000000))\n"
-                            "      (place (locset \"root\") (mechanism \"expsyn\"))\n"
+                            "      (place (locset \"root\") (mechanism \"expsyn\") \"root_synapse\")\n"
                             "      (paint (region \"my_soma\") (temperature-kelvin 270)))\n"
                             "    (morphology\n"
                             "       (branch 0 -1\n"
diff --git a/test/unit/test_simulation.cpp b/test/unit/test_simulation.cpp
index 04c41ab4e1bb5acf08421e4797d67479dfe96761..569c4fa403889cc5dc3cdfba762d3d3de0692d8f 100644
--- a/test/unit/test_simulation.cpp
+++ b/test/unit/test_simulation.cpp
@@ -22,10 +22,8 @@ struct play_spikes: public recipe {
 
     cell_size_type num_cells() const override { return spike_times_.size(); }
     cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::spike_source; }
-    cell_size_type num_sources(cell_gid_type) const override { return 1; }
-    cell_size_type num_targets(cell_gid_type) const override { return 0; }
     util::unique_any get_cell_description(cell_gid_type gid) const override {
-        return spike_source_cell{spike_times_.at(gid)};
+        return spike_source_cell("src", spike_times_.at(gid));
     }
 
     std::vector<schedule> spike_times_;
@@ -81,11 +79,9 @@ struct lif_chain: public recipe {
     cell_size_type num_cells() const override { return n_; }
 
     cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::lif; }
-    cell_size_type num_sources(cell_gid_type) const override { return 1; }
-    cell_size_type num_targets(cell_gid_type) const override { return 1; }
     util::unique_any get_cell_description(cell_gid_type) const override {
         // A hair-trigger LIF cell with tiny time constant and no refractory period.
-        lif_cell lif;
+        lif_cell lif("src", "tgt");
         lif.tau_m = 0.01;           // time constant (ms)
         lif.t_ref = 0;              // refactory period (ms)
         lif.V_th = lif.E_L + 0.001; // threshold voltage 1 µV higher than resting
@@ -94,7 +90,7 @@ struct lif_chain: public recipe {
 
     std::vector<cell_connection> connections_on(cell_gid_type target) const override {
         if (target) {
-            return {cell_connection({target-1, 0}, 0, weight_, delay_)};
+            return {cell_connection({target-1, "src"}, {"tgt"}, weight_, delay_)};
         }
         else {
             return {};
@@ -106,7 +102,7 @@ struct lif_chain: public recipe {
             return {};
         }
         else {
-            return {schedule_generator(0, weight_, triggers_)};
+            return {schedule_generator({"tgt"}, weight_, triggers_)};
         }
     }
 
diff --git a/test/unit/test_spike_source.cpp b/test/unit/test_spike_source.cpp
index b0703a4b63fff7099b6ab2635051a7bb213cf660..82a5173f4d949dd824a775135204c30a8c1e5d6a 100644
--- a/test/unit/test_spike_source.cpp
+++ b/test/unit/test_spike_source.cpp
@@ -16,8 +16,9 @@ using ss_recipe = homogeneous_recipe<cell_kind::spike_source, spike_source_cell>
 // cell_kind enum value.
 TEST(spike_source, cell_kind)
 {
-    ss_recipe rec(1u, spike_source_cell{explicit_schedule({})});
-    spike_source_cell_group group({0}, rec);
+    ss_recipe rec(1u, spike_source_cell("src", explicit_schedule({})));
+    cell_label_range srcs, tgts;
+    spike_source_cell_group group({0}, rec, srcs, tgts);
 
     EXPECT_EQ(cell_kind::spike_source, group.get_cell_kind());
 }
@@ -39,8 +40,9 @@ static std::vector<time_type> spike_times(const std::vector<spike>& evs) {
 TEST(spike_source, matches_time_seq)
 {
     auto test_seq = [](schedule seq) {
-        ss_recipe rec(1u, spike_source_cell{seq});
-        spike_source_cell_group group({0}, rec);
+        ss_recipe rec(1u, spike_source_cell("src", seq));
+        cell_label_range srcs, tgts;
+        spike_source_cell_group group({0}, rec, srcs, tgts);
 
         // epoch ending at 10ms
         epoch ep(0, 0., 10.);
@@ -66,8 +68,9 @@ TEST(spike_source, matches_time_seq)
 TEST(spike_source, reset)
 {
     auto test_seq = [](schedule seq) {
-        ss_recipe rec(1u, spike_source_cell{seq});
-        spike_source_cell_group group({0}, rec);
+        ss_recipe rec(1u, spike_source_cell("src", seq));
+        cell_label_range srcs, tgts;
+        spike_source_cell_group group({0}, rec, srcs, tgts);
 
         // Advance for 10 ms and store generated spikes in spikes1.
         epoch ep(0, 0., 10.);
@@ -96,8 +99,9 @@ TEST(spike_source, exhaust)
 {
     // This test assumes that seq will exhaust itself before t=10 ms.
     auto test_seq = [](schedule seq) {
-        ss_recipe rec(1u, spike_source_cell{seq});
-        spike_source_cell_group group({0}, rec);
+        ss_recipe rec(1u, spike_source_cell("src", seq));
+        cell_label_range srcs, tgts;
+        spike_source_cell_group group({0}, rec, srcs, tgts);
 
         // epoch ending at 10ms
         epoch ep(0, 0., 10.);
diff --git a/test/unit/test_spikes.cpp b/test/unit/test_spikes.cpp
index b2b5158671106e744ad4d971f892295d231b63c8..ce47431f7c65abae0c5c55560c82f0f1c286d58f 100644
--- a/test/unit/test_spikes.cpp
+++ b/test/unit/test_spikes.cpp
@@ -221,9 +221,9 @@ TEST(SPIKES_TEST_CLASS, threshold_watcher_interpolation) {
     for (unsigned i = 0; i < 8; i++) {
         arb::decor decor;
         decor.set_default(arb::cv_policy_every_segment());
-        decor.place("\"mid\"", arb::threshold_detector{10});
-        decor.place("\"mid\"", arb::i_clamp::box(0.01+i*dt, duration, 0.5));
-        decor.place("\"mid\"", arb::mechanism_desc("hh"));
+        decor.place("\"mid\"", arb::threshold_detector{10}, "detector");
+        decor.place("\"mid\"", arb::i_clamp::box(0.01+i*dt, duration, 0.5), "clamp");
+        decor.place("\"mid\"", arb::mechanism_desc("expsyn"), "synapse");
 
         arb::cable_cell cell(morpho, dict, decor);
         cable1d_recipe rec({cell});
diff --git a/test/unit/test_synapses.cpp b/test/unit/test_synapses.cpp
index 60aa02cb2d946df3b279baf7f40331ee04c5b2d6..264eae23e9c6fd1a94d52d69f86e23bb0fdd08ef 100644
--- a/test/unit/test_synapses.cpp
+++ b/test/unit/test_synapses.cpp
@@ -34,9 +34,9 @@ TEST(synapses, add_to_cell) {
 
     auto description = make_cell_soma_only(false);
 
-    description.decorations.place(mlocation{0, 0.1}, "expsyn");
-    description.decorations.place(mlocation{0, 0.2}, "exp2syn");
-    description.decorations.place(mlocation{0, 0.3}, "expsyn");
+    description.decorations.place(mlocation{0, 0.1}, "expsyn", "synapse0");
+    description.decorations.place(mlocation{0, 0.2}, "exp2syn", "synapse1");
+    description.decorations.place(mlocation{0, 0.3}, "expsyn", "synapse2");
 
     cable_cell cell(description);
 
@@ -55,7 +55,7 @@ TEST(synapses, add_to_cell) {
     EXPECT_EQ("exp2syn", syns["exp2syn"][0].item.name());
 
     // adding a synapse to an invalid branch location should throw.
-    description.decorations.place(mlocation{1, 0.3}, "expsyn");
+    description.decorations.place(mlocation{1, 0.3}, "expsyn", "synapse3");
     EXPECT_THROW((cell=description), std::runtime_error);
 }