diff --git a/src/applications/maniphest/controller/ManiphestBatchEditController.php b/src/applications/maniphest/controller/ManiphestBatchEditController.php
index 81122cee51..f22269c10e 100644
--- a/src/applications/maniphest/controller/ManiphestBatchEditController.php
+++ b/src/applications/maniphest/controller/ManiphestBatchEditController.php
@@ -1,382 +1,379 @@
 <?php
 
 final class ManiphestBatchEditController extends ManiphestController {
 
   public function processRequest() {
     $this->requireApplicationCapability(
       ManiphestBulkEditCapability::CAPABILITY);
 
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $task_ids = $request->getArr('batch');
     $tasks = id(new ManiphestTaskQuery())
       ->setViewer($user)
       ->withIDs($task_ids)
       ->requireCapabilities(
         array(
           PhabricatorPolicyCapability::CAN_VIEW,
           PhabricatorPolicyCapability::CAN_EDIT,
         ))
       ->needSubscriberPHIDs(true)
       ->needProjectPHIDs(true)
       ->execute();
 
     $actions = $request->getStr('actions');
     if ($actions) {
       $actions = json_decode($actions, true);
     }
 
     if ($request->isFormPost() && is_array($actions)) {
       foreach ($tasks as $task) {
         $field_list = PhabricatorCustomField::getObjectFields(
           $task,
           PhabricatorCustomField::ROLE_EDIT);
         $field_list->readFieldsFromStorage($task);
 
         $xactions = $this->buildTransactions($actions, $task);
         if ($xactions) {
           // TODO: Set content source to "batch edit".
 
           $editor = id(new ManiphestTransactionEditor())
             ->setActor($user)
             ->setContentSourceFromRequest($request)
             ->setContinueOnNoEffect(true)
             ->setContinueOnMissingFields(true)
             ->applyTransactions($task, $xactions);
         }
       }
 
       $task_ids = implode(',', mpull($tasks, 'getID'));
 
       return id(new AphrontRedirectResponse())
         ->setURI('/maniphest/?ids='.$task_ids);
     }
 
     $handles = ManiphestTaskListView::loadTaskHandles($user, $tasks);
 
     $list = new ManiphestTaskListView();
     $list->setTasks($tasks);
     $list->setUser($user);
     $list->setHandles($handles);
 
     $template = new AphrontTokenizerTemplateView();
     $template = $template->render();
 
     $projects_source = new PhabricatorProjectDatasource();
     $mailable_source = new PhabricatorMetaMTAMailableDatasource();
     $owner_source = new PhabricatorTypeaheadOwnerDatasource();
 
     require_celerity_resource('maniphest-batch-editor');
     Javelin::initBehavior(
       'maniphest-batch-editor',
       array(
         'root' => 'maniphest-batch-edit-form',
         'tokenizerTemplate' => $template,
         'sources' => array(
           'project' => array(
             'src'           => $projects_source->getDatasourceURI(),
             'placeholder'   => $projects_source->getPlaceholderText(),
           ),
           'owner' => array(
             'src'           => $owner_source->getDatasourceURI(),
             'placeholder'   => $owner_source->getPlaceholderText(),
             'limit'         => 1,
           ),
           'cc'    => array(
             'src'           => $mailable_source->getDatasourceURI(),
             'placeholder'   => $mailable_source->getPlaceholderText(),
           ),
         ),
         'input' => 'batch-form-actions',
         'priorityMap' => ManiphestTaskPriority::getTaskPriorityMap(),
         'statusMap'   => ManiphestTaskStatus::getTaskStatusMap(),
       ));
 
     $form = new AphrontFormView();
     $form->setUser($user);
     $form->setID('maniphest-batch-edit-form');
 
     foreach ($tasks as $task) {
       $form->appendChild(
         phutil_tag(
           'input',
           array(
             'type' => 'hidden',
             'name' => 'batch[]',
             'value' => $task->getID(),
           )));
     }
 
     $form->appendChild(
       phutil_tag(
         'input',
         array(
           'type' => 'hidden',
           'name' => 'actions',
           'id'   => 'batch-form-actions',
         )));
     $form->appendChild(
       id(new PHUIFormInsetView())
         ->setTitle(pht('Actions'))
         ->setRightButton(javelin_tag(
             'a',
             array(
               'href' => '#',
               'class' => 'button green',
               'sigil' => 'add-action',
               'mustcapture' => true,
             ),
             pht('Add Another Action')))
         ->setContent(javelin_tag(
           'table',
           array(
             'sigil' => 'maniphest-batch-actions',
             'class' => 'maniphest-batch-actions-table',
           ),
           '')))
       ->appendChild(
         id(new AphrontFormSubmitControl())
           ->setValue(pht('Update Tasks'))
           ->addCancelButton('/maniphest/'));
 
     $title = pht('Batch Editor');
 
     $crumbs = $this->buildApplicationCrumbs();
     $crumbs->addTextCrumb($title);
 
     $task_box = id(new PHUIObjectBoxView())
       ->setHeaderText(pht('Selected Tasks'))
       ->appendChild($list);
 
     $form_box = id(new PHUIObjectBoxView())
       ->setHeaderText(pht('Batch Editor'))
       ->setForm($form);
 
     return $this->buildApplicationPage(
       array(
         $crumbs,
         $task_box,
         $form_box,
       ),
       array(
         'title' => $title,
       ));
   }
 
   private function buildTransactions($actions, ManiphestTask $task) {
     $value_map = array();
     $type_map = array(
       'add_comment'     => PhabricatorTransactions::TYPE_COMMENT,
       'assign'          => ManiphestTransaction::TYPE_OWNER,
       'status'          => ManiphestTransaction::TYPE_STATUS,
       'priority'        => ManiphestTransaction::TYPE_PRIORITY,
-      'add_project'     => ManiphestTransaction::TYPE_PROJECTS,
-      'remove_project'  => ManiphestTransaction::TYPE_PROJECTS,
+      'add_project'     => PhabricatorTransactions::TYPE_EDGE,
+      'remove_project'  => PhabricatorTransactions::TYPE_EDGE,
       'add_ccs'         => PhabricatorTransactions::TYPE_SUBSCRIBERS,
       'remove_ccs'      => PhabricatorTransactions::TYPE_SUBSCRIBERS,
     );
 
     $edge_edit_types = array(
       'add_project'    => true,
       'remove_project' => true,
       'add_ccs'        => true,
       'remove_ccs'     => true,
     );
 
     $xactions = array();
     foreach ($actions as $action) {
       if (empty($type_map[$action['action']])) {
         throw new Exception("Unknown batch edit action '{$action}'!");
       }
 
       $type = $type_map[$action['action']];
 
       // Figure out the current value, possibly after modifications by other
       // batch actions of the same type. For example, if the user chooses to
       // "Add Comment" twice, we should add both comments. More notably, if the
       // user chooses "Remove Project..." and also "Add Project...", we should
       // avoid restoring the removed project in the second transaction.
 
       if (array_key_exists($type, $value_map)) {
         $current = $value_map[$type];
       } else {
         switch ($type) {
           case PhabricatorTransactions::TYPE_COMMENT:
             $current = null;
             break;
           case ManiphestTransaction::TYPE_OWNER:
             $current = $task->getOwnerPHID();
             break;
           case ManiphestTransaction::TYPE_STATUS:
             $current = $task->getStatus();
             break;
           case ManiphestTransaction::TYPE_PRIORITY:
             $current = $task->getPriority();
             break;
-          case ManiphestTransaction::TYPE_PROJECTS:
+          case PhabricatorTransactions::TYPE_EDGE:
             $current = $task->getProjectPHIDs();
             break;
           case PhabricatorTransactions::TYPE_SUBSCRIBERS:
             $current = $task->getSubscriberPHIDs();
             break;
         }
       }
 
       // Check if the value is meaningful / provided, and normalize it if
       // necessary. This discards, e.g., empty comments and empty owner
       // changes.
 
       $value = $action['value'];
       switch ($type) {
         case PhabricatorTransactions::TYPE_COMMENT:
           if (!strlen($value)) {
             continue 2;
           }
           break;
         case ManiphestTransaction::TYPE_OWNER:
           if (empty($value)) {
             continue 2;
           }
           $value = head($value);
           if ($value === ManiphestTaskOwner::OWNER_UP_FOR_GRABS) {
             $value = null;
           }
           break;
-        case ManiphestTransaction::TYPE_PROJECTS:
+        case PhabricatorTransactions::TYPE_EDGE:
           if (empty($value)) {
             continue 2;
           }
           break;
         case PhabricatorTransactions::TYPE_SUBSCRIBERS:
           if (empty($value)) {
             continue 2;
           }
           break;
       }
 
       // If the edit doesn't change anything, go to the next action. This
       // check is only valid for changes like "owner", "status", etc, not
       // for edge edits, because we should still apply an edit like
       // "Remove Projects: A, B" to a task with projects "A, B".
 
       if (empty($edge_edit_types[$action['action']])) {
         if ($value == $current) {
           continue;
         }
       }
 
       // Apply the value change; for most edits this is just replacement, but
       // some need to merge the current and edited values (add/remove project).
 
       switch ($type) {
         case PhabricatorTransactions::TYPE_COMMENT:
           if (strlen($current)) {
             $value = $current."\n\n".$value;
           }
           break;
-        case ManiphestTransaction::TYPE_PROJECTS:
+        case PhabricatorTransactions::TYPE_EDGE:
           $is_remove = $action['action'] == 'remove_project';
 
           $current = array_fill_keys($current, true);
           $value   = array_fill_keys($value, true);
 
           $new = $current;
           $did_something = false;
 
           if ($is_remove) {
             foreach ($value as $phid => $ignored) {
               if (isset($new[$phid])) {
                 unset($new[$phid]);
                 $did_something = true;
               }
             }
           } else {
             foreach ($value as $phid => $ignored) {
               if (empty($new[$phid])) {
                 $new[$phid] = true;
                 $did_something = true;
               }
             }
           }
 
           if (!$did_something) {
             continue 2;
           }
 
           $value = array_keys($new);
           break;
         case PhabricatorTransactions::TYPE_SUBSCRIBERS:
           $is_remove = $action['action'] == 'remove_ccs';
 
           $current = array_fill_keys($current, true);
 
           $new = array();
           $did_something = false;
 
           if ($is_remove) {
             foreach ($value as $phid) {
               if (isset($current[$phid])) {
                 $new[$phid] = true;
                 $did_something = true;
               }
             }
             if ($new) {
               $value = array('-' => array_keys($new));
             }
           } else {
             $new = array();
             foreach ($value as $phid) {
               $new[$phid] = true;
               $did_something = true;
             }
             if ($new) {
               $value = array('+' => array_keys($new));
             }
           }
           if (!$did_something) {
             continue 2;
           }
 
           break;
       }
 
       $value_map[$type] = $value;
     }
 
     $template = new ManiphestTransaction();
 
     foreach ($value_map as $type => $value) {
       $xaction = clone $template;
       $xaction->setTransactionType($type);
 
       switch ($type) {
         case PhabricatorTransactions::TYPE_COMMENT:
           $xaction->attachComment(
             id(new ManiphestTransactionComment())
               ->setContent($value));
           break;
-        case ManiphestTransaction::TYPE_PROJECTS:
-
-          // TODO: Clean this mess up.
+        case PhabricatorTransactions::TYPE_EDGE:
           $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
           $xaction
-            ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
             ->setMetadataValue('edge:type', $project_type)
             ->setNewValue(
               array(
                 '=' => array_fuse($value),
               ));
           break;
         default:
           $xaction->setNewValue($value);
           break;
       }
 
       $xactions[] = $xaction;
     }
 
     return $xactions;
   }
 
 }
diff --git a/src/applications/maniphest/controller/ManiphestTaskDetailController.php b/src/applications/maniphest/controller/ManiphestTaskDetailController.php
index af85f34828..16514a6a82 100644
--- a/src/applications/maniphest/controller/ManiphestTaskDetailController.php
+++ b/src/applications/maniphest/controller/ManiphestTaskDetailController.php
@@ -1,592 +1,592 @@
 <?php
 
 final class ManiphestTaskDetailController extends ManiphestController {
 
   private $id;
 
   public function shouldAllowPublic() {
     return true;
   }
 
   public function willProcessRequest(array $data) {
     $this->id = $data['id'];
   }
 
   public function processRequest() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $e_title = null;
 
     $priority_map = ManiphestTaskPriority::getTaskPriorityMap();
 
     $task = id(new ManiphestTaskQuery())
       ->setViewer($user)
       ->withIDs(array($this->id))
       ->needSubscriberPHIDs(true)
       ->executeOne();
     if (!$task) {
       return new Aphront404Response();
     }
 
     $workflow = $request->getStr('workflow');
     $parent_task = null;
     if ($workflow && is_numeric($workflow)) {
       $parent_task = id(new ManiphestTaskQuery())
         ->setViewer($user)
         ->withIDs(array($workflow))
         ->executeOne();
     }
 
     $field_list = PhabricatorCustomField::getObjectFields(
       $task,
       PhabricatorCustomField::ROLE_VIEW);
     $field_list
       ->setViewer($user)
       ->readFieldsFromStorage($task);
 
     $e_commit = ManiphestTaskHasCommitEdgeType::EDGECONST;
     $e_dep_on = PhabricatorEdgeConfig::TYPE_TASK_DEPENDS_ON_TASK;
     $e_dep_by = PhabricatorEdgeConfig::TYPE_TASK_DEPENDED_ON_BY_TASK;
     $e_rev    = ManiphestTaskHasRevisionEdgeType::EDGECONST;
     $e_mock   = PhabricatorEdgeConfig::TYPE_TASK_HAS_MOCK;
 
     $phid = $task->getPHID();
 
     $query = id(new PhabricatorEdgeQuery())
       ->withSourcePHIDs(array($phid))
       ->withEdgeTypes(
         array(
           $e_commit,
           $e_dep_on,
           $e_dep_by,
           $e_rev,
           $e_mock,
         ));
     $edges = idx($query->execute(), $phid);
     $phids = array_fill_keys($query->getDestinationPHIDs(), true);
 
     if ($task->getOwnerPHID()) {
       $phids[$task->getOwnerPHID()] = true;
     }
     $phids[$task->getAuthorPHID()] = true;
 
     $attached = $task->getAttached();
     foreach ($attached as $type => $list) {
       foreach ($list as $phid => $info) {
         $phids[$phid] = true;
       }
     }
 
     if ($parent_task) {
       $phids[$parent_task->getPHID()] = true;
     }
 
     $phids = array_keys($phids);
 
     $this->loadHandles($phids);
 
     $handles = $this->getLoadedHandles();
 
     $context_bar = null;
 
     if ($parent_task) {
       $context_bar = new AphrontContextBarView();
       $context_bar->addButton(phutil_tag(
       'a',
       array(
         'href' => '/maniphest/task/create/?parent='.$parent_task->getID(),
         'class' => 'green button',
       ),
       pht('Create Another Subtask')));
       $context_bar->appendChild(hsprintf(
         'Created a subtask of <strong>%s</strong>',
         $this->getHandle($parent_task->getPHID())->renderLink()));
     } else if ($workflow == 'create') {
       $context_bar = new AphrontContextBarView();
       $context_bar->addButton(phutil_tag('label', array(), 'Create Another'));
       $context_bar->addButton(phutil_tag(
         'a',
         array(
           'href' => '/maniphest/task/create/?template='.$task->getID(),
           'class' => 'green button',
         ),
         pht('Similar Task')));
       $context_bar->addButton(phutil_tag(
         'a',
         array(
           'href' => '/maniphest/task/create/',
           'class' => 'green button',
         ),
         pht('Empty Task')));
       $context_bar->appendChild(pht('New task created.'));
     }
 
     $engine = new PhabricatorMarkupEngine();
     $engine->setViewer($user);
     $engine->addObject($task, ManiphestTask::MARKUP_FIELD_DESCRIPTION);
 
     $timeline = $this->buildTransactionTimeline(
       $task,
       new ManiphestTransactionQuery(),
       $engine);
 
     $resolution_types = ManiphestTaskStatus::getTaskStatusMap();
 
     $transaction_types = array(
       PhabricatorTransactions::TYPE_COMMENT     => pht('Comment'),
       ManiphestTransaction::TYPE_STATUS         => pht('Change Status'),
       ManiphestTransaction::TYPE_OWNER          => pht('Reassign / Claim'),
       PhabricatorTransactions::TYPE_SUBSCRIBERS => pht('Add CCs'),
       ManiphestTransaction::TYPE_PRIORITY       => pht('Change Priority'),
-      ManiphestTransaction::TYPE_PROJECTS       => pht('Associate Projects'),
+      PhabricatorTransactions::TYPE_EDGE        => pht('Associate Projects'),
     );
 
     // Remove actions the user doesn't have permission to take.
 
     $requires = array(
       ManiphestTransaction::TYPE_OWNER =>
         ManiphestEditAssignCapability::CAPABILITY,
       ManiphestTransaction::TYPE_PRIORITY =>
         ManiphestEditPriorityCapability::CAPABILITY,
-      ManiphestTransaction::TYPE_PROJECTS =>
+      PhabricatorTransactions::TYPE_EDGE =>
         ManiphestEditProjectsCapability::CAPABILITY,
       ManiphestTransaction::TYPE_STATUS =>
         ManiphestEditStatusCapability::CAPABILITY,
     );
 
     foreach ($transaction_types as $type => $name) {
       if (isset($requires[$type])) {
         if (!$this->hasApplicationCapability($requires[$type])) {
           unset($transaction_types[$type]);
         }
       }
     }
 
     // Don't show an option to change to the current status, or to change to
     // the duplicate status explicitly.
     unset($resolution_types[$task->getStatus()]);
     unset($resolution_types[ManiphestTaskStatus::getDuplicateStatus()]);
 
     // Don't show owner/priority changes for closed tasks, as they don't make
     // much sense.
     if ($task->isClosed()) {
       unset($transaction_types[ManiphestTransaction::TYPE_PRIORITY]);
       unset($transaction_types[ManiphestTransaction::TYPE_OWNER]);
     }
 
     $default_claim = array(
       $user->getPHID() => $user->getUsername().' ('.$user->getRealName().')',
     );
 
     $draft = id(new PhabricatorDraft())->loadOneWhere(
       'authorPHID = %s AND draftKey = %s',
       $user->getPHID(),
       $task->getPHID());
     if ($draft) {
       $draft_text = $draft->getDraft();
     } else {
       $draft_text = null;
     }
 
     $comment_form = new AphrontFormView();
     $comment_form
       ->setUser($user)
       ->setWorkflow(true)
       ->setAction('/maniphest/transaction/save/')
       ->setEncType('multipart/form-data')
       ->addHiddenInput('taskID', $task->getID())
       ->appendChild(
         id(new AphrontFormSelectControl())
           ->setLabel(pht('Action'))
           ->setName('action')
           ->setOptions($transaction_types)
           ->setID('transaction-action'))
       ->appendChild(
         id(new AphrontFormSelectControl())
           ->setLabel(pht('Status'))
           ->setName('resolution')
           ->setControlID('resolution')
           ->setControlStyle('display: none')
           ->setOptions($resolution_types))
       ->appendChild(
         id(new AphrontFormTokenizerControl())
           ->setLabel(pht('Assign To'))
           ->setName('assign_to')
           ->setControlID('assign_to')
           ->setControlStyle('display: none')
           ->setID('assign-tokenizer')
           ->setDisableBehavior(true))
       ->appendChild(
         id(new AphrontFormTokenizerControl())
           ->setLabel(pht('CCs'))
           ->setName('ccs')
           ->setControlID('ccs')
           ->setControlStyle('display: none')
           ->setID('cc-tokenizer')
           ->setDisableBehavior(true))
       ->appendChild(
         id(new AphrontFormSelectControl())
           ->setLabel(pht('Priority'))
           ->setName('priority')
           ->setOptions($priority_map)
           ->setControlID('priority')
           ->setControlStyle('display: none')
           ->setValue($task->getPriority()))
       ->appendChild(
         id(new AphrontFormTokenizerControl())
           ->setLabel(pht('Projects'))
           ->setName('projects')
           ->setControlID('projects')
           ->setControlStyle('display: none')
           ->setID('projects-tokenizer')
           ->setDisableBehavior(true))
       ->appendChild(
         id(new AphrontFormFileControl())
           ->setLabel(pht('File'))
           ->setName('file')
           ->setControlID('file')
           ->setControlStyle('display: none'))
       ->appendChild(
         id(new PhabricatorRemarkupControl())
           ->setUser($user)
           ->setLabel(pht('Comments'))
           ->setName('comments')
           ->setValue($draft_text)
           ->setID('transaction-comments')
           ->setUser($user))
       ->appendChild(
         id(new AphrontFormSubmitControl())
           ->setValue(pht('Submit')));
 
     $control_map = array(
       ManiphestTransaction::TYPE_STATUS         => 'resolution',
       ManiphestTransaction::TYPE_OWNER          => 'assign_to',
       PhabricatorTransactions::TYPE_SUBSCRIBERS => 'ccs',
       ManiphestTransaction::TYPE_PRIORITY       => 'priority',
-      ManiphestTransaction::TYPE_PROJECTS       => 'projects',
+      PhabricatorTransactions::TYPE_EDGE        => 'projects',
     );
 
     $projects_source = new PhabricatorProjectDatasource();
     $users_source = new PhabricatorPeopleDatasource();
     $mailable_source = new PhabricatorMetaMTAMailableDatasource();
 
     $tokenizer_map = array(
-      ManiphestTransaction::TYPE_PROJECTS => array(
+      PhabricatorTransactions::TYPE_EDGE => array(
         'id'          => 'projects-tokenizer',
         'src'         => $projects_source->getDatasourceURI(),
         'placeholder' => $projects_source->getPlaceholderText(),
       ),
       ManiphestTransaction::TYPE_OWNER => array(
         'id'          => 'assign-tokenizer',
         'src'         => $users_source->getDatasourceURI(),
         'value'       => $default_claim,
         'limit'       => 1,
         'placeholder' => $users_source->getPlaceholderText(),
       ),
       PhabricatorTransactions::TYPE_SUBSCRIBERS => array(
         'id'          => 'cc-tokenizer',
         'src'         => $mailable_source->getDatasourceURI(),
         'placeholder' => $mailable_source->getPlaceholderText(),
       ),
     );
 
     // TODO: Initializing these behaviors for logged out users fatals things.
     if ($user->isLoggedIn()) {
       Javelin::initBehavior('maniphest-transaction-controls', array(
         'select'     => 'transaction-action',
         'controlMap' => $control_map,
         'tokenizers' => $tokenizer_map,
       ));
 
       Javelin::initBehavior('maniphest-transaction-preview', array(
         'uri'        => '/maniphest/transaction/preview/'.$task->getID().'/',
         'preview'    => 'transaction-preview',
         'comments'   => 'transaction-comments',
         'action'     => 'transaction-action',
         'map'        => $control_map,
         'tokenizers' => $tokenizer_map,
       ));
     }
 
     $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
     $comment_header = $is_serious
       ? pht('Add Comment')
       : pht('Weigh In');
 
     $preview_panel = phutil_tag_div(
       'aphront-panel-preview',
       phutil_tag(
         'div',
         array('id' => 'transaction-preview'),
         phutil_tag_div(
           'aphront-panel-preview-loading-text',
           pht('Loading preview...'))));
 
     $object_name = 'T'.$task->getID();
     $actions = $this->buildActionView($task);
 
     $crumbs = $this->buildApplicationCrumbs()
       ->addTextCrumb($object_name, '/'.$object_name)
       ->setActionList($actions);
 
     $header = $this->buildHeaderView($task);
     $properties = $this->buildPropertyView(
       $task, $field_list, $edges, $actions);
     $description = $this->buildDescriptionView($task, $engine);
 
     if (!$user->isLoggedIn()) {
       // TODO: Eventually, everything should run through this. For now, we're
       // only using it to get a consistent "Login to Comment" button.
       $comment_box = id(new PhabricatorApplicationTransactionCommentView())
         ->setUser($user)
         ->setRequestURI($request->getRequestURI());
       $preview_panel = null;
     } else {
       $comment_box = id(new PHUIObjectBoxView())
         ->setFlush(true)
         ->setHeaderText($comment_header)
         ->appendChild($comment_form);
       $timeline->setQuoteTargetID('transaction-comments');
       $timeline->setQuoteRef($object_name);
     }
 
     $object_box = id(new PHUIObjectBoxView())
       ->setHeader($header)
       ->addPropertyList($properties);
 
     if ($description) {
       $object_box->addPropertyList($description);
     }
 
     return $this->buildApplicationPage(
       array(
         $crumbs,
         $context_bar,
         $object_box,
         $timeline,
         $comment_box,
         $preview_panel,
       ),
       array(
         'title' => 'T'.$task->getID().' '.$task->getTitle(),
         'pageObjects' => array($task->getPHID()),
       ));
   }
 
   private function buildHeaderView(ManiphestTask $task) {
     $view = id(new PHUIHeaderView())
       ->setHeader($task->getTitle())
       ->setUser($this->getRequest()->getUser())
       ->setPolicyObject($task);
 
     $status = $task->getStatus();
     $status_name = ManiphestTaskStatus::renderFullDescription($status);
 
     $view->addProperty(PHUIHeaderView::PROPERTY_STATUS, $status_name);
 
     return $view;
   }
 
 
   private function buildActionView(ManiphestTask $task) {
     $viewer = $this->getRequest()->getUser();
     $viewer_phid = $viewer->getPHID();
 
     $id = $task->getID();
     $phid = $task->getPHID();
 
     $can_edit = PhabricatorPolicyFilter::hasCapability(
       $viewer,
       $task,
       PhabricatorPolicyCapability::CAN_EDIT);
 
     $view = id(new PhabricatorActionListView())
       ->setUser($viewer)
       ->setObject($task)
       ->setObjectURI($this->getRequest()->getRequestURI());
 
     $view->addAction(
       id(new PhabricatorActionView())
         ->setName(pht('Edit Task'))
         ->setIcon('fa-pencil')
         ->setHref($this->getApplicationURI("/task/edit/{$id}/"))
         ->setDisabled(!$can_edit)
         ->setWorkflow(!$can_edit));
 
     $view->addAction(
       id(new PhabricatorActionView())
         ->setName(pht('Merge Duplicates In'))
         ->setHref("/search/attach/{$phid}/TASK/merge/")
         ->setWorkflow(true)
         ->setIcon('fa-compress')
         ->setDisabled(!$can_edit)
         ->setWorkflow(true));
 
     $view->addAction(
       id(new PhabricatorActionView())
         ->setName(pht('Create Subtask'))
         ->setHref($this->getApplicationURI("/task/create/?parent={$id}"))
         ->setIcon('fa-level-down'));
 
     $view->addAction(
       id(new PhabricatorActionView())
         ->setName(pht('Edit Blocking Tasks'))
         ->setHref("/search/attach/{$phid}/TASK/blocks/")
         ->setWorkflow(true)
         ->setIcon('fa-link')
         ->setDisabled(!$can_edit)
         ->setWorkflow(true));
 
     return $view;
   }
 
   private function buildPropertyView(
     ManiphestTask $task,
     PhabricatorCustomFieldList $field_list,
     array $edges,
     PhabricatorActionListView $actions) {
 
     $viewer = $this->getRequest()->getUser();
 
     $view = id(new PHUIPropertyListView())
       ->setUser($viewer)
       ->setObject($task)
       ->setActionList($actions);
 
     $view->addProperty(
       pht('Assigned To'),
       $task->getOwnerPHID()
       ? $this->getHandle($task->getOwnerPHID())->renderLink()
       : phutil_tag('em', array(), pht('None')));
 
     $view->addProperty(
       pht('Priority'),
       ManiphestTaskPriority::getTaskPriorityName($task->getPriority()));
 
     $view->addProperty(
       pht('Author'),
       $this->getHandle($task->getAuthorPHID())->renderLink());
 
     $source = $task->getOriginalEmailSource();
     if ($source) {
       $subject = '[T'.$task->getID().'] '.$task->getTitle();
       $view->addProperty(
         pht('From Email'),
         phutil_tag(
           'a',
           array(
             'href' => 'mailto:'.$source.'?subject='.$subject,
           ),
           $source));
     }
 
     $edge_types = array(
       PhabricatorEdgeConfig::TYPE_TASK_DEPENDED_ON_BY_TASK
         => pht('Blocks'),
       PhabricatorEdgeConfig::TYPE_TASK_DEPENDS_ON_TASK
         => pht('Blocked By'),
       ManiphestTaskHasRevisionEdgeType::EDGECONST
         => pht('Differential Revisions'),
       PhabricatorEdgeConfig::TYPE_TASK_HAS_MOCK
         => pht('Pholio Mocks'),
     );
 
     $revisions_commits = array();
     $handles = $this->getLoadedHandles();
 
     $commit_phids = array_keys(
       $edges[ManiphestTaskHasCommitEdgeType::EDGECONST]);
     if ($commit_phids) {
       $commit_drev = PhabricatorEdgeConfig::TYPE_COMMIT_HAS_DREV;
       $drev_edges = id(new PhabricatorEdgeQuery())
         ->withSourcePHIDs($commit_phids)
         ->withEdgeTypes(array($commit_drev))
         ->execute();
 
       foreach ($commit_phids as $phid) {
         $revisions_commits[$phid] = $handles[$phid]->renderLink();
         $revision_phid = key($drev_edges[$phid][$commit_drev]);
         $revision_handle = idx($handles, $revision_phid);
         if ($revision_handle) {
           $task_drev = ManiphestTaskHasRevisionEdgeType::EDGECONST;
           unset($edges[$task_drev][$revision_phid]);
           $revisions_commits[$phid] = hsprintf(
             '%s / %s',
             $revision_handle->renderLink($revision_handle->getName()),
             $revisions_commits[$phid]);
         }
       }
     }
 
     foreach ($edge_types as $edge_type => $edge_name) {
       if ($edges[$edge_type]) {
         $view->addProperty(
           $edge_name,
           $this->renderHandlesForPHIDs(array_keys($edges[$edge_type])));
       }
     }
 
     if ($revisions_commits) {
       $view->addProperty(
         pht('Commits'),
         phutil_implode_html(phutil_tag('br'), $revisions_commits));
     }
 
     $attached = $task->getAttached();
     if (!is_array($attached)) {
       $attached = array();
     }
 
     $file_infos = idx($attached, PhabricatorFileFilePHIDType::TYPECONST);
     if ($file_infos) {
       $file_phids = array_keys($file_infos);
 
       // TODO: These should probably be handles or something; clean this up
       // as we sort out file attachments.
       $files = id(new PhabricatorFileQuery())
         ->setViewer($viewer)
         ->withPHIDs($file_phids)
         ->execute();
 
       $file_view = new PhabricatorFileLinkListView();
       $file_view->setFiles($files);
 
       $view->addProperty(
         pht('Files'),
         $file_view->render());
     }
 
     $view->invokeWillRenderEvent();
 
     $field_list->appendFieldsToPropertyList(
       $task,
       $viewer,
       $view);
 
     return $view;
   }
 
   private function buildDescriptionView(
     ManiphestTask $task,
     PhabricatorMarkupEngine $engine) {
 
     $section = null;
     if (strlen($task->getDescription())) {
       $section = new PHUIPropertyListView();
       $section->addSectionHeader(
         pht('Description'),
         PHUIPropertyListView::ICON_SUMMARY);
       $section->addTextContent(
         phutil_tag(
           'div',
           array(
             'class' => 'phabricator-remarkup',
           ),
           $engine->getOutput($task, ManiphestTask::MARKUP_FIELD_DESCRIPTION)));
     }
 
     return $section;
   }
 
 }
diff --git a/src/applications/maniphest/controller/ManiphestTaskEditController.php b/src/applications/maniphest/controller/ManiphestTaskEditController.php
index 5d91917d4e..cfd121e59b 100644
--- a/src/applications/maniphest/controller/ManiphestTaskEditController.php
+++ b/src/applications/maniphest/controller/ManiphestTaskEditController.php
@@ -1,761 +1,759 @@
 <?php
 
 final class ManiphestTaskEditController extends ManiphestController {
 
   private $id;
 
   public function willProcessRequest(array $data) {
     $this->id = idx($data, 'id');
   }
 
   public function processRequest() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $response_type = $request->getStr('responseType', 'task');
     $order = $request->getStr('order', PhabricatorProjectColumn::DEFAULT_ORDER);
 
     $can_edit_assign = $this->hasApplicationCapability(
       ManiphestEditAssignCapability::CAPABILITY);
     $can_edit_policies = $this->hasApplicationCapability(
       ManiphestEditPoliciesCapability::CAPABILITY);
     $can_edit_priority = $this->hasApplicationCapability(
       ManiphestEditPriorityCapability::CAPABILITY);
     $can_edit_projects = $this->hasApplicationCapability(
       ManiphestEditProjectsCapability::CAPABILITY);
     $can_edit_status = $this->hasApplicationCapability(
       ManiphestEditStatusCapability::CAPABILITY);
 
     $parent_task = null;
     $template_id = null;
 
     if ($this->id) {
       $task = id(new ManiphestTaskQuery())
         ->setViewer($user)
         ->requireCapabilities(
           array(
             PhabricatorPolicyCapability::CAN_VIEW,
             PhabricatorPolicyCapability::CAN_EDIT,
           ))
         ->withIDs(array($this->id))
         ->needSubscriberPHIDs(true)
         ->needProjectPHIDs(true)
         ->executeOne();
       if (!$task) {
         return new Aphront404Response();
       }
     } else {
       $task = ManiphestTask::initializeNewTask($user);
 
       // We currently do not allow you to set the task status when creating
       // a new task, although now that statuses are custom it might make
       // sense.
       $can_edit_status = false;
 
       // These allow task creation with defaults.
       if (!$request->isFormPost()) {
         $task->setTitle($request->getStr('title'));
 
         if ($can_edit_projects) {
           $projects = $request->getStr('projects');
           if ($projects) {
             $tokens = $request->getStrList('projects');
 
             $type_project = PhabricatorProjectProjectPHIDType::TYPECONST;
             foreach ($tokens as $key => $token) {
               if (phid_get_type($token) == $type_project) {
                 // If this is formatted like a PHID, leave it as-is.
                 continue;
               }
 
               if (preg_match('/^#/', $token)) {
                 // If this already has a "#", leave it as-is.
                 continue;
               }
 
               // Add a "#" prefix.
               $tokens[$key] = '#'.$token;
             }
 
             $default_projects = id(new PhabricatorObjectQuery())
               ->setViewer($user)
               ->withNames($tokens)
               ->execute();
             $default_projects = mpull($default_projects, 'getPHID');
 
             if ($default_projects) {
               $task->attachProjectPHIDs($default_projects);
             }
           }
         }
 
         if ($can_edit_priority) {
           $priority = $request->getInt('priority');
           if ($priority !== null) {
             $priority_map = ManiphestTaskPriority::getTaskPriorityMap();
             if (isset($priority_map[$priority])) {
                 $task->setPriority($priority);
             }
           }
         }
 
         $task->setDescription($request->getStr('description'));
 
         if ($can_edit_assign) {
           $assign = $request->getStr('assign');
           if (strlen($assign)) {
             $assign_user = id(new PhabricatorPeopleQuery())
               ->setViewer($user)
               ->withUsernames(array($assign))
               ->executeOne();
             if (!$assign_user) {
               $assign_user = id(new PhabricatorPeopleQuery())
                 ->setViewer($user)
                 ->withPHIDs(array($assign))
                 ->executeOne();
             }
 
             if ($assign_user) {
               $task->setOwnerPHID($assign_user->getPHID());
             }
           }
         }
       }
 
       $template_id = $request->getInt('template');
 
       // You can only have a parent task if you're creating a new task.
       $parent_id = $request->getInt('parent');
       if ($parent_id) {
         $parent_task = id(new ManiphestTaskQuery())
           ->setViewer($user)
           ->withIDs(array($parent_id))
           ->executeOne();
         if (!$template_id) {
           $template_id = $parent_id;
         }
       }
     }
 
     $errors = array();
     $e_title = true;
 
     $field_list = PhabricatorCustomField::getObjectFields(
       $task,
       PhabricatorCustomField::ROLE_EDIT);
     $field_list->setViewer($user);
     $field_list->readFieldsFromStorage($task);
 
     $aux_fields = $field_list->getFields();
 
     if ($request->isFormPost()) {
       $changes = array();
 
       $new_title = $request->getStr('title');
       $new_desc = $request->getStr('description');
       $new_status = $request->getStr('status');
 
       if (!$task->getID()) {
         $workflow = 'create';
       } else {
         $workflow = '';
       }
 
       $changes[ManiphestTransaction::TYPE_TITLE] = $new_title;
       $changes[ManiphestTransaction::TYPE_DESCRIPTION] = $new_desc;
 
       if ($can_edit_status) {
         $changes[ManiphestTransaction::TYPE_STATUS] = $new_status;
       } else if (!$task->getID()) {
         // Create an initial status transaction for the burndown chart.
         // TODO: We can probably remove this once Facts comes online.
         $changes[ManiphestTransaction::TYPE_STATUS] = $task->getStatus();
       }
 
       $owner_tokenizer = $request->getArr('assigned_to');
       $owner_phid = reset($owner_tokenizer);
 
       if (!strlen($new_title)) {
         $e_title = pht('Required');
         $errors[] = pht('Title is required.');
       }
 
       $old_values = array();
       foreach ($aux_fields as $aux_arr_key => $aux_field) {
         // TODO: This should be buildFieldTransactionsFromRequest() once we
         // switch to ApplicationTransactions properly.
 
         $aux_old_value = $aux_field->getOldValueForApplicationTransactions();
         $aux_field->readValueFromRequest($request);
         $aux_new_value = $aux_field->getNewValueForApplicationTransactions();
 
         // TODO: We're faking a call to the ApplicaitonTransaction validation
         // logic here. We need valid objects to pass, but they aren't used
         // in a meaningful way. For now, build User objects. Once the Maniphest
         // objects exist, this will switch over automatically. This is a big
         // hack but shouldn't be long for this world.
         $placeholder_editor = new PhabricatorUserProfileEditor();
 
         $field_errors = $aux_field->validateApplicationTransactions(
           $placeholder_editor,
           PhabricatorTransactions::TYPE_CUSTOMFIELD,
           array(
             id(new ManiphestTransaction())
               ->setOldValue($aux_old_value)
               ->setNewValue($aux_new_value),
           ));
 
         foreach ($field_errors as $error) {
           $errors[] = $error->getMessage();
         }
 
         $old_values[$aux_field->getFieldKey()] = $aux_old_value;
       }
 
       if ($errors) {
         $task->setTitle($new_title);
         $task->setDescription($new_desc);
         $task->setPriority($request->getInt('priority'));
         $task->setOwnerPHID($owner_phid);
         $task->attachSubscriberPHIDs($request->getArr('cc'));
         $task->attachProjectPHIDs($request->getArr('projects'));
       } else {
 
         if ($can_edit_priority) {
           $changes[ManiphestTransaction::TYPE_PRIORITY] =
             $request->getInt('priority');
         }
         if ($can_edit_assign) {
           $changes[ManiphestTransaction::TYPE_OWNER] = $owner_phid;
         }
 
         $changes[PhabricatorTransactions::TYPE_SUBSCRIBERS] =
           array('=' => $request->getArr('cc'));
 
         if ($can_edit_projects) {
           $projects = $request->getArr('projects');
-          $changes[ManiphestTransaction::TYPE_PROJECTS] =
+          $changes[PhabricatorTransactions::TYPE_EDGE] =
             $projects;
           $column_phid = $request->getStr('columnPHID');
           // allow for putting a task in a project column at creation -only-
           if (!$task->getID() && $column_phid && $projects) {
             $column = id(new PhabricatorProjectColumnQuery())
               ->setViewer($user)
               ->withProjectPHIDs($projects)
               ->withPHIDs(array($column_phid))
               ->executeOne();
             if ($column) {
               $changes[ManiphestTransaction::TYPE_PROJECT_COLUMN] =
                 array(
                   'new' => array(
                     'projectPHID' => $column->getProjectPHID(),
                     'columnPHIDs' => array($column_phid),
                   ),
                   'old' => array(
                     'projectPHID' => $column->getProjectPHID(),
                     'columnPHIDs' => array(),
                   ),
                 );
             }
           }
         }
 
         if ($can_edit_policies) {
           $changes[PhabricatorTransactions::TYPE_VIEW_POLICY] =
             $request->getStr('viewPolicy');
           $changes[PhabricatorTransactions::TYPE_EDIT_POLICY] =
             $request->getStr('editPolicy');
         }
 
         $template = new ManiphestTransaction();
         $transactions = array();
 
         foreach ($changes as $type => $value) {
           $transaction = clone $template;
           $transaction->setTransactionType($type);
           if ($type == ManiphestTransaction::TYPE_PROJECT_COLUMN) {
             $transaction->setNewValue($value['new']);
             $transaction->setOldValue($value['old']);
-          } else if ($type == ManiphestTransaction::TYPE_PROJECTS) {
-            // TODO: Gross.
+          } else if ($type == PhabricatorTransactions::TYPE_EDGE) {
             $project_type =
               PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
             $transaction
-              ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
               ->setMetadataValue('edge:type', $project_type)
               ->setNewValue(
                 array(
                   '=' => array_fuse($value),
                 ));
           } else {
             $transaction->setNewValue($value);
           }
           $transactions[] = $transaction;
         }
 
         if ($aux_fields) {
           foreach ($aux_fields as $aux_field) {
             $transaction = clone $template;
             $transaction->setTransactionType(
               PhabricatorTransactions::TYPE_CUSTOMFIELD);
             $aux_key = $aux_field->getFieldKey();
             $transaction->setMetadataValue('customfield:key', $aux_key);
             $old = idx($old_values, $aux_key);
             $new = $aux_field->getNewValueForApplicationTransactions();
 
             $transaction->setOldValue($old);
             $transaction->setNewValue($new);
 
             $transactions[] = $transaction;
           }
         }
 
         if ($transactions) {
           $is_new = !$task->getID();
 
           $event = new PhabricatorEvent(
             PhabricatorEventType::TYPE_MANIPHEST_WILLEDITTASK,
             array(
               'task'          => $task,
               'new'           => $is_new,
               'transactions'  => $transactions,
             ));
           $event->setUser($user);
           $event->setAphrontRequest($request);
           PhutilEventEngine::dispatchEvent($event);
 
           $task = $event->getValue('task');
           $transactions = $event->getValue('transactions');
 
           $editor = id(new ManiphestTransactionEditor())
             ->setActor($user)
             ->setContentSourceFromRequest($request)
             ->setContinueOnNoEffect(true)
             ->applyTransactions($task, $transactions);
 
           $event = new PhabricatorEvent(
             PhabricatorEventType::TYPE_MANIPHEST_DIDEDITTASK,
             array(
               'task'          => $task,
               'new'           => $is_new,
               'transactions'  => $transactions,
             ));
           $event->setUser($user);
           $event->setAphrontRequest($request);
           PhutilEventEngine::dispatchEvent($event);
         }
 
 
         if ($parent_task) {
           // TODO: This should be transactional now.
           id(new PhabricatorEdgeEditor())
             ->addEdge(
               $parent_task->getPHID(),
               PhabricatorEdgeConfig::TYPE_TASK_DEPENDS_ON_TASK,
               $task->getPHID())
             ->save();
           $workflow = $parent_task->getID();
         }
 
         if ($request->isAjax()) {
           switch ($response_type) {
             case 'card':
               $owner = null;
               if ($task->getOwnerPHID()) {
                 $owner = id(new PhabricatorHandleQuery())
                   ->setViewer($user)
                   ->withPHIDs(array($task->getOwnerPHID()))
                   ->executeOne();
               }
               $tasks = id(new ProjectBoardTaskCard())
                 ->setViewer($user)
                 ->setTask($task)
                 ->setOwner($owner)
                 ->setCanEdit(true)
                 ->getItem();
 
               $column = id(new PhabricatorProjectColumnQuery())
                 ->setViewer($user)
                 ->withPHIDs(array($request->getStr('columnPHID')))
                 ->executeOne();
               if (!$column) {
                 return new Aphront404Response();
               }
 
               $positions = id(new PhabricatorProjectColumnPositionQuery())
                 ->setViewer($user)
                 ->withColumns(array($column))
                 ->execute();
               $task_phids = mpull($positions, 'getObjectPHID');
 
               $column_tasks = id(new ManiphestTaskQuery())
                 ->setViewer($user)
                 ->withPHIDs($task_phids)
                 ->execute();
 
               if ($order == PhabricatorProjectColumn::ORDER_NATURAL) {
                 // TODO: This is a little bit awkward, because PHP and JS use
                 // slightly different sort order parameters to achieve the same
                 // effect. It would be unify this a bit at some point.
                 $sort_map = array();
                 foreach ($positions as $position) {
                   $sort_map[$position->getObjectPHID()] = array(
                     -$position->getSequence(),
                     $position->getID(),
                   );
                 }
               } else {
                 $sort_map = mpull(
                   $column_tasks,
                   'getPrioritySortVector',
                   'getPHID');
               }
 
               $data = array(
                 'sortMap' => $sort_map,
               );
               break;
             case 'task':
             default:
               $tasks = $this->renderSingleTask($task);
               $data = array();
               break;
           }
           return id(new AphrontAjaxResponse())->setContent(
             array(
               'tasks' => $tasks,
               'data' => $data,
             ));
         }
 
         $redirect_uri = '/T'.$task->getID();
 
         if ($workflow) {
           $redirect_uri .= '?workflow='.$workflow;
         }
 
         return id(new AphrontRedirectResponse())
           ->setURI($redirect_uri);
       }
     } else {
       if (!$task->getID()) {
         $task->attachSubscriberPHIDs(array(
           $user->getPHID(),
         ));
         if ($template_id) {
           $template_task = id(new ManiphestTaskQuery())
             ->setViewer($user)
             ->withIDs(array($template_id))
             ->needSubscriberPHIDs(true)
             ->needProjectPHIDs(true)
             ->executeOne();
           if ($template_task) {
             $cc_phids = array_unique(array_merge(
               $template_task->getSubscriberPHIDs(),
               array($user->getPHID())));
             $task->attachSubscriberPHIDs($cc_phids);
             $task->attachProjectPHIDs($template_task->getProjectPHIDs());
             $task->setOwnerPHID($template_task->getOwnerPHID());
             $task->setPriority($template_task->getPriority());
             $task->setViewPolicy($template_task->getViewPolicy());
             $task->setEditPolicy($template_task->getEditPolicy());
 
             $template_fields = PhabricatorCustomField::getObjectFields(
               $template_task,
               PhabricatorCustomField::ROLE_EDIT);
 
             $fields = $template_fields->getFields();
             foreach ($fields as $key => $field) {
               if (!$field->shouldCopyWhenCreatingSimilarTask()) {
                 unset($fields[$key]);
               }
               if (empty($aux_fields[$key])) {
                 unset($fields[$key]);
               }
             }
 
             if ($fields) {
               id(new PhabricatorCustomFieldList($fields))
                 ->setViewer($user)
                 ->readFieldsFromStorage($template_task);
 
               foreach ($fields as $key => $field) {
                 $aux_fields[$key]->setValueFromStorage(
                   $field->getValueForStorage());
               }
             }
           }
         }
       }
     }
 
     $phids = array_merge(
       array($task->getOwnerPHID()),
       $task->getSubscriberPHIDs(),
       $task->getProjectPHIDs());
 
     if ($parent_task) {
       $phids[] = $parent_task->getPHID();
     }
 
     $phids = array_filter($phids);
     $phids = array_unique($phids);
 
     $handles = $this->loadViewerHandles($phids);
 
     $error_view = null;
     if ($errors) {
       $error_view = new AphrontErrorView();
       $error_view->setErrors($errors);
     }
 
     $priority_map = ManiphestTaskPriority::getTaskPriorityMap();
 
     if ($task->getOwnerPHID()) {
       $assigned_value = array($handles[$task->getOwnerPHID()]);
     } else {
       $assigned_value = array();
     }
 
     if ($task->getSubscriberPHIDs()) {
       $cc_value = array_select_keys($handles, $task->getSubscriberPHIDs());
     } else {
       $cc_value = array();
     }
 
     if ($task->getProjectPHIDs()) {
       $projects_value = array_select_keys($handles, $task->getProjectPHIDs());
     } else {
       $projects_value = array();
     }
 
     $cancel_id = nonempty($task->getID(), $template_id);
     if ($cancel_id) {
       $cancel_uri = '/T'.$cancel_id;
     } else {
       $cancel_uri = '/maniphest/';
     }
 
     if ($task->getID()) {
       $button_name = pht('Save Task');
       $header_name = pht('Edit Task');
     } else if ($parent_task) {
       $cancel_uri = '/T'.$parent_task->getID();
       $button_name = pht('Create Task');
       $header_name = pht('Create New Subtask');
     } else {
       $button_name = pht('Create Task');
       $header_name = pht('Create New Task');
     }
 
     require_celerity_resource('maniphest-task-edit-css');
 
     $project_tokenizer_id = celerity_generate_unique_node_id();
 
     $form = new AphrontFormView();
     $form
       ->setUser($user)
       ->addHiddenInput('template', $template_id)
       ->addHiddenInput('responseType', $response_type)
       ->addHiddenInput('order', $order)
       ->addHiddenInput('ungrippable', $request->getStr('ungrippable'))
       ->addHiddenInput('columnPHID', $request->getStr('columnPHID'));
 
     if ($parent_task) {
       $form
         ->appendChild(
           id(new AphrontFormStaticControl())
             ->setLabel(pht('Parent Task'))
             ->setValue($handles[$parent_task->getPHID()]->getFullName()))
         ->addHiddenInput('parent', $parent_task->getID());
     }
 
     $form
       ->appendChild(
         id(new AphrontFormTextAreaControl())
           ->setLabel(pht('Title'))
           ->setName('title')
           ->setError($e_title)
           ->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_SHORT)
           ->setValue($task->getTitle()));
 
     if ($can_edit_status) {
       // See T4819.
       $status_map = ManiphestTaskStatus::getTaskStatusMap();
       $dup_status = ManiphestTaskStatus::getDuplicateStatus();
 
       if ($task->getStatus() != $dup_status) {
         unset($status_map[$dup_status]);
       }
 
       $form
         ->appendChild(
           id(new AphrontFormSelectControl())
             ->setLabel(pht('Status'))
             ->setName('status')
             ->setValue($task->getStatus())
             ->setOptions($status_map));
     }
 
     $policies = id(new PhabricatorPolicyQuery())
       ->setViewer($user)
       ->setObject($task)
       ->execute();
 
     if ($can_edit_assign) {
       $form->appendChild(
         id(new AphrontFormTokenizerControl())
           ->setLabel(pht('Assigned To'))
           ->setName('assigned_to')
           ->setValue($assigned_value)
           ->setUser($user)
           ->setDatasource(new PhabricatorPeopleDatasource())
           ->setLimit(1));
     }
 
     $form
       ->appendChild(
         id(new AphrontFormTokenizerControl())
           ->setLabel(pht('CC'))
           ->setName('cc')
           ->setValue($cc_value)
           ->setUser($user)
           ->setDatasource(new PhabricatorMetaMTAMailableDatasource()));
 
     if ($can_edit_priority) {
       $form
         ->appendChild(
           id(new AphrontFormSelectControl())
             ->setLabel(pht('Priority'))
             ->setName('priority')
             ->setOptions($priority_map)
             ->setValue($task->getPriority()));
     }
 
     if ($can_edit_policies) {
       $form
         ->appendChild(
           id(new AphrontFormPolicyControl())
             ->setUser($user)
             ->setCapability(PhabricatorPolicyCapability::CAN_VIEW)
             ->setPolicyObject($task)
             ->setPolicies($policies)
             ->setName('viewPolicy'))
         ->appendChild(
           id(new AphrontFormPolicyControl())
             ->setUser($user)
             ->setCapability(PhabricatorPolicyCapability::CAN_EDIT)
             ->setPolicyObject($task)
             ->setPolicies($policies)
             ->setName('editPolicy'));
     }
 
     if ($can_edit_projects) {
       $form
         ->appendChild(
           id(new AphrontFormTokenizerControl())
             ->setLabel(pht('Projects'))
             ->setName('projects')
             ->setValue($projects_value)
             ->setID($project_tokenizer_id)
             ->setCaption(
               javelin_tag(
                 'a',
                 array(
                   'href'        => '/project/create/',
                   'mustcapture' => true,
                   'sigil'       => 'project-create',
                 ),
                 pht('Create New Project')))
             ->setDatasource(new PhabricatorProjectDatasource()));
     }
 
     $field_list->appendFieldsToForm($form);
 
     require_celerity_resource('aphront-error-view-css');
 
     Javelin::initBehavior('project-create', array(
       'tokenizerID' => $project_tokenizer_id,
     ));
 
     $description_control = new PhabricatorRemarkupControl();
     // "Upsell" creating tasks via email in create flows if the instance is
     // configured for this awesomeness.
     $email_create = PhabricatorEnv::getEnvConfig(
       'metamta.maniphest.public-create-email');
     if (!$task->getID() && $email_create) {
       $email_hint = pht(
         'You can also create tasks by sending an email to: %s',
         phutil_tag('tt', array(), $email_create));
       $description_control->setCaption($email_hint);
     }
 
     $description_control
       ->setLabel(pht('Description'))
       ->setName('description')
       ->setID('description-textarea')
       ->setValue($task->getDescription())
       ->setUser($user);
 
     $form
       ->appendChild($description_control);
 
 
     if ($request->isAjax()) {
       $dialog = id(new AphrontDialogView())
         ->setUser($user)
         ->setWidth(AphrontDialogView::WIDTH_FULL)
         ->setTitle($header_name)
         ->appendChild(
           array(
             $error_view,
             $form->buildLayoutView(),
           ))
         ->addCancelButton($cancel_uri)
         ->addSubmitButton($button_name);
       return id(new AphrontDialogResponse())->setDialog($dialog);
     }
 
     $form
       ->appendChild(
         id(new AphrontFormSubmitControl())
           ->addCancelButton($cancel_uri)
           ->setValue($button_name));
 
     $form_box = id(new PHUIObjectBoxView())
       ->setHeaderText($header_name)
       ->setFormErrors($errors)
       ->setForm($form);
 
     $preview = id(new PHUIRemarkupPreviewPanel())
       ->setHeader(pht('Description Preview'))
       ->setControlID('description-textarea')
       ->setPreviewURI($this->getApplicationURI('task/descriptionpreview/'));
 
     if ($task->getID()) {
       $page_objects = array($task->getPHID());
     } else {
       $page_objects = array();
     }
 
     $crumbs = $this->buildApplicationCrumbs();
 
     if ($task->getID()) {
       $crumbs->addTextCrumb('T'.$task->getID(), '/T'.$task->getID());
     }
 
     $crumbs->addTextCrumb($header_name);
 
     return $this->buildApplicationPage(
       array(
         $crumbs,
         $form_box,
         $preview,
       ),
       array(
         'title' => $header_name,
         'pageObjects' => $page_objects,
       ));
   }
 
 }
diff --git a/src/applications/maniphest/controller/ManiphestTransactionPreviewController.php b/src/applications/maniphest/controller/ManiphestTransactionPreviewController.php
index 6f1d4f4787..25f78c00df 100644
--- a/src/applications/maniphest/controller/ManiphestTransactionPreviewController.php
+++ b/src/applications/maniphest/controller/ManiphestTransactionPreviewController.php
@@ -1,134 +1,134 @@
 <?php
 
 final class ManiphestTransactionPreviewController extends ManiphestController {
 
   private $id;
 
   public function willProcessRequest(array $data) {
     $this->id = $data['id'];
   }
 
   public function processRequest() {
 
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $comments = $request->getStr('comments');
 
     $task = id(new ManiphestTaskQuery())
       ->setViewer($user)
       ->withIDs(array($this->id))
       ->executeOne();
     if (!$task) {
       return new Aphront404Response();
     }
 
     id(new PhabricatorDraft())
       ->setAuthorPHID($user->getPHID())
       ->setDraftKey($task->getPHID())
       ->setDraft($comments)
       ->replaceOrDelete();
 
     $action = $request->getStr('action');
 
     $transaction = new ManiphestTransaction();
     $transaction->setAuthorPHID($user->getPHID());
     $transaction->setTransactionType($action);
 
     // This should really be split into a separate transaction, but it should
     // all come out in the wash once we fully move to modern stuff.
     $transaction->attachComment(
       id(new ManiphestTransactionComment())
         ->setContent($comments));
 
     $value = $request->getStr('value');
     // grab phids for handles and set transaction values based on action and
     // value (empty or control-specific format) coming in from the wire
     switch ($action) {
       case ManiphestTransaction::TYPE_PRIORITY:
         $transaction->setOldValue($task->getPriority());
         $transaction->setNewValue($value);
         break;
       case ManiphestTransaction::TYPE_OWNER:
         if ($value) {
           $value = current(json_decode($value));
           $phids = array($value);
         } else {
           $phids = array();
         }
         $transaction->setNewValue($value);
         break;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         if ($value) {
           $value = json_decode($value);
         }
         if (!$value) {
           $value = array();
         }
         $phids = array();
         foreach ($value as $cc_phid) {
           $phids[] = $cc_phid;
         }
         $transaction->setOldValue(array());
         $transaction->setNewValue($phids);
         break;
-      case ManiphestTransaction::TYPE_PROJECTS:
+      case PhabricatorTransactions::TYPE_EDGE:
         if ($value) {
           $value = json_decode($value);
         }
         if (!$value) {
           $value = array();
         }
 
         $phids = array();
         $value = array_fuse($value);
         foreach ($value as $project_phid) {
           $phids[] = $project_phid;
           $value[$project_phid] = array('dst' => $project_phid);
         }
 
         $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
         $transaction
           ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
           ->setMetadataValue('edge:type', $project_type)
           ->setOldValue(array())
           ->setNewValue($value);
         break;
       case ManiphestTransaction::TYPE_STATUS:
         $phids = array();
         $transaction->setOldValue($task->getStatus());
         $transaction->setNewValue($value);
         break;
       default:
         $phids = array();
         $transaction->setNewValue($value);
         break;
     }
     $phids[] = $user->getPHID();
 
     $handles = $this->loadViewerHandles($phids);
 
     $transactions = array();
     $transactions[] = $transaction;
 
     $engine = new PhabricatorMarkupEngine();
     $engine->setViewer($user);
     if ($transaction->hasComment()) {
       $engine->addObject(
         $transaction->getComment(),
         PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT);
     }
     $engine->process();
 
     $transaction->setHandles($handles);
 
     $view = id(new PhabricatorApplicationTransactionView())
       ->setUser($user)
       ->setTransactions($transactions)
       ->setIsPreview(true);
 
     return id(new AphrontAjaxResponse())
       ->setContent((string)phutil_implode_html('', $view->buildEvents()));
   }
 
 }
diff --git a/src/applications/maniphest/controller/ManiphestTransactionSaveController.php b/src/applications/maniphest/controller/ManiphestTransactionSaveController.php
index cc5be68982..6f6e60df27 100644
--- a/src/applications/maniphest/controller/ManiphestTransactionSaveController.php
+++ b/src/applications/maniphest/controller/ManiphestTransactionSaveController.php
@@ -1,217 +1,215 @@
 <?php
 
 final class ManiphestTransactionSaveController extends ManiphestController {
 
   public function processRequest() {
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $task = id(new ManiphestTaskQuery())
       ->setViewer($user)
       ->withIDs(array($request->getStr('taskID')))
       ->needSubscriberPHIDs(true)
       ->needProjectPHIDs(true)
       ->executeOne();
     if (!$task) {
       return new Aphront404Response();
     }
 
     $task_uri = '/'.$task->getMonogram();
 
     $transactions = array();
 
     $action = $request->getStr('action');
 
     // Compute new CCs added by @mentions. Several things can cause CCs to
     // be added as side effects: mentions, explicit CCs, users who aren't
     // CC'd interacting with the task, and ownership changes. We build up a
     // list of all the CCs and then construct a transaction for them at the
     // end if necessary.
     $added_ccs = PhabricatorMarkupEngine::extractPHIDsFromMentions(
       $user,
       array(
         $request->getStr('comments'),
       ));
 
     $cc_transaction = new ManiphestTransaction();
     $cc_transaction
       ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
 
     $transaction = new ManiphestTransaction();
     $transaction
       ->setTransactionType($action);
 
     switch ($action) {
       case ManiphestTransaction::TYPE_STATUS:
         $transaction->setNewValue($request->getStr('resolution'));
         break;
       case ManiphestTransaction::TYPE_OWNER:
         $assign_to = $request->getArr('assign_to');
         $assign_to = reset($assign_to);
         $transaction->setNewValue($assign_to);
         break;
-      case ManiphestTransaction::TYPE_PROJECTS:
+      case PhabricatorTransactions::TYPE_EDGE:
         $projects = $request->getArr('projects');
         $projects = array_merge($projects, $task->getProjectPHIDs());
         $projects = array_filter($projects);
         $projects = array_unique($projects);
 
-        // TODO: Bleh.
         $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
         $transaction
-          ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
           ->setMetadataValue('edge:type', $project_type)
           ->setNewValue(
             array(
               '+' => array_fuse($projects),
             ));
         break;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         // Accumulate the new explicit CCs into the array that we'll add in
         // the CC transaction later.
         $added_ccs = array_merge($added_ccs, $request->getArr('ccs'));
 
         // Throw away the primary transaction.
         $transaction = null;
         break;
       case ManiphestTransaction::TYPE_PRIORITY:
         $transaction->setNewValue($request->getInt('priority'));
         break;
       case PhabricatorTransactions::TYPE_COMMENT:
         // Nuke this, we're going to create it below.
         $transaction = null;
         break;
       default:
         throw new Exception('unknown action');
     }
 
     if ($transaction) {
       $transactions[] = $transaction;
     }
 
 
     // When you interact with a task, we add you to the CC list so you get
     // further updates, and possibly assign the task to you if you took an
     // ownership action (closing it) but it's currently unowned. We also move
     // previous owners to CC if ownership changes. Detect all these conditions
     // and create side-effect transactions for them.
 
     $implicitly_claimed = false;
     if ($action == ManiphestTransaction::TYPE_OWNER) {
       if ($task->getOwnerPHID() == $transaction->getNewValue()) {
         // If this is actually no-op, don't generate the side effect.
       } else {
         // Otherwise, when a task is reassigned, move the previous owner to CC.
         $added_ccs[] = $task->getOwnerPHID();
       }
     }
 
     if ($action == ManiphestTransaction::TYPE_STATUS) {
       $resolution = $request->getStr('resolution');
       if (!$task->getOwnerPHID() &&
           ManiphestTaskStatus::isClosedStatus($resolution)) {
         // Closing an unassigned task. Assign the user as the owner of
         // this task.
         $assign = new ManiphestTransaction();
         $assign->setTransactionType(ManiphestTransaction::TYPE_OWNER);
         $assign->setNewValue($user->getPHID());
         $transactions[] = $assign;
 
         $implicitly_claimed = true;
       }
     }
 
     $user_owns_task = false;
     if ($implicitly_claimed) {
       $user_owns_task = true;
     } else {
       if ($action == ManiphestTransaction::TYPE_OWNER) {
         if ($transaction->getNewValue() == $user->getPHID()) {
           $user_owns_task = true;
         }
       } else if ($task->getOwnerPHID() == $user->getPHID()) {
         $user_owns_task = true;
       }
     }
 
     if (!$user_owns_task) {
       // If we aren't making the user the new task owner and they aren't the
       // existing task owner, add them to CC unless they're aleady CC'd.
       if (!in_array($user->getPHID(), $task->getSubscriberPHIDs())) {
         $added_ccs[] = $user->getPHID();
       }
     }
 
     // Evade no-effect detection in the new editor stuff until we can switch
     // to subscriptions.
     $added_ccs = array_filter(array_diff(
       $added_ccs,
       $task->getSubscriberPHIDs()));
 
     if ($added_ccs) {
       // We've added CCs, so include a CC transaction.
       $all_ccs = array_merge($task->getSubscriberPHIDs(), $added_ccs);
       $cc_transaction->setNewValue(array('=' => $all_ccs));
       $transactions[] = $cc_transaction;
     }
 
     $comments = $request->getStr('comments');
     if (strlen($comments) || !$transactions) {
       $transactions[] = id(new ManiphestTransaction())
         ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT)
         ->attachComment(
           id(new ManiphestTransactionComment())
             ->setContent($comments));
     }
 
     $event = new PhabricatorEvent(
       PhabricatorEventType::TYPE_MANIPHEST_WILLEDITTASK,
       array(
         'task'          => $task,
         'new'           => false,
         'transactions'  => $transactions,
       ));
     $event->setUser($user);
     $event->setAphrontRequest($request);
     PhutilEventEngine::dispatchEvent($event);
 
     $task = $event->getValue('task');
     $transactions = $event->getValue('transactions');
 
     $editor = id(new ManiphestTransactionEditor())
       ->setActor($user)
       ->setContentSourceFromRequest($request)
       ->setContinueOnMissingFields(true)
       ->setContinueOnNoEffect($request->isContinueRequest());
 
     try {
       $editor->applyTransactions($task, $transactions);
     } catch (PhabricatorApplicationTransactionNoEffectException $ex) {
       return id(new PhabricatorApplicationTransactionNoEffectResponse())
         ->setCancelURI($task_uri)
         ->setException($ex);
     }
 
     $draft = id(new PhabricatorDraft())->loadOneWhere(
       'authorPHID = %s AND draftKey = %s',
       $user->getPHID(),
       $task->getPHID());
     if ($draft) {
       $draft->delete();
     }
 
     $event = new PhabricatorEvent(
       PhabricatorEventType::TYPE_MANIPHEST_DIDEDITTASK,
       array(
         'task'          => $task,
         'new'           => false,
         'transactions'  => $transactions,
       ));
     $event->setUser($user);
     $event->setAphrontRequest($request);
     PhutilEventEngine::dispatchEvent($event);
 
     return id(new AphrontRedirectResponse())->setURI($task_uri);
   }
 
 }
diff --git a/src/applications/project/edge/PhabricatorProjectObjectHasProjectEdgeType.php b/src/applications/project/edge/PhabricatorProjectObjectHasProjectEdgeType.php
index 789a3cbba5..eae9a72c90 100644
--- a/src/applications/project/edge/PhabricatorProjectObjectHasProjectEdgeType.php
+++ b/src/applications/project/edge/PhabricatorProjectObjectHasProjectEdgeType.php
@@ -1,102 +1,108 @@
 <?php
 
 final class PhabricatorProjectObjectHasProjectEdgeType
   extends PhabricatorEdgeType {
 
   const EDGECONST = 41;
 
   public function getInverseEdgeConstant() {
     return PhabricatorProjectProjectHasObjectEdgeType::EDGECONST;
   }
 
+  public function getTransactionPreviewString($actor) {
+    return pht(
+      '%s edited associated projects.',
+      $actor);
+  }
+
   public function getTransactionAddString(
     $actor,
     $add_count,
     $add_edges) {
 
     return pht(
       '%s added %s project(s): %s.',
       $actor,
       $add_count,
       $add_edges);
   }
 
   public function getTransactionRemoveString(
     $actor,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s removed %s project(s): %s.',
       $actor,
       $rem_count,
       $rem_edges);
   }
 
   public function getTransactionEditString(
     $actor,
     $total_count,
     $add_count,
     $add_edges,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s edited %s project(s), added %s: %s; removed %s: %s.',
       $actor,
       $total_count,
       $add_count,
       $add_edges,
       $rem_count,
       $rem_edges);
   }
 
   public function getFeedAddString(
     $actor,
     $object,
     $add_count,
     $add_edges) {
 
     return pht(
       '%s added %s project(s) to %s: %s.',
       $actor,
       $add_count,
       $object,
       $add_edges);
   }
 
   public function getFeedRemoveString(
     $actor,
     $object,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s removed %s project(s) from %s: %s.',
       $actor,
       $rem_count,
       $object,
       $rem_edges);
   }
 
   public function getFeedEditString(
     $actor,
     $object,
     $total_count,
     $add_count,
     $add_edges,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s edited %s project(s) for %s, added %s: %s; removed %s: %s.',
       $actor,
       $total_count,
       $object,
       $add_count,
       $add_edges,
       $rem_count,
       $rem_edges);
   }
 
 }
diff --git a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
index 570c67af7f..016b01f1a9 100644
--- a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
+++ b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
@@ -1,1177 +1,1176 @@
 <?php
 
 abstract class PhabricatorApplicationTransaction
   extends PhabricatorLiskDAO
   implements
     PhabricatorPolicyInterface,
     PhabricatorDestructibleInterface {
 
   const TARGET_TEXT = 'text';
   const TARGET_HTML = 'html';
 
   protected $phid;
   protected $objectPHID;
   protected $authorPHID;
   protected $viewPolicy;
   protected $editPolicy;
 
   protected $commentPHID;
   protected $commentVersion = 0;
   protected $transactionType;
   protected $oldValue;
   protected $newValue;
   protected $metadata = array();
 
   protected $contentSource;
 
   private $comment;
   private $commentNotLoaded;
 
   private $handles;
   private $renderingTarget = self::TARGET_HTML;
   private $transactionGroup = array();
   private $viewer = self::ATTACHABLE;
   private $object = self::ATTACHABLE;
   private $oldValueHasBeenSet = false;
 
   private $ignoreOnNoEffect;
 
 
   /**
    * Flag this transaction as a pure side-effect which should be ignored when
    * applying transactions if it has no effect, even if transaction application
    * would normally fail. This both provides users with better error messages
    * and allows transactions to perform optional side effects.
    */
   public function setIgnoreOnNoEffect($ignore) {
     $this->ignoreOnNoEffect = $ignore;
     return $this;
   }
 
   public function getIgnoreOnNoEffect() {
     return $this->ignoreOnNoEffect;
   }
 
   public function shouldGenerateOldValue() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_BUILDABLE:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         return false;
     }
     return true;
   }
 
   abstract public function getApplicationTransactionType();
 
   private function getApplicationObjectTypeName() {
     $types = PhabricatorPHIDType::getAllTypes();
 
     $type = idx($types, $this->getApplicationTransactionType());
     if ($type) {
       return $type->getTypeName();
     }
 
     return pht('Object');
   }
 
   public function getApplicationTransactionCommentObject() {
     throw new PhutilMethodNotImplementedException();
   }
 
   public function getApplicationTransactionViewObject() {
     return new PhabricatorApplicationTransactionView();
   }
 
   public function getMetadataValue($key, $default = null) {
     return idx($this->metadata, $key, $default);
   }
 
   public function setMetadataValue($key, $value) {
     $this->metadata[$key] = $value;
     return $this;
   }
 
   public function generatePHID() {
     $type = PhabricatorApplicationTransactionTransactionPHIDType::TYPECONST;
     $subtype = $this->getApplicationTransactionType();
 
     return PhabricatorPHID::generateNewPHID($type, $subtype);
   }
 
   public function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => true,
       self::CONFIG_SERIALIZATION => array(
         'oldValue' => self::SERIALIZATION_JSON,
         'newValue' => self::SERIALIZATION_JSON,
         'metadata' => self::SERIALIZATION_JSON,
       ),
       self::CONFIG_COLUMN_SCHEMA => array(
         'commentPHID' => 'phid?',
         'commentVersion' => 'uint32',
         'contentSource' => 'text',
         'transactionType' => 'text32',
       ),
       self::CONFIG_KEY_SCHEMA => array(
         'key_object' => array(
           'columns' => array('objectPHID'),
         ),
       ),
     ) + parent::getConfiguration();
   }
 
   public function setContentSource(PhabricatorContentSource $content_source) {
     $this->contentSource = $content_source->serialize();
     return $this;
   }
 
   public function getContentSource() {
     return PhabricatorContentSource::newFromSerialized($this->contentSource);
   }
 
   public function hasComment() {
     return $this->getComment() && strlen($this->getComment()->getContent());
   }
 
   public function getComment() {
     if ($this->commentNotLoaded) {
       throw new Exception('Comment for this transaction was not loaded.');
     }
     return $this->comment;
   }
 
   public function attachComment(
     PhabricatorApplicationTransactionComment $comment) {
     $this->comment = $comment;
     $this->commentNotLoaded = false;
     return $this;
   }
 
   public function setCommentNotLoaded($not_loaded) {
     $this->commentNotLoaded = $not_loaded;
     return $this;
   }
 
   public function attachObject($object) {
     $this->object = $object;
     return $this;
   }
 
   public function getObject() {
     return $this->assertAttached($this->object);
   }
 
   public function getRemarkupBlocks() {
     $blocks = array();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           $custom_blocks = $field->getApplicationTransactionRemarkupBlocks(
             $this);
           foreach ($custom_blocks as $custom_block) {
             $blocks[] = $custom_block;
           }
         }
         break;
     }
 
     if ($this->getComment()) {
       $blocks[] = $this->getComment()->getContent();
     }
 
     return $blocks;
   }
 
   public function setOldValue($value) {
     $this->oldValueHasBeenSet = true;
     $this->writeField('oldValue', $value);
     return $this;
   }
 
   public function hasOldValue() {
     return $this->oldValueHasBeenSet;
   }
 
 
 /* -(  Rendering  )---------------------------------------------------------- */
 
   public function setRenderingTarget($rendering_target) {
     $this->renderingTarget = $rendering_target;
     return $this;
   }
 
   public function getRenderingTarget() {
     return $this->renderingTarget;
   }
 
   public function attachViewer(PhabricatorUser $viewer) {
     $this->viewer = $viewer;
     return $this;
   }
 
   public function getViewer() {
     return $this->assertAttached($this->viewer);
   }
 
   public function getRequiredHandlePHIDs() {
     $phids = array();
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     $phids[] = array($this->getAuthorPHID());
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           $phids[] = $field->getApplicationTransactionRequiredHandlePHIDs(
             $this);
         }
         break;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $phids[] = $old;
         $phids[] = $new;
         break;
       case PhabricatorTransactions::TYPE_EDGE:
         $phids[] = ipull($old, 'dst');
         $phids[] = ipull($new, 'dst');
         break;
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         if (!PhabricatorPolicyQuery::isGlobalPolicy($old)) {
           $phids[] = array($old);
         }
         if (!PhabricatorPolicyQuery::isGlobalPolicy($new)) {
           $phids[] = array($new);
         }
         break;
       case PhabricatorTransactions::TYPE_TOKEN:
         break;
       case PhabricatorTransactions::TYPE_BUILDABLE:
         $phid = $this->getMetadataValue('harbormaster:buildablePHID');
         if ($phid) {
           $phids[] = array($phid);
         }
         break;
     }
 
     if ($this->getComment()) {
       $phids[] = array($this->getComment()->getAuthorPHID());
     }
 
     return array_mergev($phids);
   }
 
   public function setHandles(array $handles) {
     $this->handles = $handles;
     return $this;
   }
 
   public function getHandle($phid) {
     if (empty($this->handles[$phid])) {
       throw new Exception(
         pht(
           'Transaction ("%s", of type "%s") requires a handle ("%s") that it '.
           'did not load.',
           $this->getPHID(),
           $this->getTransactionType(),
           $phid));
     }
     return $this->handles[$phid];
   }
 
   public function getHandleIfExists($phid) {
     return idx($this->handles, $phid);
   }
 
   public function getHandles() {
     if ($this->handles === null) {
       throw new Exception(
         'Transaction requires handles and it did not load them.'
       );
     }
     return $this->handles;
   }
 
   public function renderHandleLink($phid) {
     if ($this->renderingTarget == self::TARGET_HTML) {
       return $this->getHandle($phid)->renderLink();
     } else {
       return $this->getHandle($phid)->getLinkName();
     }
   }
 
   public function renderHandleList(array $phids) {
     $links = array();
     foreach ($phids as $phid) {
       $links[] = $this->renderHandleLink($phid);
     }
     if ($this->renderingTarget == self::TARGET_HTML) {
       return phutil_implode_html(', ', $links);
     } else {
       return implode(', ', $links);
     }
   }
 
   private function renderSubscriberList(array $phids, $change_type) {
     if ($this->getRenderingTarget() == self::TARGET_TEXT) {
       return $this->renderHandleList($phids);
     } else {
       $handles = array_select_keys($this->getHandles(), $phids);
       return id(new SubscriptionListStringBuilder())
         ->setHandles($handles)
         ->setObjectPHID($this->getPHID())
         ->buildTransactionString($change_type);
     }
   }
 
   protected function renderPolicyName($phid, $state = 'old') {
     $policy = PhabricatorPolicy::newFromPolicyAndHandle(
       $phid,
       $this->getHandleIfExists($phid));
     if ($this->renderingTarget == self::TARGET_HTML) {
       switch ($policy->getType()) {
         case PhabricatorPolicyType::TYPE_CUSTOM:
           $policy->setHref('/transactions/'.$state.'/'.$this->getPHID().'/');
           $policy->setWorkflow(true);
           break;
         default:
           break;
       }
       $output = $policy->renderDescription();
     } else {
       $output = hsprintf('%s', $policy->getFullName());
     }
     return $output;
   }
 
   public function getIcon() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $comment = $this->getComment();
         if ($comment && $comment->getIsRemoved()) {
           return 'fa-eraser';
         }
         return 'fa-comment';
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return 'fa-envelope';
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return 'fa-lock';
       case PhabricatorTransactions::TYPE_EDGE:
         return 'fa-link';
       case PhabricatorTransactions::TYPE_BUILDABLE:
         return 'fa-wrench';
       case PhabricatorTransactions::TYPE_TOKEN:
         return 'fa-trophy';
     }
 
     return 'fa-pencil';
   }
 
   public function getToken() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
         $old = $this->getOldValue();
         $new = $this->getNewValue();
         if ($new) {
           $icon = substr($new, 10);
         } else {
           $icon = substr($old, 10);
         }
         return array($icon, !$this->getNewValue());
     }
 
     return array(null, null);
   }
 
   public function getColor() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT;
         $comment = $this->getComment();
         if ($comment && $comment->getIsRemoved()) {
           return 'black';
         }
         break;
       case PhabricatorTransactions::TYPE_BUILDABLE:
         switch ($this->getNewValue()) {
           case HarbormasterBuildable::STATUS_PASSED:
             return 'green';
           case HarbormasterBuildable::STATUS_FAILED:
             return 'red';
         }
         break;
     }
     return null;
   }
 
   protected function getTransactionCustomField() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $key = $this->getMetadataValue('customfield:key');
         if (!$key) {
           return null;
         }
 
         $field = PhabricatorCustomField::getObjectField(
           $this->getObject(),
           PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
           $key);
         if (!$field) {
           return null;
         }
 
         $field->setViewer($this->getViewer());
         return $field;
     }
 
     return null;
   }
 
   public function shouldHide() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         if ($this->getOldValue() === null) {
           return true;
         } else {
           return false;
         }
         break;
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->shouldHideInApplicationTransactions($this);
         }
       case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $this->getMetadataValue('edge:type');
         switch ($edge_type) {
           case PhabricatorObjectMentionsObject::EDGECONST:
             return true;
             break;
           case PhabricatorObjectMentionedByObject::EDGECONST:
             $new = ipull($this->getNewValue(), 'dst');
             $old = ipull($this->getOldValue(), 'dst');
             $add = array_diff($new, $old);
             $add_value = reset($add);
             $add_handle = $this->getHandle($add_value);
             if ($add_handle->getPolicyFiltered()) {
               return true;
             }
             return false;
             break;
           default:
             break;
         }
         break;
     }
 
     return false;
   }
 
   public function shouldHideForMail(array $xactions) {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
         return true;
       case PhabricatorTransactions::TYPE_BUILDABLE:
         switch ($this->getNewValue()) {
           case HarbormasterBuildable::STATUS_FAILED:
             // For now, only ever send mail when builds fail. We might let
             // you customize this later, but in most cases this is probably
             // completely uninteresting.
             return false;
         }
         return true;
      case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $this->getMetadataValue('edge:type');
         switch ($edge_type) {
           case PhabricatorObjectMentionsObject::EDGECONST:
           case PhabricatorObjectMentionedByObject::EDGECONST:
             return true;
             break;
           default:
             break;
         }
         break;
     }
 
     return $this->shouldHide();
   }
 
   public function shouldHideForFeed() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_TOKEN:
         return true;
       case PhabricatorTransactions::TYPE_BUILDABLE:
         switch ($this->getNewValue()) {
           case HarbormasterBuildable::STATUS_FAILED:
             // For now, don't notify on build passes either. These are pretty
             // high volume and annoying, with very little present value. We
             // might want to turn them back on in the specific case of
             // build successes on the current document?
             return false;
         }
         return true;
      case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $this->getMetadataValue('edge:type');
         switch ($edge_type) {
           case PhabricatorObjectMentionsObject::EDGECONST:
           case PhabricatorObjectMentionedByObject::EDGECONST:
             return true;
             break;
           default:
             break;
         }
         break;
     }
 
     return $this->shouldHide();
   }
 
   public function getTitleForMail() {
     return id(clone $this)->setRenderingTarget('text')->getTitle();
   }
 
   public function getBodyForMail() {
     $comment = $this->getComment();
     if ($comment && strlen($comment->getContent())) {
       return $comment->getContent();
     }
     return null;
   }
 
   public function getNoEffectDescription() {
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht('You can not post an empty comment.');
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         return pht(
           'This %s already has that view policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         return pht(
           'This %s already has that edit policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return pht(
           'This %s already has that join policy.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return pht(
           'All users are already subscribed to this %s.',
           $this->getApplicationObjectTypeName());
       case PhabricatorTransactions::TYPE_EDGE:
         return pht('Edges already exist; transaction has no effect.');
     }
 
     return pht('Transaction has no effect.');
   }
 
   public function getTitle() {
     $author_phid = $this->getAuthorPHID();
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht(
           '%s added a comment.',
           $this->renderHandleLink($author_phid));
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         return pht(
           '%s changed the visibility of this %s from "%s" to "%s".',
           $this->renderHandleLink($author_phid),
           $this->getApplicationObjectTypeName(),
           $this->renderPolicyName($old, 'old'),
           $this->renderPolicyName($new, 'new'));
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         return pht(
           '%s changed the edit policy of this %s from "%s" to "%s".',
           $this->renderHandleLink($author_phid),
           $this->getApplicationObjectTypeName(),
           $this->renderPolicyName($old, 'old'),
           $this->renderPolicyName($new, 'new'));
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return pht(
           '%s changed the join policy of this %s from "%s" to "%s".',
           $this->renderHandleLink($author_phid),
           $this->getApplicationObjectTypeName(),
           $this->renderPolicyName($old, 'old'),
           $this->renderPolicyName($new, 'new'));
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $add = array_diff($new, $old);
         $rem = array_diff($old, $new);
 
         if ($add && $rem) {
           return pht(
             '%s edited subscriber(s), added %d: %s; removed %d: %s.',
             $this->renderHandleLink($author_phid),
             count($add),
             $this->renderSubscriberList($add, 'add'),
             count($rem),
             $this->renderSubscriberList($rem, 'rem'));
         } else if ($add) {
           return pht(
             '%s added %d subscriber(s): %s.',
             $this->renderHandleLink($author_phid),
             count($add),
             $this->renderSubscriberList($add, 'add'));
         } else if ($rem) {
           return pht(
             '%s removed %d subscriber(s): %s.',
             $this->renderHandleLink($author_phid),
             count($rem),
             $this->renderSubscriberList($rem, 'rem'));
         } else {
           // This is used when rendering previews, before the user actually
           // selects any CCs.
           return pht(
             '%s updated subscribers...',
             $this->renderHandleLink($author_phid));
         }
         break;
       case PhabricatorTransactions::TYPE_EDGE:
         $new = ipull($new, 'dst');
         $old = ipull($old, 'dst');
         $add = array_diff($new, $old);
         $rem = array_diff($old, $new);
         $type = $this->getMetadata('edge:type');
         $type = head($type);
 
         $type_obj = PhabricatorEdgeType::getByConstant($type);
 
         if ($add && $rem) {
           return $type_obj->getTransactionEditString(
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($add) + count($rem)),
             new PhutilNumber(count($add)),
             $this->renderHandleList($add),
             new PhutilNumber(count($rem)),
             $this->renderHandleList($rem));
         } else if ($add) {
           return $type_obj->getTransactionAddString(
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($add)),
             $this->renderHandleList($add));
         } else if ($rem) {
           return $type_obj->getTransactionRemoveString(
             $this->renderHandleLink($author_phid),
             new PhutilNumber(count($rem)),
             $this->renderHandleList($rem));
         } else {
-          return pht(
-            '%s edited edge metadata.',
+          return $type_obj->getTransactionPreviewString(
             $this->renderHandleLink($author_phid));
         }
 
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionTitle($this);
         } else {
           return pht(
             '%s edited a custom field.',
             $this->renderHandleLink($author_phid));
         }
 
       case PhabricatorTransactions::TYPE_TOKEN:
         if ($old && $new) {
           return pht(
             '%s updated a token.',
             $this->renderHandleLink($author_phid));
         } else if ($old) {
           return pht(
             '%s rescinded a token.',
             $this->renderHandleLink($author_phid));
         } else {
           return pht(
             '%s awarded a token.',
             $this->renderHandleLink($author_phid));
         }
 
       case PhabricatorTransactions::TYPE_BUILDABLE:
         switch ($this->getNewValue()) {
           case HarbormasterBuildable::STATUS_BUILDING:
             return pht(
               '%s started building %s.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(
                 $this->getMetadataValue('harbormaster:buildablePHID')));
           case HarbormasterBuildable::STATUS_PASSED:
             return pht(
               '%s completed building %s.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(
                 $this->getMetadataValue('harbormaster:buildablePHID')));
           case HarbormasterBuildable::STATUS_FAILED:
             return pht(
               '%s failed to build %s!',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(
                 $this->getMetadataValue('harbormaster:buildablePHID')));
           default:
             return null;
         }
 
       default:
         return pht(
           '%s edited this %s.',
           $this->renderHandleLink($author_phid),
           $this->getApplicationObjectTypeName());
     }
   }
 
   public function getTitleForFeed(PhabricatorFeedStory $story) {
     $author_phid = $this->getAuthorPHID();
     $object_phid = $this->getObjectPHID();
 
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht(
           '%s added a comment to %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         return pht(
           '%s changed the visibility for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         return pht(
           '%s changed the edit policy for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return pht(
           '%s changed the join policy for %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return pht(
           '%s updated subscribers of %s.',
           $this->renderHandleLink($author_phid),
           $this->renderHandleLink($object_phid));
       case PhabricatorTransactions::TYPE_EDGE:
         $new = ipull($new, 'dst');
         $old = ipull($old, 'dst');
         $add = array_diff($new, $old);
         $rem = array_diff($old, $new);
         $type = $this->getMetadata('edge:type');
         $type = head($type);
 
         $type_obj = PhabricatorEdgeType::getByConstant($type);
 
         if ($add && $rem) {
           return $type_obj->getFeedEditString(
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             new PhutilNumber(count($add) + count($rem)),
             new PhutilNumber(count($add)),
             $this->renderHandleList($add),
             new PhutilNumber(count($rem)),
             $this->renderHandleList($rem));
         } else if ($add) {
           return $type_obj->getFeedAddString(
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             new PhutilNumber(count($add)),
             $this->renderHandleList($add));
         } else if ($rem) {
           return $type_obj->getFeedRemoveString(
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid),
             new PhutilNumber(count($rem)),
             $this->renderHandleList($rem));
         } else {
           return pht(
             '%s edited edge metadata for %s.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid));
         }
 
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionTitleForFeed($this, $story);
         } else {
           return pht(
             '%s edited a custom field on %s.',
             $this->renderHandleLink($author_phid),
             $this->renderHandleLink($object_phid));
         }
       case PhabricatorTransactions::TYPE_BUILDABLE:
         switch ($this->getNewValue()) {
           case HarbormasterBuildable::STATUS_BUILDING:
             return pht(
               '%s started building %s for %s.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(
                 $this->getMetadataValue('harbormaster:buildablePHID')),
               $this->renderHandleLink($object_phid));
           case HarbormasterBuildable::STATUS_PASSED:
             return pht(
               '%s completed building %s for %s.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(
                 $this->getMetadataValue('harbormaster:buildablePHID')),
               $this->renderHandleLink($object_phid));
           case HarbormasterBuildable::STATUS_FAILED:
             return pht(
               '%s failed to build %s for %s.',
               $this->renderHandleLink($author_phid),
               $this->renderHandleLink(
                 $this->getMetadataValue('harbormaster:buildablePHID')),
               $this->renderHandleLink($object_phid));
           default:
             return null;
         }
 
     }
 
     return $this->getTitle();
   }
 
   public function getMarkupFieldsForFeed(PhabricatorFeedStory $story) {
     $fields = array();
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $text = $this->getComment()->getContent();
         if (strlen($text)) {
           $fields[] = 'comment/'.$this->getID();
         }
         break;
     }
 
     return $fields;
   }
 
   public function getMarkupTextForFeed(PhabricatorFeedStory $story, $field) {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $text = $this->getComment()->getContent();
         return PhabricatorMarkupEngine::summarize($text);
     }
 
     return null;
   }
 
   public function getBodyForFeed(PhabricatorFeedStory $story) {
     $old = $this->getOldValue();
     $new = $this->getNewValue();
 
     $body = null;
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         $text = $this->getComment()->getContent();
         if (strlen($text)) {
           $body = $story->getMarkupFieldOutput('comment/'.$this->getID());
         }
         break;
     }
 
     return $body;
   }
 
   public function getActionStrength() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return 0.5;
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $old = $this->getOldValue();
         $new = $this->getNewValue();
 
         $add = array_diff($old, $new);
         $rem = array_diff($new, $old);
 
         // If this action is the actor subscribing or unsubscribing themselves,
         // it is less interesting. In particular, if someone makes a comment and
         // also implicitly subscribes themselves, we should treat the
         // transaction group as "comment", not "subscribe". In this specific
         // case (one affected user, and that affected user it the actor),
         // decrease the action strength.
 
         if ((count($add) + count($rem)) != 1) {
           // Not exactly one CC change.
           break;
         }
 
         $affected_phid = head(array_merge($add, $rem));
         if ($affected_phid != $this->getAuthorPHID()) {
           // Affected user is someone else.
           break;
         }
 
         // Make this weaker than TYPE_COMMENT.
         return 0.25;
     }
     return 1.0;
   }
 
   public function isCommentTransaction() {
     if ($this->hasComment()) {
       return true;
     }
 
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return true;
     }
 
     return false;
   }
 
   public function getActionName() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return pht('Commented On');
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return pht('Changed Policy');
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return pht('Changed Subscribers');
       case PhabricatorTransactions::TYPE_BUILDABLE:
         switch ($this->getNewValue()) {
           case HarbormasterBuildable::STATUS_PASSED:
             return pht('Build Passed');
           case HarbormasterBuildable::STATUS_FAILED:
             return pht('Build Failed');
           default:
             return pht('Build Status');
         }
       default:
         return pht('Updated');
     }
   }
 
   public function getMailTags() {
     return array();
   }
 
   public function hasChangeDetails() {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionHasChangeDetails($this);
         }
         break;
     }
     return false;
   }
 
   public function renderChangeDetails(PhabricatorUser $viewer) {
     switch ($this->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getTransactionCustomField();
         if ($field) {
           return $field->getApplicationTransactionChangeDetails($this, $viewer);
         }
         break;
     }
 
     return $this->renderTextCorpusChangeDetails(
       $viewer,
       $this->getOldValue(),
       $this->getNewValue());
   }
 
   public function renderTextCorpusChangeDetails(
     PhabricatorUser $viewer,
     $old,
     $new) {
 
     require_celerity_resource('differential-changeset-view-css');
 
     $view = id(new PhabricatorApplicationTransactionTextDiffDetailView())
       ->setUser($viewer)
       ->setOldText($old)
       ->setNewText($new);
 
     return $view->render();
   }
 
   public function attachTransactionGroup(array $group) {
     assert_instances_of($group, 'PhabricatorApplicationTransaction');
     $this->transactionGroup = $group;
     return $this;
   }
 
   public function getTransactionGroup() {
     return $this->transactionGroup;
   }
 
   /**
    * Should this transaction be visually grouped with an existing transaction
    * group?
    *
    * @param list<PhabricatorApplicationTransaction> List of transactions.
    * @return bool True to display in a group with the other transactions.
    */
   public function shouldDisplayGroupWith(array $group) {
     $this_source = null;
     if ($this->getContentSource()) {
       $this_source = $this->getContentSource()->getSource();
     }
 
     foreach ($group as $xaction) {
       // Don't group transactions by different authors.
       if ($xaction->getAuthorPHID() != $this->getAuthorPHID()) {
         return false;
       }
 
       // Don't group transactions for different objects.
       if ($xaction->getObjectPHID() != $this->getObjectPHID()) {
         return false;
       }
 
       // Don't group anything into a group which already has a comment.
       if ($xaction->isCommentTransaction()) {
         return false;
       }
 
       // Don't group transactions from different content sources.
       $other_source = null;
       if ($xaction->getContentSource()) {
         $other_source = $xaction->getContentSource()->getSource();
       }
 
       if ($other_source != $this_source) {
         return false;
       }
 
       // Don't group transactions which happened more than 2 minutes apart.
       $apart = abs($xaction->getDateCreated() - $this->getDateCreated());
       if ($apart > (60 * 2)) {
         return false;
       }
     }
 
     return true;
   }
 
   public function renderExtraInformationLink() {
     $herald_xscript_id = $this->getMetadataValue('herald:transcriptID');
 
     if ($herald_xscript_id) {
       return phutil_tag(
         'a',
         array(
           'href' => '/herald/transcript/'.$herald_xscript_id.'/',
         ),
         pht('View Herald Transcript'));
     }
 
     return null;
   }
 
   public function renderAsTextForDoorkeeper(
     DoorkeeperFeedStoryPublisher $publisher,
     PhabricatorFeedStory $story,
     array $xactions) {
 
     $text = array();
     $body = array();
 
     foreach ($xactions as $xaction) {
       $xaction_body = $xaction->getBodyForMail();
       if ($xaction_body !== null) {
         $body[] = $xaction_body;
       }
 
       if ($xaction->shouldHideForMail($xactions)) {
         continue;
       }
 
       $old_target = $xaction->getRenderingTarget();
       $new_target = PhabricatorApplicationTransaction::TARGET_TEXT;
       $xaction->setRenderingTarget($new_target);
 
       if ($publisher->getRenderWithImpliedContext()) {
         $text[] = $xaction->getTitle();
       } else {
         $text[] = $xaction->getTitleForFeed($story);
       }
 
       $xaction->setRenderingTarget($old_target);
     }
 
     $text = implode("\n", $text);
     $body = implode("\n\n", $body);
 
     return rtrim($text."\n\n".$body);
   }
 
 
 
 /* -(  PhabricatorPolicyInterface Implementation  )-------------------------- */
 
 
   public function getCapabilities() {
     return array(
       PhabricatorPolicyCapability::CAN_VIEW,
       PhabricatorPolicyCapability::CAN_EDIT,
     );
   }
 
   public function getPolicy($capability) {
     switch ($capability) {
       case PhabricatorPolicyCapability::CAN_VIEW:
         return $this->getViewPolicy();
       case PhabricatorPolicyCapability::CAN_EDIT:
         return $this->getEditPolicy();
     }
   }
 
   public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
     return ($viewer->getPHID() == $this->getAuthorPHID());
   }
 
   public function describeAutomaticCapability($capability) {
     // TODO: (T603) Exact policies are unclear here.
     return null;
   }
 
 
 /* -(  PhabricatorDestructibleInterface  )----------------------------------- */
 
 
   public function destroyObjectPermanently(
     PhabricatorDestructionEngine $engine) {
 
     $this->openTransaction();
       $comment_template = null;
       try {
         $comment_template = $this->getApplicationTransactionCommentObject();
       } catch (Exception $ex) {
         // Continue; no comments for these transactions.
       }
 
       if ($comment_template) {
         $comments = $comment_template->loadAllWhere(
           'transactionPHID = %s',
           $this->getPHID());
         foreach ($comments as $comment) {
           $engine->destroyObject($comment);
         }
       }
 
       $this->delete();
     $this->saveTransaction();
   }
 
 
 }
diff --git a/src/infrastructure/edges/type/PhabricatorEdgeType.php b/src/infrastructure/edges/type/PhabricatorEdgeType.php
index 6c2f28e584..29352b0dea 100644
--- a/src/infrastructure/edges/type/PhabricatorEdgeType.php
+++ b/src/infrastructure/edges/type/PhabricatorEdgeType.php
@@ -1,234 +1,240 @@
 <?php
 
 /**
  * Defines an edge type.
  *
  * Edges are typed, directed connections between two objects. They are used to
  * represent most simple relationships, like when a user is subscribed to an
  * object or an object is a member of a project.
  *
  * @task load   Loading Types
  */
 abstract class PhabricatorEdgeType extends Phobject {
 
   // TODO: Make this final after we remove PhabricatorLegacyEdgeType.
   /* final */ public function getEdgeConstant() {
     $class = new ReflectionClass($this);
 
     $const = $class->getConstant('EDGECONST');
     if ($const === false) {
       throw new Exception(
         pht(
           'EdgeType class "%s" must define an EDGECONST property.',
           get_class($this)));
     }
 
     if (!is_int($const) || ($const <= 0)) {
       throw new Exception(
         pht(
           'EdgeType class "%s" has an invalid EDGECONST property. Edge '.
           'constants must be positive integers.',
           get_class($this)));
     }
 
     return $const;
   }
 
   public function getInverseEdgeConstant() {
     return null;
   }
 
   public function shouldPreventCycles() {
     return false;
   }
 
   public function shouldWriteInverseTransactions() {
     return false;
   }
 
+  public function getTransactionPreviewString($actor) {
+    return pht(
+      '%s edited edge metadata.',
+      $actor);
+  }
+
   public function getTransactionAddString(
     $actor,
     $add_count,
     $add_edges) {
 
     return pht(
       '%s added %s edge(s): %s.',
       $actor,
       $add_count,
       $add_edges);
   }
 
   public function getTransactionRemoveString(
     $actor,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s removed %s edge(s): %s.',
       $actor,
       $rem_count,
       $rem_edges);
   }
 
   public function getTransactionEditString(
     $actor,
     $total_count,
     $add_count,
     $add_edges,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s edited %s edge(s), added %s: %s; removed %s: %s.',
       $actor,
       $total_count,
       $add_count,
       $add_edges,
       $rem_count,
       $rem_edges);
   }
 
   public function getFeedAddString(
     $actor,
     $object,
     $add_count,
     $add_edges) {
 
     return pht(
       '%s added %s edge(s) to %s: %s.',
       $actor,
       $add_count,
       $object,
       $add_edges);
   }
 
   public function getFeedRemoveString(
     $actor,
     $object,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s removed %s edge(s) from %s: %s.',
       $actor,
       $rem_count,
       $object,
       $rem_edges);
   }
 
   public function getFeedEditString(
     $actor,
     $object,
     $total_count,
     $add_count,
     $add_edges,
     $rem_count,
     $rem_edges) {
 
     return pht(
       '%s edited %s edge(s) for %s, added %s: %s; removed %s: %s.',
       $actor,
       $total_count,
       $object,
       $add_count,
       $add_edges,
       $rem_count,
       $rem_edges);
   }
 
 
 /* -(  Loading Types  )------------------------------------------------------ */
 
 
   /**
    * @task load
    */
   public static function getAllTypes() {
     static $type_map;
 
     if ($type_map === null) {
       $types = id(new PhutilSymbolLoader())
         ->setAncestorClass(__CLASS__)
         ->loadObjects();
 
       $map = array();
 
 
       // TODO: Remove this once everything is migrated.
       $exclude = mpull($types, 'getEdgeConstant');
       $map = PhabricatorEdgeConfig::getLegacyTypes($exclude);
       unset($types['PhabricatorLegacyEdgeType']);
 
 
       foreach ($types as $class => $type) {
         $const = $type->getEdgeConstant();
 
         if (isset($map[$const])) {
           throw new Exception(
             pht(
               'Two edge types ("%s", "%s") share the same edge constant '.
               '(%d). Each edge type must have a unique constant.',
               $class,
               get_class($map[$const]),
               $const));
         }
 
         $map[$const] = $type;
       }
 
       // Check that all the inverse edge definitions actually make sense. If
       // edge type A says B is its inverse, B must exist and say that A is its
       // inverse.
 
       foreach ($map as $const => $type) {
         $inverse = $type->getInverseEdgeConstant();
         if ($inverse === null) {
           continue;
         }
 
         if (empty($map[$inverse])) {
           throw new Exception(
             pht(
               'Edge type "%s" ("%d") defines an inverse type ("%d") which '.
               'does not exist.',
               get_class($type),
               $const,
               $inverse));
         }
 
         $inverse_inverse = $map[$inverse]->getInverseEdgeConstant();
         if ($inverse_inverse !== $const) {
           throw new Exception(
             pht(
               'Edge type "%s" ("%d") defines an inverse type ("%d"), but that '.
               'inverse type defines a different type ("%d") as its '.
               'inverse.',
               get_class($type),
               $const,
               $inverse,
               $inverse_inverse));
         }
       }
 
       $type_map = $map;
     }
 
     return $type_map;
   }
 
 
   /**
    * @task load
    */
   public static function getByConstant($const) {
     $type = idx(self::getAllTypes(), $const);
 
     if (!$type) {
       throw new Exception(
         pht('Unknown edge constant "%s"!', $const));
     }
 
     return $type;
   }
 
 }