diff --git a/hercules_ir/src/dataflow.rs b/hercules_ir/src/dataflow.rs
index 69a00b65118aa357df93cfc833e8f738f28df500..92886d3b6c0eff7f685bb78330e1f97f9ad49ed8 100644
--- a/hercules_ir/src/dataflow.rs
+++ b/hercules_ir/src/dataflow.rs
@@ -8,7 +8,7 @@ use crate::*;
  * Trait for a type that is a semilattice. Semilattice types must also be Eq,
  * so that the dataflow analysis can determine when to terminate.
  */
-pub trait Semilattice: Eq {
+pub trait Semilattice: Eq + Clone {
     fn meet(a: &Self, b: &Self) -> Self;
     fn bottom() -> Self;
     fn top() -> Self;
@@ -37,10 +37,18 @@ where
     let uses: Vec<NodeUses> = function.nodes.iter().map(|n| get_uses(n)).collect();
 
     // Step 2: create initial set of "out" points.
+    let start_node_output = flow_function(&[&L::bottom()], NodeID::new(0));
     let mut outs: Vec<L> = (0..function.nodes.len())
         .map(|id| {
             flow_function(
-                &vec![&(if id == 0 { L::bottom() } else { L::top() }); uses[id].as_ref().len()],
+                &vec![
+                    &(if id == 0 {
+                        start_node_output.clone()
+                    } else {
+                        L::top()
+                    });
+                    uses[id].as_ref().len()
+                ],
                 NodeID::new(id),
             )
         })
@@ -124,3 +132,142 @@ fn reverse_postorder_helper(
         (order, visited)
     }
 }
+
+/*
+ * A bit vector set is a very general kind of semilattice. This variant is for
+ * "intersecting" flow functions.
+ */
+#[derive(PartialEq, Eq, Clone, Debug)]
+pub enum IntersectNodeSet {
+    Empty,
+    Bits(BitVec<u8, Lsb0>),
+    Full,
+}
+
+impl IntersectNodeSet {
+    pub fn is_set(&self, id: NodeID) -> bool {
+        match self {
+            IntersectNodeSet::Empty => false,
+            IntersectNodeSet::Bits(bits) => bits[id.idx()],
+            IntersectNodeSet::Full => true,
+        }
+    }
+}
+
+impl Semilattice for IntersectNodeSet {
+    fn meet(a: &Self, b: &Self) -> Self {
+        match (a, b) {
+            (IntersectNodeSet::Full, b) => b.clone(),
+            (a, IntersectNodeSet::Full) => a.clone(),
+            (IntersectNodeSet::Bits(a), IntersectNodeSet::Bits(b)) => {
+                assert!(
+                    a.len() == b.len(),
+                    "IntersectNodeSets must have same length to meet."
+                );
+                IntersectNodeSet::Bits(a.clone() & b)
+            }
+            (IntersectNodeSet::Empty, _) => IntersectNodeSet::Empty,
+            (_, IntersectNodeSet::Empty) => IntersectNodeSet::Empty,
+        }
+    }
+
+    fn bottom() -> Self {
+        // For intersecting flow functions, the bottom state is empty.
+        IntersectNodeSet::Empty
+    }
+
+    fn top() -> Self {
+        // For intersecting flow functions, the top state is full.
+        IntersectNodeSet::Full
+    }
+}
+
+/*
+ * A bit vector set is a very general kind of semilattice. This variant is for
+ * "unioning" flow functions.
+ */
+#[derive(PartialEq, Eq, Clone, Debug)]
+pub enum UnionNodeSet {
+    Empty,
+    Bits(BitVec<u8, Lsb0>),
+    Full,
+}
+
+impl UnionNodeSet {
+    pub fn is_set(&self, id: NodeID) -> bool {
+        match self {
+            UnionNodeSet::Empty => false,
+            UnionNodeSet::Bits(bits) => bits[id.idx()],
+            UnionNodeSet::Full => true,
+        }
+    }
+}
+
+impl Semilattice for UnionNodeSet {
+    fn meet(a: &Self, b: &Self) -> Self {
+        match (a, b) {
+            (UnionNodeSet::Empty, b) => b.clone(),
+            (a, UnionNodeSet::Empty) => a.clone(),
+            (UnionNodeSet::Bits(a), UnionNodeSet::Bits(b)) => {
+                assert!(
+                    a.len() == b.len(),
+                    "UnionNodeSets must have same length to meet."
+                );
+                UnionNodeSet::Bits(a.clone() | b)
+            }
+            (UnionNodeSet::Full, _) => UnionNodeSet::Full,
+            (_, UnionNodeSet::Full) => UnionNodeSet::Full,
+        }
+    }
+
+    fn bottom() -> Self {
+        // For unioning flow functions, the bottom state is full.
+        UnionNodeSet::Full
+    }
+
+    fn top() -> Self {
+        // For unioning flow functions, the top state is empty.
+        UnionNodeSet::Empty
+    }
+}
+
+/*
+ * Below are some common flow functions. They all take a slice of semilattice
+ * references as their first argument, and a node ID as their second. However,
+ * they may in addition take more arguments (meaning that these functions
+ * should be used inside closures at a callsite of a top level dataflow
+ * function).
+ */
+
+/*
+ * Flow function for collecting all of a node's uses of "control outputs". What
+ * this flow function does is collect all immediate phi, thread ID, and collect
+ * nodes that every other node depends on through data nodes. Flow is ended at
+ * a control node, or at a phi, thread ID, or collect node.
+ */
+pub fn control_output_flow(
+    inputs: &[&UnionNodeSet],
+    node_id: NodeID,
+    function: &Function,
+) -> UnionNodeSet {
+    // Step 1: union inputs.
+    let mut out = UnionNodeSet::top();
+    for input in inputs {
+        out = UnionNodeSet::meet(&out, input);
+    }
+    let node = &function.nodes[node_id.idx()];
+
+    // Step 2: clear all bits, if applicable.
+    if node.is_strictly_control() || node.is_thread_id() || node.is_collect() || node.is_phi() {
+        out = UnionNodeSet::Empty;
+    }
+
+    // Step 3: set bit for current node, if applicable.
+    if node.is_thread_id() || node.is_collect() || node.is_phi() {
+        let mut singular = bitvec![u8, Lsb0; 0; function.nodes.len()];
+        singular.set(node_id.idx(), true);
+        out = UnionNodeSet::meet(&out, &UnionNodeSet::Bits(singular));
+    }
+
+    out
+}
diff --git a/hercules_ir/src/dom.rs b/hercules_ir/src/dom.rs
index 002cbd04a8b1d16d714bb72fe17a5e719e87aa79..d359db6591cd571bf18b913c67efb734ba158995 100644
--- a/hercules_ir/src/dom.rs
+++ b/hercules_ir/src/dom.rs
@@ -38,6 +38,14 @@ impl DomTree {
     pub fn does_prop_dom(&self, a: NodeID, b: NodeID) -> bool {
         a != b && self.does_dom(a, b)
     }
+
+    /*
+     * Check if a node is in the dom tree (if the node is the root of the tree,
+     * will still return true).
+     */
+    pub fn is_non_root(&self, x: NodeID) -> bool {
+        self.idom.contains_key(&x)
+    }
 }
 
 /*
diff --git a/hercules_ir/src/ir.rs b/hercules_ir/src/ir.rs
index 9400f0265f7937ec253c3d2279180042cffb1365..dee8fb9e0d420b5f6c691ef0099cdf89728c28e5 100644
--- a/hercules_ir/src/ir.rs
+++ b/hercules_ir/src/ir.rs
@@ -46,7 +46,7 @@ pub struct Function {
  */
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
 pub enum Type {
-    Control(Box<[DynamicConstantID]>),
+    Control(Box<[NodeID]>),
     Boolean,
     Integer8,
     Integer16,
@@ -285,17 +285,77 @@ pub enum BinaryOperator {
     RSh,
 }
 
+/*
+ * Simple predicate functions on nodes take a lot of space, so use a macro.
+ */
+
+macro_rules! define_pattern_predicate {
+    ($x: ident, $y: pat) => {
+        pub fn $x(&self) -> bool {
+            if let $y = self {
+                true
+            } else {
+                false
+            }
+        }
+    };
+}
+
 impl Node {
-    pub fn is_return(&self) -> bool {
-        if let Node::Return {
+    define_pattern_predicate!(is_start, Node::Start);
+    define_pattern_predicate!(is_region, Node::Region { preds: _ });
+    define_pattern_predicate!(
+        is_if,
+        Node::If {
+            control: _,
+            cond: _,
+        }
+    );
+    define_pattern_predicate!(
+        is_fork,
+        Node::Fork {
+            control: _,
+            factor: _,
+        }
+    );
+    define_pattern_predicate!(is_join, Node::Join { control: _ });
+    define_pattern_predicate!(
+        is_phi,
+        Node::Phi {
+            control: _,
+            data: _,
+        }
+    );
+    define_pattern_predicate!(is_thread_id, Node::ThreadID { control: _ });
+    define_pattern_predicate!(
+        is_collect,
+        Node::Collect {
+            control: _,
+            data: _,
+        }
+    );
+    define_pattern_predicate!(
+        is_return,
+        Node::Return {
             control: _,
             data: _,
-        } = self
-        {
-            true
-        } else {
-            false
         }
+    );
+    define_pattern_predicate!(is_match, Node::Match { control: _, sum: _ });
+
+    /*
+     * ReadProd nodes can be considered control when following an if or match
+     * node. However, it is sometimes useful to exclude such nodes when
+     * considering control nodes.
+     */
+    pub fn is_strictly_control(&self) -> bool {
+        self.is_start()
+            || self.is_region()
+            || self.is_if()
+            || self.is_fork()
+            || self.is_join()
+            || self.is_return()
+            || self.is_return()
     }
 
     pub fn upper_case_name(&self) -> &'static str {
diff --git a/hercules_ir/src/parse.rs b/hercules_ir/src/parse.rs
index fcabe771c6e42e10e7d0784785ea6a6b5a344ddb..8b666be06427f2e3e6436e163851d7360277847d 100644
--- a/hercules_ir/src/parse.rs
+++ b/hercules_ir/src/parse.rs
@@ -638,7 +638,10 @@ fn parse_type<'a>(ir_text: &'a str, context: &RefCell<Context<'a>>) -> nom::IRes
                         nom::character::complete::char(','),
                         nom::character::complete::multispace0,
                     )),
-                    |x| parse_dynamic_constant_id(x, context),
+                    |x| {
+                        let (ir_text, node) = parse_identifier(x)?;
+                        Ok((ir_text, context.borrow_mut().get_node_id(node)))
+                    },
                 ),
                 nom::character::complete::multispace0,
                 nom::character::complete::char(')'),
diff --git a/hercules_ir/src/typecheck.rs b/hercules_ir/src/typecheck.rs
index 6722a66dbaa0aff09ad8d5dcf254ff4ac11c24bb..137f4411ef08153a34b6fe48b52a9156a3618710 100644
--- a/hercules_ir/src/typecheck.rs
+++ b/hercules_ir/src/typecheck.rs
@@ -8,7 +8,7 @@ use self::TypeSemilattice::*;
 /*
  * Enum for type semilattice.
  */
-#[derive(Eq, Clone)]
+#[derive(Eq, Clone, Debug)]
 enum TypeSemilattice {
     Unconstrained,
     Concrete(TypeID),
@@ -25,6 +25,9 @@ impl TypeSemilattice {
     }
 }
 
+/* Define custom PartialEq, so that dataflow will terminate right away if there
+ * are errors.
+ */
 impl PartialEq for TypeSemilattice {
     fn eq(&self, other: &Self) -> bool {
         match (self, other) {
@@ -39,7 +42,6 @@ impl PartialEq for TypeSemilattice {
 impl Semilattice for TypeSemilattice {
     fn meet(a: &Self, b: &Self) -> Self {
         match (a, b) {
-            (Unconstrained, Unconstrained) => Unconstrained,
             (Unconstrained, b) => b.clone(),
             (a, Unconstrained) => a.clone(),
             (Concrete(id1), Concrete(id2)) => {
@@ -248,7 +250,10 @@ fn typeflow(
 
             inputs[0].clone()
         }
-        Node::Fork { control: _, factor } => {
+        Node::Fork {
+            control: _,
+            factor: _,
+        } => {
             if inputs.len() != 1 {
                 return Error(String::from("Fork node must have exactly one input."));
             }
@@ -257,7 +262,7 @@ fn typeflow(
                 if let Type::Control(factors) = &types[id.idx()] {
                     // Fork adds a new factor to the thread spawn factor list.
                     let mut new_factors = factors.clone().into_vec();
-                    new_factors.push(*factor);
+                    new_factors.push(node_id);
 
                     // Out type is control type, with the new thread spawn
                     // factor.
@@ -291,7 +296,14 @@ fn typeflow(
                         return Error(String::from("Join node's first input must have a control type with at least one thread replication factor."));
                     }
                     let mut new_factors = factors.clone().into_vec();
-                    join_factor_map.insert(node_id, new_factors.pop().unwrap());
+                    let factor = if let Node::Fork { control: _, factor } =
+                        function.nodes[new_factors.pop().unwrap().idx()]
+                    {
+                        factor
+                    } else {
+                        panic!("Node ID in factor list doesn't correspond with a fork node.");
+                    };
+                    join_factor_map.insert(node_id, factor);
 
                     // Out type is the new control type.
                     let control_out_id = get_type_id(
diff --git a/hercules_ir/src/verify.rs b/hercules_ir/src/verify.rs
index 89192c01a48cc58d14180bb88f70faeab3f787c4..262f749ebee5c9a26c3e3f06e0763c847b939e0d 100644
--- a/hercules_ir/src/verify.rs
+++ b/hercules_ir/src/verify.rs
@@ -1,5 +1,6 @@
 extern crate bitvec;
 
+use std::collections::HashMap;
 use std::iter::zip;
 
 use verify::bitvec::prelude::*;
@@ -11,7 +12,7 @@ use crate::*;
  * useful results (typing, dominator trees, etc.), so if verification succeeds,
  * return those useful results. Otherwise, return the first error string found.
  */
-pub fn verify(module: &mut Module) -> Result<ModuleTyping, String> {
+pub fn verify(module: &mut Module) -> Result<(ModuleTyping, Vec<DomTree>, Vec<DomTree>), String> {
     let def_uses: Vec<_> = module
         .functions
         .iter()
@@ -32,20 +33,37 @@ pub fn verify(module: &mut Module) -> Result<ModuleTyping, String> {
         verify_structure(function, def_use, typing, &module.types)?;
     }
 
-    // Check SSA, fork, and join dominance relations.
-    for (function, def_use) in zip(module.functions.iter(), def_uses) {
-        let subgraph = control_subgraph(function, &def_use);
+    // Check SSA, fork, and join dominance relations. Collect domtrees.
+    let mut doms = vec![];
+    let mut postdoms = vec![];
+    for ((function, typing), (def_use, reverse_postorder)) in zip(
+        zip(module.functions.iter(), typing.iter()),
+        zip(def_uses.iter(), reverse_postorders.iter()),
+    ) {
+        let control_output_dependencies =
+            forward_dataflow(function, reverse_postorder, |inputs, id| {
+                control_output_flow(inputs, id, function)
+            });
+        let subgraph = control_subgraph(function, def_use);
         let dom = dominator(&subgraph, NodeID::new(0));
         let postdom = postdominator(subgraph, NodeID::new(function.nodes.len()));
-        println!("{:?}", dom);
-        println!("{:?}", postdom);
+        verify_dominance_relationships(
+            function,
+            typing,
+            &module.types,
+            &control_output_dependencies,
+            &dom,
+            &postdom,
+        )?;
+        doms.push(dom);
+        postdoms.push(postdom);
     }
 
-    Ok(typing)
+    Ok((typing, doms, postdoms))
 }
 
 /*
- * There are structural constraints the IR must follow, such as all Phi nodes'
+ * There are structural constraints the IR must follow, such as all phi nodes'
  * control input must be a region node. This is where those properties are
  * verified.
  */
@@ -161,5 +179,124 @@ fn verify_structure(
             _ => {}
         };
     }
+
+    Ok(())
+}
+
+/*
+ * There are dominance relationships the IR must follow, such as all uses of a
+ * phi node must be dominated by the corresponding region node.
+ */
+fn verify_dominance_relationships(
+    function: &Function,
+    typing: &Vec<TypeID>,
+    types: &Vec<Type>,
+    control_output_dependencies: &Vec<UnionNodeSet>,
+    dom: &DomTree,
+    postdom: &DomTree,
+) -> Result<(), String> {
+    let mut fork_join_map = HashMap::new();
+    for idx in 0..function.nodes.len() {
+        match function.nodes[idx] {
+            // Verify that joins are dominated by their corresponding forks. At
+            // the same time, assemble a map from forks to their corresponding
+            // joins.
+            Node::Join { control } => {
+                // Check type of control predecessor. The last node ID
+                // in the factor list is the corresponding fork node ID.
+                if let Type::Control(factors) = &types[typing[control.idx()].idx()] {
+                    let join_id = NodeID::new(idx);
+                    let fork_id = *factors.last().unwrap();
+                    if !dom.does_dom(fork_id, join_id) {
+                        Err(format!("Fork node (ID {}) doesn't dominate its corresponding join node (ID {}).", fork_id.idx(), join_id.idx()))?;
+                    }
+                    if !postdom.does_dom(join_id, fork_id) {
+                        Err(format!("Join node (ID {}) doesn't postdominate its corresponding fork node (ID {}).", join_id.idx(), fork_id.idx()))?;
+                    }
+                    fork_join_map.insert(fork_id, join_id);
+                } else {
+                    panic!("Join node's control predecessor has a non-control type.");
+                }
+            }
+            _ => {}
+        }
+    }
+
+    // Loop over the nodes twice, since we need to completely assemble the
+    // fork_join_map in the first loop before using it in this second loop.
+    for idx in 0..function.nodes.len() {
+        // Having a control output dependency only matters if
+        // this node is a control node, or if this node is a
+        // control output of a control node. If this node is a
+        // control output, then we want to consider the control
+        // node itself.
+        let this_id = if let Node::Phi {
+            control: dominated_control,
+            data: _,
+        }
+        | Node::ThreadID {
+            control: dominated_control,
+        }
+        | Node::Collect {
+            control: dominated_control,
+            data: _,
+        } = function.nodes[idx]
+        {
+            dominated_control
+        } else {
+            NodeID::new(idx)
+        };
+
+        // control_output_dependencies contains the "out" values from the
+        // control output dataflow analysis, while we need the "in" values.
+        // This can be easily reconstructed.
+        let mut dependencies = UnionNodeSet::top();
+        for input in get_uses(&function.nodes[idx]).as_ref() {
+            dependencies =
+                UnionNodeSet::meet(&dependencies, &control_output_dependencies[input.idx()]);
+        }
+        for pred_idx in 0..function.nodes.len() {
+            if dependencies.is_set(NodeID::new(pred_idx)) {
+                match function.nodes[pred_idx] {
+                    // Verify that uses of phis / collect nodes are dominated
+                    // by the corresponding region / join nodes, respectively.
+                    Node::Phi { control, data: _ } | Node::Collect { control, data: _ } => {
+                        if dom.is_non_root(this_id) && !dom.does_dom(control, this_id) {
+                            Err(format!(
+                                "{} node (ID {}) doesn't dominate its use (ID {}).",
+                                function.nodes[pred_idx].upper_case_name(),
+                                pred_idx,
+                                idx
+                            ))?;
+                        }
+                    }
+                    // Verify that uses of thread ID nodes are dominated by the
+                    // corresponding fork nodes.
+                    Node::ThreadID { control } => {
+                        if dom.is_non_root(this_id) && !dom.does_dom(control, this_id) {
+                            Err(format!(
+                                "ThreadID node (ID {}) doesn't dominate its use (ID {}).",
+                                pred_idx, idx
+                            ))?;
+                        }
+
+                        // Every use of a thread ID must be postdominated by
+                        // the thread ID's fork's corresponding join node. We
+                        // don't need to check for the case where the thread ID
+                        // flows through the collect node out of the fork-join,
+                        // because after the collect, the thread ID is no longer
+                        // considered an immediate control output use.
+                        if postdom.is_non_root(this_id)
+                            && !postdom.does_dom(*fork_join_map.get(&control).unwrap(), this_id)
+                        {
+                            Err(format!("ThreadID node's (ID {}) fork's join doesn't postdominate its use (ID {}).", pred_idx, idx))?;
+                        }
+                    }
+                    _ => {}
+                }
+            }
+        }
+    }
+
     Ok(())
 }
diff --git a/hercules_tools/src/hercules_dot/main.rs b/hercules_tools/src/hercules_dot/main.rs
index 226f91db9121ef0d02cb61c9c520f2b3596ce086..c943472fa06ebde732b7a8f4a3e4da5f88bed3e5 100644
--- a/hercules_tools/src/hercules_dot/main.rs
+++ b/hercules_tools/src/hercules_dot/main.rs
@@ -28,8 +28,8 @@ fn main() {
         .expect("PANIC: Unable to read input file contents.");
     let mut module =
         hercules_ir::parse::parse(&contents).expect("PANIC: Failed to parse Hercules IR file.");
-    let _types = hercules_ir::verify::verify(&mut module)
-        .expect("PANIC: Failed to typecheck Hercules IR module.");
+    let (_types, _doms, _postdoms) = hercules_ir::verify::verify(&mut module)
+        .expect("PANIC: Failed to verify Hercules IR module.");
     if args.output.is_empty() {
         let mut tmp_path = temp_dir();
         tmp_path.push("hercules_dot.dot");