diff --git a/assets/default/css/theme.css b/assets/default/css/theme.css
index 26680176598c1b8916aaccacb041713d34074a16..e2120f71909cc711027c791bd0573437bdb04adb 100644
--- a/assets/default/css/theme.css
+++ b/assets/default/css/theme.css
@@ -118,6 +118,12 @@ fieldset {
 a {
   color: #0072bc;
   text-decoration: none;
+  display: inline-block;
+  margin-bottom: 1px;
+}
+a:hover {
+    border-bottom: 1px dotted #0072bc;
+    margin-bottom: 0;
 }
 h1 {
   color: #00AEEF;
@@ -789,9 +795,6 @@ label.required, span.required {
   font-size: 1em;
   background-image: url('../images/icons/thread.gif?1319556657');
 }
-.Icon:hover {
-  text-decoration: underline;
-}
 #ticketTable {
   border: 1px solid #aaa;
   border-left: none;
@@ -828,25 +831,22 @@ label.required, span.required {
 #ticketTable tr.alt td {
   background: #f9f9f9;
 }
-#ticketSearchForm {
-  display: inline-block;
-  float: left;
-  padding: 0 0 5px 0;
+i.refresh {
+  color: #0a0;
+  font-size: 80%;
+  vertical-align: middle;
 }
-a.refresh {
-  display: block;
-  width: auto;
-  float: right;
-  height: 20px;
-  line-height: 20px;
-  text-align: center;
-  padding: 0 10px 0 28px;
-  border: 1px solid #aaa;
-  margin-left: 10px;
-  color: #333;
-  background-position: 5px 50%;
-  background-repeat: no-repeat;
-  background-image: url('../images/icons/refresh.png');
+.states small {
+    font-size: 70%;
+}
+.active.state {
+    font-weight: bold;
+}
+.search.well {
+    padding: 10px;
+    background-color: rgba(0,0,0,0.05);
+    margin-bottom: 10px;
+    margin-top: -15px;
 }
 .infoTable {
   background: #F4FAFF;
@@ -999,7 +999,7 @@ img.sign-in-image {
 }
 .span8 {
     display: inline-block;
-    width: 66.5%;
+    width: 66.0%;
     margin: 0 1%;
     vertical-align: top;
 }
@@ -1153,6 +1153,10 @@ img.avatar {
 .thread-body .attachments .filesize {
   margin-left: 0.5em;
 }
+.thread-body .attachments a,
+.thread-body .attachments a:hover {
+  text-decoration: none;
+}
 .thread-body .attachment-info {
     margin-right: 10px;
     display: inline-block;
diff --git a/css/thread.css b/css/thread.css
index 4ac6615703681721278f9d2f8815c499d2a6aff9..4e810094a70447cb92dd33671fe85bb4c58704eb 100644
--- a/css/thread.css
+++ b/css/thread.css
@@ -66,7 +66,7 @@
 .thread-body kbd,
 .thread-body pre,
 .thread-body samp {
-  font-family: monospace, serif;
+  font-family: 'Source Code Pro', 'Monaco', 'Consolas', monospace, serif;
   font-size: 1em;
 }
 .thread-body pre {
@@ -420,11 +420,18 @@
 	margin: 0;
 	margin-bottom: 10px;
 	border: none;
-	background: none !important;
+    background: none;
 	box-shadow: none !important;
     text-indent: 0 !important;
 }
 
+.thread-body pre {
+    background: #f5f5f5;
+    background-color: rgba(0,0,0,0.05);
+    border-radius: 5px;
+    padding: 0.5em;
+}
+
 .thread-body iframe,
 .thread-body object,
 .thread-body hr {
diff --git a/include/ajax.search.php b/include/ajax.search.php
index d228018e911f963d9ec8b201aca8d2921e201c70..4013d27e4290e3a5ffa3187069a6fa04c1c8fcf4 100644
--- a/include/ajax.search.php
+++ b/include/ajax.search.php
@@ -22,23 +22,14 @@ require_once(INCLUDE_DIR.'class.ajax.php');
 
 class SearchAjaxAPI extends AjaxController {
 
-    static function ensureConsistentFormFieldIds($reset=false) {
-        // Maintain unique form field IDs over the life of the session
-        FormField::$uid = $reset ?: 1000;
-    }
-
     function getAdvancedSearchDialog() {
         global $thisstaff;
 
         if (!$thisstaff)
             Http::response(403, 'Agent login required');
 
-        self::ensureConsistentFormFieldIds();
         $search = SavedSearch::create();
-        // Don't send the state as the souce because it is not in the
-        // ::parse format (it's in ::to_php format)
-        $form = $search->getFormFromSession('advsearch');
-        $form->loadState($_SESSION['advsearch']);
+        $form = $search->getFormFromSession('advsearch') ?: $search->getForm();
         $matches = self::_getSupportedTicketMatches();
 
         include STAFFINC_DIR . 'templates/advanced-search.tmpl.php';
@@ -50,7 +41,7 @@ class SearchAjaxAPI extends AjaxController {
         if (!$thisstaff)
             Http::response(403, 'Agent login required');
 
-        list($type, $id) = explode('!', $name, 2);
+        @list($type, $id) = explode('!', $name, 2);
 
         switch (strtolower($type)) {
         case ':ticket':
@@ -62,17 +53,23 @@ class SearchAjaxAPI extends AjaxController {
                 list(,$id) = explode('!', $id, 2);
             if (!($field = DynamicFormField::lookup($id)))
                 Http::response(404, 'No such field: ', print_r($id, true));
+
+            $impl = $field->getImpl();
+            $impl->set('label', sprintf('%s / %s',
+                $field->form->getLocal('title'), $field->getLocal('label')
+            ));
             break;
+
         default:
+            $extended = SavedSearch::getExtendedTicketFields();
+
+            if (isset($extended[$name])) {
+                $impl = $extended[$name];
+                break;
+            }
             Http::response(400, 'No such field type');
         }
 
-        self::ensureConsistentFormFieldIds($_GET['ff_uid']);
-
-        $impl = $field->getImpl();
-        $impl->set('label', sprintf('%s / %s',
-            $field->form->getLocal('title'), $field->getLocal('label')
-        ));
         $fields = SavedSearch::getSearchField($impl, $name);
         $form = new SimpleForm($fields);
         // Check the box to search the field by default
@@ -96,7 +93,6 @@ class SearchAjaxAPI extends AjaxController {
         global $thisstaff;
 
         $search = SavedSearch::create();
-        self::ensureConsistentFormFieldIds();
 
         $form = $search->getForm($_POST);
         if (!$form->isValid()) {
@@ -132,7 +128,6 @@ class SearchAjaxAPI extends AjaxController {
             else
                 $data[$name] = $info['value'];
         }
-        self::ensureConsistentFormFieldIds();
         $form = $search->getForm($data);
         if (!$data || !$form->isValid()) {
             Http::response(422, 'Validation errors exist on form');
@@ -152,7 +147,9 @@ class SearchAjaxAPI extends AjaxController {
 
     function _getSupportedTicketMatches() {
         // User information
-        $matches = array();
+        $matches = array(
+            __('Ticket Built-In') => SavedSearch::getExtendedTicketFields(),
+        );
         foreach (array('ticket'=>'TicketForm', 'user'=>'UserForm', 'organization'=>'OrganizationForm') as $k=>$F) {
             $form = $F::objects()->one();
             $fields = &$matches[$form->getLocal('title')];
@@ -203,12 +200,12 @@ class SearchAjaxAPI extends AjaxController {
             Http::response(404, 'No such saved search');
         }
 
-        self::ensureConsistentFormFieldIds();
-        $form = $search->getForm();
-        if ($state = JsonDataParser::parse($search->config))
+        if ($state = JsonDataParser::parse($search->config)) {
+            $form = $search->loadFromState($state);
             $form->loadState($state);
-
+        }
         $matches = self::_getSupportedTicketMatches();
+
         include STAFFINC_DIR . 'templates/advanced-search.tmpl.php';
     }
 
diff --git a/include/ajax.thread.php b/include/ajax.thread.php
index 4a8b72016d69ce2ff4565d5026134b380f6e0649..470f9276e24a6d3c4c15a7d054422799db70c9b1 100644
--- a/include/ajax.thread.php
+++ b/include/ajax.thread.php
@@ -114,19 +114,6 @@ class ThreadAjaxAPI extends AjaxController {
             // FIXME: Refuse to add ticket owner??
             if (($c=$thread->addCollaborator($user,
                             array('isactive'=>1), $errors))) {
-                $note = Format::htmlchars(sprintf(__('%s <%s> added as a collaborator'),
-                            Format::htmlchars($c->getName()), $c->getEmail()));
-
-                $thread->getObject()->postThreadEntry('N',
-                        array(
-                            'title' => __('New Collaborator Added'),
-                            'note' => $note
-                            ),
-                        array(
-                            'poster' => $thisstaff,
-                            'alert' => false
-                            )
-                        );
                 $info = array('msg' => sprintf(__('%s added as a collaborator'),
                             Format::htmlchars($c->getName())));
                 return self::_collaborators($thread, $info);
diff --git a/include/class.attachment.php b/include/class.attachment.php
index b787ee25093c3a5b6fcb23cfef08e054c45e50e7..6a904015421c5d1b899927cf9b21b5a4cc79baad 100644
--- a/include/class.attachment.php
+++ b/include/class.attachment.php
@@ -128,7 +128,9 @@ extends InstrumentedList {
                 $fileId = $file;
             elseif (is_array($file) && isset($file['id']))
                 $fileId = $file['id'];
-            elseif ($F = AttachmentFile::upload($file))
+            elseif (isset($file['tmp_name']) && ($F = AttachmentFile::upload($file)))
+                $fileId = $F->getId();
+            elseif ($F = AttachmentFile::create($file))
                 $fileId = $F->getId();
             else
                 continue;
diff --git a/include/class.client.php b/include/class.client.php
index eb4ef00a870c62a6355d3269ec5b6c0476ed27c5..bbd6270c5f7ee781f58f2335ea29e20c895bb687 100644
--- a/include/class.client.php
+++ b/include/class.client.php
@@ -245,6 +245,10 @@ class  EndUser extends BaseAuthenticatedUser {
         return ($stats=$this->getTicketStats())?$stats['closed']:0;
     }
 
+    function getNumTopicTickets($topic_id) {
+        return ($stats=$this->getTicketStats())?$stats['topics'][$topic_id]:0;
+    }
+
     function getNumOrganizationTickets() {
         if (!($stats=$this->getTicketStats()))
             return 0;
@@ -272,58 +276,20 @@ class  EndUser extends BaseAuthenticatedUser {
     }
 
     private function getStats() {
-
-        $where = ' WHERE ticket.user_id = '.db_input($this->getId())
-                .' OR collab.user_id = '.db_input($this->getId()).' ';
-
-        $where2 = ' WHERE user.org_id > 0 AND user.org_id = '.db_input($this->getOrgId()).' ';
-
-        $join  =  'LEFT JOIN '.THREAD_TABLE.' thread
-                    ON (ticket.ticket_id = thread.object_id and thread.object_type = \'T\')
-                   LEFT JOIN '.THREAD_COLLABORATOR_TABLE.' collab
-                    ON (collab.thread_id=thread.id
-                            AND collab.user_id = '.db_input($this->getId()).' ) ';
-
-        $sql =  'SELECT \'open\', count( ticket.ticket_id ) AS tickets '
-                .'FROM ' . TICKET_TABLE . ' ticket '
-                .'INNER JOIN '.TICKET_STATUS_TABLE. ' status
-                    ON (ticket.status_id=status.id
-                            AND status.state=\'open\') '
-                . $join
-                . $where
-
-                .'UNION SELECT \'closed\', count( ticket.ticket_id ) AS tickets '
-                .'FROM ' . TICKET_TABLE . ' ticket '
-                .'INNER JOIN '.TICKET_STATUS_TABLE. ' status
-                    ON (ticket.status_id=status.id
-                            AND status.state=\'closed\' ) '
-                . $join
-                . $where
-
-                .'UNION SELECT \'org-open\', count( ticket.ticket_id ) AS tickets '
-                .'FROM ' . TICKET_TABLE . ' ticket '
-                .'INNER JOIN '.USER_TABLE.' user ON (ticket.user_id = user.id) '
-                .'INNER JOIN '.TICKET_STATUS_TABLE. ' status
-                    ON (ticket.status_id=status.id
-                            AND status.state=\'open\' ) '
-                . $join
-                . $where2
-
-                .'UNION SELECT \'org-closed\', count( ticket.ticket_id ) AS tickets '
-                .'FROM ' . TICKET_TABLE . ' ticket '
-                .'INNER JOIN '.USER_TABLE.' user ON (ticket.user_id = user.id) '
-                .'INNER JOIN '.TICKET_STATUS_TABLE. ' status
-                    ON (ticket.status_id=status.id
-                            AND status.state=\'closed\' ) '
-                . $join
-                . $where2;
-
-        $res = db_query($sql);
-        $stats = array();
-        while($row = db_fetch_row($res)) {
-            $stats[$row[0]] = $row[1];
+        $basic = Ticket::objects()
+            ->annotate(array('count' => SqlAggregate::COUNT('ticket_id')))
+            ->values('status__state', 'topic_id')
+            ->filter(Q::any(array(
+                'user_id' => $this->getId(),
+                'thread__collaborators__user_id' => $this->getId(),
+            )));
+
+        $stats = array('open' => 0, 'closed' => 0, 'topics' => array());
+        foreach ($basic as $row) {
+            $stats[$row['status__state']] += $row['count'];
+            if ($row['topic_id'])
+                $stats['topics'][$row['topic_id']] += $row['count'];
         }
-
         return $stats;
     }
 
diff --git a/include/class.faq.php b/include/class.faq.php
index f3d9d5bb76cfde2e0d454387b153d12475ade707..9f0b83ccd5161f08700fbfb03cfec1fea7a47d1a 100644
--- a/include/class.faq.php
+++ b/include/class.faq.php
@@ -367,10 +367,10 @@ class FAQ extends VerySimpleModel {
     }
 
     static function allPublic() {
-        return static::objects()->exclude(array(
+        return static::objects()->exclude(Q::any(array(
             'ispublished'=>self::VISIBILITY_PRIVATE,
             'category__ispublic'=>Category::VISIBILITY_PRIVATE,
-        ));
+        )));
     }
 
     static function countPublishedFAQs() {
diff --git a/include/class.file.php b/include/class.file.php
index 5f4013ff762f85b780c61bc746d84190deba714b..e0953876d7571c4e6f322d78b4dd7a48a59ea85a 100644
--- a/include/class.file.php
+++ b/include/class.file.php
@@ -25,6 +25,12 @@ class AttachmentFile extends VerySimpleModel {
             ),
         ),
     );
+    static $keyCache = array();
+
+    function __onload() {
+        // Cache for lookup in the ::lookupByHash method below
+        static::$keyCache[$this->key] = $this;
+    }
 
     function getHashtable() {
         return $this->ht;
@@ -532,13 +538,11 @@ class AttachmentFile extends VerySimpleModel {
     }
 
     static function lookupByHash($hash) {
-        static $keyCache = array();
-
-        if (isset($keyCache[$hash]))
-            return $keyCache[$hash];
+        if (isset(static::$keyCache[$hash]))
+            return static::$keyCache[$hash];
 
         // Cache a negative lookup if no such file exists
-        return $keyCache[$hash] = parent::lookup(array('key' => $hash));
+        return parent::lookup(array('key' => $hash));
     }
 
     static function lookup($id) {
diff --git a/include/class.format.php b/include/class.format.php
index d062b69fdc5011712aa71863ae3c86c2dd13b65a..d13c2dd8b39a292698b73beda6272052d127eee6 100644
--- a/include/class.format.php
+++ b/include/class.format.php
@@ -317,7 +317,7 @@ class Format {
         global $ost;
 
         // Find all text between tags
-        $text = preg_replace_callback(':^[^<]+|>[^<]+:',
+        return preg_replace_callback(':^[^<]+|>[^<]+:',
             function($match) {
                 // Scan for things that look like URLs
                 return preg_replace_callback(
@@ -344,32 +344,6 @@ class Format {
                     $match[0]);
             },
             $text);
-
-        // Now change @href and @src attributes to come back through our
-        // system as well
-        $config = array(
-            'hook_tag' => function($e, $a=0) use ($target) {
-                static $eE = array('area'=>1, 'br'=>1, 'col'=>1, 'embed'=>1,
-                    'hr'=>1, 'img'=>1, 'input'=>1, 'isindex'=>1, 'param'=>1);
-                if ($e == 'a' && $a) {
-                    $a['target'] = $target;
-                    $a['class'] = 'no-pjax';
-                }
-
-                $at = '';
-                if (is_array($a)) {
-                    foreach ($a as $k=>$v)
-                        $at .= " $k=\"$v\"";
-                    return "<{$e}{$at}".(isset($eE[$e])?" /":"").">";
-                } else {
-                    return "</{$e}>";
-                }
-            },
-            'schemes' => 'href: aim, feed, file, ftp, gopher, http, https, irc, mailto, news, nntp, sftp, ssh, telnet; *:file, http, https; src: cid, http, https, data',
-            'elements' => '*+iframe',
-            'spec' => 'span=data-src,width,height;img=data-cid',
-        );
-        return Format::html($text, $config);
     }
 
     function stripEmptyLines($string) {
@@ -379,18 +353,9 @@ class Format {
 
     function viewableImages($html, $script=false) {
         $cids = $images = array();
-        // Try and get information for all the files in one query
-        if (preg_match_all('/"cid:([\w._-]{32})"/', $html, $cids)) {
-            foreach (AttachmentFile::objects()
-                ->filter(array('key__in' => $cids[1]))
-                as $file
-            ) {
-                $images[strtolower($file->getKey())] = $file;
-            }
-        }
         return preg_replace_callback('/"cid:([\w._-]{32})"/',
         function($match) use ($script, $images) {
-            if (!($file = $images[strtolower($match[1])]))
+            if (!($file = AttachmentFile::lookup($match[1])))
                 return $match[0];
             return sprintf('"%s" data-cid="%s"',
                 $file->getDownloadUrl(false, 'inline', $script), $match[1]);
@@ -441,6 +406,7 @@ class Format {
     function __formatDate($timestamp, $format, $fromDb, $dayType, $timeType,
             $strftimeFallback, $timezone, $user=false) {
         global $cfg;
+        static $cache;
 
         if (!$timestamp)
             return '';
@@ -449,18 +415,28 @@ class Format {
             $timestamp = Misc::db2gmtime($timestamp);
 
         if (class_exists('IntlDateFormatter')) {
-            $formatter = new IntlDateFormatter(
-                Internationalization::getCurrentLocale($user),
-                $dayType,
-                $timeType,
-                $timezone,
-                IntlDateFormatter::GREGORIAN,
-                $format ?: null
-            );
-            if ($cfg->isForce24HourTime()) {
-                $format = str_replace(array('a', 'h'), array('', 'H'),
-                    $formatter->getPattern());
-                $formatter->setPattern($format);
+            $locale = Internationalization::getCurrentLocale($user);
+            $key = "{$locale}:{$dayType}:{$timeType}:{$timezone}:{$format}";
+            if (!isset($cache[$key])) {
+                // Setting up the IntlDateFormatter is pretty expensive, so
+                // cache it since there aren't many variations of the
+                // arguments passed to the constructor
+                $cache[$key] = $formatter = new IntlDateFormatter(
+                    $locale,
+                    $dayType,
+                    $timeType,
+                    $timezone,
+                    IntlDateFormatter::GREGORIAN,
+                    $format ?: null
+                );
+                if ($cfg->isForce24HourTime()) {
+                    $format = str_replace(array('a', 'h'), array('', 'H'),
+                        $formatter->getPattern());
+                    $formatter->setPattern($format);
+                }
+            }
+            else {
+                $formatter = $cache[$key];
             }
             return $formatter->format($timestamp);
         }
diff --git a/include/class.forms.php b/include/class.forms.php
index a3dc7579634996b2da6d1837bdd4e31b3faf42f4..ea0947e44fbd2db2450f7a5c65d96ce15f229cef 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -388,6 +388,10 @@ class FormField {
         $this->ht[$field] = $value;
     }
 
+    function getId() {
+        return $this->ht['id'];
+    }
+
     /**
      * getClean
      *
@@ -422,7 +426,7 @@ class FormField {
         return $this->_clean;
     }
     function reset() {
-        $this->_clean = $this->_widget = null;
+        $this->value = $this->_clean = $this->_widget = null;
     }
 
     function getValue() {
@@ -1248,6 +1252,18 @@ class BooleanField extends FormField {
             'set.not' => null,
         );
     }
+
+    function getSearchQ($method, $value, $name=false) {
+        $name = $name ?: $this->get('name');
+        switch ($method) {
+        case 'set':
+            return new Q(array($name => '1'));
+        case 'set.not':
+            return new Q(array($name => '0'));
+        default:
+            return parent::getSearchQ($method, $value, $name);
+        }
+    }
 }
 
 class ChoiceField extends FormField {
@@ -2124,8 +2140,16 @@ class FileUploadField extends FormField {
     static function getFileTypes() {
         static $filetypes;
 
-        if (!isset($filetypes))
-            $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml');
+        if (!isset($filetypes)) {
+            if (function_exists('apc_fetch')) {
+                $key = md5(SECRET_SALT . GIT_VERSION . 'filetypes');
+                $filetypes = apc_fetch($key);
+            }
+            if (!$filetypes)
+                $filetypes = YamlDataParser::load(INCLUDE_DIR . '/config/filetype.yaml');
+            if ($key)
+                apc_store($key, $filetypes, 7200);
+        }
         return $filetypes;
     }
 
@@ -2910,7 +2934,9 @@ class CheckboxWidget extends Widget {
         $data = $this->field->getSource();
         if (count($data)) {
             if (!isset($data[$this->name]))
-                return false;
+                // Indeterminite. Likely false, but consider current field
+                // value
+                return null;
             return @in_array($this->field->get('id'), $data[$this->name]);
         }
         return parent::getValue();
diff --git a/include/class.i18n.php b/include/class.i18n.php
index 082d8040b7a2e01ab4b99e4dd5ef85a203f2c7fc..ed3df93bfcdbbb44fcfc9b4a1f7d0e02dcff2ece 100644
--- a/include/class.i18n.php
+++ b/include/class.i18n.php
@@ -129,7 +129,6 @@ class Internationalization {
             $sql = 'INSERT INTO '.PAGE_TABLE.' SET type='.db_input($type)
                 .', name='.db_input($page['name'])
                 .', body='.db_input($page['body'])
-                .', lang='.db_input($tpl->getLang())
                 .', notes='.db_input($page['notes'])
                 .', created=NOW(), updated=NOW(), isactive=1';
             if (db_query($sql) && ($id = db_insert_id())
@@ -139,17 +138,14 @@ class Internationalization {
         // Default Language
         $_config->set('system_language', $this->langs[0]);
 
-        // content_id defaults to the `id` field value
-        db_query('UPDATE '.PAGE_TABLE.' SET content_id=id');
-
         // Canned response examples
         if (($tpl = $this->getTemplate('templates/premade.yaml'))
                 && ($canned = $tpl->getData())) {
             foreach ($canned as $c) {
-                if (($premade = Canned::create($c))
-                        && isset($c['attachments'])) {
+                if (!($premade = Canned::create($c)) || !$premade->save())
+                    continue;
+                if (isset($c['attachments'])) {
                     $premade->attachments->upload($c['attachments']);
-                    $premade->save();
                 }
             }
         }
@@ -284,6 +280,10 @@ class Internationalization {
     // Algorithm borrowed from Drupal 7 (locale.inc)
     static function getDefaultLanguage() {
         global $cfg;
+        static $lang;
+
+        if (isset($lang))
+            return $lang;
 
         if (empty($_SERVER["HTTP_ACCEPT_LANGUAGE"]))
             return $cfg ? $cfg->getPrimaryLanguage() : 'en_US';
@@ -362,10 +362,9 @@ class Internationalization {
           }
         }
 
-        if (self::isLanguageInstalled($best_match_langcode))
-            return $best_match_langcode;
-        else
-            return $cfg->getPrimaryLanguage();
+        return $lang = self::isLanguageInstalled($best_match_langcode)
+            ? $best_match_langcode
+            : $cfg->getPrimaryLanguage();
     }
 
     static function getCurrentLanguage($user=false) {
diff --git a/include/class.mailer.php b/include/class.mailer.php
index a752d4043f062c83b9388a09a1f12824bd74dd57..f41d0e55a63ea814519aeac09b64749509029779 100644
--- a/include/class.mailer.php
+++ b/include/class.mailer.php
@@ -433,11 +433,10 @@ class Mailer {
                 function($match) use ($domain, $mime, $self) {
                     $file = false;
                     foreach ($self->attachments as $id=>$F) {
+                        if ($F instanceof Attachment)
+                            $F = $F->getFile();
                         if (strcasecmp($F->getKey(), $match[1]) === 0) {
-                            if ($F instanceof Attachment)
-                                $file = $F->getFile();
-                            else
-                                $file = $F;
+                            $file = $F;
                             break;
                         }
                     }
diff --git a/include/class.mailfetch.php b/include/class.mailfetch.php
index d1ad8fbec966ce1a9b6b08211d0aacd54afb3930..d4d2029cb9a504b88fd525590f8d770ded296ce5 100644
--- a/include/class.mailfetch.php
+++ b/include/class.mailfetch.php
@@ -762,7 +762,7 @@ class MailFetcher {
         }
         // Allow continuation of thread without initial message or note
         elseif (($thread = Thread::lookupByEmailHeaders($vars))
-            && ($message = $entry->postEmail($vars))
+            && ($message = $thread->postEmail($vars))
         ) {
             // NOTE: This might not be a "ticket"
             $ticket = $thread->getObject();
diff --git a/include/class.orm.php b/include/class.orm.php
index d1aa0c5386ed981b4f0ebf5d26f804a3d53fd8b4..3157c0a8497d93351ba911ddae3935b88f44e122 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -19,7 +19,13 @@
 class OrmException extends Exception {}
 class OrmConfigurationException extends Exception {}
 // Database fields/tables do not match codebase
-class InconsistentModelException extends OrmException {}
+class InconsistentModelException extends OrmException {
+    function __construct() {
+        // Drop the model cache (just incase)
+        ModelMeta::flushModelCache();
+        call_user_func_array(array('parent', '__construct'), func_get_args());
+    }
+}
 
 /**
  * Meta information about a model including edges (relationships), table
@@ -36,6 +42,8 @@ class ModelMeta implements ArrayAccess {
         'defer' => array(),
         'select_related' => array(),
         'view' => false,
+        'joins' => array(),
+        'foreign_keys' => array(),
     );
     static $model_cache;
 
@@ -43,26 +51,27 @@ class ModelMeta implements ArrayAccess {
 
     function __construct($model) {
         $this->model = $model;
-        $parent = get_parent_class($model);
 
         // Merge ModelMeta from parent model (if inherited)
+        $parent = get_parent_class($this->model);
         if (is_subclass_of($parent, 'VerySimpleModel')) {
-            $parent::_inspect();
-            $meta = $parent::$meta->extend($model::$meta);
+            $meta = $parent::getMeta()->extend($model::$meta);
         }
         else {
             $meta = $model::$meta + self::$base;
         }
 
-        if (!$meta['table'])
-            throw new OrmConfigurationException(
-                sprintf(__('%s: Model does not define meta.table'), $model));
-        elseif (!$meta['pk'])
-            throw new OrmConfigurationException(
-                sprintf(__('%s: Model does not define meta.pk'), $model));
+        if (!$meta['view']) {
+            if (!$meta['table'])
+                throw new OrmConfigurationException(
+                    sprintf(__('%s: Model does not define meta.table'), $this->model));
+            elseif (!$meta['pk'])
+                throw new OrmConfigurationException(
+                    sprintf(__('%s: Model does not define meta.pk'), $this->model));
+        }
 
         // Ensure other supported fields are set and are arrays
-        foreach (array('pk', 'ordering', 'defer') as $f) {
+        foreach (array('pk', 'ordering', 'defer', 'select_related') as $f) {
             if (!isset($meta[$f]))
                 $meta[$f] = array();
             elseif (!is_array($meta[$f]))
@@ -70,8 +79,6 @@ class ModelMeta implements ArrayAccess {
         }
 
         // Break down foreign-key metadata
-        if (!isset($meta['joins']))
-            $meta['joins'] = array();
         foreach ($meta['joins'] as $field => &$j) {
             $this->processJoin($j);
             if ($j['local'])
@@ -87,10 +94,32 @@ class ModelMeta implements ArrayAccess {
         return $meta + $this->base + self::$base;
     }
 
+    /**
+     * Adds some more information to a declared relationship. If the
+     * relationship is a reverse relation, then the information from the
+     * reverse relation is loaded into the local definition
+     *
+     * Compiled-Join-Structure:
+     * 'constraint' => array(local => array(foreign_field, foreign_class)),
+     *      Constraint used to construct a JOIN in an SQL query
+     * 'list' => boolean
+     *      TRUE if an InstrumentedList should be employed to fetch a list
+     *      of related items
+     * 'broker' => Handler for the 'list' property. Usually a subclass of
+     *      'InstrumentedList'
+     * 'null' => boolean
+     *      TRUE if relation is nullable
+     * 'fkey' => array(class, pk)
+     *      Classname and field of the first item in the constraint that
+     *      points to a PK field of a foreign model
+     * 'local' => string
+     *      The local field corresponding to the 'fkey' property
+     */
     function processJoin(&$j) {
         $constraint = array();
         if (isset($j['reverse'])) {
             list($fmodel, $key) = explode('.', $j['reverse']);
+            // NOTE: It's ok if the forein meta data is not yet inspected.
             $info = $fmodel::$meta['joins'][$key];
             if (!is_array($info['constraint']))
                 throw new OrmConfigurationException(sprintf(__(
@@ -129,6 +158,11 @@ class ModelMeta implements ArrayAccess {
         $j['constraint'] = $constraint;
     }
 
+    function addJoin($name, array $join) {
+        $this->base['joins'][$name] = $join;
+        $this->processJoin($this->base['joins'][$name]);
+    }
+
     function offsetGet($field) {
         if (!isset($this->base[$field]))
             $this->setupLazy($field);
@@ -205,11 +239,8 @@ class VerySimpleModel {
     function get($field, $default=false) {
         if (array_key_exists($field, $this->ht))
             return $this->ht[$field];
-        elseif (isset(static::$meta['joins'][$field])) {
-            // Make sure joins were inspected
-            if (!static::$meta instanceof ModelMeta)
-                static::_inspect();
-            $j = static::$meta['joins'][$field];
+        elseif (($joins = static::getMeta('joins')) && isset($joins[$field])) {
+            $j = $joins[$field];
             // Support instrumented lists and such
             if (isset($j['list']) && $j['list']) {
                 $class = $j['fkey'][0];
@@ -290,11 +321,9 @@ class VerySimpleModel {
     function set($field, $value) {
         // Update of foreign-key by assignment to model instance
         $related = false;
-        if (isset(static::$meta['joins'][$field])) {
-            // XXX: This is likely not necessary
-            if (!isset(static::$meta['joins'][$field]['fkey']))
-                static::_inspect();
-            $j = static::$meta['joins'][$field];
+        $joins = static::getMeta('joins');
+        if (isset($joins[$field])) {
+            $j = $joins[$field];
             if ($j['list'] && ($value instanceof InstrumentedList)) {
                 // Magic list property
                 $this->ht[$field] = $value;
@@ -321,7 +350,7 @@ class VerySimpleModel {
             else
                 throw new InvalidArgumentException(
                     sprintf(__('Expecting NULL or instance of %s. Got a %s instead'),
-                    $j['fkey'][0], get_class($value)));
+                    $j['fkey'][0], is_object($value) ? get_class($value) : gettype($value)));
 
             // Capture the foreign key id value
             $field = $j['local'];
@@ -359,12 +388,17 @@ class VerySimpleModel {
     static function __oninspect() {}
 
     static function _inspect() {
-        if (!static::$meta instanceof ModelMeta) {
-            static::$meta = new ModelMeta(get_called_class());
+        static::$meta = new ModelMeta(get_called_class());
 
-            // Let the model participate
-            static::__oninspect();
-        }
+        // Let the model participate
+        static::__oninspect();
+    }
+
+    static function getMeta($key=false) {
+        if (!static::$meta instanceof ModelMeta)
+            static::_inspect();
+        $M = static::$meta;
+        return ($key) ? $M->offsetGet($key) : $M;
     }
 
     /**
@@ -406,14 +440,12 @@ class VerySimpleModel {
      * no such instance exists.
      */
     static function lookup($criteria) {
-        // Autoinsepct model
-        static::_inspect();
-
         // Model::lookup(1), where >1< is the pk value
         if (!is_array($criteria)) {
             $criteria = array();
+            $pk = static::getMeta('pk');
             foreach (func_get_args() as $i=>$f)
-                $criteria[static::$meta['pk'][$i]] = $f;
+                $criteria[$pk[$i]] = $f;
 
             // Only consult cache for PK lookup, which is assumed if the
             // values are passed as args rather than an array
@@ -449,14 +481,13 @@ class VerySimpleModel {
         if ($this->__deleted__)
             throw new OrmException('Trying to update a deleted object');
 
-        $pk = static::$meta['pk'];
+        $pk = static::getMeta('pk');
         $wasnew = $this->__new__;
 
         // First, if any foreign properties of this object are connected to
         // another *new* object, then save those objects first and set the
         // local foreign key field values
-        static::_inspect();
-        foreach (static::$meta['joins'] as $prop => $j) {
+        foreach (static::getMeta('joins') as $prop => $j) {
             if (isset($this->ht[$prop])
                 && ($foreign = $this->ht[$prop])
                 && $foreign instanceof VerySimpleModel
@@ -514,7 +545,7 @@ class VerySimpleModel {
         if ($wasnew) {
             // Attempt to update foreign, unsaved objects with the PK of
             // this newly created object
-            foreach (static::$meta['joins'] as $prop => $j) {
+            foreach (static::getMeta('joins') as $prop => $j) {
                 if (isset($this->ht[$prop])
                     && ($foreign = $this->ht[$prop])
                     && in_array($j['local'], $pk)
@@ -551,7 +582,7 @@ class VerySimpleModel {
 
     private function getPk() {
         $pk = array();
-        foreach ($this::$meta['pk'] as $f)
+        foreach ($this::getMeta('pk') as $f)
             $pk[$f] = $this->ht[$f];
         return $pk;
     }
@@ -582,6 +613,7 @@ class AnnotatedModel {
             return $this->annotations[$what];
         return $this->model->get($what, null);
     }
+
     function __set($what, $to) {
         return $this->set($what, $to);
     }
@@ -591,6 +623,10 @@ class AnnotatedModel {
         return $this->model->set($what, $to);
     }
 
+    function __isset($what) {
+        return isset($this->annotations[$what]) || $this->model->__isset($what);
+    }
+
     // Delegate everything else to the model
     function __call($what, $how) {
         return call_user_func_array(array($this->model, $what), $how);
@@ -647,6 +683,8 @@ class SqlCase extends SqlFunction {
     }
 
     function when($expr, $result) {
+        if (is_array($expr))
+            $expr = new Q($expr);
         $this->cases[] = array($expr, $result);
         return $this;
     }
@@ -805,14 +843,15 @@ class SqlAggregate extends SqlFunction {
         list($field, $rmodel) = $compiler->getField($E, $model, $options);
         if ($this->distinct) {
             $pk = false;
-            foreach ($rmodel::$meta['pk'] as $f) {
+            $fpk  = $rmodel::getMeta('pk');
+            foreach ($fpk as $f) {
                 $pk |= false !== strpos($field, $f);
             }
             if (!$pk) {
                 // Try and use the foriegn primary key
-                if (count($rmodel::$meta['pk']) == 1) {
+                if (count($fpk) == 1) {
                     list($field) = $compiler->getField(
-                        $this->expr . '__' . $rmodel::$meta['pk'][0],
+                        $this->expr . '__' . $fpk[0],
                         $model, $options);
                 }
                 else {
@@ -845,6 +884,7 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
     var $related = array();
     var $values = array();
     var $defer = array();
+    var $aggregated = false;
     var $annotations = array();
     var $extra = array();
     var $distinct = array();
@@ -1006,6 +1046,14 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
         return $this->_count = $compiler->compileCount($this);
     }
 
+    function toSql($compiler, $model, $alias) {
+        // FIXME: Force root model of the compiler to $model
+        $exec = $this->getQuery(array('compiler' => get_class($compiler)));
+        foreach ($exec->params as $P)
+            $compiler->params[] = $P;
+        return "({$exec})".($alias ? " AS {$alias}" : '');
+    }
+
     /**
      * exists
      *
@@ -1044,6 +1092,17 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
         return $this;
     }
 
+    function aggregate($annotations) {
+        // Aggregate works like annotate, except that it sets up values
+        // fetching which will disable model creation
+        $this->annotate($annotations);
+        $this->values();
+        // Disable other fields from being fetched
+        $this->aggregated = true;
+        $this->related = false;
+        return $this;
+    }
+
     function delete() {
         $class = $this->compiler;
         $compiler = new $class();
@@ -1099,14 +1158,14 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl
         // Load defaults from model
         $model = $this->model;
         $query = clone $this;
-        if (!$query->ordering && isset($model::$meta['ordering']))
-            $query->ordering = $model::$meta['ordering'];
-        if (false !== $query->related && !$query->values && $model::$meta['select_related'])
-            $query->related = $model::$meta['select_related'];
-        if (!$query->defer && $model::$meta['defer'])
-            $query->defer = $model::$meta['defer'];
-
-        $class = $this->compiler;
+        if (!$options['nosort'] && !$query->ordering && $model::getMeta('ordering'))
+            $query->ordering = $model::getMeta('ordering');
+        if (false !== $query->related && !$query->values && $model::getMeta('select_related'))
+            $query->related = $model::getMeta('select_related');
+        if (!$query->defer && $model::getMeta('defer'))
+            $query->defer = $model::getMeta('defer');
+
+        $class = $options['compiler'] ?: $this->compiler;
         $compiler = new $class($options);
         $this->query = $compiler->compileSelect($query);
 
@@ -1167,10 +1226,14 @@ abstract class ResultSet implements Iterator, ArrayAccess, Countable {
         $this->queryset = $queryset;
         if ($queryset) {
             $this->model = $queryset->model;
-            $this->resource = $queryset->getQuery();
         }
     }
 
+    function prime() {
+        if (!isset($this->resource) && $this->queryset)
+            $this->resource = $this->queryset->getQuery();
+    }
+
     abstract function fillTo($index);
 
     function asArray() {
@@ -1225,17 +1288,9 @@ class ModelInstanceManager extends ResultSet {
 
     static $objectCache = array();
 
-    function __construct($queryset=false) {
-        parent::__construct($queryset);
-        if ($queryset) {
-            $this->map = $this->resource->getMap();
-        }
-    }
-
     function cache($model) {
-        $model::_inspect();
         $key = sprintf('%s.%s',
-            $model::$meta->model, implode('.', $model->pk));
+            $model::$meta->model, implode('.', $model->get('pk')));
         self::$objectCache[$key] = $model;
     }
 
@@ -1253,8 +1308,8 @@ class ModelInstanceManager extends ResultSet {
     }
 
     static function checkCache($modelClass, $fields) {
-        $key = $modelClass;
-        foreach ($modelClass::$meta['pk'] as $f)
+        $key = $modelClass::$meta->model;
+        foreach ($modelClass::getMeta('pk') as $f)
             $key .= '.'.$fields[$f];
         return @self::$objectCache[$key];
     }
@@ -1276,7 +1331,7 @@ class ModelInstanceManager extends ResultSet {
     function getOrBuild($modelClass, $fields, $cache=true) {
         // Check for NULL primary key, used with related model fetching. If
         // the PK is NULL, then consider the object to also be NULL
-        foreach ($modelClass::$meta['pk'] as $pkf) {
+        foreach ($modelClass::getMeta('pk') as $pkf) {
             if (!isset($fields[$pkf])) {
                 return null;
             }
@@ -1305,6 +1360,12 @@ class ModelInstanceManager extends ResultSet {
             if ($cache)
                 $this->cache($m);
         }
+        elseif (get_class($m) != $modelClass) {
+            // Change the class of the object to be returned to match what
+            // was expected
+            // TODO: Emit a warning?
+            $m = new $modelClass($m->ht);
+        }
         // Wrap annotations in an AnnotatedModel
         if ($extras) {
             $m = new AnnotatedModel($m, $extras);
@@ -1365,30 +1426,36 @@ class ModelInstanceManager extends ResultSet {
     }
 
     function fillTo($index) {
+        $this->prime();
         $func = ($this->map) ? 'getRow' : 'getArray';
         while ($this->resource && $index >= count($this->cache)) {
             if ($row = $this->resource->{$func}()) {
                 $this->cache[] = $this->buildModel($row);
             } else {
                 $this->resource->close();
-                $this->resource = null;
+                $this->resource = false;
                 break;
             }
         }
     }
+
+    function prime() {
+        parent::prime();
+        if ($this->resource) {
+            $this->map = $this->resource->getMap();
+        }
+    }
 }
 
 class FlatArrayIterator extends ResultSet {
-    function __construct($queryset) {
-        $this->resource = $queryset->getQuery();
-    }
     function fillTo($index) {
+        $this->prime();
         while ($this->resource && $index >= count($this->cache)) {
             if ($row = $this->resource->getRow()) {
                 $this->cache[] = $row;
             } else {
                 $this->resource->close();
-                $this->resource = null;
+                $this->resource = false;
                 break;
             }
         }
@@ -1396,16 +1463,14 @@ class FlatArrayIterator extends ResultSet {
 }
 
 class HashArrayIterator extends ResultSet {
-    function __construct($queryset) {
-        $this->resource = $queryset->getQuery();
-    }
     function fillTo($index) {
+        $this->prime();
         while ($this->resource && $index >= count($this->cache)) {
             if ($row = $this->resource->getArray()) {
                 $this->cache[] = $row;
             } else {
                 $this->resource->close();
-                $this->resource = null;
+                $this->resource = false;
                 break;
             }
         }
@@ -1414,12 +1479,14 @@ class HashArrayIterator extends ResultSet {
 
 class InstrumentedList extends ModelInstanceManager {
     var $key;
-    var $model;
 
     function __construct($fkey, $queryset=false) {
         list($model, $this->key) = $fkey;
-        if (!$queryset)
+        if (!$queryset) {
             $queryset = $model::objects()->filter($this->key);
+            if ($related = $model::getMeta('select_related'))
+                $queryset->select_related($related);
+        }
         parent::__construct($queryset);
         $this->model = $model;
     }
@@ -1449,7 +1516,8 @@ class InstrumentedList extends ModelInstanceManager {
         if ($delete)
             $object->delete();
         else
-            $object->set($this->key, null);
+            foreach ($this->key as $field=>$value)
+                $object->set($field, null);
     }
 
     function reset() {
@@ -1463,10 +1531,10 @@ class InstrumentedList extends ModelInstanceManager {
      */
     function window($constraint) {
         $model = $this->model;
-        $meta = $model::$meta;
+        $fields = $model::getMeta('fields');
         $key = $this->key;
         foreach ($constraint as $field=>$value) {
-            if (!is_string($field) || false === in_array($field, $meta['fields']))
+            if (!is_string($field) || false === in_array($field, $fields))
                 throw new OrmException('InstrumentedList windowing must be performed on local fields only');
             $key[$field] = $value;
         }
@@ -1601,52 +1669,52 @@ class SqlCompiler {
             }
         }
 
-        $path = array();
+        $path = '';
         $rootModel = $model;
 
         // Call pushJoin for each segment in the join path. A new JOIN
         // fragment will need to be emitted and/or cached
         $joins = array();
-        $push = function($p, $path, $model) use (&$joins) {
-            $model::_inspect();
-            if (!($info = $model::$meta['joins'][$p])) {
+        $push = function($p, $model) use (&$joins, &$path) {
+            $J = $model::getMeta('joins');
+            if (!($info = $J[$p])) {
                 throw new OrmException(sprintf(
                    'Model `%s` does not have a relation called `%s`',
                     $model, $p));
             }
-            $crumb = implode('__', $path);
-            $tip = ($crumb) ? "{$crumb}__{$p}" : $p;
-            $joins[] = array($crumb, $tip, $model, $info);
+            $crumb = $path;
+            $path = ($path) ? "{$path}__{$p}" : $p;
+            $joins[] = array($crumb, $path, $model, $info);
             // Roll to foreign model
             return $info['fkey'];
         };
 
         foreach ($parts as $p) {
-            list($model) = $push($p, $path, $model);
-            $path[] = $p;
+            list($model) = $push($p, $model);
         }
 
         // If comparing a relationship, join the foreign table
         // This is a comparison with a relationship — use the foreign key
-        if (isset($model::$meta['joins'][$field])) {
-            list($model, $field) = $push($field, $path, $model);
-        }
-
-        // Add the conststraint as the last arg to the last join
-        if (isset($options['constraint'])) {
-            $joins[count($joins)-1][] = $options['constraint'];
+        $J = $model::getMeta('joins');
+        if (isset($J[$field])) {
+            list($model, $field) = $push($field, $model);
         }
 
         // Apply the joins list to $this->pushJoin
-        foreach ($joins as $A) {
-            $alias = call_user_func_array(array($this, 'pushJoin'), $A);
+        $last = count($joins) - 1;
+        $constraint = false;
+        foreach ($joins as $i=>$A) {
+            // Add the conststraint as the last arg to the last join
+            if ($i == $last)
+                $constraint = $options['constraint'];
+            $alias = $this->pushJoin($A[0], $A[1], $A[2], $A[3], $contraint);
         }
 
         if (!isset($alias)) {
             // Determine the alias for the root model table
             $alias = (isset($this->joins['']))
                 ? $this->joins['']['alias']
-                : $this->quote($rootModel::$meta['table']);
+                : $this->quote($rootModel::getMeta('table'));
         }
 
         if (isset($options['table']) && $options['table'])
@@ -1754,6 +1822,8 @@ class SqlCompiler {
                 }
                 if ($value === null)
                     $filter[] = sprintf('%s IS NULL', $field);
+                elseif ($value instanceof SqlField)
+                    $filter[] = sprintf($op, $field, $value->toSql($this, $model));
                 // Allow operators to be callable rather than sprintf
                 // strings
                 elseif (is_callable($op))
@@ -1898,7 +1968,7 @@ class MySqlCompiler extends SqlCompiler {
     function __in($a, $b) {
         if (is_array($b)) {
             $vals = array_map(array($this, 'input'), $b);
-            $b = implode(', ', $vals);
+            $b = '('.implode(', ', $vals).')';
         }
         // MySQL doesn't support LIMIT or OFFSET in subqueries. Instead, add
         // the query as a JOIN and add the join constraint into the WHERE
@@ -1912,7 +1982,7 @@ class MySqlCompiler extends SqlCompiler {
         else {
             $b = $this->input($b);
         }
-        return sprintf('%s IN (%s)', $a, $b);
+        return sprintf('%s IN %s', $a, $b);
     }
 
     function __isnull($a, $b) {
@@ -1942,7 +2012,7 @@ class MySqlCompiler extends SqlCompiler {
         if (isset($this->joins[$tip]))
             $table = $this->joins[$tip]['alias'];
         else
-            $table = $this->quote($model::$meta['table']);
+            $table = $this->quote($model::getMeta('table'));
         foreach ($info['constraint'] as $local => $foreign) {
             list($rmodel, $right) = $foreign;
             // Support a constant constraint with
@@ -1975,10 +2045,10 @@ class MySqlCompiler extends SqlCompiler {
         if (!isset($rmodel))
             $rmodel = $model;
         // Support inline views
-        $table = ($rmodel::$meta['view'])
+        $table = ($rmodel::getMeta('view'))
             // XXX: Support parameters from the nested query
             ? $rmodel::getQuery($this)
-            : $this->quote($rmodel::$meta['table']);
+            : $this->quote($rmodel::getMeta('table'));
         $base = "{$join}{$table} {$alias}";
         if ($constraints)
            $base .= ' ON ('.implode(' AND ', $constraints).')';
@@ -2003,7 +2073,7 @@ class MySqlCompiler extends SqlCompiler {
         if ($what instanceof QuerySet) {
             $q = $what->getQuery(array('nosort'=>true));
             $this->params = array_merge($this->params, $q->params);
-            return $q->sql;
+            return '('.$q->sql.')';
         }
         elseif ($what instanceof SqlFunction) {
             return $what->toSql($this);
@@ -2052,7 +2122,7 @@ class MySqlCompiler extends SqlCompiler {
 
     function compileCount($queryset) {
         $model = $queryset->model;
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
         list($where, $having) = $this->getWhereHavingClause($queryset);
         $joins = $this->getJoins($queryset);
         $sql = 'SELECT COUNT(*) AS count FROM '.$this->quote($table).$joins.$where;
@@ -2072,7 +2142,7 @@ class MySqlCompiler extends SqlCompiler {
 
         // Compile the ORDER BY clause
         $sort = '';
-        if (($columns = $queryset->getSortFields()) && !isset($this->options['nosort'])) {
+        if ($columns = $queryset->getSortFields()) {
             $orders = array();
             foreach ($columns as $sort) {
                 $dir = 'ASC';
@@ -2084,30 +2154,34 @@ class MySqlCompiler extends SqlCompiler {
                         $dir = 'DESC';
                         $sort = substr($sort, 1);
                     }
-                    list($field) = $this->getField($sort, $model);
+                    // If the field is already an annotation, then don't
+                    // compile the annotation again below. It's included in
+                    // the select clause, which is sufficient
+                    if (isset($this->annotations[$sort]))
+                        $field = $this->quote($sort);
+                    else
+                        list($field) = $this->getField($sort, $model);
                 }
-                // TODO: Throw exception if $field can be indentified as
-                //       invalid
                 if ($field instanceof SqlFunction)
                     $field = $field->toSql($this, $model);
+                // TODO: Throw exception if $field can be indentified as
+                //       invalid
 
-                $orders[] = $field.' '.$dir;
+                $orders[] = "{$field} {$dir}";
             }
             $sort = ' ORDER BY '.implode(', ', $orders);
         }
 
         // Compile the field listing
-        $fields = array();
-        $group_by = array();
-        $table = $this->quote($model::$meta['table']).' '.$rootAlias;
+        $fields = $group_by = array();
+        $table = $this->quote($model::getMeta('table')).' '.$rootAlias;
         // Handle related tables
         if ($queryset->related) {
             $count = 0;
             $fieldMap = $theseFields = array();
             $defer = $queryset->defer ?: array();
             // Add local fields first
-            $model::_inspect();
-            foreach ($model::$meta['fields'] as $f) {
+            foreach ($model::getMeta('fields') as $f) {
                 // Handle deferreds
                 if (isset($defer[$f]))
                     continue;
@@ -2129,8 +2203,7 @@ class MySqlCompiler extends SqlCompiler {
                     $theseFields = array();
                     list($alias, $fmodel) = $this->getField($full_path, $model,
                         array('table'=>true, 'model'=>true));
-                    $fmodel::_inspect();
-                    foreach ($fmodel::$meta['fields'] as $f) {
+                    foreach ($fmodel::getMeta('fields') as $f) {
                         // Handle deferreds
                         if (isset($defer[$sr . '__' . $f]))
                             continue;
@@ -2165,10 +2238,9 @@ class MySqlCompiler extends SqlCompiler {
             }
         }
         // Simple selection from one table
-        else {
+        elseif (!$queryset->aggregated) {
             if ($queryset->defer) {
-                $model::_inspect();
-                foreach ($model::$meta['fields'] as $f) {
+                foreach ($model::getMeta('fields') as $f) {
                     if (isset($queryset->defer[$f]))
                         continue;
                     $fields[$rootAlias .'.'. $this->quote($f)] = true;
@@ -2195,8 +2267,8 @@ class MySqlCompiler extends SqlCompiler {
                 }
             }
             // If no group by has been set yet, use the root model pk
-            if (!$group_by) {
-                foreach ($model::$meta['pk'] as $pk)
+            if (!$group_by && !$queryset->aggregated) {
+                foreach ($model::getMeta('pk') as $pk)
                     $group_by[] = $rootAlias .'.'. $pk;
             }
         }
@@ -2246,8 +2318,8 @@ class MySqlCompiler extends SqlCompiler {
     }
 
     function compileUpdate(VerySimpleModel $model) {
-        $pk = $model::$meta['pk'];
-        $sql = 'UPDATE '.$this->quote($model::$meta['table']);
+        $pk = $model::getMeta('pk');
+        $sql = 'UPDATE '.$this->quote($model::getMeta('table'));
         $sql .= $this->__compileUpdateSet($model, $pk);
         // Support PK updates
         $criteria = array();
@@ -2261,15 +2333,15 @@ class MySqlCompiler extends SqlCompiler {
     }
 
     function compileInsert(VerySimpleModel $model) {
-        $pk = $model::$meta['pk'];
-        $sql = 'INSERT INTO '.$this->quote($model::$meta['table']);
+        $pk = $model::getMeta('pk');
+        $sql = 'INSERT INTO '.$this->quote($model::getMeta('table'));
         $sql .= $this->__compileUpdateSet($model, $pk);
 
         return new MySqlExecutor($sql, $this->params);
     }
 
     function compileDelete($model) {
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
 
         $where = ' WHERE '.implode(' AND ',
             $this->compileConstraints(array(new Q($model->pk)), $model));
@@ -2279,7 +2351,7 @@ class MySqlCompiler extends SqlCompiler {
 
     function compileBulkDelete($queryset) {
         $model = $queryset->model;
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
         list($where, $having) = $this->getWhereHavingClause($queryset);
         $joins = $this->getJoins($queryset);
         $sql = 'DELETE '.$this->quote($table).'.* FROM '
@@ -2289,7 +2361,7 @@ class MySqlCompiler extends SqlCompiler {
 
     function compileBulkUpdate($queryset, array $what) {
         $model = $queryset->model;
-        $table = $model::$meta['table'];
+        $table = $model::getMeta('table');
         $set = array();
         foreach ($what as $field=>$value)
             $set[] = sprintf('%s = %s', $this->quote($field), $this->input($value));
@@ -2376,7 +2448,7 @@ class MySqlExecutor {
 
         $types = '';
         $ps = array();
-        foreach ($params as &$p) {
+        foreach ($params as $i=>&$p) {
             if (is_int($p) || is_bool($p))
                 $types .= 'i';
             elseif (is_float($p))
diff --git a/include/class.search.php b/include/class.search.php
index 137c1d375b041492ba8d285c6d2c27e792195867..b68a0d3ed37c6cf92e712987c141c36e4ff0f378 100644
--- a/include/class.search.php
+++ b/include/class.search.php
@@ -327,6 +327,8 @@ class MysqlSearchBackend extends SearchBackend {
     function find($query, QuerySet $criteria) {
         global $thisstaff;
 
+        $criteria = clone $criteria;
+
         $mode = ' IN BOOLEAN MODE';
         #if (count(explode(' ', $query)) == 1)
         #    $mode = ' WITH QUERY EXPANSION';
@@ -450,7 +452,8 @@ class MysqlSearchBackend extends SearchBackend {
             return false;
 
         while ($row = db_fetch_row($res)) {
-            $ticket = Ticket::lookup($row[0]);
+            if (!($ticket = Ticket::lookup($row[0])))
+                continue;
             $cdata = $ticket->loadDynamicData();
             $content = array();
             foreach ($cdata as $k=>$a)
@@ -618,22 +621,25 @@ class SavedSearch extends VerySimpleModel {
         )));
     }
 
-    function getFormFromSession($key, $source=false) {
-        if (isset($_SESSION[$key])) {
-            $source = $source ?: array();
-            $state = $_SESSION[$key];
-            // Pull out 'other' fields from the state so the fields will be
-            // added to the form. The state will be loaded below
-            foreach ($state as $k=>$v) {
-                $info = array();
-                if (!preg_match('/^:(\w+)!(\d+)\+search/', $k, $info)) {
-                    continue;
-                }
-                list($k,) = explode('+', $k, 2);
-                $source['fields'][] = ":{$info[1]}!{$info[2]}";
+    function loadFromState($source=false) {
+        // Pull out 'other' fields from the state so the fields will be
+        // added to the form. The state will be loaded below
+        $state = $source ?: array();
+        foreach ($state as $k=>$v) {
+            $info = array();
+            if (!preg_match('/^:(\w+)(?:!(\d+))?\+search/', $k, $info)) {
+                continue;
             }
+            list($k,) = explode('+', $k, 2);
+            $state['fields'][] = $k;
+        }
+        return $this->getForm($state);
+    }
+
+    function getFormFromSession($key) {
+        if (isset($_SESSION[$key])) {
+            return $this->loadFromState($_SESSION[$key]);
         }
-        return $this->getForm($source);
     }
 
     function getForm($source=false) {
@@ -643,6 +649,7 @@ class SavedSearch extends VerySimpleModel {
         $searchable = $this->getCurrentSearchFields($source);
         $fields = array(
             'keywords' => new TextboxField(array(
+                'id' => 3001,
                 'configuration' => array(
                     'size' => 40,
                     'length' => 400,
@@ -656,7 +663,10 @@ class SavedSearch extends VerySimpleModel {
             $fields = array_merge($fields, self::getSearchField($field, $name));
         }
 
-        $form = new SimpleForm($fields, $source);
+        // Don't send the state as the souce because it is not in the
+        // ::parse format (it's in ::to_php format). Instead, source is set
+        // via ::loadState() below
+        $form = new AdvancedSearchForm($fields, $source);
         $form->addValidator(function($form) {
             $selected = 0;
             foreach ($form->getFields() as $F) {
@@ -669,41 +679,48 @@ class SavedSearch extends VerySimpleModel {
             if (!$selected)
                 $form->addError(__('No fields selected for searching'));
         });
+        if ($source)
+            $form->loadState($source);
         return $form;
     }
 
     function getCurrentSearchFields($source=false) {
         $core = array(
-            'state' =>      new TicketStateChoiceField(array(
-                'label' => __('State'),
-            )),
             'status_id' =>  new TicketStatusChoiceField(array(
+                'id' => 3101,
                 'label' => __('Status'),
             )),
-            'flags' =>      new TicketFlagChoiceField(array(
-                'label' => __('Flags'),
-            )),
             'dept_id'   =>  new DepartmentChoiceField(array(
+                'id' => 3102,
                 'label' => __('Department'),
             )),
             'assignee'  =>  new AssigneeChoiceField(array(
+                'id' => 3103,
                 'label' => __('Assignee'),
             )),
             'topic_id'  =>  new HelpTopicChoiceField(array(
+                'id' => 3104,
                 'label' => __('Help Topic'),
             )),
             'created'   =>  new DateTimeField(array(
+                'id' => 3105,
                 'label' => __('Created'),
             )),
             'duedate'   =>  new DateTimeField(array(
+                'id' => 3106,
                 'label' => __('Due Date'),
             )),
         );
 
         // Add 'other' fields added dynamically
         if (is_array($source) && isset($source['fields'])) {
+            $extended = self::getExtendedTicketFields();
             foreach ($source['fields'] as $f) {
                 $info = array();
+                if (isset($extended[$f])) {
+                    $core[$f] = $extended[$f];
+                    continue;
+                }
                 if (!preg_match('/^:(\w+)!(\d+)/', $f, $info)) {
                     continue;
                 }
@@ -717,27 +734,56 @@ class SavedSearch extends VerySimpleModel {
                 }
             }
         }
-
         return $core;
     }
 
+    static function getExtendedTicketFields() {
+        return array(
+#            ':user' =>       new UserChoiceField(array(
+#                'label' => __('Ticket Owner'),
+#            )),
+#            ':org' =>        new OrganizationChoiceField(array(
+#                'label' => __('Organization'),
+#            )),
+            ':source' =>     new TicketSourceChoiceField(array(
+                'id' => 3201,
+                'label' => __('Source'),
+            )),
+            ':state' =>      new TicketStateChoiceField(array(
+                'id' => 3202,
+                'label' => __('State'),
+            )),
+            ':flags' =>      new TicketFlagChoiceField(array(
+                'id' => 3203,
+                'label' => __('Flags'),
+            )),
+        );
+    }
+
     static function getSearchField($field, $name) {
+        $baseId = $field->getId() * 20;
         $pieces = array();
         $pieces["{$name}+search"] = new BooleanField(array(
-            'configuration' => array('desc' => $field->get('label'))
+            'id' => $baseId + 50000,
+            'configuration' => array(
+                'desc' => $field->get('label'),
+            ),
         ));
         $methods = $field->getSearchMethods();
         $pieces["{$name}+method"] = new ChoiceField(array(
+            'id' => $baseId + 50001,
             '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['id'] = $baseId + 50002 + $offs++;
             $args['required'] = true;
             $args['visibility'] = new VisibilityConstraint(new Q(array(
                     "{$name}+method__eq" => $m,
@@ -749,13 +795,14 @@ class SavedSearch extends VerySimpleModel {
 
     function mangleQuerySet(QuerySet $qs, $form=false) {
         $form = $form ?: $this->getForm();
-        $searchable = $this->getCurrentSearchFields($form->getSource());
+        $searchable = $this->getCurrentSearchFields($form->state);
         $qs = clone $qs;
 
         // Figure out fields to search on
         foreach ($form->getFields() as $f) {
             if (substr($f->get('name'), -7) == '+search' && $f->getClean()) {
                 $name = substr($f->get('name'), 0, -7);
+                $filter = new Q();
                 // Determine the search method and fetch the original field
                 if (($M = $form->getField("{$name}+method"))
                     && ($method = $M->getClean())
@@ -777,12 +824,6 @@ class SavedSearch extends VerySimpleModel {
                         );
                         $column = $field->get('name') ?: 'field_'.$field->get('id');
                         list($type,$id) = explode('!', $name, 2);
-                        $OP = $other_paths[$type];
-                        if ($type == ':field') {
-                            $DF = DynamicFormField::lookup($id);
-                            TicketModel::registerCustomData($DF->form);
-                            $OP = 'cdata+'.$DF->form->id.'__';
-                        }
                         // XXX: Last mile — find a better idea
                         switch (array($type, $column)) {
                         case array(':user', 'name'):
@@ -795,13 +836,21 @@ class SavedSearch extends VerySimpleModel {
                             $name = 'user__org__name';
                             break;
                         default:
+                            if ($type == ':field' && $id) {
+                                $name = 'entries__answers__value';
+                                $filter->add(array('entries__answers__field_id' => $id));
+                                break;
+                            }
+                            $OP = $other_paths[$type];
                             $name = $OP . $column;
                         }
                     }
 
                     // Add the criteria to the QuerySet
-                    if ($Q = $field->getSearchQ($method, $value, $name))
-                        $qs = $qs->filter($Q);
+                    if ($Q = $field->getSearchQ($method, $value, $name)) {
+                        $filter->add($Q);
+                        $qs = $qs->filter($filter);
+                    }
                 }
             }
         }
@@ -846,6 +895,15 @@ class SavedSearch extends VerySimpleModel {
     }
 }
 
+class AdvancedSearchForm extends SimpleForm {
+    var $state;
+
+    function __construct($fields, $state) {
+        parent::__construct($fields);
+        $this->state = $state;
+    }
+}
+
 // Advanced search special fields
 
 class HelpTopicChoiceField extends ChoiceField {
@@ -1008,6 +1066,29 @@ class TicketFlagChoiceField extends ChoiceField {
     }
 }
 
+class TicketSourceChoiceField extends ChoiceField {
+    function getChoices() {
+        return array(
+            'w' => __('Web'),
+            'e' => __('Email'),
+            'p' => __('Phone'),
+            'a' => __('API'),
+            'o' => __('Other'),
+        );
+    }
+
+    function getSearchMethods() {
+        return array(
+            'includes' =>   __('is'),
+            '!includes' =>  __('is not'),
+        );
+    }
+
+    function getSearchQ($method, $value, $name=false) {
+        return parent::getSearchQ($method, $value, 'source');
+    }
+}
+
 class TicketStatusChoiceField extends SelectionField {
     static $widget = 'ChoicesWidget';
 
diff --git a/include/class.template.php b/include/class.template.php
index 18c04548731719bcc09a169d50f0d639dc437a8c..f3a459f6de0855b27c0aca7647107d2733e8481e 100644
--- a/include/class.template.php
+++ b/include/class.template.php
@@ -161,8 +161,7 @@ class EmailTemplateGroup {
         'task.overdue.alert'=>array(
             'group'=>'c.task',
             'name'=>/* @trans */ 'Overdue Task Alert',
-            'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue
-            task.',
+            'desc'=>/* @trans */ 'Alert sent to agents on stale or overdue task.',
         ),
     );
 
diff --git a/include/class.thread.php b/include/class.thread.php
index eb02999f4161070eb369d97fd6f360dccfe4ac01..f64d1c7dd09ddf6eabfd131e3beb1ba97173aff4 100644
--- a/include/class.thread.php
+++ b/include/class.thread.php
@@ -86,15 +86,20 @@ class Thread extends VerySimpleModel {
         return $this->entries->count();
     }
 
+    var $_entries;
     function getEntries($criteria=false) {
-        $base = $this->entries->annotate(array(
-            'has_attachments' => SqlAggregate::COUNT('attachments', false,
-                new Q(array('attachments__inline'=>0)))
-        ));
-        $base->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN));
-        if ($criteria)
-            $base->filter($criteria);
-        return $base;
+        if (!isset($this->_entries)) {
+            $this->_entries = $this->entries->annotate(array(
+                'has_attachments' => SqlAggregate::COUNT(SqlCase::N()
+                    ->when(array('attachments__inline'=>0), 1)
+                    ->otherwise(null)
+                ),
+            ));
+            $this->_entries->exclude(array('flags__hasbit'=>ThreadEntry::FLAG_HIDDEN));
+            if ($criteria)
+                $this->_entries->filter($criteria);
+        }
+        return $this->_entries;
     }
 
     // Collaborators
@@ -134,7 +139,7 @@ class Thread extends VerySimpleModel {
         return $collaborators;
     }
 
-    function addCollaborator($user, $vars, &$errors) {
+    function addCollaborator($user, $vars, &$errors, $event=true) {
 
         if (!$user)
             return null;
@@ -147,6 +152,16 @@ class Thread extends VerySimpleModel {
 
         $this->_collaborators = null;
 
+        if ($event)
+            $this->getEvents()->log($this->getObject(),
+                'collab',
+                array('add' => array($user->getId() => array(
+                        'name' => $user->getName()->getOriginal(),
+                        'src' => @$vars['source'],
+                    ))
+                )
+            );
+
         return $c;
     }
 
@@ -164,11 +179,9 @@ class Thread extends VerySimpleModel {
                         && $c->delete())
                      $collabs[] = $c;
             }
-
-            $this->getObject()->postThreadEntry('N',
-                    array(
-                        'title' => _S('Collaborators Removed'),
-                        'note' => implode("<br>", $collabs)));
+            $this->getEvents()->log($this->getObject(), 'collab', array(
+                'del' => array($c->user_id => array('name' => $c->getName()->getOriginal()))
+            ));
         }
 
         //statuses
@@ -205,6 +218,11 @@ class Thread extends VerySimpleModel {
         if ($type && is_array($type))
             $entries->filter(array('type__in' => $type));
 
+        // Precache all the attachments on this thread
+        AttachmentFile::objects()->filter(array(
+            'attachments__thread_entry__thread__id' => $this->id
+        ))->all();
+
         $events = $this->getEvents();
         $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
         include $inc . 'templates/thread-entries.tmpl.php';
@@ -381,10 +399,10 @@ class Thread extends VerySimpleModel {
             // Try not to destroy the format of the body
             $header = sprintf("Received From: %s <%s>\n\n", $mailinfo['name'],
                 $mailinfo['email']);
-            if ($body instanceof HtmlThreadBody)
+            if ($body instanceof HtmlThreadEntryBody)
                 $header = nl2br(Format::htmlchars($header));
             // Add the banner to the top of the message
-            if ($body instanceof ThreadBody)
+            if ($body instanceof ThreadEntryBody)
                 $body->prepend($header);
             $vars['message'] = $body;
             $vars['userId'] = 0; //Unknown user! //XXX: Assume ticket owner?
@@ -499,6 +517,9 @@ class Thread extends VerySimpleModel {
 
         $this->entries->delete();
 
+        // Null out the events
+        $this->events->update(array('thread_id' => 0));
+
         return true;
     }
 
@@ -562,6 +583,7 @@ implements TemplateVariable {
     const FLAG_EDITED                   = 0x0002;
     const FLAG_HIDDEN                   = 0x0004;
     const FLAG_GUARDED                  = 0x0008;   // No replace on edit
+    const FLAG_RESENT                   = 0x0010;
 
     const PERM_EDIT     = 'thread.edit';
 
@@ -759,6 +781,17 @@ implements TemplateVariable {
         return $this->user;
     }
 
+    function getEditor() {
+        static $types = array(
+            'U' => 'User',
+            'S' => 'Staff',
+        );
+        if (!isset($types[$this->editor_type]))
+            return null;
+
+        return $types[$this->editor_type]::lookup($this->editor);
+    }
+
     function getName() {
         if ($this->staff_id)
             return $this->staff->getName();
@@ -908,8 +941,9 @@ implements TemplateVariable {
         }
         if ($filename) {
             // This should be a noop since the ORM caches on PK
-            $file = AttachmentFile::lookup($fileId);
-            if ($file->name != $filename)
+            $F = $F ?: AttachmentFile::lookup($fileId);
+            // XXX: This is not Unicode safe
+            if ($F && 0 !== strcasecmp($F->name, $filename))
                 $att->name = $filename;
         }
 
@@ -1522,83 +1556,23 @@ class ThreadEvent extends VerySimpleModel {
             'overdue'   => 'time',
             'transferred' => 'share-alt',
             'edited'    => 'pencil',
+            'closed'    => 'thumbs-up-alt',
+            'reopened'  => 'rotate-right',
+            'resent'    => 'reply-all icon-flip-horizontal',
         );
         return @$icons[$this->state] ?: 'chevron-sign-right';
     }
 
     function getDescription($mode=self::MODE_STAFF) {
-        static $descs;
-        if (!isset($descs))
-            $descs = array(
-            'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'),
-            'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'),
-            'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'),
-            'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'),
-            'collab:org' => __('Collaborators for {<Organization>data.org} organization added'),
-            'collab:del' => function($evt) {
-                $data = $evt->getData();
-                $base = __('<b>{username}</b> removed %s from the collaborators.');
-                return $data['del']
-                    ? Format::htmlchars(sprintf($base, implode(', ', $data['del'])))
-                    : 'somebody';
-            },
-            'collab:add' => function($evt) {
-                $data = $evt->getData();
-                $base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}');
-                $collabs = array();
-                if ($data['add']) {
-                    foreach ($data['add'] as $c) {
-                        $collabs[] = Format::htmlchars($c);
-                    }
-                }
-                return $collabs
-                    ? sprintf($base, implode(', ', $collabs))
-                    : 'somebody';
-            },
-            'created' => __('Created by <b>{username}</b> {timestamp}'),
-            'closed' => __('Closed by <b>{username}</b> {timestamp}'),
-            'reopened' => __('Reopened by <b>{username}</b> {timestamp}'),
-            'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'),
-            'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'),
-            'overdue' => __('Flagged as overdue by the system {timestamp}'),
-            'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'),
-            'edited:fields' => function($evt) use ($mode) {
-                $base = __('Updated by <b>{username}</b> {timestamp} — %s');
-                $data = $evt->getData();
-                $fields = $changes = array();
-                foreach (DynamicFormField::objects()->filter(array(
-                    'id__in' => array_keys($data['fields'])
-                )) as $F) {
-                    $fields[$F->id] = $F;
-                }
-                foreach ($data['fields'] as $id=>$f) {
-                    $field = $fields[$id];
-                    if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers())
-                        continue;
-                    list($old, $new) = $f;
-                    $impl = $field->getImpl($field);
-                    $before = $impl->to_php($old);
-                    $after = $impl->to_php($new);
-                    $changes[] = sprintf('<strong>%s</strong> %s',
-                        $field->getLocal('label'), $impl->whatChanged($before, $after));
-                }
-                if (!$changes)
-                    return '';
-                return sprintf($base, implode(', ', $changes));
-            },
-        );
-        $self = $this;
-        $data = $this->getData();
-        $state = $this->state;
-        if (is_array($data)) {
-            foreach (array_keys($data) as $k)
-                if (isset($descs[$state . ':' . $k]))
-                    $state .= ':' . $k;
-        }
-        $description = $descs[$state];
-        if (is_callable($description))
-            $description = $description($this);
+        // Abstract description
+        return $this->template(sprintf(
+            __('%s by {somebody} {timestamp}'),
+            $this->state
+        ));
+    }
 
+    function template($description) {
+        $self = $this;
         return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/',
             function ($m) use ($self) {
                 switch ($m['key']) {
@@ -1613,7 +1587,7 @@ class ThreadEvent extends VerySimpleModel {
                         $assignees[] = $T->getLocalName();
                     }
                     return implode('/', $assignees);
-                case 'username':
+                case 'somebody':
                     $name = $self->getUserName();
                     if ($url = $self->getAvatar())
                         $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name;
@@ -1657,40 +1631,53 @@ class ThreadEvent extends VerySimpleModel {
 
     function render($mode) {
         $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR;
-        $event = $this;
+        $event = $this->getTypedEvent();
         include $inc . 'templates/thread-event.tmpl.php';
     }
 
-    static function create($ht=false) {
+    static function create($ht=false, $user=false) {
         $inst = parent::create($ht);
         $inst->timestamp = SqlFunction::NOW();
 
         global $thisstaff, $thisclient;
-        if ($thisstaff) {
+        $user = is_object($user) ? $user : $thisstaff ?: $thisclient;
+        if ($user instanceof Staff) {
             $inst->uid_type = 'S';
-            $inst->uid = $thisstaff->getId();
+            $inst->uid = $user->getId();
         }
-        else if ($thisclient) {
+        elseif ($user instanceof User) {
             $inst->uid_type = 'U';
-            $inst->uid = $thisclient->getId();
+            $inst->uid = $user->getId();
         }
 
         return $inst;
     }
 
-    static function forTicket($ticket, $state) {
+    static function forTicket($ticket, $state, $user=false) {
         $inst = static::create(array(
             'staff_id' => $ticket->getStaffId(),
             'team_id' => $ticket->getTeamId(),
             'dept_id' => $ticket->getDeptId(),
             'topic_id' => $ticket->getTopicId(),
-        ));
-        if (!isset($inst->uid_type) && $state == self::CREATED) {
-            $inst->uid_type = 'U';
-            $inst->uid = $ticket->getOwnerId();
-        }
+        ), $user);
         return $inst;
     }
+
+    function getTypedEvent() {
+        static $subclasses;
+
+        if (!isset($subclasses)) {
+            $parent = get_class($this);
+            $subclasses = array();
+            foreach (get_declared_classes() as $class) {
+                if (is_subclass_of($class, $parent))
+                    $subclasses[$class::$state] = $class;
+            }
+        }
+        if (!($class = $subclasses[$this->state]))
+            return $this;
+        return new $class($this->ht);
+    }
 }
 
 class ThreadEvents extends InstrumentedList {
@@ -1700,13 +1687,27 @@ class ThreadEvents extends InstrumentedList {
             ->update(array('annulled' => 1));
     }
 
-    function log($object, $state, $data=null, $annul=null, $username=null) {
+    /**
+     * Add an event to the thread activity log.
+     *
+     * Parameters:
+     * $object - Object to log activity for
+     * $state - State name of the activity (one of 'created', 'edited',
+     *      'deleted', 'closed', 'reopened', 'error', 'collab', 'resent',
+     *      'assigned', 'transferred')
+     * $data - (array?) Details about the state change
+     * $user - (string|User|Staff) user triggering the state change
+     * $annul - (state) a corresponding state change that is annulled by
+     *      this event
+     */
+    function log($object, $state, $data=null, $user=null, $annul=null) {
         global $thisstaff, $thisclient;
 
         if ($object instanceof Ticket)
-            $event = ThreadEvent::forTicket($object, $state);
+            // TODO: Use $object->createEvent()
+            $event = ThreadEvent::forTicket($object, $state, $user);
         else
-            $event = ThreadEvent::create();
+            $event = ThreadEvent::create(false, $user);
 
         # Annul previous entries if requested (for instance, reopening a
         # ticket will annul an 'closed' entry). This will be useful to
@@ -1715,11 +1716,14 @@ class ThreadEvents extends InstrumentedList {
             $this->annul($annul);
         }
 
-        if ($username === null) {
-            if ($thisstaff) {
-                $username = $thisstaff->getUserName();
+        $username = $user;
+        $user = is_object($user) ? $user : $thisclient ?: $thisstaff;
+        if (!is_string($username)) {
+            if ($user instanceof Staff) {
+                $username = $user->getUserName();
             }
-            else if ($thisclient) {
+            // XXX: Use $user here
+            elseif ($thisclient) {
                 if ($thisclient->hasAccount)
                     $username = $thisclient->getAccount()->getUserName();
                 if (!$username)
@@ -1748,6 +1752,186 @@ class ThreadEvents extends InstrumentedList {
     }
 }
 
+class AssignmentEvent extends ThreadEvent {
+    static $icon = 'hand-right';
+    static $state = 'assigned';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        $data = $this->getData();
+        switch (true) {
+        case !is_array($data):
+        default:
+            $desc = __('Assignee changed by <b>{somebody}</b> to <strong>{assignees}</strong> {timestamp}');
+            break;
+        case isset($data['staff']):
+            $desc = __('<b>{somebody}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}');
+            break;
+        case isset($data['team']):
+            $desc = __('<b>{somebody}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}');
+            break;
+        case isset($data['claim']):
+            $desc = __('<b>{somebody}</b> claimed this {timestamp}');
+            break;
+        }
+        return $this->template($desc);
+    }
+}
+
+class CloseEvent extends ThreadEvent {
+    static $icon = 'thumbs-up-alt';
+    static $state = 'closed';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Closed by <b>{somebody}</b> {timestamp}'));
+    }
+}
+
+class CollaboratorEvent extends ThreadEvent {
+    static $icon = 'group';
+    static $state = 'collab';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        $data = $this->getData();
+        switch (true) {
+        case isset($data['org']):
+            $desc = __('Collaborators for {<Organization>data.org} organization added');
+            break;
+        case isset($data['del']):
+            $base = __('<b>{somebody}</b> removed <strong>%s</strong> from the collaborators {timestamp}');
+            $collabs = array();
+            $users = User::objects()->filter(array('id__in' => array_keys($data['del'])));
+            foreach ($data['del'] as $id=>$c) {
+                $U = false;
+                foreach ($users as $user) {
+                    if ($user->id == $id) {
+                        $U = $user;
+                        break;
+                    }
+                }
+                $collabs[] = Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c);
+            }
+            $desc = sprintf($base, implode(', ', $collabs));
+            break;
+        case isset($data['add']):
+            $base = __('<b>{somebody}</b> added <strong>%s</strong> as collaborators {timestamp}');
+            $collabs = array();
+            if ($data['add']) {
+                $users = User::objects()->filter(array('id__in' => array_keys($data['add'])));
+                foreach ($data['add'] as $id=>$c) {
+                    $U = false;
+                    foreach ($users as $user) {
+                        if ($user->id == $id) {
+                            $U = $user;
+                            break;
+                        }
+                    }
+                    $c = sprintf(__("%s via %s"
+                        /* e.g. "Me <me@company.me> via Email (to)" */),
+                        Format::htmlchars($U ? $U->getName() : @$c['name'] ?: $c),
+                        $c['src'] ?: '?'
+                    );
+                    $collabs[] = $c;
+                }
+            }
+            $desc = $collabs
+                ? sprintf($base, implode(', ', $collabs))
+                : 'somebody';
+            break;
+        }
+        return $this->template($desc);
+    }
+}
+
+class CreationEvent extends ThreadEvent {
+    static $icon = 'magic';
+    static $state = 'created';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Created by <b>{somebody}</b> {timestamp}'));
+    }
+}
+
+class EditEvent extends ThreadEvent {
+    static $icon = 'pencil';
+    static $state = 'edited';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        $data = $this->getData();
+        switch (true) {
+        case isset($data['owner']):
+            $desc = __('<b>{somebody}</b> changed ownership to {<User>data.owner} {timestamp}');
+            break;
+        case isset($data['status']):
+            $desc = __('<b>{somebody}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}');
+            break;
+        case isset($data['fields']):
+            $base = __('Updated by <b>{somebody}</b> {timestamp} — %s');
+            $fields = $changes = array();
+            foreach (DynamicFormField::objects()->filter(array(
+                'id__in' => array_keys($data['fields'])
+            )) as $F) {
+                $fields[$F->id] = $F;
+            }
+            foreach ($data['fields'] as $id=>$f) {
+                $field = $fields[$id];
+                if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers())
+                    continue;
+                list($old, $new) = $f;
+                $impl = $field->getImpl($field);
+                $before = $impl->to_php($old);
+                $after = $impl->to_php($new);
+                $changes[] = sprintf('<strong>%s</strong> %s',
+                    $field->getLocal('label'), $impl->whatChanged($before, $after));
+            }
+            $desc = $changes
+                ? sprintf($base, implode(', ', $changes)) : '';
+            break;
+        }
+
+        return $this->template($desc);
+    }
+}
+
+class OverdueEvent extends ThreadEvent {
+    static $icon = 'time';
+    static $state = 'overdue';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Flagged as overdue by the system {timestamp}'));
+    }
+}
+
+class ReopenEvent extends ThreadEvent {
+    static $icon = 'rotate-right';
+    static $state = 'reopened';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('Reopened by <b>{somebody}</b> {timestamp}'));
+    }
+}
+
+class ResendEvent extends ThreadEvent {
+    static $icon = 'reply-all icon-flip-horizontal';
+    static $state = 'resent';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('<b>{somebody}</b> resent <strong><a href="#thread-entry-{data.entry}">a previous response</a></strong> {timestamp}'));
+    }
+}
+
+class TransferEvent extends ThreadEvent {
+    static $icon = 'share-alt';
+    static $state = 'transferred';
+
+    function getDescription($mode=self::MODE_STAFF) {
+        return $this->template(__('<b>{somebody}</b> transferred this to <strong>{dept}</strong> {timestamp}'));
+    }
+}
+
+class ViewEvent extends ThreadEvent {
+    static $state = 'viewed';
+}
+
 class ThreadEntryBody /* extends SplString */ {
 
     static $types = array('text', 'html');
@@ -2088,10 +2272,9 @@ class NoteThreadEntry extends ThreadEntry {
 // Object specific thread utils.
 class ObjectThread extends Thread
 implements TemplateVariable {
-    private $_entries = array();
-
     static $types = array(
         ObjectModel::OBJECT_TYPE_TASK => 'TaskThread',
+        ObjectModel::OBJECT_TYPE_TICKET => 'TicketThread',
     );
 
     var $counts;
diff --git a/include/class.thread_actions.php b/include/class.thread_actions.php
index 15758498fdabb968929300fded2c0402f3af7610..df4cf37e02e1d48cb087a47b293053d20ab17236 100644
--- a/include/class.thread_actions.php
+++ b/include/class.thread_actions.php
@@ -88,12 +88,12 @@ $.dialog(url, [201], function(xhr, resp) {
   var json = JSON.parse(resp);
   if (!json || !json.thread_id)
     return;
-  $('#thread-id-'+json.thread_id)
-    .attr('id', 'thread-id-' + json.new_id)
-    .find('div')
-    .html(json.body)
-    .closest('td')
-    .effect('highlight')
+  $('#thread-entry-'+json.thread_id)
+    .attr('id', 'thread-entry-' + json.new_id)
+    .html(json.entry)
+    .find('.thread-body')
+    .delay(500)
+    .effect('highlight');
 }, {size:'large'});
 JS
         , $this->getAjaxUrl());
@@ -118,10 +118,10 @@ JS
     }
 
     function updateEntry($guard=false) {
+        global $thisstaff;
+
         $old = $this->entry;
-        $type = ($old->format == 'html')
-            ? 'HtmlThreadEntryBody' : 'TextThreadEntryBody';
-        $new = new $type($_POST['body']);
+        $new = ThreadEntryBody::fromFormattedText($_POST['body'], $old->format);
 
         if ($new->getClean() == $old->body)
             // No update was performed
@@ -139,7 +139,7 @@ JS
             'pid' => $old->id,
 
             // Add in new stuff
-            'title' => $_POST['title'],
+            'title' => Format::htmlchars($_POST['title']),
             'body' => $new,
             'ip_address' => $_SERVER['REMOTE_ADDR'],
         ));
@@ -151,6 +151,8 @@ JS
         // that way for email header lookups and such to remain consistent
 
         if ($old->flags & ThreadEntry::FLAG_EDITED
+            // If editing another person's edit, make a new entry
+            and ($old->editor == $thisstaff->getId() && $old->editor_type == 'S')
             and !($old->flags & ThreadEntry::FLAG_GUARDED)
         ) {
             // Replace previous edit --------------------------
@@ -162,20 +164,24 @@ JS
             $old = $original;
         }
 
-        // Mark the new entry as edited (but not hidden)
-        $entry->flags = ($old->flags & ~ThreadEntry::FLAG_HIDDEN)
+        // Mark the new entry as edited (but not hidden nor guarded)
+        $entry->flags = ($old->flags & ~(ThreadEntry::FLAG_HIDDEN | ThreadEntry::FLAG_GUARDED))
             | ThreadEntry::FLAG_EDITED;
 
         // Guard against deletes on future edit if requested. This is done
         // if an email was triggered by the last edit. In such a case, it
-        // should not be replace by a subsequent edit.
+        // should not be replaced by a subsequent edit.
         if ($guard)
             $entry->flags |= ThreadEntry::FLAG_GUARDED;
 
-        // Sort in the same place in the thread — XXX: Add a `sequence` id
+        // Log the editor
+        $entry->editor = $thisstaff->getId();
+        $entry->editor_type = 'S';
+
+        // Sort in the same place in the thread
         $entry->created = $old->created;
         $entry->updated = SqlFunction::NOW();
-        $entry->save();
+        $entry->save(true);
 
         // Hide the old entry from the object thread
         $old->flags |= ThreadEntry::FLAG_HIDDEN;
@@ -190,10 +196,14 @@ JS
         if (!($entry = $this->updateEntry()))
             return $this->trigger__get();
 
+        ob_start();
+        include STAFFINC_DIR . 'templates/thread-entry.tmpl.php';
+        $content = ob_get_clean();
+
         Http::response('201', JsonDataEncoder::encode(array(
-            'thread_id' => $this->entry->id,
+            'thread_id' => $this->entry->id, # This is the old id!
             'new_id' => $entry->id,
-            'body' => $entry->getBody()->toHtml(),
+            'entry' => $content,
         )));
     }
 }
@@ -250,13 +260,17 @@ class TEA_EditAndResendThreadEntry extends TEA_EditThreadEntry {
         if (!($entry = $this->updateEntry($resend)))
             return $this->trigger__get();
 
-        if (@$_POST['commit'] == 'resend')
+        if ($resend)
             $this->resend($entry);
 
+        ob_start();
+        include STAFFINC_DIR . 'templates/thread-entry.tmpl.php';
+        $content = ob_get_clean();
+
         Http::response('201', JsonDataEncoder::encode(array(
-            'thread_id' => $this->entry->id,
+            'thread_id' => $this->entry->id, # This is the old id!
             'new_id' => $entry->id,
-            'body' => $entry->getBody()->toHtml(),
+            'entry' => $content,
         )));
     }
 
@@ -299,6 +313,13 @@ class TEA_EditAndResendThreadEntry extends TEA_EditThreadEntry {
         }
         // TODO: Add an option to the dialog
         $ticket->notifyCollaborators($response, array('signature' => $signature));
+
+        // Log an event that the item was resent
+        $ticket->logEvent('resent', array('entry' => $response->id));
+
+        // Flag the entry as resent
+        $response->flags |= ThreadEntry::FLAG_RESENT;
+        $response->save();
     }
 }
 ThreadEntry::registerAction(/* trans */ 'Manage', 'TEA_EditAndResendThreadEntry');
diff --git a/include/class.ticket.php b/include/class.ticket.php
index 48057249b8c6365fa18e5208f617ca5a4315a3e8..217718d956bc22b8415aaa52eb3846fdecbbce35 100644
--- a/include/class.ticket.php
+++ b/include/class.ticket.php
@@ -73,7 +73,7 @@ class TicketModel extends VerySimpleModel {
                 'null' => true,
             ),
             'thread' => array(
-                'reverse' => 'Thread.ticket',
+                'reverse' => 'TicketThread.ticket',
                 'list' => false,
                 'null' => true,
             ),
@@ -81,6 +81,12 @@ class TicketModel extends VerySimpleModel {
                 'reverse' => 'TicketCData.ticket',
                 'list' => false,
             ),
+            'entries' => array(
+                'constraint' => array(
+                    "'T'" => 'DynamicFormEntry.object_type',
+                    'ticket_id' => 'DynamicFormEntry.object_id',
+                ),
+            ),
         )
     );
 
@@ -144,14 +150,6 @@ class TicketModel extends VerySimpleModel {
          ));
     }
 
-    function delete() {
-
-        if (($ticket=Ticket::lookup($this->getId())) && @$ticket->delete())
-            return true;
-
-        return false;
-    }
-
     static function registerCustomData(DynamicForm $form) {
         if (!isset(static::$meta['joins']['cdata+'.$form->id])) {
             $cdata_class = <<<EOF
@@ -163,7 +161,8 @@ class DynamicForm{$form->id} extends DynamicForm {
         return \$instance;
     }
 }
-class TicketCdataForm{$form->id} {
+class TicketCdataForm{$form->id}
+extends VerySimpleModel {
     static \$meta = array(
         'view' => true,
         'pk' => array('ticket_id'),
@@ -179,13 +178,19 @@ class TicketCdataForm{$form->id} {
 }
 EOF;
             eval($cdata_class);
-            static::$meta['joins']['cdata+'.$form->id] = array(
-                'reverse' => 'TicketCdataForm'.$form->id.'.ticket',
-                'null' => true,
+            $join = array(
+                'constraint' => array('ticket_id' => 'TicketCdataForm'.$form->id.'.ticket_id'),
+                'list' => true,
             );
             // This may be necessary if the model has already been inspected
             if (static::$meta instanceof ModelMeta)
-                static::$meta->processJoin(static::$meta['joins']['cdata+'.$form->id]);
+                static::$meta->addJoin('cdata+'.$form->id, $join);
+            else {
+                static::$meta['joins']['cdata+'.$form->id] = array(
+                    'constraint' => array('ticket_id' => 'TicketCdataForm'.$form->id.'.ticket_id'),
+                    'list' => true,
+                );
+            }
         }
     }
 
@@ -216,10 +221,12 @@ class Ticket extends TicketModel
 implements RestrictedAccess, Threadable {
 
     static $meta = array(
-        'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread'),
+        'select_related' => array('topic', 'staff', 'user', 'team', 'dept', 'sla', 'thread',
+            'user__default_email'),
     );
 
     var $lastMsgId;
+    var $last_message;
 
     var $owner;     // TicketOwner
     var $_user;      // EndUser
@@ -236,12 +243,15 @@ implements RestrictedAccess, Threadable {
     function loadDynamicData() {
         if (!isset($this->_answers)) {
             $this->_answers = array();
-            foreach (DynamicFormEntry::forTicket($this->getId(), true) as $form) {
-                foreach ($form->getAnswers() as $answer) {
-                    $tag = mb_strtolower($answer->field->name)
-                        ?: 'field.' . $answer->field->id;
-                        $this->_answers[$tag] = $answer;
-                }
+            foreach (DynamicFormEntryAnswer::objects()
+                ->filter(array(
+                    'entry__object_id' => $this->getId(),
+                    'entry__object_type' => 'T'
+                )) as $answer
+            ) {
+                $tag = mb_strtolower($answer->field->name)
+                    ?: 'field.' . $answer->field->id;
+                    $this->_answers[$tag] = $answer;
             }
         }
         return $this->_answers;
@@ -654,20 +664,20 @@ implements RestrictedAccess, Threadable {
     }
 
     function getLastRespondent() {
-
         if (!isset($this->lastrespondent)) {
             $this->lastresponent = Staff::objects()
                 ->filter(array(
                 'staff_id' => static::objects()
                     ->filter(array(
-                        'thread__entry__type' => 'R',
-                        'thread__entry__staff_id__gt' => 0
+                        'thread__entries__type' => 'R',
+                        'thread__entries__staff_id__gt' => 0
                     ))
-                    ->values_flat('thread__entry__staff_id')
-                    ->order_by('-thread__entry__id')
-                    ->first()
+                    ->values_flat('thread__entries__staff_id')
+                    ->order_by('-thread__entries__id')
+                    ->limit(1)
                 ))
-                ->first();
+                ->first()
+                ?: false;
         }
         return $this->lastrespondent;
     }
@@ -806,21 +816,15 @@ implements RestrictedAccess, Threadable {
         return $fields[0];
     }
 
-    function addCollaborator($user, $vars, &$errors) {
+    function addCollaborator($user, $vars, &$errors, $event=true) {
 
         if (!$user || $user->getId() == $this->getOwnerId())
             return null;
 
-        $vars = array_merge(array(
-                'threadId' => $this->getThreadId(),
-                'userId' => $user->getId()), $vars);
-        if (!($c=Collaborator::add($vars, $errors)))
-            return null;
-
-        $this->collaborators = null;
-        $this->recipients = null;
-
-        $this->logEvent('collab', array('add' => array($c->toString())));
+        if ($c = $this->getThread()->addCollaborator($user, $vars, $errors, $event)) {
+            $this->collaborators = null;
+            $this->recipients = null;
+        }
 
         return $c;
     }
@@ -1027,11 +1031,9 @@ implements RestrictedAccess, Threadable {
         if ($this->getStatusId() == $status->getId())
             return true;
 
-        $this->status = $status;
-
-        //TODO: move this up.
+        // Perform checks on the *new* status, _before_ the status changes
         $ecb = null;
-        switch($status->getState()) {
+        switch ($status->getState()) {
             case 'closed':
                 if ($this->getMissingRequiredFields()) {
                     $errors['err'] = sprintf(__(
@@ -1043,7 +1045,7 @@ implements RestrictedAccess, Threadable {
                 $this->duedate = null;
                 if ($thisstaff && $set_closing_agent)
                     $this->staff = $thisstaff;
-                $this->clearOverdue();
+                $this->clearOverdue(false);
 
                 $ecb = function($t) {
                     $t->logEvent('closed');
@@ -1055,7 +1057,7 @@ implements RestrictedAccess, Threadable {
                 if ($this->isClosed()) {
                     $this->closed = $this->lastupdate = $this->reopened = SqlFunction::NOW();
                     $ecb = function ($t) {
-                        $t->logEvent('reopened', false, 'closed');
+                        $t->logEvent('reopened', false, null, 'closed');
                     };
                 }
 
@@ -1068,24 +1070,19 @@ implements RestrictedAccess, Threadable {
 
         }
 
+        $this->status = $status;
         if (!$this->save())
             return false;
 
         // Log status change b4 reload — if currently has a status. (On new
         // ticket, the ticket is opened and thereafter the status is set to
         // the requested status).
-        if ($current_status = $this->getStatus()) {
-            $note = sprintf(__('Status changed from %1$s to %2$s by %3$s'),
-                    $this->getStatus(),
-                    $status,
-                    $thisstaff ?: 'SYSTEM');
-
+        if ($hadStatus) {
             $alert = false;
             if ($comments) {
-                $note .= sprintf('<hr>%s', $comments);
                 // Send out alerts if comments are included
                 $alert = true;
-                $this->logNote(__('Status Changed'), $note, $thisstaff, $alert);
+                $this->logNote(__('Status Changed'), $comments, $thisstaff, $alert);
             }
         }
         // Log events via callback
@@ -1139,7 +1136,7 @@ implements RestrictedAccess, Threadable {
         if (!($status=$this->getStatus()->getReopenStatus()))
             $status = $cfg->getDefaultTicketStatusId();
 
-        return $status ? $this->setStatus($status, 'Reopened') : false;
+        return $status ? $this->setStatus($status) : false;
     }
 
     function onNewTicket($message, $autorespond=true, $alertstaff=true) {
@@ -1575,10 +1572,12 @@ implements RestrictedAccess, Threadable {
             );
             // Send the alerts.
             $sentlist = array();
-            $options = array(
-                'inreplyto'=>$note->getEmailMessageId(),
-                'references'=>$note->getEmailReferences(),
-                'thread'=>$note);
+            $options = $note instanceof ThreadEntry
+                ? array(
+                    'inreplyto'=>$note->getEmailMessageId(),
+                    'references'=>$note->getEmailReferences(),
+                    'thread'=>$note)
+                : array();
             foreach ($recipients as $k=>$staff) {
                 if (!is_object($staff)
                     || !$staff->isAvailable()
@@ -1796,7 +1795,7 @@ implements RestrictedAccess, Threadable {
         return true;
     }
 
-    function clearOverdue() {
+    function clearOverdue($save=true) {
         if (!$this->isOverdue())
             return true;
 
@@ -1812,7 +1811,7 @@ implements RestrictedAccess, Threadable {
         if ($this->getSLADueDate() && Misc::db2gmtime($this->getSLADueDate()) <= Misc::gmtime())
             $this->sla = null;
 
-        return $this->save();
+        return $save ? $this->save() : true;
     }
 
     //Dept Tranfer...with alert.. done by staff
@@ -2081,14 +2080,17 @@ implements RestrictedAccess, Threadable {
                     continue;
 
                 if (($user=User::fromVars($recipient)))
-                    if ($c=$this->addCollaborator($user, $info, $errors))
+                    if ($c=$this->addCollaborator($user, $info, $errors, false))
                         // FIXME: This feels very unwise — should be a
                         // string indexed array for future
-                        $collabs[] = array((string)$c, $recipient['source']);
+                        $collabs[$c->user_id] = array(
+                            'name' => $c->getName()->getOriginal(),
+                            'src' => $recipient['source'],
+                        );
             }
             // TODO: Can collaborators add others?
             if ($collabs) {
-                $this->logEvent('collab', array('add' => $collabs));
+                $this->logEvent('collab', array('add' => $collabs), $message->user);
             }
         }
 
@@ -2325,8 +2327,8 @@ implements RestrictedAccess, Threadable {
     }
 
     // History log -- used for statistics generation (pretty reports)
-    function logEvent($state, $data=null, $annul=null, $staff=null) {
-        $this->getThread()->getEvents()->log($this, $state, $data, $annul, $staff);
+    function logEvent($state, $data=null, $user=null, $annul=null) {
+        $this->getThread()->getEvents()->log($this, $state, $data, $user, $annul);
     }
 
     //Insert Internal Notes
@@ -2381,8 +2383,7 @@ implements RestrictedAccess, Threadable {
         if ($vars['note_status_id']
             && ($status=TicketStatus::lookup($vars['note_status_id']))
         ) {
-            if ($this->setStatus($status))
-                $this->reload();
+            $this->setStatus($status);
         }
 
         $activity = $vars['activity'] ?: _S('New Internal Note');
@@ -2522,8 +2523,8 @@ implements RestrictedAccess, Threadable {
         if ($errors)
             return false;
 
-        $this->topic_id = $vars['topic_id'];
-        $this->sla_id = $vars['sla_id'];
+        $this->topic_id = $vars['topicId'];
+        $this->sla_id = $vars['slaId'];
         $this->source = $vars['source'];
         $this->duedate = $vars['duedate']
             ? date('Y-m-d G:i',Misc::dbtime($vars['duedate'].' '.$vars['time']))
@@ -2582,11 +2583,11 @@ implements RestrictedAccess, Threadable {
         }
 
         Signal::send('model.updated', $this);
-        return true;
+        return $this->save();
     }
 
    /*============== Static functions. Use Ticket::function(params); =============nolint*/
-    function getIdByNumber($number, $email=null, $ticket=false) {
+    static function getIdByNumber($number, $email=null, $ticket=false) {
 
         if (!$number)
             return 0;
@@ -2608,8 +2609,8 @@ implements RestrictedAccess, Threadable {
         }
     }
 
-    function lookupByNumber($number, $email=null) {
-        return self::getIdByNumber($number, $email, true);
+    static function lookupByNumber($number, $email=null) {
+        return static::getIdByNumber($number, $email, true);
     }
 
     static function isTicketNumberUnique($number) {
@@ -3076,9 +3077,10 @@ implements RestrictedAccess, Threadable {
         $ticket = parent::create(array(
             'created' => SqlFunction::NOW(),
             'lastupdate' => SqlFunction::NOW(),
+            'number' => $number,
             'user' => $user,
-            'dept' => $deptId,
-            'topicId' => $topicId,
+            'dept_id' => $deptId,
+            'topic_id' => $topicId,
             'ip_address' => $ipaddress,
             'source' => $source,
         ));
@@ -3120,6 +3122,9 @@ implements RestrictedAccess, Threadable {
 
         $dept = $ticket->getDept();
 
+        // Start tracking ticket lifecycle events (created should come first!)
+        $ticket->logEvent('created', null, $thisstaff ?: $user);
+
         // Add organizational collaborators
         if ($org && $org->autoAddCollabs()) {
             $pris = $org->autoAddPrimaryContactsAsCollabs();
@@ -3163,11 +3168,10 @@ implements RestrictedAccess, Threadable {
             // Auto assign staff or team - auto assignment based on filter
             // rules. Both team and staff can be assigned
             if ($vars['staffId'])
-                 $ticket->assignToStaff($vars['staffId'], _S('Auto Assignment'));
+                 $ticket->assignToStaff($vars['staffId'], false);
             if ($vars['teamId'])
                 // No team alert if also assigned to an individual agent
-                $ticket->assignToTeam($vars['teamId'], _S('Auto Assignment'),
-                    !$vars['staffId']);
+                $ticket->assignToTeam($vars['teamId'], false, !$vars['staffId']);
         }
 
         // Update the estimated due date in the database
@@ -3223,9 +3227,6 @@ implements RestrictedAccess, Threadable {
             $ticket->onOpenLimit($autorespond && strcasecmp($origin, 'staff'));
         }
 
-        /* Start tracking ticket lifecycle events */
-        $ticket->logEvent('created');
-
         // Fire post-create signal (for extra email sending, searching)
         Signal::send('ticket.created', $ticket);
 
@@ -3293,8 +3294,6 @@ implements RestrictedAccess, Threadable {
             $ticket->logNote(_S('New Ticket'), $vars['note'], $thisstaff, false);
         }
 
-        $ticket->reload();
-
         if (!$cfg->notifyONNewStaffTicket()
             || !isset($vars['alertuser'])
             || !($dept=$ticket->getDept())
diff --git a/include/class.user.php b/include/class.user.php
index 655edfd00e4dcb9cd6f6023018a1192ff54c02c6..3ba2b54ae1d3cb0d7f194036d1a5412199b1afd4 100644
--- a/include/class.user.php
+++ b/include/class.user.php
@@ -51,7 +51,7 @@ class UserModel extends VerySimpleModel {
             'account' => array(
                 'list' => false,
                 'null' => true,
-                'reverse' => 'UserAccount.user',
+                'reverse' => 'ClientAccount.user',
             ),
             'org' => array(
                 'null' => true,
diff --git a/include/client/faq-category.inc.php b/include/client/faq-category.inc.php
index eb6606af8b261f9daf9d3e8fed1d17c345423a90..3ce0b7230dcdff9e3ded235c902b8db55b2f9315 100644
--- a/include/client/faq-category.inc.php
+++ b/include/client/faq-category.inc.php
@@ -13,9 +13,11 @@ if(!defined('OSTCLIENTINC') || !$category || !$category->isPublic()) die('Access
 <?php
 $faqs = FAQ::objects()
     ->filter(array('category'=>$category))
-    ->exclude(array('ispublished'=>false))
-    ->annotate(array('has_attachments'=>SqlAggregate::COUNT('attachments', false,
-        array('attachments__inline'=>0))))
+    ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE))
+    ->annotate(array('has_attachments' => SqlAggregate::COUNT(SqlCase::N()
+        ->when(array('attachments__inline'=>0), 1)
+        ->otherwise(null)
+    )))
     ->order_by('-ispublished', 'question');
 
 if ($faqs->exists(true)) {
diff --git a/include/client/kb-categories.inc.php b/include/client/kb-categories.inc.php
index c6cc4e930aad3076b4237db87f8d932d6aac1df6..6129180fa8b9a01cf8a66c0e124bdc1c514d447b 100644
--- a/include/client/kb-categories.inc.php
+++ b/include/client/kb-categories.inc.php
@@ -2,10 +2,13 @@
 <div class="span8">
 <?php
     $categories = Category::objects()
-        ->exclude(Q::any(array('ispublic'=>false, 'faqs__ispublished'=>false)))
+        ->exclude(Q::any(array(
+            'ispublic'=>Category::VISIBILITY_PRIVATE,
+            'faqs__ispublished'=>FAQ::VISIBILITY_PRIVATE,
+        )))
         ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs')))
         ->filter(array('faq_count__gt'=>0));
-    if ($categories->all()) { ?>
+    if ($categories->exists(true)) { ?>
         <div><?php echo __('Click on the category to browse FAQs.'); ?></div>
         <ul id="kb">
 <?php
@@ -18,7 +21,7 @@
                 <?php echo Format::safe_html($C->getLocalDescriptionWithImages()); ?>
             </div>
 <?php       foreach ($C->faqs
-                    ->exclude(array('ispublished'=>false))
+                    ->exclude(array('ispublished'=>FAQ::VISIBILITY_PRIVATE))
                     ->order_by('-views')->limit(5) as $F) { ?>
                 <div class="popular-faq"><i class="icon-file-alt"></i>
                 <a href="faq.php?id=<?php echo $F->getId(); ?>">
diff --git a/include/client/kb-search.inc.php b/include/client/kb-search.inc.php
index 5166a6616fbd5ee2ff677124e46aeaaae15b0a2c..a81f5e4fdea5f7064c1f5dacbeb88fab99fb296c 100644
--- a/include/client/kb-search.inc.php
+++ b/include/client/kb-search.inc.php
@@ -5,7 +5,7 @@
 <?php
     if ($faqs->exists(true)) {
         echo '<div id="faq">'.sprintf(__('%d FAQs matched your search criteria.'),
-            count($faqs->all()))
+            $faqs->count())
             .'<ol>';
         foreach ($faqs as $F) {
             echo sprintf(
diff --git a/include/client/templates/thread-entries.tmpl.php b/include/client/templates/thread-entries.tmpl.php
index d031182cad2fed9947e5bf75a953b05fb81b0374..9b1fc704db6ed350c06b932abd3fcb93bcf00bf6 100644
--- a/include/client/templates/thread-entries.tmpl.php
+++ b/include/client/templates/thread-entries.tmpl.php
@@ -25,7 +25,7 @@ if (count($entries)) {
         //       changes in dates between thread items.
         foreach ($entries as $entry) {
             // Emit all events prior to this entry
-            while ($event && $event->timestamp <= $entry->created) {
+            while ($event && $event->timestamp < $entry->created) {
                 $event->render(ThreadEvent::MODE_CLIENT);
                 $events->next();
                 $event = $events->current();
diff --git a/include/client/templates/thread-entry.tmpl.php b/include/client/templates/thread-entry.tmpl.php
index 70e4bbb5c6fae24ba42a48a2c67520b044d03e58..6c16c0660d09c5e71527c90cffd302b0731df65a 100644
--- a/include/client/templates/thread-entry.tmpl.php
+++ b/include/client/templates/thread-entry.tmpl.php
@@ -57,6 +57,7 @@ if ($user && ($url = $user->get_gravatar(48)))
     </div>
     <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>">
         <div><?php echo $entry->getBody()->toHtml(); ?></div>
+        <div class="clear"></div>
 <?php
     if ($entry->has_attachments) { ?>
     <div class="attachments"><?php
diff --git a/include/client/tickets.inc.php b/include/client/tickets.inc.php
index 58d056a1786b624bb3ea0e63e5c10ae62cc24d80..12908ece25e65bc1f0240ca6ca88522973a01744 100644
--- a/include/client/tickets.inc.php
+++ b/include/client/tickets.inc.php
@@ -1,29 +1,33 @@
 <?php
 if(!defined('OSTCLIENTINC') || !is_object($thisclient) || !$thisclient->isValid()) die('Access Denied');
 
+$settings = &$_SESSION['client:Q'];
+
+// Unpack search, filter, and sort requests
+if (isset($_REQUEST['clear']))
+    $settings = array();
+if (isset($_REQUEST['keywords']))
+    $settings['keywords'] = $_REQUEST['keywords'];
+if (isset($_REQUEST['topic_id']))
+    $settings['topic_id'] = $_REQUEST['topic_id'];
+if (isset($_REQUEST['status']))
+    $settings['status'] = $_REQUEST['status'];
+
 $tickets = TicketModel::objects();
 
 $qs = array();
 $status=null;
-if(isset($_REQUEST['status'])) { //Query string status has nothing to do with the real status used below.
-    $qs += array('status' => $_REQUEST['status']);
-    //Status we are actually going to use on the query...making sure it is clean!
-    $status=strtolower($_REQUEST['status']);
-    switch(strtolower($_REQUEST['status'])) {
-     case 'open':
-		$results_type=__('Open Tickets');
-        $tickets->filter(array('status__state'=>'open'));
-        break;
-     case 'closed':
-		$results_type=__('Closed Tickets');
-        $tickets->filter(array('status__state'=>'closed'));
+
+if ($settings['status'])
+    $status = strtolower($settings['status']);
+    switch ($status) {
+    default:
+        $status = 'open';
+    case 'open':
+    case 'closed':
+		$results_type = ($status == 'closed') ? __('Closed Tickets') : __('Open Tickets');
+        $tickets->filter(array('status__state' => $status));
         break;
-     default:
-        $status=''; //ignore
-    }
-} elseif($thisclient->getNumOpenTickets()) {
-    $status='open'; //Defaulting to open
-	$results_type=__('Open Tickets');
 }
 
 $sortOptions=array('id'=>'number', 'subject'=>'cdata__subject',
@@ -49,17 +53,20 @@ $tickets->filter(Q::any(array(
 )));
 
 // Perform basic search
-$search=($_REQUEST['a']=='search' && $_REQUEST['q']);
-if($search) {
-    $qs += array('a' => $_REQUEST['a'], 'q' => $_REQUEST['q']);
-    if (is_numeric($_REQUEST['q'])) {
-        $tickets->filter(array('number__startswith'=>$_REQUEST['q']));
+if ($settings['keywords']) {
+    $q = $settings['keywords'];
+    if (is_numeric($q)) {
+        $tickets->filter(array('number__startswith'=>$q));
     } else { //Deep search!
         // Use the search engine to perform the search
-        $tickets = $ost->searcher->find($_REQUEST['q'], $tickets);
+        $tickets = $ost->searcher->find($q, $tickets);
     }
 }
 
+if ($settings['topic_id']) {
+    $tickets = $tickets->filter(array('topic_id' => $settings['topic_id']));
+}
+
 TicketForm::ensureDynamicDataView();
 
 $total=$tickets->count();
@@ -89,28 +96,62 @@ $tickets->values(
 );
 
 ?>
-<h1><?php echo __('Tickets');?></h1>
-<br>
+<div class="search well">
+<div class="flush-left">
 <form action="tickets.php" method="get" id="ticketSearchForm">
     <input type="hidden" name="a"  value="search">
-    <input type="text" name="q" size="20" value="<?php echo Format::htmlchars($_REQUEST['q']); ?>">
-    <select name="status">
-        <option value="">&mdash; <?php echo __('Any Status');?> &mdash;</option>
-        <option value="open"
-            <?php echo ($status=='open') ? 'selected="selected"' : '';?>>
-            <?php echo _P('ticket-status', 'Open');?> (<?php echo $thisclient->getNumOpenTickets(); ?>)</option>
-        <?php
-        if($thisclient->getNumClosedTickets()) {
-            ?>
-        <option value="closed"
-            <?php echo ($status=='closed') ? 'selected="selected"' : '';?>>
-            <?php echo __('Closed');?> (<?php echo $thisclient->getNumClosedTickets(); ?>)</option>
-        <?php
-        } ?>
+    <input type="search" name="keywords" size="30" value="<?php echo Format::htmlchars($settings['keywords']); ?>">
+    <input type="submit" value="<?php echo __('Search');?>">
+<div class="pull-right">
+    <?php echo __('Help Topic'); ?>:
+    <select name="topic_id" class="nowarn" onchange="javascript: this.form.submit(); ">
+        <option value="">&mdash; <?php echo __('All Help Topics');?> &mdash;</option>
+<?php foreach (Topic::getHelpTopics(true) as $id=>$name) {
+        $count = $thisclient->getNumTopicTickets($id);
+        if ($count == 0)
+            continue;
+?>
+        <option value="<?php echo $id; ?>"i
+            <?php if ($settings['topic_id'] == $id) echo 'selected="selected"'; ?>
+            ><?php echo sprintf('%s (%d)', Format::htmlchars($name),
+                $thisclient->getNumTopicTickets($id)); ?></option>
+<?php } ?>
     </select>
-    <input type="submit" value="<?php echo __('Go');?>">
+</div>
 </form>
-<a class="refresh" href="<?php echo Format::htmlchars($_SERVER['REQUEST_URI']); ?>"><?php echo __('Refresh'); ?></a>
+</div>
+
+<?php if ($settings['keywords'] || $settings['topic_id'] || $_REQUEST['sort']) { ?>
+<div style="margin-top:10px"><strong><a href="?clear" style="color:#777"><i class="icon-remove-circle"></i> <?php echo __('Clear all filters and sort'); ?></a></strong></div>
+<?php } ?>
+
+</div>
+
+
+<h1 style="margin:10px 0">
+    <a href="<?php echo Format::htmlchars($_SERVER['REQUEST_URI']); ?>"
+        ><i class="refresh icon-refresh"></i>
+    <?php echo __('Tickets'); ?>
+    </a>
+
+<div class="pull-right states">
+    <small>
+    <i class="icon-file-alt"></i>
+    <a class="state <?php if ($status == 'open') echo 'active'; ?>"
+        href="?<?php echo Http::build_query(array('a' => 'search', 'status' => 'open')); ?>">
+    <?php echo sprintf('%s (%d)', _P('ticket-status', 'Open'), $thisclient->getNumOpenTickets()); ?>
+    </a>
+    &nbsp;
+    <span style="color:lightgray">|</span>
+    &nbsp;
+    <i class="icon-file-text"></i>
+    <a class="state <?php if ($status == 'closed') echo 'active'; ?>"
+        href="?<?php echo Http::build_query(array('a' => 'search', 'status' => 'closed')); ?>">
+    <?php echo sprintf('%s (%d)', __('Closed'), $thisclient->getNumClosedTickets()); ?>
+    </a>
+    </small>
+</div>
+</h1>
 <table id="ticketTable" width="800" border="0" cellspacing="0" cellpadding="0">
     <caption><?php echo $showing; ?></caption>
     <thead>
@@ -170,7 +211,7 @@ $tickets->values(
         }
 
      } else {
-         echo '<tr><td colspan="6">'.__('Your query did not match any records').'</td></tr>';
+         echo '<tr><td colspan="5">'.__('Your query did not match any records').'</td></tr>';
      }
     ?>
     </tbody>
diff --git a/include/client/view.inc.php b/include/client/view.inc.php
index 498e6d6aecd75a1aaca5e61a432ee731e1e0f12f..2be53bc477943706361921063d776169e1398dce 100644
--- a/include/client/view.inc.php
+++ b/include/client/view.inc.php
@@ -31,9 +31,9 @@ if ($thisclient && $thisclient->isGuest()
     <tr>
         <td colspan="2" width="100%">
             <h1>
+                <a href="tickets.php?id=<?php echo $ticket->getId(); ?>" title="<?php echo __('Reload'); ?>"><i class="refresh icon-refresh"></i></a>
                 <b><?php echo $ticket->getSubject(); ?></b>
                 <small>#<?php echo $ticket->getNumber(); ?></small>
-                <a href="tickets.php?id=<?php echo $ticket->getId(); ?>" title="<?php echo __('Reload'); ?>"><span class="Icon refresh">&nbsp;</span></a>
 <div class="pull-right">
     <a class="action-button" href="tickets.php?a=print&id=<?php
         echo $ticket->getId(); ?>"><i class="icon-print"></i> <?php echo __('Print'); ?></a>
diff --git a/include/staff/tasks.inc.php b/include/staff/tasks.inc.php
index 01c0be6986e239d6ac055d5559d598f9c200daee..ab826da6aeb550dd0fdcb42c2a4c62a0d86f410b 100644
--- a/include/staff/tasks.inc.php
+++ b/include/staff/tasks.inc.php
@@ -42,10 +42,10 @@ case 'search':
             'cdata__title__contains' => $_REQUEST['query'],
         )));
         break;
-    } elseif (isset($_SESSION['advsearch'])) {
+    } elseif (isset($_SESSION['advsearch:tasks'])) {
         // XXX: De-duplicate and simplify this code
-        $form = $search->getFormFromSession('advsearch');
-        $form->loadState($_SESSION['advsearch']);
+        $form = $search->getFormFromSession('advsearch:tasks');
+        $form->loadState($_SESSION['advsearch:tasks']);
         $tasks = $search->mangleQuerySet($tasks, $form);
         $results_type=__('Advanced Search')
             . '<a class="action-button" href="?clear_filter"><i class="icon-ban-circle"></i> <em>' . __('clear') . '</em></a>';
diff --git a/include/staff/templates/advanced-search-field.tmpl.php b/include/staff/templates/advanced-search-field.tmpl.php
index 40712cc18cbf18a639437b3331a71ead5828a7b7..5191a0cf4dac8217e664798d76d925e4623e6e08 100644
--- a/include/staff/templates/advanced-search-field.tmpl.php
+++ b/include/staff/templates/advanced-search-field.tmpl.php
@@ -1,7 +1,8 @@
 <input type="hidden" name="fields[]" value="<?php echo $name; ?>"/>
 <?php foreach ($fields as $F) { ?>
 <fieldset id="field<?php echo $F->getWidget()->id;
-    ?>" <?php if (!$F->isVisible()) echo 'style="display:none;"'; ?>>
+    ?>" <?php if (!$F->isVisible()) echo 'style="display:none;"'; ?>
+    <?php if (substr($F->get('name'), -7) === '+search') echo 'class="advanced-search-field"'; ?>>
     <?php echo $F->render(); ?>
     <?php foreach ($F->errors() as $E) {
         ?><div class="error"><?php echo $E; ?></div><?php
diff --git a/include/staff/templates/advanced-search.tmpl.php b/include/staff/templates/advanced-search.tmpl.php
index cfe1971b1e6d8325c24df867a5ecd5dd235bebca..94d193b598e851fff60469cadcf5e2706b23c32c 100644
--- a/include/staff/templates/advanced-search.tmpl.php
+++ b/include/staff/templates/advanced-search.tmpl.php
@@ -16,14 +16,17 @@ foreach ($form->errors(true) ?: array() as $message) {
 
 foreach ($form->getFields() as $name=>$field) { ?>
     <fieldset id="field<?php echo $field->getWidget()->id;
-        ?>" <?php if (!$field->isVisible()) echo 'style="display:none;"'; ?>>
+        ?>" <?php if (!$field->isVisible()) echo 'class="hidden"'; ?>
+        <?php if (substr($field->get('name'), -7) === '+search') echo 'class="advanced-search-field"'; ?>>
         <?php echo $field->render(); ?>
         <?php foreach ($field->errors() as $E) {
             ?><div class="error"><?php echo $E; ?></div><?php
         } ?>
     </fieldset>
-    <?php if ($name[0] == ':') { ?>
-    <input type="hidden" name="fields[]" value="<?php echo $name; ?>"/>
+    <?php if ($name[0] == ':' && substr($name, -7) == '+search') {
+        list($N,) = explode('+', $name, 2);
+?>
+    <input type="hidden" name="fields[]" value="<?php echo $N; ?>"/>
     <?php }
 }
 ?>
@@ -38,7 +41,7 @@ foreach ($matches as $name => $fields) { ?>
     foreach ($fields as $id => $desc) { ?>
         <option value="<?php echo $id; ?>" <?php
             if (isset($state[$id])) echo 'disabled="disabled"';
-        ?>><?php echo $desc; ?></option>
+        ?>><?php echo ($desc instanceof FormField ? $desc->getLocal('label') : $desc); ?></option>
 <?php } ?>
     </optgroup>
 <?php } ?>
diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php
index 0be5722c5004d59e9bf62415af5413f5c249e247..7ac199444b8b2607346451a97e5435cb5dda0753 100644
--- a/include/staff/templates/thread-entries.tmpl.php
+++ b/include/staff/templates/thread-entries.tmpl.php
@@ -28,7 +28,9 @@ if (count($entries)) {
                 $events->next();
                 $event = $events->current();
             }
+            ?><div id="thread-entry-<?php echo $entry->getId(); ?>"><?php
             include STAFFINC_DIR . 'templates/thread-entry.tmpl.php';
+            ?></div><?php
         }
         $i++;
     }
diff --git a/include/staff/templates/thread-entry-edit.tmpl.php b/include/staff/templates/thread-entry-edit.tmpl.php
index bab5e4eb65223cb4705a8499a3909ed58a404b7b..1440780061aa0831d3009522a54f501c63425ca4 100644
--- a/include/staff/templates/thread-entry-edit.tmpl.php
+++ b/include/staff/templates/thread-entry-edit.tmpl.php
@@ -33,7 +33,7 @@
     class="large <?php
         if ($cfg->isRichTextEnabled() && $this->entry->format == 'html')
             echo 'richtext';
-    ?>"><?php echo Format::viewableImages($this->entry->body);
+    ?>"><?php echo htmlspecialchars(Format::viewableImages($this->entry->body));
 ?></textarea>
 
 <?php if ($this->entry->type == 'R') { ?>
diff --git a/include/staff/templates/thread-entry-view.tmpl.php b/include/staff/templates/thread-entry-view.tmpl.php
index 51a0a10da8bc411507c049337ee7cc2b92a86e5b..0b5a542bae73f87eadfa31a810487042c05e54f5 100644
--- a/include/staff/templates/thread-entry-view.tmpl.php
+++ b/include/staff/templates/thread-entry-view.tmpl.php
@@ -16,7 +16,7 @@ do {
         // If you originally posted it, you can see all the edits
         && $E->staff_id != $thisstaff->getId()
         // You can see your own edits
-        //  && $E->editor != $thisstaff->getId()
+        && ($E->editor != $thisstaff->getId() || $E->editor_type != 'S')
     ) {
         // Skip edits made by other agents
         continue;
@@ -26,7 +26,8 @@ do {
     <strong><?php if ($E->title)
         echo Format::htmlchars($E->title).' — '; ?></strong>
     <em><?php if (strpos($E->updated, '0000-') === false)
-        echo sprintf(__('Edited on %s'), Format::datetime($E->updated));
+        echo sprintf(__('Edited on %s by %s'), Format::datetime($E->updated),
+            ($editor = $E->getEditor()) ? $editor->getName() : '');
     else
         echo __('Original'); ?></em>
     </a>
diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php
index cc5a704ab300058b15959a048dc0112521e63728..62cd3e3362509d15f13faee81508f9b51060059d 100644
--- a/include/staff/templates/thread-entry.tmpl.php
+++ b/include/staff/templates/thread-entry.tmpl.php
@@ -38,14 +38,19 @@ if ($user && ($url = $user->get_gravatar(48)))
         <span style="vertical-align:middle;" class="textra">
 <?php   if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?>
             <span class="label label-bare" title="<?php
-        echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You');
+            echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated),
+                ($editor = $entry->getEditor()) ? $editor->getName() : '');
                 ?>"><?php echo __('Edited'); ?></span>
-        <?php } ?>
+<?php   } ?>
+<?php   if ($entry->flags & ThreadEntry::FLAG_RESENT) { ?>
+            <span class="label label-bare"><?php echo __('Resent'); ?></span>
+<?php   } ?>
         </span>
         </div>
 <?php
         echo sprintf(__('<b>%s</b> posted %s'), $name,
-            sprintf('<time class="relative" datetime="%s" title="%s">%s</time>',
+            sprintf('<a name="entry-%d" href="#entry-%1$s"><time class="relative" datetime="%s" title="%s">%s</time></a>',
+                $entry->id,
                 date(DateTime::W3C, Misc::db2gmtime($entry->created)),
                 Format::daydatetime($entry->created),
                 Format::relativeTime(Misc::db2gmtime($entry->created))
@@ -55,10 +60,15 @@ if ($user && ($url = $user->get_gravatar(48)))
             echo $entry->title; ?></span>
         </span>
     </div>
-    <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>">
+    <div class="thread-body no-pjax">
         <div><?php echo $entry->getBody()->toHtml(); ?></div>
+        <div class="clear"></div>
 <?php
-    if ($entry->has_attachments) { ?>
+    // The strangeness here is because .has_attachments is an annotation from
+    // Thread::getEntries(); however, this template may be used in other
+    // places such as from thread entry editing
+    if (isset($entry->has_attachments) ? $entry->has_attachments
+            : $entry->attachments->filter(array('inline'=>0))->count()) { ?>
     <div class="attachments"><?php
         foreach ($entry->attachments as $A) {
             if ($A->inline)
@@ -81,7 +91,7 @@ if ($user && ($url = $user->get_gravatar(48)))
 <?php
     if ($urls = $entry->getAttachmentUrls()) { ?>
         <script type="text/javascript">
-            $('#thread-id-<?php echo $entry->getId(); ?>')
+            $('#thread-entry-<?php echo $entry->getId(); ?>')
                 .data('urls', <?php
                     echo JsonDataEncoder::encode($urls); ?>)
                 .data('id', <?php echo $entry->getId(); ?>);
diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php
index da1d522122e01fd806a756355ade4b2a43fa0ccf..32d556b3f7bb66191337103b82322ac45a834842 100644
--- a/include/staff/tickets.inc.php
+++ b/include/staff/tickets.inc.php
@@ -77,24 +77,35 @@ case 'search':
     if ($_REQUEST['query']) {
         $results_type=__('Search Results');
         // Use an index if possible
-        if ($_REQUEST['search-type'] == 'email') {
+        if ($_REQUEST['search-type'] == 'typeahead' && Validator::is_email($_REQUEST['query'])) {
             $tickets = $tickets->filter(array(
                 'user__emails__address' => $_REQUEST['query'],
             ));
         }
         else {
-            $tickets = $tickets->filter(Q::any(array(
+            $basic_search = Q::any(array(
                 'number__startswith' => $_REQUEST['query'],
                 'user__name__contains' => $_REQUEST['query'],
                 'user__emails__address__contains' => $_REQUEST['query'],
                 'user__org__name__contains' => $_REQUEST['query'],
-            )));
+            ));
+            if (!$_REQUEST['search-type']) {
+                // [Search] click, consider keywords too. This is a
+                // relatively ugly hack. SearchBackend::find() add in a
+                // constraint for the search. We need to pop that off and
+                // include it as an OR with the above constraints
+                $tickets = $ost->searcher->find($_REQUEST['query'], $tickets);
+                $keywords = array_pop($tickets->constraints);
+                $basic_search->add($keywords);
+                // FIXME: The subquery technique below will crash with
+                //        keyword search
+                $use_subquery = false;
+            }
+            $tickets->filter($basic_search);
         }
         break;
     } elseif (isset($_SESSION['advsearch'])) {
-        // XXX: De-duplicate and simplify this code
         $form = $search->getFormFromSession('advsearch');
-        $form->loadState($_SESSION['advsearch']);
         $tickets = $search->mangleQuerySet($tickets, $form);
         $view_all_tickets = $thisstaff->getRole()->hasPerm(SearchBackend::PERM_EVERYTHING);
         $results_type=__('Advanced Search')
@@ -274,23 +285,21 @@ TicketForm::ensureDynamicDataView();
 
 // Select pertinent columns
 // ------------------------------------------------------------
-$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__:priority__priority_color', 'cdata__:priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate');
+$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__:priority__priority_color', 'cdata__:priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate', 'isanswered');
 
 // Add in annotations
 $tickets->annotate(array(
-    'collab_count' => SqlAggregate::COUNT('thread__collaborators', true),
-    'attachment_count' => SqlAggregate::COUNT(SqlCase::N()
-       ->when(new SqlField('thread__entries__attachments__inline'), null)
-       ->otherwise(new SqlField('thread__entries__attachments')),
-        true
-    ),
-    'thread_count' => SqlAggregate::COUNT(SqlCase::N()
-        ->when(
-            new Q(array('thread__entries__flags__hasbit'=>ThreadEntry::FLAG_HIDDEN)),
-            null)
-        ->otherwise(new SqlField('thread__entries__id')),
-       true
-    ),
+    'collab_count' => TicketThread::objects()
+        ->filter(array('ticket__ticket_id' => new SqlField('ticket_id')))
+        ->aggregate(array('count' => SqlAggregate::COUNT('collaborators__id'))),
+    'attachment_count' => TicketThread::objects()
+        ->filter(array('ticket__ticket_id' => new SqlField('ticket_id')))
+        ->filter(array('entries__attachments__inline' => 0))
+        ->aggregate(array('count' => SqlAggregate::COUNT('entries__attachments__id'))),
+    'thread_count' => TicketThread::objects()
+        ->filter(array('ticket__ticket_id' => new SqlField('ticket_id')))
+        ->filter(Q::not(array('entries__flags__hasbit' => ThreadEntry::FLAG_HIDDEN)))
+        ->aggregate(array('count' => SqlAggregate::COUNT('entries__id'))),
 ));
 
 // Save the query to the session for exporting
diff --git a/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql b/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql
index 401b6780cfd4680deea77c8efa1a935f5184a5a3..c6bf19b42ba79cfde7f076d9b7b056856d0a0b55 100644
--- a/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql
+++ b/include/upgrader/streams/core/9143a511-0d6099a6.patch.sql
@@ -22,7 +22,7 @@ CREATE TABLE `%TABLE_PREFIX%_ticket_thread_evt`
     WHERE `object_type` = 'T';
 
 UPDATE `%TABLE_PREFIX%thread_event` A1
-    JOIN `%TABLE_PREFIX%_ticket_thread_evt` A2 ON (A1.`thread_id` = A2.`object_id`)
+    LEFT JOIN `%TABLE_PREFIX%_ticket_thread_evt` A2 ON (A1.`thread_id` = A2.`object_id`)
     SET A1.`thread_id` = A2.`id`;
 
 DROP TABLE `%TABLE_PREFIX%_ticket_thread_evt`;
diff --git a/js/osticket.js b/js/osticket.js
index 28fd56fc8fd5df9153eac06242e8116b8cdf5dc0..d885b9216e66a2111639312e7d54ce8f0102618d 100644
--- a/js/osticket.js
+++ b/js/osticket.js
@@ -21,7 +21,7 @@ $(document).ready(function(){
         left : ($(window).width() / 2 - 160)
      });
 
-    $("form :input").change(function() {
+    $(document).on('change', "form :input:not(.nowarn)", function() {
         var fObj = $(this).closest('form');
         if(!fObj.data('changed')){
             fObj.data('changed', true);
@@ -30,7 +30,7 @@ $(document).ready(function(){
                 return __("Are you sure you want to leave? Any changes or info you've entered will be discarded!");
              });
         }
-       });
+    });
 
     $("form :input[type=reset]").click(function() {
         var fObj = $(this).closest('form');
@@ -133,6 +133,10 @@ $(document).ready(function(){
             // TODO: Add a hover-button to show just one image
         });
     });
+
+    $('div.thread-body a').each(function() {
+        $(this).attr('target', '_blank');
+    });
 });
 
 showImagesInline = function(urls, thread_id) {
diff --git a/scp/css/scp.css b/scp/css/scp.css
index c965cb7a97cc4a025750ed740c6e0123b532ff97..ad7c976ade5c91a161e2b1e345801a253f5fa85d 100644
--- a/scp/css/scp.css
+++ b/scp/css/scp.css
@@ -70,6 +70,9 @@ div#header a {
 time[title]:hover {
     text-decoration: underline;
 }
+a time.relative {
+    color: initial;
+}
 
 .small[class^="icon-"],
 .small[class*=" icon-"] {
@@ -1610,10 +1613,6 @@ time.faq {
     width:100%;
 }
 
-#advanced-search div.closed_by, #advanced-search span.spinner {
-    display:none;
-}
-
 .dialog fieldset {
     margin:0;
     padding:0 0;
@@ -1669,42 +1668,14 @@ time.faq {
     vertical-align: top;
 }
 
-#advanced-search .query input {
-    width:100%;
-    padding: 4px;
-    margin-bottom: 10px;
-}
-
-#advanced-search .date_range {
-    margin-bottom: 5px;
-}
-#advanced-search .date_range input {
-    width:227px;
-    width: calc(49% - 73px);
-}
-
-#advanced-search .date_range i {
-    display:inline-block;
-    margin-left:3px;
-    position:relative;
-    top:5px;
-    width:16px;
-    height:16px;
-    background:url(../images/cal.png) bottom left no-repeat;
-}
-
-#advanced-search fieldset.sorting select {
-    width:130px;
-}
-
-#advanced-search p {
-    text-align:center;
-}
-
 .search-dropdown {
     padding-left: 19px;
 }
 
+.advanced-search-field {
+    margin-top: 5px !important;
+}
+
 .dialog input[type="submit"],
 .dialog input[type="reset"],
 .dialog input[type="button"],
@@ -2388,6 +2359,9 @@ td.indented {
     padding: 0 2px 15px;
     margin-left: 60px;
 }
+.thread-event a {
+    color: inherit;
+}
 .type-icon {
     border-radius: 8px;
     background-color: #f4f4f4;
diff --git a/scp/js/scp.js b/scp/js/scp.js
index 2d93705cdbbe88dfc41d3e5e57faf06e5f1b40a4..dfa6ed4e08beb7a497e2ab57710186153d3b449e 100644
--- a/scp/js/scp.js
+++ b/scp/js/scp.js
@@ -261,7 +261,7 @@ var scp_prep = function() {
         },
         onselect: function (obj) {
             var form = $('#basic-ticket-search').closest('form');
-            form.find('input[name=search-type]').val('email');
+            form.find('input[name=search-type]').val('typeahead');
             $('#basic-ticket-search').val(obj.value);
             form.submit();
         },
@@ -1068,3 +1068,31 @@ function addSearchParam(key, value) {
     //this will reload the page, it's likely better to store this until finished
     return kvp.join('&');
 }
+
+// Periodically adjust relative times
+window.relativeAdjust = setInterval(function() {
+  // Thanks, http://stackoverflow.com/a/7641822/1025836
+  var prettyDate = function(time) {
+    var date = new Date((time || "").replace(/-/g, "/").replace(/[TZ]/g, " ")),
+        diff = (((new Date()).getTime() - date.getTime()) / 1000),
+        day_diff = Math.floor(diff / 86400);
+
+    if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) return;
+
+    return day_diff == 0 && (
+         diff < 60 && __("just now")
+      || diff < 120 && __("about a minute ago")
+      || diff < 3600 && __("%d minutes ago").replace('%d', Math.floor(diff/60))
+      || diff < 7200 && __("about an hour ago")
+      || diff < 86400 &&  __("%d hours ago").replace('%d', Math.floor(diff/3600))
+    )
+    || day_diff == 1 && __("yesterday")
+    || day_diff < 7 && __("%d days ago").replace('%d', day_diff);
+    // Longer dates don't need to change dynamically
+  };
+  $('time.relative[datetime]').each(function() {
+    var rel = prettyDate($(this).attr('datetime'));
+    if (rel) $(this).text(rel);
+  });
+}, 20000);
+
diff --git a/scp/js/ticket.js b/scp/js/ticket.js
index 7f7b879e7eb8a13f0723ee764dd668398ff5fdca..de289ca296892b8fb7196599a398d8105480c429 100644
--- a/scp/js/ticket.js
+++ b/scp/js/ticket.js
@@ -214,7 +214,7 @@ var autoLock = {
             async: false,
             cache: false,
             success: function() {
-                autoLock.lockId = 0;
+                autoLock.destroy();
             }
         });
     },
@@ -281,6 +281,11 @@ var autoLock = {
           function () { autoLock.monitorEvents(); },
           time || 30000
         );
+    },
+
+    destroy: function() {
+        autoLock.clearTimeout();
+        autoLock.lockId = 0;
     }
 };
 $.autoLock = autoLock;
@@ -305,7 +310,7 @@ $.showNonLocalImage = function(div) {
 $.showImagesInline = function(urls, thread_id) {
     var selector = (thread_id == undefined)
         ? '.thread-body img[data-cid]'
-        : '.thread-body#thread-id-'+thread_id+' img[data-cid]';
+        : '.thread-body#thread-entry-'+thread_id+' img[data-cid]';
     $(selector).each(function(i, el) {
         var e = $(el),
             cid = e.data('cid').toLowerCase(),
@@ -448,6 +453,10 @@ var ticket_onload = function($) {
                 fx.end = last_entry.offset().top - 50;
         }
     });
+
+    $('div.thread-body a').each(function() {
+        $(this).attr('target', '_blank');
+    });
 };
 $(ticket_onload);
 $(document).on('pjax:success', function() { ticket_onload(jQuery); });
diff --git a/scp/tasks.php b/scp/tasks.php
index ab0996e8822a3370f33b8b8e8d64cb62ba297d9c..2f4791356b83b0bc46dcde1b96b4e36624828f8f 100644
--- a/scp/tasks.php
+++ b/scp/tasks.php
@@ -82,7 +82,7 @@ $stats= $thisstaff->getTasksStats();
 
 // Clear advanced search upon request
 if (isset($_GET['clear_filter']))
-    unset($_SESSION['advsearch']);
+    unset($_SESSION['advsearch:tasks']);
 
 //Navigation
 $nav->setTabActive('tasks');
@@ -94,7 +94,7 @@ $nav->addSubMenu(array('desc'=>$open_name.' ('.number_format($stats['open']).')'
                        'title'=>__('Open Tasks'),
                        'href'=>'tasks.php?status=open',
                        'iconclass'=>'Ticket'),
-                    ((!$_REQUEST['status'] && !isset($_SESSION['advsearch'])) || $_REQUEST['status']=='open'));
+                    ((!$_REQUEST['status'] && !isset($_SESSION['advsearch:tasks'])) || $_REQUEST['status']=='open'));
 
 if ($stats['assigned']) {
 
@@ -124,11 +124,11 @@ if ($stats['closed']) {
                         ($_REQUEST['status']=='closed'));
 }
 
-if (isset($_SESSION['advsearch'])) {
+if (isset($_SESSION['advsearch:tasks'])) {
     // XXX: De-duplicate and simplify this code
     $search = SavedSearch::create();
-    $form = $search->getFormFromSession('advsearch');
-    $form->loadState($_SESSION['advsearch']);
+    $form = $search->getFormFromSession('advsearch:tasks');
+    $form->loadState($_SESSION['advsearch:tasks']);
     $tasks = Task::objects();
     $tasks = $search->mangleQuerySet($tasks, $form);
     $count = $tasks->count();
diff --git a/scp/tickets.php b/scp/tickets.php
index 9e244bcc61115fb39e9817c0f6283ecea41bf438..8c1f84047c82f21330b73bb3569e8ba7b680fc28 100644
--- a/scp/tickets.php
+++ b/scp/tickets.php
@@ -358,8 +358,6 @@ if($_POST && !$errors):
         default:
             $errors['err']=__('Unknown action');
         endswitch;
-        if($ticket && is_object($ticket))
-            $ticket->reload();//Reload ticket info following post processing
     }elseif($_POST['a']) {
 
         switch($_POST['a']) {
@@ -475,7 +473,6 @@ if (isset($_SESSION['advsearch'])) {
     // XXX: De-duplicate and simplify this code
     $search = SavedSearch::create();
     $form = $search->getFormFromSession('advsearch');
-    $form->loadState($_SESSION['advsearch']);
     $tickets = TicketModel::objects();
     $tickets = $search->mangleQuerySet($tickets, $form);
     $count = $tickets->count();
diff --git a/setup/css/wizard.css b/setup/css/wizard.css
index a742c75cbbbf0cc2c91b72ffbabf6a99abef16a0..9d4e8b6ae79985567148c53753b782965a23a334 100644
--- a/setup/css/wizard.css
+++ b/setup/css/wizard.css
@@ -11,7 +11,7 @@ a { color: #2a67ac; display: inline-block; }
 .hidden { display: none;}
 .error { color:#f00;}
 
-#header { height: 72px; margin-bottom: 20px; width: 100%; }
+#header { min-height: 72px; margin-bottom: 20px; width: 100%; }
 #header #logo { width: 280px; height: 72px; display: block; float: left; }
 #header .info { font-size: 11pt; font-weight: bold; border-bottom: 1px solid #2a67ac; color: #444; text-align: right; float: right; }
 #header ul { margin: 0; padding: 0; text-align: right; }
diff --git a/setup/inc/install-prereq.inc.php b/setup/inc/install-prereq.inc.php
index 12c97730a035cadf8aec02d677280f38b81c8e8a..659b01c0f29dee918377b403087e07ae2012994c 100644
--- a/setup/inc/install-prereq.inc.php
+++ b/setup/inc/install-prereq.inc.php
@@ -36,6 +36,10 @@ if(!defined('SETUPINC')) die('Kwaheri!');
                     echo __('recommended for all installations');?></li>
                 <li class="<?php echo extension_loaded('phar')?'yes':'no'; ?>">Phar <?php echo __('extension');?> &mdash; <?php
                     echo __('recommended for plugins and language packs');?></li>
+                <li class="<?php echo extension_loaded('intl')?'yes':'no'; ?>">Intl <?php echo __('extension');?> &mdash; <?php
+                    echo __('recommended for improved localization');?></li>
+                <li class="<?php echo extension_loaded('apc')?'yes':'no'; ?>">APC <?php echo __('extension');?> &mdash; <?php
+                    echo __('(faster performance)');?></li>
             </ul>
             <div id="bar">
                 <form method="post" action="install.php">
diff --git a/tickets.php b/tickets.php
index 875fdfec40dc2d4770e4891c8a5cd76832c3a5d7..070cb2567b350d42b3b7af1bbed54e543769b4ef 100644
--- a/tickets.php
+++ b/tickets.php
@@ -103,7 +103,6 @@ if ($_POST && is_object($ticket) && $ticket->getId()) {
     default:
         $errors['err']=__('Unknown action');
     }
-    $ticket->reload();
 }
 elseif (is_object($ticket) && $ticket->getId()) {
     switch(strtolower($_REQUEST['a'])) {