diff --git a/include/class.orm.php b/include/class.orm.php
index 5b047ab05629e387d9fdfcbea5ccf7b7684d3012..fe5d6e2781b0efd07779578b1bab208665168dc5 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -15,6 +15,7 @@
 
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
+require_once INCLUDE_DIR . 'class.util.php';
 
 class OrmException extends Exception {}
 class OrmConfigurationException extends Exception {}
@@ -723,11 +724,11 @@ class AnnotatedModel {
             $classes[$class] = eval(<<<END_CLASS
 class {$extra}AnnotatedModel___{$class}
 extends {$class} {
-    var \$__overlay__;
+    protected \$__overlay__;
     use {$extra}AnnotatedModelTrait;
 
     function __construct(\$ht, \$annotations) {
-        parent::__construct(\$ht);
+        \$this->ht = \$ht;
         \$this->__overlay__ = \$annotations;
     }
 }
@@ -779,7 +780,8 @@ trait WriteableAnnotatedModelTrait {
     }
 
     function set($what, $to) {
-        if ($this->__overlay__->__isset($what)) {
+        if (isset($this->__overlay__)
+            && $this->__overlay__->__isset($what)) {
             return $this->__overlay__->set($what, $to);
         }
         return parent::set($what, $to);
@@ -1558,54 +1560,54 @@ class DoesNotExist extends Exception {}
 class ObjectNotUnique extends Exception {}
 
 class CachedResultSet
-implements IteratorAggregate, Countable, ArrayAccess {
+extends BaseList
+implements ArrayAccess {
     protected $inner;
     protected $eoi = false;
-    protected $cache = array();
 
     function __construct(IteratorAggregate $iterator) {
         $this->inner = $iterator->getIterator();
     }
 
     function fillTo($level) {
-        while (!$this->eoi && count($this->cache) < $level) {
+        while (!$this->eoi && count($this->storage) < $level) {
             if (!$this->inner->valid()) {
                 $this->eoi = true;
                 break;
             }
-            $this->cache[] = $this->inner->current();
+            $this->storage[] = $this->inner->current();
             $this->inner->next();
         }
     }
 
-    function reset() {
-        $this->eoi = false;
-        $this->cache = array();
-        // XXX: Should the inner be recreated to refetch?
-        $this->inner->rewind();
-    }
-
     function asArray() {
         $this->fillTo(PHP_INT_MAX);
         return $this->getCache();
     }
 
     function getCache() {
-        return $this->cache;
+        return $this->storage;
+    }
+
+    function reset() {
+        $this->eoi = false;
+        $this->storage = array();
+        // XXX: Should the inner be recreated to refetch?
+        $this->inner->rewind();
     }
 
     function getIterator() {
         $this->asArray();
-        return new ArrayIterator($this->cache);
+        return new ArrayIterator($this->storage);
     }
 
     function offsetExists($offset) {
         $this->fillTo($offset+1);
-        return count($this->cache) > $offset;
+        return count($this->storage) > $offset;
     }
     function offsetGet($offset) {
         $this->fillTo($offset+1);
-        return $this->cache[$offset];
+        return $this->storage[$offset];
     }
     function offsetUnset($a) {
         throw new Exception(__('QuerySet is read-only'));
@@ -1616,7 +1618,7 @@ implements IteratorAggregate, Countable, ArrayAccess {
 
     function count() {
         $this->asArray();
-        return count($this->cache);
+        return count($this->storage);
     }
 
     /**
@@ -1636,22 +1638,7 @@ implements IteratorAggregate, Countable, ArrayAccess {
     function sort($key=false, $reverse=false) {
         // Fetch all records into the cache
         $this->asArray();
-        if (is_callable($key)) {
-            array_multisort(
-                array_map($key, $this->cache),
-                $reverse ? SORT_DESC : SORT_ASC,
-                $this->cache);
-        }
-        elseif ($key) {
-            array_multisort($this->cache,
-                $reverse ? SORT_DESC : SORT_ASC, $key);
-        }
-        elseif ($reverse) {
-            rsort($this->cache);
-        }
-        else
-            sort($this->cache);
-        return $this;
+        return parent::sort($key, $reverse);
     }
 
     /**
@@ -1659,8 +1646,7 @@ implements IteratorAggregate, Countable, ArrayAccess {
      */
     function reverse() {
         $this->asArray();
-        array_reverse($this->cache);
-        return $this;
+        return parent::reverse();
     }
 }
 
@@ -1695,7 +1681,7 @@ extends CachedResultSet {
      * database.
      */
     function findAll($criteria, $limit=false) {
-        $records = array();
+        $records = new ListObject();
         foreach ($this as $record) {
             $matches = true;
             foreach ($criteria as $field=>$check) {
@@ -1984,14 +1970,16 @@ class InstrumentedList
 extends ModelResultSet {
     var $key;
 
-    function __construct($fkey, $queryset=false) {
+    function __construct($fkey, $queryset=false,
+        $iterator='ModelInstanceManager'
+    ) {
         list($model, $this->key) = $fkey;
         if (!$queryset) {
             $queryset = $model::objects()->filter($this->key);
             if ($related = $model::getMeta('select_related'))
                 $queryset->select_related($related);
         }
-        parent::__construct(new ModelInstanceManager($queryset));
+        parent::__construct(new $iterator($queryset));
         $this->model = $model;
         $this->queryset = $queryset;
     }
@@ -2013,9 +2001,9 @@ extends ModelResultSet {
             $object->save();
 
         if ($at !== false)
-            $this->cache[$at] = $object;
+            $this->storage[$at] = $object;
         else
-            $this->cache[] = $object;
+            $this->storage[] = $object;
 
         return $object;
     }
@@ -2072,12 +2060,11 @@ extends ModelResultSet {
      * XXX: Move this to a parent class?
      */
     function setCache(array $cache) {
-        if (count($this->cache) > 0)
+        if (count($this->storage) > 0)
             throw new Exception('Cache must be set before fetching records');
         // Set cache and disable fetching
         $this->reset();
-        $this->cache = $cache;
-        $this->resource = false;
+        $this->storage = $cache;
     }
 
     // Save all changes made to any list items
@@ -2107,11 +2094,11 @@ extends ModelResultSet {
 
     function offsetUnset($a) {
         $this->fillTo($a);
-        $this->cache[$a]->delete();
+        $this->storage[$a]->delete();
     }
     function offsetSet($a, $b) {
         $this->fillTo($a);
-        if ($obj = $this->cache[$a])
+        if ($obj = $this->storage[$a])
             $obj->delete();
         $this->add($b, $a);
     }
diff --git a/include/class.queue.php b/include/class.queue.php
index 143e37ba0b8063184e1b0e7a45ab95786e8a23a4..bd0775da7ee17ec44d5150626ed6b594f3727fdb 100644
--- a/include/class.queue.php
+++ b/include/class.queue.php
@@ -14,30 +14,442 @@
 
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
-require_once INCLUDE_DIR . 'class.search.php';
 
-class CustomQueue extends SavedSearch {
+class CustomQueue extends VerySimpleModel {
     static $meta = array(
+        'table' => QUEUE_TABLE,
+        'pk' => array('id'),
+        'ordering' => array('sort'),
         'select_related' => array('parent'),
         'joins' => array(
+            'children' => array(
+                'reverse' => 'CustomQueue.parent',
+                'constrain' => ['children__id__gt' => 0],
+            ),
             'columns' => array(
                 'reverse' => 'QueueColumnGlue.queue',
                 'broker' => 'QueueColumnListBroker',
             ),
-            'children' => array(
-                'reverse' => 'CustomQueue.parent',
+            'parent' => array(
+                'constraint' => array(
+                    'parent_id' => 'CustomQueue.id',
+                ),
+                'null' => true,
+            ),
+            'staff' => array(
+                'constraint' => array(
+                    'staff_id' => 'Staff.staff_id',
+                )
             ),
         )
     );
 
+    const FLAG_PUBLIC =         0x0001; // Shows up in e'eryone's saved searches
+    const FLAG_QUEUE =          0x0002; // Shows up in queue navigation
+    const FLAG_CONTAINER =      0x0004; // Container for other queues ('Open')
+    const FLAG_INHERIT_CRITERIA = 0x0008; // Include criteria from parent
+    const FLAG_INHERIT_COLUMNS = 0x0010; // Inherit column layout from parent
+
+    var $criteria;
+
     static function queues() {
         return parent::objects()->filter(array(
             'flags__hasbit' => static::FLAG_QUEUE
         ));
     }
 
+    function getId() {
+        return $this->id;
+    }
+
+    function getName() {
+        return $this->title;
+    }
+
+    function getHref() {
+        // TODO: Get base page from getRoot();
+        $root = $this->getRoot();
+        return 'tickets.php?queue='.$this->getId();
+    }
+
+    function getRoot() {
+        switch ($this->root) {
+        case 'T':
+        default:
+            return 'Ticket';
+        }
+    }
+
+    function getPath() {
+        return $this->path ?: $this->buildPath();
+    }
+
+    function getCriteria($include_parent=false) {
+        if (!isset($this->criteria)) {
+            $old = @$this->config[0] === '{';
+            $this->criteria = is_string($this->config)
+                ? JsonDataParser::decode($this->config)
+                : $this->config;
+            // Auto-upgrade criteria to new format
+            if ($old) {
+                // TODO: Upgrade old ORM path names
+                $this->criteria = $this->isolateCriteria($this->criteria);
+            }
+        }
+        $criteria = $this->criteria ?: array();
+        if ($include_parent && $this->parent_id && $this->parent) {
+            $criteria = array_merge($this->parent->getCriteria(true),
+                $criteria);
+        }
+        return $criteria;
+    }
+
+    function describeCriteria($criteria=false){
+        $all = $this->getSupportedMatches($this->getRoot());
+        $items = array();
+        $criteria = $criteria ?: $this->getCriteria(true);
+        foreach ($criteria as $C) {
+            list($path, $method, $value) = $C;
+            if (!isset($all[$path]))
+                continue;
+             list($label, $field) = $all[$path];
+             $items[] = $field->describeSearch($method, $value, $label);
+        }
+        return implode("\nAND ", $items);
+    }
+
+    /**
+     * Fetch an AdvancedSearchForm instance for use in displaying or
+     * configuring this search in the user interface.
+     *
+     * Parameters:
+     * $search - <array> Request parameters ($_POST) used to update the
+     *      search beyond the current configuration of the search criteria
+     */
+    function getForm($source=null) {
+        $searchable = $this->getCurrentSearchFields($source);
+        $fields = array(
+            ':keywords' => new TextboxField(array(
+                'id' => 3001,
+                'configuration' => array(
+                    'size' => 40,
+                    'length' => 400,
+                    'autofocus' => true,
+                    'classes' => 'full-width headline',
+                    'placeholder' => __('Keywords — Optional'),
+                ),
+            )),
+        );
+        foreach ($searchable as $path=>$field) {
+            $fields = array_merge($fields, static::getSearchField($field, $path));
+        }
+
+        $form = new AdvancedSearchForm($fields, $source);
+        $form->addValidator(function($form) {
+            $selected = 0;
+            foreach ($form->getFields() as $F) {
+                if (substr($F->get('name'), -7) == '+search' && $F->getClean())
+                    $selected += 1;
+                // Consider keyword searches
+                elseif ($F->get('name') == ':keywords' && $F->getClean())
+                    $selected += 1;
+            }
+            if (!$selected)
+                $form->addError(__('No fields selected for searching'));
+        });
+
+        // Load state from current configuraiton
+        if (!$source) {
+            foreach ($this->getCriteria() as $I) {
+                list($path, $method, $value) = $I;
+                if ($path == ':keywords' && $method === null) {
+                    if ($F = $form->getField($path))
+                        $F->value = $value;
+                    continue;
+                }
+
+                if (!($F = $form->getField("{$path}+search")))
+                    continue;
+                $F->value = true;
+
+                if (!($F = $form->getField("{$path}+method")))
+                    continue;
+                $F->value = $method;
+
+                if ($value && ($F = $form->getField("{$path}+{$method}")))
+                    $F->value = $value;
+            }
+        }
+        return $form;
+    }
+
+    /**
+     * Fetch a bucket of fields for a custom search. The fields should be
+     * added to a form before display. One searchable field may encompass 10
+     * or more actual fields because fields are expanded to support multiple
+     * search methods along with the fields for each search method. This
+     * method returns all the FormField instances for all the searchable
+     * model fields currently in use.
+     *
+     * Parameters:
+     * $source - <array> data from a request. $source['fields'] is expected
+     *      to contain a list extra fields by ORM path, of newly added
+     *      fields not yet saved in this object's getCriteria().
+     */
+    function getCurrentSearchFields($source=array()) {
+        static $basic = array(
+            'Ticket' => array(
+                'status__state',
+                'dept_id',
+                'assignee',
+                'topic_id',
+                'created',
+                'est_duedate',
+            )
+        );
+
+        $all = $this->getSupportedMatches();
+        $core = array();
+
+        // Include basic fields for new searches
+        if (!isset($this->id))
+            foreach ($basic[$this->getRoot()] as $path)
+                if (isset($all[$path]))
+                    $core[$path] = $all[$path];
+
+        // Add others from current configuration
+        foreach ($this->getCriteria() as $C) {
+            list($path) = $C;
+            if (isset($all[$path]))
+                $core[$path] = $all[$path];
+        }
+
+        if (isset($source['fields']))
+            foreach ($source['fields'] as $path)
+                if (isset($all[$path]))
+                    $core[$path] = $all[$path];
+
+        return $core;
+    }
+
+    /**
+     * Fetch all supported ORM fields searchable by this search object. The
+     * returned list represents searchable fields, keyed by the ORM path.
+     * Use ::getCurrentSearchFields() or ::getSearchField() to retrieve for
+     * use in the user interface.
+     */
+    function getSupportedMatches() {
+        return static::getSearchableFields($this->getRoot());
+    }
+
+    /**
+     * Trace ORM fields from a base object and retrieve a complete list of
+     * fields which can be used in an ORM query based on the base object.
+     * The base object must implement Searchable interface and extend from
+     * VerySimpleModel. Then all joins from the object are also inspected,
+     * and any which implement the Searchable interface are traversed and
+     * automatically added to the list. The resulting list is cached based
+     * on the $base class, so multiple calls for the same $base return
+     * quickly.
+     *
+     * Parameters:
+     * $base - Class, name of a class implementing Searchable
+     * $recurse - int, number of levels to recurse, default is 2
+     * $cache - bool, cache results for future class for the same base
+     * $customData - bool, include all custom data fields for all general
+     *      forms
+     */
+    static function getSearchableFields($base, $recurse=2,
+        $customData=true, $exclude=array()
+    ) {
+        static $cache = array(), $otherFields;
+
+        if (!in_array('Searchable', class_implements($base)))
+            return array();
+
+        // Early exit if already cached
+        $fields = &$cache[$base];
+        if ($fields)
+            return $fields;
+
+        $fields = $fields ?: array();
+        foreach ($base::getSearchableFields() as $path=>$F) {
+            if (is_array($F)) {
+                list($label, $field) = $F;
+            }
+            else {
+                $label = $F->get('label');
+                $field = $F;
+            }
+            $fields[$path] = array($label, $field);
+        }
+
+        if ($customData && $base::supportsCustomData()) {
+            if (!isset($otherFields)) {
+                $otherFields = array();
+                $dfs = DynamicFormField::objects()
+                    ->filter(array('form__type' => 'G'))
+                    ->select_related('form');
+                foreach ($dfs as $field) {
+                    $otherFields[$field->getId()] = array($field->form,
+                        $field->getImpl());
+                }
+            }
+            foreach ($otherFields as $id=>$F) {
+                list($form, $field) = $F;
+                $label = sprintf("%s / %s",
+                    $form->getTitle(), $field->get('label'));
+                $fields["entries__answers!{$id}__value"] = array(
+                    $label, $field);
+            }
+        }
+
+        if ($recurse) {
+            $exclude[$base] = 1;
+            foreach ($base::getMeta('joins') as $path=>$j) {
+                $fc = $j['fkey'][0];
+                if (isset($exclude[$fc]) || $j['list'])
+                    continue;
+                foreach (static::getSearchableFields($fc, $recurse-1,
+                    true, $exclude)
+                as $path2=>$F) {
+                    list($label, $field) = $F;
+                    $fields["{$path}__{$path2}"] = array(
+                        sprintf("%s / %s", $fc, $label),
+                        $field);
+                }
+            }
+        }
+
+        return $fields;
+    }
+
+    /**
+     * Fetch the FormField instances used when for configuring a searchable
+     * field in the user interface. This is the glue between a field
+     * representing a searchable model field and the configuration of that
+     * search in the user interface.
+     *
+     * Parameters:
+     * $F - <array<string, FormField>> the label and the FormField instance
+     *      representing the configurable search
+     * $name - <string> ORM path for the search
+     */
+    static function getSearchField($F, $name) {
+        list($label, $field) = $F;
+
+        $pieces = array();
+        $pieces["{$name}+search"] = new BooleanField(array(
+            'id' => sprintf('%u', crc32($name)) >> 1,
+            'configuration' => array(
+                'desc' => $label ?: $field->getLocal('label'),
+                'classes' => 'inline',
+            ),
+        ));
+        $methods = $field->getSearchMethods();
+        $pieces["{$name}+method"] = new ChoiceField(array(
+            'choices' => $methods,
+            'default' => key($methods),
+            'visibility' => new VisibilityConstraint(new Q(array(
+                "{$name}+search__eq" => true,
+            )), VisibilityConstraint::HIDDEN),
+        ));
+        $offs = 0;
+        foreach ($field->getSearchMethodWidgets() as $m=>$w) {
+            if (!$w)
+                continue;
+            list($class, $args) = $w;
+            $args['required'] = true;
+            $args['__searchval__'] = true;
+            $args['visibility'] = new VisibilityConstraint(new Q(array(
+                    "{$name}+method__eq" => $m,
+                )), VisibilityConstraint::HIDDEN);
+            $pieces["{$name}+{$m}"] = new $class($args);
+        }
+        return $pieces;
+    }
+
+    function getField($path) {
+        $searchable = $this->getSupportedMatches();
+        return $searchable[$path];
+    }
+
+    // Remove this and adjust advanced-search-criteria template to use the
+    // getCriteria() list and getField()
+    function getSearchFields($form=false) {
+        $form = $form ?: $this->getForm();
+        $searchable = $this->getCurrentSearchFields();
+        $info = array();
+        foreach ($form->getFields() as $f) {
+            if (substr($f->get('name'), -7) == '+search') {
+                $name = substr($f->get('name'), 0, -7);
+                $value = null;
+                // Determine the search method and fetch the original field
+                if (($M = $form->getField("{$name}+method"))
+                    && ($method = $M->getClean())
+                    && (list(,$field) = $searchable[$name])
+                ) {
+                    // Request the field to generate a search Q for the
+                    // search method and given value
+                    if ($value = $form->getField("{$name}+{$method}"))
+                        $value = $value->getClean();
+                }
+                $info[$name] = array(
+                    'field' => $field,
+                    'method' => $method,
+                    'value' => $value,
+                    'active' =>  $f->getClean(),
+                );
+            }
+        }
+        return $info;
+    }
+
+    /**
+     * Take the criteria from the SavedSearch fields setup and isolate the
+     * field name being search, the method used for searhing, and the method-
+     * specific data entered in the UI.
+     */
+    function isolateCriteria($criteria, $root=null) {
+        $searchable = static::getSearchableFields($root ?: $this->getRoot());
+        $items = array();
+        if (!$criteria)
+            return null;
+        foreach ($criteria as $k=>$v) {
+            if (substr($k, -7) === '+method') {
+                list($name,) = explode('+', $k, 2);
+                if (!isset($searchable[$name]))
+                    continue;
+
+                // Require checkbox to be checked too
+                if (!$criteria["{$name}+search"])
+                    continue;
+
+                // Lookup the field to search this condition
+                list($label, $field) = $searchable[$name];
+
+                // Get the search method and value
+                $method = $v;
+                // Not all search methods require a value
+                $value = $criteria["{$name}+{$method}"];
+
+                $items[] = array($name, $method, $value);
+            }
+        }
+        if (isset($criteria[':keywords'])) {
+            $items[] = array(':keywords', null, $criteria[':keywords']);
+        }
+        return $items;
+    }
+
     function getColumns() {
-        if ($this->parent_id
+        if ($this->columns_id
+            && ($q = CustomQueue::lookup($this->columns_id))
+        ) {
+            // Use columns from cited queue
+            return $q->getColumns();
+        }
+        elseif ($this->parent_id
             && $this->hasFlag(self::FLAG_INHERIT_COLUMNS)
             && $this->parent
         ) {
@@ -46,7 +458,46 @@ class CustomQueue extends SavedSearch {
         elseif (count($this->columns)) {
             return $this->columns;
         }
-        return parent::getColumns();
+
+        // Last resort — use standard columns
+        return array(
+            new QueueColumn(array(
+                "heading" => "Number",
+                "primary" => 'number',
+                "width" => 85,
+                "filter" => "link:ticketP",
+                "annotations" => '[{"c":"TicketSourceDecoration","p":"b"}]',
+                "conditions" => '[{"crit":["isanswered","set",null],"prop":{"font-weight":"bold"}}]',
+            )),
+            new QueueColumn(array(
+                "heading" => "Created",
+                "primary" => 'created',
+                "width" => 100,
+            )),
+            new QueueColumn(array(
+                "heading" => "Subject",
+                "primary" => 'cdata__subject',
+                "width" => 250,
+                "filter" => "link:ticket",
+                "annotations" => '[{"c":"TicketThreadCount","p":">"},{"c":"ThreadAttachmentCount","p":"a"},{"c":"OverdueFlagDecoration","p":"<"}]',
+                "truncate" => 'ellipsis',
+            )),
+            new QueueColumn(array(
+                "heading" => "From",
+                "primary" => 'user__name',
+                "width" => 150,
+            )),
+            new QueueColumn(array(
+                "heading" => "Priority",
+                "primary" => 'cdata__priority',
+                "width" => 120,
+            )),
+            new QueueColumn(array(
+                "heading" => "Assignee",
+                "primary" => 'assignee',
+                "width" => 100,
+            )),
+        );
     }
 
     function addColumn(QueueColumn $col) {
@@ -113,7 +564,7 @@ class CustomQueue extends SavedSearch {
         if (isset($quick_filter)
             && ($qf = $this->getQuickFilterField($quick_filter))
         ) {
-            $this->filter = @SavedSearch::getOrmPath($this->filter, $query);
+            $this->filter = @self::getOrmPath($this->filter, $query);
             $query = $qf->applyQuickFilter($query, $quick_filter,
                 $this->filter); 
         }
@@ -132,7 +583,7 @@ class CustomQueue extends SavedSearch {
             }
         }
         elseif ($this->filter
-            && ($fields = SavedSearch::getSearchableFields($this->getRoot()))
+            && ($fields = self::getSearchableFields($this->getRoot()))
             && (list(,$f) = @$fields[$this->filter])
             && $f->supportsQuickFilter()
         ) {
@@ -141,15 +592,122 @@ class CustomQueue extends SavedSearch {
         }
     }
 
+    /**
+     * Get a description of a field in a search. Expects an entry from the
+     * array retrieved in ::getSearchFields()
+     */
+    function describeField($info, $name=false) {
+        return $info['field']->describeSearch($info['method'], $info['value'], $name);
+    }
+
+    function mangleQuerySet(QuerySet $qs, $form=false) {
+        $qs = clone $qs;
+        $searchable = $this->getSupportedMatches();
+
+        // Figure out fields to search on
+        foreach ($this->getCriteria() as $I) {
+            list($name, $method, $value) = $I;
+
+            // Consider keyword searching
+            if ($name === ':keywords') {
+                global $ost;
+                $qs = $ost->searcher->find($value, $qs, false);
+            }
+            else {
+                // XXX: Move getOrmPath to be more of a utility
+                // Ensure the special join is created to support custom data joins
+                $name = @static::getOrmPath($name, $qs);
+
+                if (preg_match('/__answers!\d+__/', $name)) {
+                    $qs->annotate(array($name2 => SqlAggregate::MAX($name)));
+                }
+
+                // Fetch a criteria Q for the query
+                if (list(,$field) = $searchable[$name])
+                    if ($q = $field->getSearchQ($method, $value, $name))
+                        $qs = $qs->filter($q);
+            }
+        }
+        return $qs;
+    }
+
+    function checkAccess(Staff $agent) {
+        return $agent->getId() == $this->staff_id
+            || $this->hasFlag(self::FLAG_PUBLIC);
+    }
+
+    function ignoreVisibilityConstraints() {
+        global $thisstaff;
+
+        // For saved searches (not queues), staff can have a permission to
+        // see all records
+        return !$this->isAQueue()
+            && $thisstaff->hasPerm(SearchBackend::PERM_EVERYTHING);
+    }
+
+    function inheritCriteria() {
+        return $this->flags & self::FLAG_INHERIT_CRITERIA;
+    }
+
+    function inheritColumns() {
+        return $this->hasFlag(self::FLAG_INHERIT_COLUMNS);
+    }
+
+    function buildPath() {
+        if (!$this->id)
+            return;
+
+        $path = $this->parent ? $this->parent->getPath() : '';
+        return $path . "/{$this->id}";
+    }
+
+    function getFullName() {
+        $base = $this->getName();
+        if ($this->parent)
+            $base = sprintf("%s / %s", $this->parent->getFullName(), $base);
+        return $base;
+    }
+
+    function isAQueue() {
+        return $this->hasFlag(self::FLAG_QUEUE);
+    }
+
+    function isPrivate() {
+        return !$this->isAQueue() && !$this->hasFlag(self::FLAG_PUBLIC);
+    }
+
+    protected function hasFlag($flag) {
+        return ($this->flags & $flag) !== 0;
+    }
+
+    protected function clearFlag($flag) {
+        return $this->flags &= ~$flag;
+    }
+
+    protected function setFlag($flag, $value=true) {
+        return $value
+            ? $this->flags |= $flag
+            : $this->clearFlag($flag);
+    }
+
+
     function update($vars, &$errors=array()) {
-        if (!parent::update($vars, false, $errors))
-            return false;
+        // Set basic search information
+        if (!$vars['name'])
+            $errors['name'] = __('A title is required');
+
+        $this->title = $vars['name'];
+        $this->parent_id = @$vars['parent_id'] ?: 0;
+        if (!$this->parent)
+            $errors['parent_id'] = __('Select a valid queue');
 
         // Set basic queue information
         $this->filter = $vars['filter'];
         $this->path = $this->buildPath();
         $this->setFlag(self::FLAG_INHERIT_CRITERIA,
             $this->parent_id > 0 && isset($vars['inherit']));
+        $this->setFlag(self::FLAG_INHERIT_COLUMNS,
+            $this->parent_id > 0 && isset($vars['inherit-columns']));
 
         // Update queue columns (but without save)
         if (isset($vars['columns'])) {
@@ -170,7 +728,7 @@ class CustomQueue extends SavedSearch {
             }
             // Add new columns
             foreach ($new as $info) {
-                $glue = QueueColumnGlue::create(array(
+                $glue = new QueueColumnGlue(array(
                     'column_id' => $info['column_id'], 
                     'sort' => array_search($info['column_id'], $order),
                     'heading' => $info['heading'],
@@ -188,12 +746,27 @@ class CustomQueue extends SavedSearch {
             // No columns -- imply column inheritance
             $this->setFlag(self::FLAG_INHERIT_COLUMNS);
         }
+
+        // TODO: Move this to SavedSearch::update() and adjust
+        //       AjaxSearch::_saveSearch()
+        $form = $form ?: $this->getForm($vars);
+        if (!$vars || !$form->isValid()) {
+            $errors['criteria'] = __('Validation errors exist on criteria');
+        }
+        else {
+            $this->config = JsonDataEncoder::encode(
+                $this->isolateCriteria($form->getClean()));
+        }
+
         return 0 === count($errors);
     }
 
     function save($refetch=false) {
         $wasnew = !isset($this->id);
-        if (!($rv = parent::save($refetch)))
+
+        if ($this->dirty)
+            $this->updated = SqlFunction::NOW();
+        if (!($rv = parent::save($refetch || $this->dirty)))
             return $rv;
 
         if ($wasnew) {
@@ -203,11 +776,41 @@ class CustomQueue extends SavedSearch {
         return $this->columns->saveAll();
     }
 
+    static function getOrmPath($name, $query=null) {
+        // Special case for custom data `__answers!id__value`. Only add the
+        // join and constraint on the query the first pass, when the query
+        // being mangled is received.
+        $path = array();
+        if ($query && preg_match('/^(.+?)__(answers!(\d+))/', $name, $path)) {
+            // Add a join to the model of the queryset where the custom data
+            // is forked from — duplicate the 'answers' join and add the
+            // constraint to the query based on the field_id
+            // $path[1] - part before the answers (user__org__entries)
+            // $path[2] - answers!xx join part
+            // $path[3] - the `xx` part of the answers!xx join component
+            $root = $query->model;
+            $meta = $root::getMeta()->getByPath($path[1]);
+            $joins = $meta['joins'];
+            if (!isset($joins[$path[2]])) {
+                $meta->addJoin($path[2], $joins['answers']);
+            }
+            // Ensure that the query join through answers!xx is only for the
+            // records which match field_id=xx
+            $query->constrain(array("{$path[1]}__{$path[2]}" =>
+                array("{$path[1]}__{$path[2]}__field_id" => (int) $path[3])
+            ));
+            // Leave $name unchanged
+        }
+        return $name;
+    }
+
+
     static function create($vars=false) {
         global $thisstaff;
 
-        $queue = parent::create($vars);
-        $queue->setFlag(SavedSearch::FLAG_QUEUE);
+        $queue = new static($vars);
+        $queue->created = SqlFunction::NOW();
+        $queue->setFlag(self::FLAG_QUEUE);
         if ($thisstaff)
             $queue->staff_id = $thisstaff->getId();
 
@@ -218,7 +821,7 @@ class CustomQueue extends SavedSearch {
         $q = static::create($vars);
         $q->save();
         foreach ($vars['columns'] as $info) {
-            $glue = QueueColumnGlue::create($info);
+            $glue = new QueueColumnGlue($info);
             $glue->queue_id = $q->getId();
             $glue->save();
         }
@@ -474,7 +1077,7 @@ extends ChoiceField {
         $config = $this->getConfiguration();
         $root = $config['root'];
         $fields = array();
-        foreach (SavedSearch::getSearchableFields($root) as $path=>$f) {
+        foreach (CustomQueue::getSearchableFields($root) as $path=>$f) {
             list($label,) = $f;
             $fields[$path] = $label;
         }
@@ -514,7 +1117,7 @@ class QueueColumnCondition {
         // FIXME
         #$root = $this->getColumn()->getRoot();
         $root = 'Ticket';
-        $searchable = SavedSearch::getSearchableFields($root);
+        $searchable = CustomQueue::getSearchableFields($root);
 
         if (!isset($name))
             list($name) = $this->config['crit'];
@@ -539,7 +1142,7 @@ class QueueColumnCondition {
 
         // XXX: Move getOrmPath to be more of a utility
         // Ensure the special join is created to support custom data joins
-        $name = @SavedSearch::getOrmPath($name, $query);
+        $name = @CustomQueue::getOrmPath($name, $query);
 
         $name2 = null;
         if (preg_match('/__answers!\d+__/', $name)) {
@@ -560,7 +1163,7 @@ class QueueColumnCondition {
      * specific data entered in the UI.
      */
     static function isolateCriteria($criteria, $root='Ticket') {
-        $searchable = SavedSearch::getSearchableFields($root);
+        $searchable = CustomQueue::getSearchableFields($root);
         foreach ($criteria as $k=>$v) {
             if (substr($k, -7) === '+method') {
                 list($name,) = explode('+', $k, 2);
@@ -836,9 +1439,9 @@ extends VerySimpleModel {
 
     function renderBasicValue($row) {
         $root = ($q = $this->getQueue()) ? $q->getRoot() : 'Ticket';
-        $fields = SavedSearch::getSearchableFields($root);
-        $primary = SavedSearch::getOrmPath($this->primary);
-        $secondary = SavedSearch::getOrmPath($this->secondary);
+        $fields = CustomQueue::getSearchableFields($root);
+        $primary = CustomQueue::getOrmPath($this->primary);
+        $secondary = CustomQueue::getOrmPath($this->secondary);
 
         // Return a lazily ::display()ed value so that the value to be
         // rendered by the field could be changed or display()ed when
@@ -894,16 +1497,16 @@ extends VerySimpleModel {
 
     function mangleQuery($query, $root=null) {
         // Basic data
-        $fields = SavedSearch::getSearchableFields($root ?: $this->getQueue()->getRoot());
+        $fields = CustomQueue::getSearchableFields($root ?: $this->getQueue()->getRoot());
         if ($primary = $fields[$this->primary]) {
             list(,$field) = $primary;
             $query = $this->addToQuery($query, $field,
-                SavedSearch::getOrmPath($this->primary, $query));
+                CustomQueue::getOrmPath($this->primary, $query));
         }
         if ($secondary = $fields[$this->secondary]) {
             list(,$field) = $secondary;
             $query = $this->addToQuery($query, $field,
-                SavedSearch::getOrmPath($this->secondary, $query));
+                CustomQueue::getOrmPath($this->secondary, $query));
         }
 
         if ($filter = $this->getFilter())
@@ -956,7 +1559,7 @@ extends VerySimpleModel {
     }
 
     static function __create($vars) {
-        $c = static::create($vars);
+        $c = new static($vars);
         $c->save();
         return $c;
     }
@@ -989,11 +1592,11 @@ extends VerySimpleModel {
                     continue;
                 // Determine the criteria
                 $name = $vars['condition_field'][$i];
-                $fields = SavedSearch::getSearchableFields($root);
+                $fields = CustomQueue::getSearchableFields($root);
                 if (!isset($fields[$name]))
                     // No such field exists for this queue root type
                     continue;
-                $parts = SavedSearch::getSearchField($fields[$name], $name);
+                $parts = CustomQueue::getSearchField($fields[$name], $name);
                 $search_form = new SimpleForm($parts, $vars, array('id' => $id));
                 $search_form->getField("{$name}+search")->value = true;
                 $crit = $search_form->getClean();
@@ -1049,13 +1652,8 @@ extends VerySimpleModel {
     );
 }
 
-class QueueColumnListBroker
-extends InstrumentedList {
-    function __construct($fkey, $queryset=false) {
-        parent::__construct($fkey, $queryset);
-        $this->queryset->select_related('column');
-    }
-
+class QueueColumnGlueMIM
+extends ModelInstanceManager {
     function getOrBuild($modelClass, $fields, $cache=true) {
         $m = parent::getOrBuild($modelClass, $fields, $cache);
         if ($m && $modelClass === 'QueueColumnGlue') {
@@ -1065,9 +1663,17 @@ extends InstrumentedList {
         }
         return $m;
     }
+}
+
+class QueueColumnListBroker
+extends InstrumentedList {
+    function __construct($fkey, $queryset=false) {
+        parent::__construct($fkey, $queryset, 'QueueColumnGlueMIM');
+        $this->queryset->select_related('column');
+    }
 
     function add($column, $glue=null) {
-        $glue = $glue ?: QueueColumnGlue::create();
+        $glue = $glue ?: new QueueColumnGlue();
         $glue->column = $column;
         $anno = AnnotatedModel::wrap($column, $glue);
         parent::add($anno);
diff --git a/include/class.search.php b/include/class.search.php
index a203040185a42900a8f75a9f771862b0e2007e32..66eae2ba56a335a03749a9015c9a2a416cae3bae 100644
--- a/include/class.search.php
+++ b/include/class.search.php
@@ -23,6 +23,7 @@
 **********************************************************************/
 require_once INCLUDE_DIR . 'class.role.php';
 require_once INCLUDE_DIR . 'class.list.php';
+require_once INCLUDE_DIR . 'class.queue.php';
 
 abstract class SearchBackend {
     static $id = false;
@@ -645,45 +646,11 @@ MysqlSearchBackend::register();
 // Saved search system
 
 /**
- *
- * Fields:
- * id - (int:unsigned:auto:pk) unique identifier
- * flags - (int:unsigned) flags for this queue
- * staff_id - (int:unsigned) Agent to whom this queue belongs (can be null
- *      for public saved searches)
- * title - (text:60) name of the queue
- * config - (text) JSON encoded search configuration for the queue
- * created - (date) date initially created
- * updated - (date:auto_update) time of last update
+ * A special case of the custom queues used to represent an advanced search.
  */
-class SavedSearch extends VerySimpleModel {
-    static $meta = array(
-        'table' => QUEUE_TABLE,
-        'pk' => array('id'),
-        'ordering' => array('sort'),
-        'joins' => array(
-            'staff' => array(
-                'constraint' => array(
-                    'staff_id' => 'Staff.staff_id',
-                )
-            ),
-            'parent' => array(
-                'constraint' => array(
-                    'parent_id' => 'CustomQueue.id',
-                ),
-                'null' => true,
-            ),
-        ),
-    );
-
-    const FLAG_PUBLIC =         0x0001; // Shows up in e'eryone's saved searches
-    const FLAG_QUEUE =          0x0002; // Shows up in queue navigation
-    const FLAG_CONTAINER =      0x0004; // Container for other queues ('Open')
-    const FLAG_INHERIT_CRITERIA = 0x0008; // Include criteria from parent
-    const FLAG_INHERIT_COLUMNS = 0x0010; // Inherit column layout from parent
-
-    var $criteria;
-    private $columns;
+class SavedSearch extends CustomQueue {
+    // Override the ORM relationship to force no children
+    private $children = false;
 
     static function forStaff(Staff $agent) {
         return static::objects()->filter(Q::any(array(
@@ -693,627 +660,27 @@ class SavedSearch extends VerySimpleModel {
         ->exclude(array('flags__hasbit'=>self::FLAG_QUEUE));
     }
 
-    function getId() {
-        return $this->id;
-    }
-
-    function getName() {
-        return $this->title;
-    }
-
-    function getHref() {
-        // TODO: Get base page from getRoot();
-        $root = $this->getRoot();
-        return 'tickets.php?queue='.$this->getId();
-    }
-
-    function getRoot() {
-        switch ($this->root) {
-        case 'T':
-        default:
-            return 'Ticket';
-        }
-    }
-
-    function getPath() {
-        return $this->path ?: $this->buildPath();
-    }
-
-    function getCriteria($include_parent=false) {
-        if (!isset($this->criteria)) {
-            $old = @$this->config[0] === '{';
-            $this->criteria = is_string($this->config)
-                ? JsonDataParser::decode($this->config)
-                : $this->config;
-            // Auto-upgrade criteria to new format
-            if ($old) {
-                // TODO: Upgrade old ORM path names
-                $this->criteria = $this->isolateCriteria($this->criteria);
-            }
-        }
-        $criteria = $this->criteria ?: array();
-        if ($include_parent && $this->parent_id && $this->parent) {
-            $criteria = array_merge($this->parent->getCriteria(true),
-                $criteria);
-        }
-        return $criteria;
-    }
-
-    function describeCriteria($criteria=false){
-        $all = $this->getSupportedMatches($this->getRoot());
-        $items = array();
-        $criteria = $criteria ?: $this->getCriteria(true);
-        foreach ($criteria as $C) {
-            list($path, $method, $value) = $C;
-            if (!isset($all[$path]))
-                continue;
-             list($label, $field) = $all[$path];
-             $items[] = $field->describeSearch($method, $value, $label);
-        }
-        return implode("\nAND ", $items);
-    }
-
-    /**
-     * Fetch an AdvancedSearchForm instance for use in displaying or
-     * configuring this search in the user interface.
-     *
-     * Parameters:
-     * $search - <array> Request parameters ($_POST) used to update the
-     *      search beyond the current configuration of the search criteria
-     */
-    function getForm($source=null) {
-        $searchable = $this->getCurrentSearchFields($source);
-        $fields = array(
-            ':keywords' => new TextboxField(array(
-                'id' => 3001,
-                'configuration' => array(
-                    'size' => 40,
-                    'length' => 400,
-                    'autofocus' => true,
-                    'classes' => 'full-width headline',
-                    'placeholder' => __('Keywords — Optional'),
-                ),
-            )),
-        );
-        foreach ($searchable as $path=>$field) {
-            $fields = array_merge($fields, self::getSearchField($field, $path));
-        }
-
-        $form = new AdvancedSearchForm($fields, $source);
-        $form->addValidator(function($form) {
-            $selected = 0;
-            foreach ($form->getFields() as $F) {
-                if (substr($F->get('name'), -7) == '+search' && $F->getClean())
-                    $selected += 1;
-                // Consider keyword searches
-                elseif ($F->get('name') == ':keywords' && $F->getClean())
-                    $selected += 1;
-            }
-            if (!$selected)
-                $form->addError(__('No fields selected for searching'));
-        });
-
-        // Load state from current configuraiton
-        if (!$source) {
-            foreach ($this->getCriteria() as $I) {
-                list($path, $method, $value) = $I;
-                if ($path == ':keywords' && $method === null) {
-                    if ($F = $form->getField($path))
-                        $F->value = $value;
-                    continue;
-                }
-
-                if (!($F = $form->getField("{$path}+search")))
-                    continue;
-                $F->value = true;
-
-                if (!($F = $form->getField("{$path}+method")))
-                    continue;
-                $F->value = $method;
-
-                if ($value && ($F = $form->getField("{$path}+{$method}")))
-                    $F->value = $value;
-            }
-        }
-        return $form;
-    }
-
-    /**
-     * Fetch a bucket of fields for a custom search. The fields should be
-     * added to a form before display. One searchable field may encompass 10
-     * or more actual fields because fields are expanded to support multiple
-     * search methods along with the fields for each search method. This
-     * method returns all the FormField instances for all the searchable
-     * model fields currently in use.
-     *
-     * Parameters:
-     * $source - <array> data from a request. $source['fields'] is expected
-     *      to contain a list extra fields by ORM path, of newly added
-     *      fields not yet saved in this object's getCriteria().
-     */
-    function getCurrentSearchFields($source=array()) {
-        static $basic = array(
-            'Ticket' => array(
-                'status__state',
-                'dept_id',
-                'assignee',
-                'topic_id',
-                'created',
-                'est_duedate',
-            )
-        );
-
-        $all = $this->getSupportedMatches();
-        $core = array();
-
-        // Include basic fields for new searches
-        if (!isset($this->id))
-            foreach ($basic[$this->getRoot()] as $path)
-                if (isset($all[$path]))
-                    $core[$path] = $all[$path];
-
-        // Add others from current configuration
-        foreach ($this->getCriteria() as $C) {
-            list($path) = $C;
-            if (isset($all[$path]))
-                $core[$path] = $all[$path];
-        }
-
-        if (isset($source['fields']))
-            foreach ($source['fields'] as $path)
-                if (isset($all[$path]))
-                    $core[$path] = $all[$path];
-
-        return $core;
-    }
-
-    /**
-     * Fetch all supported ORM fields searchable by this search object. The
-     * returned list represents searchable fields, keyed by the ORM path.
-     * Use ::getCurrentSearchFields() or ::getSearchField() to retrieve for
-     * use in the user interface.
-     */
-    function getSupportedMatches() {
-        return static::getSearchableFields($this->getRoot());
-    }
-
-    /**
-     * Trace ORM fields from a base object and retrieve a complete list of
-     * fields which can be used in an ORM query based on the base object.
-     * The base object must implement Searchable interface and extend from
-     * VerySimpleModel. Then all joins from the object are also inspected,
-     * and any which implement the Searchable interface are traversed and
-     * automatically added to the list. The resulting list is cached based
-     * on the $base class, so multiple calls for the same $base return
-     * quickly.
-     *
-     * Parameters:
-     * $base - Class, name of a class implementing Searchable
-     * $recurse - int, number of levels to recurse, default is 2
-     * $cache - bool, cache results for future class for the same base
-     * $customData - bool, include all custom data fields for all general
-     *      forms
-     */
-    static function getSearchableFields($base, $recurse=2,
-        $customData=true, $exclude=array()
-    ) {
-        static $cache = array(), $otherFields;
-
-        if (!in_array('Searchable', class_implements($base)))
-            return array();
-
-        // Early exit if already cached
-        $fields = &$cache[$base];
-        if ($fields)
-            return $fields;
-
-        $fields = $fields ?: array();
-        foreach ($base::getSearchableFields() as $path=>$F) {
-            if (is_array($F)) {
-                list($label, $field) = $F;
-            }
-            else {
-                $label = $F->get('label');
-                $field = $F;
-            }
-            $fields[$path] = array($label, $field);
-        }
-
-        if ($customData && $base::supportsCustomData()) {
-            if (!isset($otherFields)) {
-                $otherFields = array();
-                $dfs = DynamicFormField::objects()
-                    ->filter(array('form__type' => 'G'))
-                    ->select_related('form');
-                foreach ($dfs as $field) {
-                    $otherFields[$field->getId()] = array($field->form,
-                        $field->getImpl());
-                }
-            }
-            foreach ($otherFields as $id=>$F) {
-                list($form, $field) = $F;
-                $label = sprintf("%s / %s",
-                    $form->getTitle(), $field->get('label'));
-                $fields["entries__answers!{$id}__value"] = array(
-                    $label, $field);
-            }
-        }
-
-        if ($recurse) {
-            $exclude[$base] = 1;
-            foreach ($base::getMeta('joins') as $path=>$j) {
-                $fc = $j['fkey'][0];
-                if (isset($exclude[$fc]) || $j['list'])
-                    continue;
-                foreach (static::getSearchableFields($fc, $recurse-1,
-                    true, $exclude)
-                as $path2=>$F) {
-                    list($label, $field) = $F;
-                    $fields["{$path}__{$path2}"] = array(
-                        sprintf("%s / %s", $fc, $label),
-                        $field);
-                }
-            }
-        }
-
-        return $fields;
-    }
-
-    /**
-     * Fetch the FormField instances used when for configuring a searchable
-     * field in the user interface. This is the glue between a field
-     * representing a searchable model field and the configuration of that
-     * search in the user interface.
-     *
-     * Parameters:
-     * $F - <array<string, FormField>> the label and the FormField instance
-     *      representing the configurable search
-     * $name - <string> ORM path for the search
-     */
-    static function getSearchField($F, $name) {
-        list($label, $field) = $F;
-
-        $pieces = array();
-        $pieces["{$name}+search"] = new BooleanField(array(
-            'id' => sprintf('%u', crc32($name)) >> 1,
-            'configuration' => array(
-                'desc' => $label ?: $field->getLocal('label'),
-                'classes' => 'inline',
-            ),
-        ));
-        $methods = $field->getSearchMethods();
-        $pieces["{$name}+method"] = new ChoiceField(array(
-            'choices' => $methods,
-            'default' => key($methods),
-            'visibility' => new VisibilityConstraint(new Q(array(
-                "{$name}+search__eq" => true,
-            )), VisibilityConstraint::HIDDEN),
-        ));
-        $offs = 0;
-        foreach ($field->getSearchMethodWidgets() as $m=>$w) {
-            if (!$w)
-                continue;
-            list($class, $args) = $w;
-            $args['required'] = true;
-            $args['__searchval__'] = true;
-            $args['visibility'] = new VisibilityConstraint(new Q(array(
-                    "{$name}+method__eq" => $m,
-                )), VisibilityConstraint::HIDDEN);
-            $pieces["{$name}+{$m}"] = new $class($args);
-        }
-        return $pieces;
-    }
-
-    function getField($path) {
-        $searchable = $this->getSupportedMatches();
-        return $searchable[$path];
-    }
-
-    // Remove this and adjust advanced-search-criteria template to use the
-    // getCriteria() list and getField()
-    function getSearchFields($form=false) {
-        $form = $form ?: $this->getForm();
-        $searchable = $this->getCurrentSearchFields();
-        $info = array();
-        foreach ($form->getFields() as $f) {
-            if (substr($f->get('name'), -7) == '+search') {
-                $name = substr($f->get('name'), 0, -7);
-                $value = null;
-                // Determine the search method and fetch the original field
-                if (($M = $form->getField("{$name}+method"))
-                    && ($method = $M->getClean())
-                    && (list(,$field) = $searchable[$name])
-                ) {
-                    // Request the field to generate a search Q for the
-                    // search method and given value
-                    if ($value = $form->getField("{$name}+{$method}"))
-                        $value = $value->getClean();
-                }
-                $info[$name] = array(
-                    'field' => $field,
-                    'method' => $method,
-                    'value' => $value,
-                    'active' =>  $f->getClean(),
-                );
-            }
-        }
-        return $info;
-    }
-
-    /**
-     * Take the criteria from the SavedSearch fields setup and isolate the
-     * field name being search, the method used for searhing, and the method-
-     * specific data entered in the UI.
-     */
-    function isolateCriteria($criteria, $root=null) {
-        $searchable = static::getSearchableFields($root ?: $this->getRoot());
-        $items = array();
-        if (!$criteria)
-            return null;
-        foreach ($criteria as $k=>$v) {
-            if (substr($k, -7) === '+method') {
-                list($name,) = explode('+', $k, 2);
-                if (!isset($searchable[$name]))
-                    continue;
-
-                // Require checkbox to be checked too
-                if (!$criteria["{$name}+search"])
-                    continue;
-
-                // Lookup the field to search this condition
-                list($label, $field) = $searchable[$name];
-
-                // Get the search method and value
-                $method = $v;
-                // Not all search methods require a value
-                $value = $criteria["{$name}+{$method}"];
-
-                $items[] = array($name, $method, $value);
-            }
-        }
-        if (isset($criteria[':keywords'])) {
-            $items[] = array(':keywords', null, $criteria[':keywords']);
-        }
-        return $items;
-    }
-
-    function getColumns() {
-        if ($this->columns_id
-            && ($q = CustomQueue::lookup($this->columns_id))
-        ) {
-            // Use columns from cited queue
-            return $q->getColumns();
-        }
-        elseif ($this->parent_id
-            && $this->hasFlag(self::FLAG_INHERIT_COLUMNS)
-            && $this->parent
-        ) {
-            return $this->parent->getColumns();
-        }
-
-        // Last resort — use standard columns
-        return array(
-            QueueColumn::create(array(
-                "heading" => "Number",
-                "primary" => 'number',
-                "width" => 85,
-                "filter" => "link:ticketP",
-                "annotations" => '[{"c":"TicketSourceDecoration","p":"b"}]',
-                "conditions" => '[{"crit":["isanswered","set",null],"prop":{"font-weight":"bold"}}]',
-            )),
-            QueueColumn::create(array(
-                "heading" => "Created",
-                "primary" => 'created',
-                "width" => 100,
-            )),
-            QueueColumn::create(array(
-                "heading" => "Subject",
-                "primary" => 'cdata__subject',
-                "width" => 250,
-                "filter" => "link:ticket",
-                "annotations" => '[{"c":"TicketThreadCount","p":">"},{"c":"ThreadAttachmentCount","p":"a"},{"c":"OverdueFlagDecoration","p":"<"}]',
-                "truncate" => 'ellipsis',
-            )),
-            QueueColumn::create(array(
-                "heading" => "From",
-                "primary" => 'user__name',
-                "width" => 150,
-            )),
-            QueueColumn::create(array(
-                "heading" => "Priority",
-                "primary" => 'cdata__priority',
-                "width" => 120,
-            )),
-            QueueColumn::create(array(
-                "heading" => "Assignee",
-                "primary" => 'assignee',
-                "width" => 100,
-            )),
-        );
-    }
-
-    /**
-     * Get a description of a field in a search. Expects an entry from the
-     * array retrieved in ::getSearchFields()
-     */
-    function describeField($info, $name=false) {
-        return $info['field']->describeSearch($info['method'], $info['value'], $name);
-    }
-
-    function getQuery() {
-        $root = $this->getRoot();
-        $base = $root::objects();
-        $query = $this->mangleQuerySet($base);
-
-        // Apply column, annotations and conditions additions
-        foreach ($this->getColumns() as $C) {
-            $query = $C->mangleQuery($query, $this->getRoot());
-        }
-        return $query;
-    }
-
-    function mangleQuerySet(QuerySet $qs, $form=false) {
-        $qs = clone $qs;
-        $searchable = $this->getSupportedMatches();
-
-        // Figure out fields to search on
-        foreach ($this->getCriteria() as $I) {
-            list($name, $method, $value) = $I;
-
-            // Consider keyword searching
-            if ($name === ':keywords') {
-                global $ost;
-                $qs = $ost->searcher->find($value, $qs, false);
-            }
-            else {
-                // XXX: Move getOrmPath to be more of a utility
-                // Ensure the special join is created to support custom data joins
-                $name = @static::getOrmPath($name, $qs);
-
-                if (preg_match('/__answers!\d+__/', $name)) {
-                    $qs->annotate(array($name2 => SqlAggregate::MAX($name)));
-                }
-
-                // Fetch a criteria Q for the query
-                if (list(,$field) = $searchable[$name])
-                    if ($q = $field->getSearchQ($method, $value, $name))
-                        $qs = $qs->filter($q);
-            }
-        }
-        return $qs;
-    }
-
-    static function getOrmPath($name, $query=null) {
-        // Special case for custom data `__answers!id__value`. Only add the
-        // join and constraint on the query the first pass, when the query
-        // being mangled is received.
-        $path = array();
-        if ($query && preg_match('/^(.+?)__(answers!(\d+))/', $name, $path)) {
-            // Add a join to the model of the queryset where the custom data
-            // is forked from — duplicate the 'answers' join and add the
-            // constraint to the query based on the field_id
-            // $path[1] - part before the answers (user__org__entries)
-            // $path[2] - answers!xx join part
-            // $path[3] - the `xx` part of the answers!xx join component
-            $root = $query->model;
-            $meta = $root::getMeta()->getByPath($path[1]);
-            $joins = $meta['joins'];
-            if (!isset($joins[$path[2]])) {
-                $meta->addJoin($path[2], $joins['answers']);
-            }
-            // Ensure that the query join through answers!xx is only for the
-            // records which match field_id=xx
-            $query->constrain(array("{$path[1]}__{$path[2]}" =>
-                array("{$path[1]}__{$path[2]}__field_id" => (int) $path[3])
-            ));
-            // Leave $name unchanged
-        }
-        return $name;
-    }
-
-
-    function checkAccess(Staff $agent) {
-        return $agent->getId() == $this->staff_id
-            || $this->hasFlag(self::FLAG_PUBLIC);
-    }
-
-    function ignoreVisibilityConstraints() {
-        global $thisstaff;
-
-        // For saved searches (not queues), staff can have a permission to
-        // see all records
-        return !$this->isAQueue()
-            && $thisstaff->hasPerm(SearchBackend::PERM_EVERYTHING);
-    }
-
-    function inheritCriteria() {
-        return $this->flags & self::FLAG_INHERIT_CRITERIA;
-    }
-
-    function buildPath() {
-        if (!$this->id)
-            return;
-
-        $path = $this->parent ? $this->parent->getPath() : '';
-        return $path . "/{$this->id}";
-    }
-
-    function getFullName() {
-        $base = $this->getName();
-        if ($this->parent)
-            $base = sprintf("%s / %s", $this->parent->getFullName(), $base);
-        return $base;
-    }
-
-    function isAQueue() {
-        return $this->hasFlag(self::FLAG_QUEUE);
-    }
-
-    function isPrivate() {
-        return !$this->isAQueue() && !$this->hasFlag(self::FLAG_PUBLIC);
-    }
-
-    protected function hasFlag($flag) {
-        return $this->flags & $flag !== 0;
-    }
-
-    protected function clearFlag($flag) {
-        return $this->flags &= ~$flag;
-    }
-
-    protected function setFlag($flag, $value=true) {
-        return $value
-            ? $this->flags |= $flag
-            : $this->clearFlag($flag);
-    }
-
-    static function create($vars=array()) {
-        $inst = new static($vars);
-        $inst->created = SqlFunction::NOW();
-        return $inst;
-    }
-
-    function save($refetch=false) {
-        if ($this->dirty)
-            $this->updated = SqlFunction::NOW();
-        return parent::save($refetch || $this->dirty);
-    }
-
     function update($vars, $form=false, &$errors=array()) {
-        // Set basic search information
-        if (!$vars['name'])
-            $errors['name'] = __('A title is required');
+        if (!parent::update($vars, $errors))
+            return false;
 
-        $this->title = $vars['name'];
-        $this->parent_id = @$vars['parent_id'] ?: 0;
-        $this->path = $this->buildPath();
         // Personal queues _always_ inherit from their parent
         $this->setFlag(self::FLAG_INHERIT_CRITERIA, $this->parent_id > 0);
 
-        // TODO: Move this to SavedSearch::update() and adjust
-        //       AjaxSearch::_saveSearch()
-        $form = $form ?: $this->getForm($vars);
-        if (!$vars || !$form->isValid()) {
-            $errors['criteria'] = __('Validation errors exist on criteria');
-        }
-        else {
-            $this->config = JsonDataEncoder::encode(
-                $this->isolateCriteria($form->getClean()));
-        }
-
-
         return count($errors) === 0;
     }
+
+    static function create() {
+        $search = parent::create();
+        $search->clearFlag(self::FLAG_QUEUE);
+        return $search;
+    }
 }
 
 class AdhocSearch
 extends SavedSearch {
     function getName() {
-        return __('Ad-Hoc Search');
-    }
-
-    function getHref() {
-        return 'tickets.php?queue=adhoc';
+        return $this->describeCriteria();
     }
 }
 
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 7cbc22ce9cd02bfa72a0454acab03113199b1855..e08fab62d8c975bace4fb206889a75d59b289c24 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -3731,7 +3731,7 @@ class TicketCData extends VerySimpleModel {
             'ticket' => array(
                 'constraint' => array('ticket_id' => 'Ticket.ticket_id'),
             ),
-            'priority' => array(
+            ':priority' => array(
                 'constraint' => array('priority' => 'Priority.priority_id'),
                 'null' => true,
             ),
diff --git a/include/class.util.php b/include/class.util.php
index b4adf4985ae2b1321d9247ab0ea6a5ae4359ae2a..a56f23b2ce70c43f95c3b63c3d90987ab731331b 100644
--- a/include/class.util.php
+++ b/include/class.util.php
@@ -1,4 +1,54 @@
 <?php
+
+abstract class BaseList
+implements IteratorAggregate, Countable {
+    protected $storage = array();
+
+    /**
+     * Sort the list in place.
+     *
+     * Parameters:
+     * $key - (callable|int) A callable function to produce the sort keys
+     *      or one of the SORT_ constants used by the array_multisort
+     *      function
+     * $reverse - (bool) true if the list should be sorted descending
+     */
+    function sort($key=false, $reverse=false) {
+        if (is_callable($key)) {
+            $keys = array_map($key, $this->storage);
+            array_multisort($keys, $this->storage,
+                $reverse ? SORT_DESC : SORT_ASC);
+        }
+        elseif ($key) {
+            array_multisort($this->storage,
+                $reverse ? SORT_DESC : SORT_ASC, $key);
+        }
+        elseif ($reverse) {
+            rsort($this->storage);
+        }
+        else
+            sort($this->storage);
+    }
+
+    function reverse() {
+        return array_reverse($this->storage);
+    }
+
+    // IteratorAggregate
+    function getIterator() {
+        return new ArrayIterator($this->storage);
+    }
+
+    // Countable
+    function count($mode=COUNT_NORMAL) {
+        return count($this->storage, $mode);
+    }
+
+    function __toString() {
+        return '['.implode(', ', $this->storage).']';
+    }
+}
+
 /**
  * Jared Hancock <jared@osticket.com>
  * Copyright (c)  2014
@@ -11,9 +61,9 @@
  * Negative indexes are supported which reference from the end of the list.
  * Therefore $queue[-1] will refer to the last item in the list.
  */
-class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Countable {
-
-    protected $storage = array();
+class ListObject
+extends BaseList
+implements ArrayAccess, Serializable {
 
     function __construct($array=array()) {
         if (!is_array($array) && !$array instanceof Traversable)
@@ -73,36 +123,6 @@ class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Counta
         return array_search($this->storage, $value);
     }
 
-    /**
-     * Sort the list in place.
-     *
-     * Parameters:
-     * $key - (callable|int) A callable function to produce the sort keys
-     *      or one of the SORT_ constants used by the array_multisort
-     *      function
-     * $reverse - (bool) true if the list should be sorted descending
-     */
-    function sort($key=false, $reverse=false) {
-        if (is_callable($key)) {
-            $keys = array_map($key, $this->storage);
-            array_multisort($keys, $this->storage,
-                $reverse ? SORT_DESC : SORT_ASC);
-        }
-        elseif ($key) {
-            array_multisort($this->storage,
-                $reverse ? SORT_DESC : SORT_ASC, $key);
-        }
-        elseif ($reverse) {
-            rsort($this->storage);
-        }
-        else
-            sort($this->storage);
-    }
-
-    function reverse() {
-        return array_reverse($this->storage);
-    }
-
     function filter($callable) {
         $new = new static();
         foreach ($this->storage as $i=>$v)
@@ -111,16 +131,6 @@ class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Counta
         return $new;
     }
 
-    // IteratorAggregate
-    function getIterator() {
-        return new ArrayIterator($this->storage);
-    }
-
-    // Countable
-    function count($mode=COUNT_NORMAL) {
-        return count($this->storage, $mode);
-    }
-
     // ArrayAccess
     function offsetGet($offset) {
         if (!is_int($offset))
@@ -166,8 +176,4 @@ class ListObject implements IteratorAggregate, ArrayAccess, Serializable, Counta
     function unserialize($what) {
         $this->storage = unserialize($what);
     }
-
-    function __toString() {
-        return '['.implode(', ', $this->storage).']';
-    }
 }
diff --git a/include/staff/queue.inc.php b/include/staff/queue.inc.php
index afaed941a412b49787d4b44e720f8699c6b2a5df..9c85a0497719711988a6b683c2b45ec8b9dd0f19 100644
--- a/include/staff/queue.inc.php
+++ b/include/staff/queue.inc.php
@@ -108,7 +108,7 @@ else {
             if ($queue->parent 
                 && ($qf = $queue->parent->getQuickFilterField()))
                 echo sprintf(' (%s)', $qf->getLabel()); ?> —</option>
-<?php foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) {
+<?php foreach (CustomQueue::getSearchableFields('Ticket') as $path=>$f) {
         list($label, $field) = $f;
         if (!$field->supportsQuickFilter())
           continue;
@@ -128,7 +128,20 @@ else {
 
   <div class="hidden tab_content" id="columns">
     <table class="table two-column">
+<?php if ($queue->parent) { ?>
       <tbody>
+        <tr>
+          <td colspan="3">
+            <input type="checkbox" name="inherit-columns" <?php
+              if ($queue->inheritColumns()) echo 'checked="checked"'; ?>
+              onchange="javascript:$(this).closest('table').find('.if-not-inherited').toggle(!$(this).prop('checked'));" />
+            <?php echo __('Inherit columns from the parent queue'); ?>
+            <br /><br />
+          </td>
+        </tr>
+      </tbody>
+<?php } ?>
+      <tbody class="if-not-inherited <?php if ($queue->inheritColumns()) echo 'hidden'; ?>">
         <tr class="header">
           <th colspan="3">
             <?php echo __("Manage columns in this queue"); ?>
@@ -143,7 +156,7 @@ else {
           <td><small><b><?php echo __('Sortable'); ?></b></small></td>
         </tr>
       </tbody>
-      <tbody class="sortable-rows">
+      <tbody class="sortable-rows if-not-inherited <?php if ($queue->inheritColumns()) echo 'hidden'; ?>">
         <tr id="column-template" class="hidden">
           <td>
             <i class="faded-more icon-sort"></i>
@@ -172,7 +185,7 @@ else {
           </td>
         </tr>
       </tbody>
-      <tbody>
+      <tbody class="if-not-inherited <?php if ($queue->inheritColumns()) echo 'hidden'; ?>">
         <tr class="header">
           <td colspan="3"></td>
         </tr>
diff --git a/include/staff/queues-ticket.inc.php b/include/staff/queues-ticket.inc.php
index acf872529df8b38f079667347504452feee46e09..77efa54d8ce0295e1b7f29d693e8a682b9d59344 100644
--- a/include/staff/queues-ticket.inc.php
+++ b/include/staff/queues-ticket.inc.php
@@ -46,7 +46,7 @@
     </thead>
     <tbody class="sortable-rows" data-sort="qsort">
 <?php
-$all_queues = CustomQueue::queues()->all();
+$all_queues = CustomQueue::queues()->getIterator();
 $emitLevel = function($queues, $level=0) use ($all_queues, &$emitLevel) { 
     $queues->sort(function($a) { return $a->sort; });
     foreach ($queues as $q) { ?>
diff --git a/include/staff/templates/queue-column-condition.tmpl.php b/include/staff/templates/queue-column-condition.tmpl.php
index 40f6d6fb6a69f00c343ee1a63d6b59344c2cc301..bc69978c877a9ed2043866091f55bd46f8d1eee4 100644
--- a/include/staff/templates/queue-column-condition.tmpl.php
+++ b/include/staff/templates/queue-column-condition.tmpl.php
@@ -20,7 +20,7 @@
   <?php echo $label ?: $field->getLabel(); ?>
   <div class="advanced-search">
 <?php
-$parts = SavedSearch::getSearchField(array($label, $field), $field_name);
+$parts = CustomQueue::getSearchField(array($label, $field), $field_name);
 // Drop the search checkbox field
 unset($parts["{$field_name}+search"]);
 list(, $crit_method, $crit_value) = $condition->getCriteria();
diff --git a/include/staff/templates/queue-column.tmpl.php b/include/staff/templates/queue-column.tmpl.php
index 41e99bc048660035f032d0a30d75495a5c750ad3..851d374cd8ae67225177868743ddfd1c5d650ffe 100644
--- a/include/staff/templates/queue-column.tmpl.php
+++ b/include/staff/templates/queue-column.tmpl.php
@@ -118,7 +118,7 @@ foreach (Internationalization::sortKeyedList($annotations) as $class=>$desc) {
   <div class="conditions">
 <?php
 if ($column->getConditions()) {
-  $fields = SavedSearch::getSearchableFields($root);
+  $fields = CustomQueue::getSearchableFields($root);
   foreach ($column->getConditions() as $i=>$condition) {
      $id = QueueColumnCondition::getUid();
      list($label, $field) = $condition->getField();
@@ -131,7 +131,7 @@ if ($column->getConditions()) {
       <select class="add-condition">
         <option>— <?php echo __("Add a condition"); ?> —</option>
 <?php
-      foreach (SavedSearch::getSearchableFields('Ticket') as $path=>$f) {
+      foreach (CustomQueue::getSearchableFields('Ticket') as $path=>$f) {
           list($label) = $f;
           echo sprintf('<option value="%s">%s</option>', $path, Format::htmlchars($label));
       }
diff --git a/include/staff/templates/queue-savedsearches-nav.tmpl.php b/include/staff/templates/queue-savedsearches-nav.tmpl.php
index 81646edf71d6440fd0369374fd991a3aedc3840a..3425ac4a563117c8d0ec4805a046848d0459c60d 100644
--- a/include/staff/templates/queue-savedsearches-nav.tmpl.php
+++ b/include/staff/templates/queue-savedsearches-nav.tmpl.php
@@ -20,7 +20,7 @@
             'staff_id' => $thisstaff->getId(),
             'parent_id' => 0,
             Q::not(array(
-                'flags__hasbit' => SavedSearch::FLAG_PUBLIC
+                'flags__hasbit' => CustomQueue::FLAG_PUBLIC
             ))
       )) as $q) {
         include 'queue-subnavigation.tmpl.php';
diff --git a/include/staff/templates/queue-subnavigation.tmpl.php b/include/staff/templates/queue-subnavigation.tmpl.php
index ddf345cfba5871cb18ea242f20ec9553d661d768..b0cbeeb75d98b1db5ac61e7004b1cd20aa48c732 100644
--- a/include/staff/templates/queue-subnavigation.tmpl.php
+++ b/include/staff/templates/queue-subnavigation.tmpl.php
@@ -2,8 +2,8 @@
 // Calling conventions
 // $q - <CustomQueue> object for this navigation entry
 $queue = $q;
-$children = $queue instanceof CustomQueue ? $queue->getPublicChildren() : array();
-$subq_searches = $queue instanceof CustomQueue ? $queue->getMyChildren() : array();
+$children = !$queue instanceof SavedSearch ? $queue->getPublicChildren() : array();
+$subq_searches = !$queue instanceof SavedSearch ? $queue->getMyChildren() : array();
 $hasChildren = count($children) + count($subq_searches) > 0;
 $selected = $_REQUEST['queue'] == $q->getId();
 global $thisstaff;
diff --git a/include/staff/templates/queue-tickets.tmpl.php b/include/staff/templates/queue-tickets.tmpl.php
index bd41e7fdbc523cc3c9c19845dd51f725eac6b8e8..7725d04deea4a9c37a2de5e22d775ef644b8ce44 100644
--- a/include/staff/templates/queue-tickets.tmpl.php
+++ b/include/staff/templates/queue-tickets.tmpl.php
@@ -103,7 +103,7 @@ if ($queue->isPrivate()) { ?>
                         <li>
                             <a class="no-pjax" href="#"
                               data-dialog="ajax.php/tickets/search/<?php echo
-                              $queue->id; ?>"><i
+                              urlencode($queue->getId()); ?>"><i
                             class="icon-fixed-width icon-save"></i>
                             <?php echo __('Edit'); ?></a>
                         </li>
@@ -192,7 +192,7 @@ foreach ($columns as $C) {
 
     // Sort by this column ?
     if (isset($sort['col']) && $sort['col'] == $C->id) {
-        $col = SavedSearch::getOrmPath($C->primary, $query);
+        $col = CustomQueue::getOrmPath($C->primary, $query);
         if ($sort['dir'])
             $col = '-' . $col;
         $tickets = $tickets->order_by($col);
diff --git a/scp/tickets.php b/scp/tickets.php
index bb4eabedb3324ff290c665f4f2f508e14e6cbcf6..aaaf653d01a33837d769d3c1ea43ce60eba70d3b 100644
--- a/scp/tickets.php
+++ b/scp/tickets.php
@@ -67,8 +67,8 @@ elseif (isset($_SESSION['advsearch'])
 ) {
     list(,$key) = explode(',', $queue_id, 2);
     // XXX: De-duplicate and simplify this code
-    $queue = SavedSearch::create(array(
-        'title' => __("Advanced Search"),
+    $queue = AdhocSearch::create(array(
+        'id' => $queue_id,
         'root' => 'T',
     ));
     // For queue=queue, use the most recent search
@@ -416,15 +416,7 @@ $nav->addSubMenu(function() use ($queue, $adhoc) {
     // A queue is selected if it is the one being displayed. It is
     // "child" selected if its ID is in the path of the one selected
     $child_selected = $queue && !$queue->isAQueue();
-    $searches = SavedSearch::objects()
-        ->filter(Q::any(array(
-            'flags__hasbit' => SavedSearch::FLAG_PUBLIC,
-            'staff_id' => $thisstaff->getId(),
-        )))
-        ->exclude(array(
-            'flags__hasbit' => SavedSearch::FLAG_QUEUE
-        ))
-        ->all();
+    $searches = SavedSearch::forStaff($thisstaff)->all();
 
     if (isset($adhoc)) {
         // TODO: Add "Ad Hoc Search" to the personal children