diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php
index 3609145c7..66dbe3aa2 100644
--- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php
+++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php
@@ -1,746 +1,738 @@
 <?php
 
 final class ManiphestTransactionEditor
   extends PhabricatorApplicationTransactionEditor {
 
   public function getEditorApplicationClass() {
     return 'PhabricatorManiphestApplication';
   }
 
   public function getEditorObjectsDescription() {
     return pht('Maniphest Tasks');
   }
 
   public function getTransactionTypes() {
     $types = parent::getTransactionTypes();
 
     $types[] = PhabricatorTransactions::TYPE_COMMENT;
     $types[] = PhabricatorTransactions::TYPE_EDGE;
     $types[] = ManiphestTransaction::TYPE_PRIORITY;
     $types[] = ManiphestTransaction::TYPE_STATUS;
     $types[] = ManiphestTransaction::TYPE_TITLE;
     $types[] = ManiphestTransaction::TYPE_DESCRIPTION;
     $types[] = ManiphestTransaction::TYPE_OWNER;
     $types[] = ManiphestTransaction::TYPE_SUBPRIORITY;
     $types[] = ManiphestTransaction::TYPE_PROJECT_COLUMN;
     $types[] = ManiphestTransaction::TYPE_MERGED_INTO;
     $types[] = ManiphestTransaction::TYPE_MERGED_FROM;
     $types[] = ManiphestTransaction::TYPE_UNBLOCK;
     $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
     $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
 
     return $types;
   }
 
   protected function getCustomTransactionOldValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case ManiphestTransaction::TYPE_PRIORITY:
         if ($this->getIsNewObject()) {
           return null;
         }
         return (int)$object->getPriority();
       case ManiphestTransaction::TYPE_STATUS:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getStatus();
       case ManiphestTransaction::TYPE_TITLE:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getTitle();
       case ManiphestTransaction::TYPE_DESCRIPTION:
         if ($this->getIsNewObject()) {
           return null;
         }
         return $object->getDescription();
       case ManiphestTransaction::TYPE_OWNER:
         return nonempty($object->getOwnerPHID(), null);
       case ManiphestTransaction::TYPE_PROJECT_COLUMN:
         // These are pre-populated.
         return $xaction->getOldValue();
       case ManiphestTransaction::TYPE_SUBPRIORITY:
         return $object->getSubpriority();
       case ManiphestTransaction::TYPE_MERGED_INTO:
       case ManiphestTransaction::TYPE_MERGED_FROM:
         return null;
     }
   }
 
   protected function getCustomTransactionNewValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case ManiphestTransaction::TYPE_PRIORITY:
         return (int)$xaction->getNewValue();
       case ManiphestTransaction::TYPE_OWNER:
         return nonempty($xaction->getNewValue(), null);
       case ManiphestTransaction::TYPE_STATUS:
       case ManiphestTransaction::TYPE_TITLE:
       case ManiphestTransaction::TYPE_DESCRIPTION:
       case ManiphestTransaction::TYPE_SUBPRIORITY:
       case ManiphestTransaction::TYPE_PROJECT_COLUMN:
       case ManiphestTransaction::TYPE_MERGED_INTO:
       case ManiphestTransaction::TYPE_MERGED_FROM:
       case ManiphestTransaction::TYPE_UNBLOCK:
         return $xaction->getNewValue();
     }
   }
 
   protected function transactionHasEffect(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $old = $xaction->getOldValue();
     $new = $xaction->getNewValue();
 
     switch ($xaction->getTransactionType()) {
       case ManiphestTransaction::TYPE_PROJECT_COLUMN:
         $new_column_phids = $new['columnPHIDs'];
         $old_column_phids = $old['columnPHIDs'];
         sort($new_column_phids);
         sort($old_column_phids);
         return ($old !== $new);
     }
 
     return parent::transactionHasEffect($object, $xaction);
   }
 
   protected function applyCustomInternalTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case ManiphestTransaction::TYPE_PRIORITY:
         return $object->setPriority($xaction->getNewValue());
       case ManiphestTransaction::TYPE_STATUS:
         return $object->setStatus($xaction->getNewValue());
       case ManiphestTransaction::TYPE_TITLE:
         return $object->setTitle($xaction->getNewValue());
       case ManiphestTransaction::TYPE_DESCRIPTION:
         return $object->setDescription($xaction->getNewValue());
       case ManiphestTransaction::TYPE_OWNER:
         $phid = $xaction->getNewValue();
 
         // Update the "ownerOrdering" column to contain the full name of the
         // owner, if the task is assigned.
 
         $handle = null;
         if ($phid) {
           $handle = id(new PhabricatorHandleQuery())
             ->setViewer($this->getActor())
             ->withPHIDs(array($phid))
             ->executeOne();
         }
 
         if ($handle) {
           $object->setOwnerOrdering($handle->getName());
         } else {
           $object->setOwnerOrdering(null);
         }
 
         return $object->setOwnerPHID($phid);
       case ManiphestTransaction::TYPE_SUBPRIORITY:
         $object->setSubpriority($xaction->getNewValue());
         return;
       case ManiphestTransaction::TYPE_PROJECT_COLUMN:
         // these do external (edge) updates
         return;
       case ManiphestTransaction::TYPE_MERGED_INTO:
         $object->setStatus(ManiphestTaskStatus::getDuplicateStatus());
         return;
       case ManiphestTransaction::TYPE_MERGED_FROM:
         return;
     }
   }
 
   protected function applyCustomExternalTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case ManiphestTransaction::TYPE_PROJECT_COLUMN:
         $board_phid = idx($xaction->getNewValue(), 'projectPHID');
         if (!$board_phid) {
           throw new Exception(
             pht(
               "Expected '%s' in column transaction.",
               'projectPHID'));
         }
 
         $old_phids = idx($xaction->getOldValue(), 'columnPHIDs', array());
         $new_phids = idx($xaction->getNewValue(), 'columnPHIDs', array());
         if (count($new_phids) !== 1) {
           throw new Exception(
             pht(
               "Expected exactly one '%s' in column transaction.",
               'columnPHIDs'));
         }
 
         $columns = id(new PhabricatorProjectColumnQuery())
           ->setViewer($this->requireActor())
           ->withPHIDs($new_phids)
           ->execute();
         $columns = mpull($columns, null, 'getPHID');
 
         $positions = id(new PhabricatorProjectColumnPositionQuery())
           ->setViewer($this->requireActor())
           ->withObjectPHIDs(array($object->getPHID()))
           ->withBoardPHIDs(array($board_phid))
           ->execute();
 
         $before_phid = idx($xaction->getNewValue(), 'beforePHID');
         $after_phid = idx($xaction->getNewValue(), 'afterPHID');
 
         if (!$before_phid && !$after_phid && ($old_phids == $new_phids)) {
           // If we are not moving the object between columns and also not
           // reordering the position, this is a move on some other order
           // (like priority). We can leave the positions untouched and just
           // bail, there's no work to be done.
           return;
         }
 
         // Otherwise, we're either moving between columns or adjusting the
         // object's position in the "natural" ordering, so we do need to update
         // some rows.
 
         // Remove all existing column positions on the board.
 
         foreach ($positions as $position) {
           $position->delete();
         }
 
         // Add the new column positions.
 
         foreach ($new_phids as $phid) {
           $column = idx($columns, $phid);
           if (!$column) {
             throw new Exception(
               pht('No such column "%s" exists!', $phid));
           }
 
           // Load the other object positions in the column. Note that we must
           // skip implicit column creation to avoid generating a new position
           // if the target column is a backlog column.
 
           $other_positions = id(new PhabricatorProjectColumnPositionQuery())
             ->setViewer($this->requireActor())
             ->withColumns(array($column))
             ->withBoardPHIDs(array($board_phid))
             ->setSkipImplicitCreate(true)
             ->execute();
           $other_positions = msort($other_positions, 'getOrderingKey');
 
           // Set up the new position object. We're going to figure out the
           // right sequence number and then persist this object with that
           // sequence number.
           $new_position = id(new PhabricatorProjectColumnPosition())
             ->setBoardPHID($board_phid)
             ->setColumnPHID($column->getPHID())
             ->setObjectPHID($object->getPHID());
 
           $updates = array();
           $sequence = 0;
 
           // If we're just dropping this into the column without any specific
           // position information, put it at the top.
           if (!$before_phid && !$after_phid) {
             $new_position->setSequence($sequence)->save();
             $sequence++;
           }
 
           foreach ($other_positions as $position) {
             $object_phid = $position->getObjectPHID();
 
             // If this is the object we're moving before and we haven't
             // saved yet, insert here.
             if (($before_phid == $object_phid) && !$new_position->getID()) {
               $new_position->setSequence($sequence)->save();
               $sequence++;
             }
 
             // This object goes here in the sequence; we might need to update
             // the row.
             if ($sequence != $position->getSequence()) {
               $updates[$position->getID()] = $sequence;
             }
             $sequence++;
 
             // If this is the object we're moving after and we haven't saved
             // yet, insert here.
             if (($after_phid == $object_phid) && !$new_position->getID()) {
               $new_position->setSequence($sequence)->save();
               $sequence++;
             }
           }
 
           // We should have found a place to put it.
           if (!$new_position->getID()) {
             throw new Exception(
               pht('Unable to find a place to insert object on column!'));
           }
 
           // If we changed other objects' column positions, bulk reorder them.
 
           if ($updates) {
             $position = new PhabricatorProjectColumnPosition();
             $conn_w = $position->establishConnection('w');
 
             $pairs = array();
             foreach ($updates as $id => $sequence) {
               // This is ugly because MySQL gets upset with us if it is
               // configured strictly and we attempt inserts which can't work.
               // We'll never actually do these inserts since they'll always
               // collide (triggering the ON DUPLICATE KEY logic), so we just
               // provide dummy values in order to get there.
 
               $pairs[] = qsprintf(
                 $conn_w,
                 '(%d, %d, "", "", "")',
                 $id,
                 $sequence);
             }
 
             queryfx(
               $conn_w,
               'INSERT INTO %T (id, sequence, boardPHID, columnPHID, objectPHID)
                 VALUES %Q ON DUPLICATE KEY UPDATE sequence = VALUES(sequence)',
               $position->getTableName(),
               implode(', ', $pairs));
           }
         }
         break;
       default:
         break;
     }
   }
 
   protected function applyFinalEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     // When we change the status of a task, update tasks this tasks blocks
     // with a message to the effect of "alincoln resolved blocking task Txxx."
     $unblock_xaction = null;
     foreach ($xactions as $xaction) {
       switch ($xaction->getTransactionType()) {
         case ManiphestTransaction::TYPE_STATUS:
           $unblock_xaction = $xaction;
           break;
       }
     }
 
     if ($unblock_xaction !== null) {
       $blocked_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $object->getPHID(),
         ManiphestTaskDependedOnByTaskEdgeType::EDGECONST);
       if ($blocked_phids) {
         // In theory we could apply these through policies, but that seems a
         // little bit surprising. For now, use the actor's vision.
         $blocked_tasks = id(new ManiphestTaskQuery())
           ->setViewer($this->getActor())
           ->withPHIDs($blocked_phids)
           ->needSubscriberPHIDs(true)
           ->needProjectPHIDs(true)
           ->execute();
 
         $old = $unblock_xaction->getOldValue();
         $new = $unblock_xaction->getNewValue();
 
         foreach ($blocked_tasks as $blocked_task) {
           $unblock_xactions = array();
 
           $unblock_xactions[] = id(new ManiphestTransaction())
             ->setTransactionType(ManiphestTransaction::TYPE_UNBLOCK)
             ->setOldValue(array($object->getPHID() => $old))
             ->setNewValue(array($object->getPHID() => $new));
 
           id(new ManiphestTransactionEditor())
             ->setActor($this->getActor())
             ->setActingAsPHID($this->getActingAsPHID())
             ->setContentSource($this->getContentSource())
             ->setContinueOnNoEffect(true)
             ->setContinueOnMissingFields(true)
             ->applyTransactions($blocked_task, $unblock_xactions);
         }
       }
     }
 
     return $xactions;
   }
 
-  protected function shouldSendMail(
-    PhabricatorLiskDAO $object,
-    array $xactions) {
-
-    $xactions = mfilter($xactions, 'shouldHide', true);
-    return $xactions;
-  }
-
   protected function getMailSubjectPrefix() {
     return PhabricatorEnv::getEnvConfig('metamta.maniphest.subject-prefix');
   }
 
   protected function getMailThreadID(PhabricatorLiskDAO $object) {
     return 'maniphest-task-'.$object->getPHID();
   }
 
   protected function getMailTo(PhabricatorLiskDAO $object) {
     $phids = array();
 
     if ($object->getOwnerPHID()) {
       $phids[] = $object->getOwnerPHID();
     }
     $phids[] = $this->getActingAsPHID();
 
     return $phids;
   }
 
   public function getMailTagsMap() {
     return array(
       ManiphestTransaction::MAILTAG_STATUS =>
         pht("A task's status changes."),
       ManiphestTransaction::MAILTAG_OWNER =>
         pht("A task's owner changes."),
       ManiphestTransaction::MAILTAG_PRIORITY =>
         pht("A task's priority changes."),
       ManiphestTransaction::MAILTAG_CC =>
         pht("A task's subscribers change."),
       ManiphestTransaction::MAILTAG_PROJECTS =>
         pht("A task's associated projects change."),
       ManiphestTransaction::MAILTAG_UNBLOCK =>
         pht('One of the tasks a task is blocked by changes status.'),
       ManiphestTransaction::MAILTAG_COLUMN =>
         pht('A task is moved between columns on a workboard.'),
       ManiphestTransaction::MAILTAG_COMMENT =>
         pht('Someone comments on a task.'),
       ManiphestTransaction::MAILTAG_OTHER =>
         pht('Other task activity not listed above occurs.'),
     );
   }
 
   protected function buildReplyHandler(PhabricatorLiskDAO $object) {
     return id(new ManiphestReplyHandler())
       ->setMailReceiver($object);
   }
 
   protected function buildMailTemplate(PhabricatorLiskDAO $object) {
     $id = $object->getID();
     $title = $object->getTitle();
 
     return id(new PhabricatorMetaMTAMail())
       ->setSubject("T{$id}: {$title}")
       ->addHeader('Thread-Topic', "T{$id}: ".$object->getOriginalTitle());
   }
 
   protected function buildMailBody(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $body = parent::buildMailBody($object, $xactions);
 
     if ($this->getIsNewObject()) {
       $body->addTextSection(
         pht('TASK DESCRIPTION'),
         $object->getDescription());
     }
 
     $body->addLinkSection(
       pht('TASK DETAIL'),
       PhabricatorEnv::getProductionURI('/T'.$object->getID()));
 
 
     $board_phids = array();
     $type_column = ManiphestTransaction::TYPE_PROJECT_COLUMN;
     foreach ($xactions as $xaction) {
       if ($xaction->getTransactionType() == $type_column) {
         $new = $xaction->getNewValue();
         $project_phid = idx($new, 'projectPHID');
         if ($project_phid) {
           $board_phids[] = $project_phid;
         }
       }
     }
 
     if ($board_phids) {
       $projects = id(new PhabricatorProjectQuery())
         ->setViewer($this->requireActor())
         ->withPHIDs($board_phids)
         ->execute();
 
       foreach ($projects as $project) {
         $body->addLinkSection(
           pht('WORKBOARD'),
           PhabricatorEnv::getProductionURI(
             '/project/board/'.$project->getID().'/'));
       }
     }
 
 
     return $body;
   }
 
   protected function shouldPublishFeedStory(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $this->shouldSendMail($object, $xactions);
   }
 
   protected function supportsSearch() {
     return true;
   }
 
   protected function shouldApplyHeraldRules(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return true;
   }
 
   protected function buildHeraldAdapter(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     return id(new HeraldManiphestTaskAdapter())
       ->setTask($object);
   }
 
   protected function didApplyHeraldRules(
     PhabricatorLiskDAO $object,
     HeraldAdapter $adapter,
     HeraldTranscript $transcript) {
 
     $xactions = array();
 
     $cc_phids = $adapter->getCcPHIDs();
     if ($cc_phids) {
       $xactions[] = id(new ManiphestTransaction())
         ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
         ->setNewValue(array('+' => $cc_phids));
     }
 
     $assign_phid = $adapter->getAssignPHID();
     if ($assign_phid) {
       $xactions[] = id(new ManiphestTransaction())
         ->setTransactionType(ManiphestTransaction::TYPE_OWNER)
         ->setNewValue($assign_phid);
     }
 
     return $xactions;
   }
 
   protected function requireCapabilities(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     parent::requireCapabilities($object, $xaction);
 
     $app_capability_map = array(
       ManiphestTransaction::TYPE_PRIORITY =>
         ManiphestEditPriorityCapability::CAPABILITY,
       ManiphestTransaction::TYPE_STATUS =>
         ManiphestEditStatusCapability::CAPABILITY,
       ManiphestTransaction::TYPE_OWNER =>
         ManiphestEditAssignCapability::CAPABILITY,
       PhabricatorTransactions::TYPE_EDIT_POLICY =>
         ManiphestEditPoliciesCapability::CAPABILITY,
       PhabricatorTransactions::TYPE_VIEW_POLICY =>
         ManiphestEditPoliciesCapability::CAPABILITY,
     );
 
 
     $transaction_type = $xaction->getTransactionType();
 
     $app_capability = null;
     if ($transaction_type == PhabricatorTransactions::TYPE_EDGE) {
       switch ($xaction->getMetadataValue('edge:type')) {
         case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST:
           $app_capability = ManiphestEditProjectsCapability::CAPABILITY;
           break;
       }
     } else {
       $app_capability = idx($app_capability_map, $transaction_type);
     }
 
     if ($app_capability) {
       $app = id(new PhabricatorApplicationQuery())
         ->setViewer($this->getActor())
         ->withClasses(array('PhabricatorManiphestApplication'))
         ->executeOne();
       PhabricatorPolicyFilter::requireCapability(
         $this->getActor(),
         $app,
         $app_capability);
     }
   }
 
   protected function adjustObjectForPolicyChecks(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $copy = parent::adjustObjectForPolicyChecks($object, $xactions);
     foreach ($xactions as $xaction) {
       switch ($xaction->getTransactionType()) {
         case ManiphestTransaction::TYPE_OWNER:
           $copy->setOwnerPHID($xaction->getNewValue());
           break;
         default:
           continue;
       }
     }
 
     return $copy;
   }
 
   /**
    * Get priorities for moving a task to a new priority.
    */
   public static function getEdgeSubpriority(
     $priority,
     $is_end) {
 
     $query = id(new ManiphestTaskQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->withPriorities(array($priority))
       ->setLimit(1);
 
     if ($is_end) {
       $query->setOrderVector(array('-priority', '-subpriority', '-id'));
     } else {
       $query->setOrderVector(array('priority', 'subpriority', 'id'));
     }
 
     $result = $query->executeOne();
     $step = (double)(2 << 32);
 
     if ($result) {
       $base = $result->getSubpriority();
       if ($is_end) {
         $sub = ($base - $step);
       } else {
         $sub = ($base + $step);
       }
     } else {
       $sub = 0;
     }
 
     return array($priority, $sub);
   }
 
 
   /**
    * Get priorities for moving a task before or after another task.
    */
   public static function getAdjacentSubpriority(
     ManiphestTask $dst,
     $is_after,
     $allow_recursion = true) {
 
     $query = id(new ManiphestTaskQuery())
       ->setViewer(PhabricatorUser::getOmnipotentUser())
       ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY)
       ->withPriorities(array($dst->getPriority()))
       ->setLimit(1);
 
     if ($is_after) {
       $query->setAfterID($dst->getID());
     } else {
       $query->setBeforeID($dst->getID());
     }
 
     $adjacent = $query->executeOne();
 
     $base = $dst->getSubpriority();
     $step = (double)(2 << 32);
 
     // If we find an adjacent task, we average the two subpriorities and
     // return the result.
     if ($adjacent) {
       $epsilon = 0.01;
 
       // If the adjacent task has a subpriority that is identical or very
       // close to the task we're looking at, we're going to move it and all
       // tasks with the same subpriority a little farther down the subpriority
       // scale.
       if ($allow_recursion &&
           (abs($adjacent->getSubpriority() - $base) < $epsilon)) {
         $conn_w = $adjacent->establishConnection('w');
 
         $min = ($adjacent->getSubpriority() - ($epsilon));
         $max = ($adjacent->getSubpriority() + ($epsilon));
 
         // Get all of the tasks with the similar subpriorities to the adjacent
         // task, including the adjacent task itself.
         $query = id(new ManiphestTaskQuery())
           ->setViewer(PhabricatorUser::getOmnipotentUser())
           ->withPriorities(array($adjacent->getPriority()))
           ->withSubpriorityBetween($min, $max);
 
         if (!$is_after) {
           $query->setOrderVector(array('-priority', '-subpriority', '-id'));
         } else {
           $query->setOrderVector(array('priority', 'subpriority', 'id'));
         }
 
         $shift_all = $query->execute();
         $shift_last = last($shift_all);
 
         // Select the most extreme subpriority in the result set as the
         // base value.
         $shift_base = head($shift_all)->getSubpriority();
 
         // Find the subpriority before or after the task at the end of the
         // block.
         list($shift_pri, $shift_sub) = self::getAdjacentSubpriority(
           $shift_last,
           $is_after,
           $allow_recursion = false);
 
         $delta = ($shift_sub - $shift_base);
         $count = count($shift_all);
 
         $shift = array();
         $cursor = 1;
         foreach ($shift_all as $shift_task) {
           $shift_target = $shift_base + (($cursor / $count) * $delta);
           $cursor++;
 
           queryfx(
             $conn_w,
             'UPDATE %T SET subpriority = %f WHERE id = %d',
             $adjacent->getTableName(),
             $shift_target,
             $shift_task->getID());
 
           // If we're shifting the adjacent task, update it.
           if ($shift_task->getID() == $adjacent->getID()) {
             $adjacent->setSubpriority($shift_target);
           }
 
           // If we're shifting the original target task, update the base
           // subpriority.
           if ($shift_task->getID() == $dst->getID()) {
             $base = $shift_target;
           }
         }
       }
 
       $sub = ($adjacent->getSubpriority() + $base) / 2;
     } else {
       // Otherwise, we take a step away from the target's subpriority and
       // use that.
       if ($is_after) {
         $sub = ($base - $step);
       } else {
         $sub = ($base + $step);
       }
     }
 
     return array($dst->getPriority(), $sub);
   }
 
 
 }
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
index bd77b7e98..176954404 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,2979 +1,2982 @@
 <?php
 
 /**
  *
  * Publishing and Managing State
  * ======
  *
  * After applying changes, the Editor queues a worker to publish mail, feed,
  * and notifications, and to perform other background work like updating search
  * indexes. This allows it to do this work without impacting performance for
  * users.
  *
  * When work is moved to the daemons, the Editor state is serialized by
  * @{method:getWorkerState}, then reloaded in a daemon process by
  * @{method:loadWorkerState}. **This is fragile.**
  *
  * State is not persisted into the daemons by default, because we can not send
  * arbitrary objects into the queue. This means the default behavior of any
  * state properties is to reset to their defaults without warning prior to
  * publishing.
  *
  * The easiest way to avoid this is to keep Editors stateless: the overwhelming
  * majority of Editors can be written statelessly. If you need to maintain
  * state, you can either:
  *
  *   - not require state to exist during publishing; or
  *   - pass state to the daemons by implementing @{method:getCustomWorkerState}
  *     and @{method:loadCustomWorkerState}.
  *
  * This architecture isn't ideal, and we may eventually split this class into
  * "Editor" and "Publisher" parts to make it more robust. See T6367 for some
  * discussion and context.
  *
  * @task mail Sending Mail
  * @task feed Publishing Feed Stories
  * @task search Search Index
  * @task files Integration with Files
  * @task workers Managing Workers
  */
 abstract class PhabricatorApplicationTransactionEditor
   extends PhabricatorEditor {
 
   private $contentSource;
   private $object;
   private $xactions;
 
   private $isNewObject;
   private $mentionedPHIDs;
   private $continueOnNoEffect;
   private $continueOnMissingFields;
   private $parentMessageID;
   private $heraldAdapter;
   private $heraldTranscript;
   private $subscribers;
   private $unmentionablePHIDMap = array();
   private $applicationEmail;
 
   private $isPreview;
   private $isHeraldEditor;
   private $isInverseEdgeEditor;
   private $actingAsPHID;
   private $disableEmail;
 
   private $heraldEmailPHIDs = array();
   private $heraldForcedEmailPHIDs = array();
   private $heraldHeader;
   private $mailToPHIDs = array();
   private $mailCCPHIDs = array();
   private $feedNotifyPHIDs = array();
   private $feedRelatedPHIDs = array();
 
   /**
    * Get the class name for the application this editor is a part of.
    *
    * Uninstalling the application will disable the editor.
    *
    * @return string Editor's application class name.
    */
   abstract public function getEditorApplicationClass();
 
 
   /**
    * Get a description of the objects this editor edits, like "Differential
    * Revisions".
    *
    * @return string Human readable description of edited objects.
    */
   abstract public function getEditorObjectsDescription();
 
 
   public function setActingAsPHID($acting_as_phid) {
     $this->actingAsPHID = $acting_as_phid;
     return $this;
   }
 
   public function getActingAsPHID() {
     if ($this->actingAsPHID) {
       return $this->actingAsPHID;
     }
     return $this->getActor()->getPHID();
   }
 
 
   /**
    * When the editor tries to apply transactions that have no effect, should
    * it raise an exception (default) or drop them and continue?
    *
    * Generally, you will set this flag for edits coming from "Edit" interfaces,
    * and leave it cleared for edits coming from "Comment" interfaces, so the
    * user will get a useful error if they try to submit a comment that does
    * nothing (e.g., empty comment with a status change that has already been
    * performed by another user).
    *
    * @param bool  True to drop transactions without effect and continue.
    * @return this
    */
   public function setContinueOnNoEffect($continue) {
     $this->continueOnNoEffect = $continue;
     return $this;
   }
 
   public function getContinueOnNoEffect() {
     return $this->continueOnNoEffect;
   }
 
 
   /**
    * When the editor tries to apply transactions which don't populate all of
    * an object's required fields, should it raise an exception (default) or
    * drop them and continue?
    *
    * For example, if a user adds a new required custom field (like "Severity")
    * to a task, all existing tasks won't have it populated. When users
    * manually edit existing tasks, it's usually desirable to have them provide
    * a severity. However, other operations (like batch editing just the
    * owner of a task) will fail by default.
    *
    * By setting this flag for edit operations which apply to specific fields
    * (like the priority, batch, and merge editors in Maniphest), these
    * operations can continue to function even if an object is outdated.
    *
    * @param bool  True to continue when transactions don't completely satisfy
    *              all required fields.
    * @return this
    */
   public function setContinueOnMissingFields($continue_on_missing_fields) {
     $this->continueOnMissingFields = $continue_on_missing_fields;
     return $this;
   }
 
   public function getContinueOnMissingFields() {
     return $this->continueOnMissingFields;
   }
 
 
   /**
    * Not strictly necessary, but reply handlers ideally set this value to
    * make email threading work better.
    */
   public function setParentMessageID($parent_message_id) {
     $this->parentMessageID = $parent_message_id;
     return $this;
   }
   public function getParentMessageID() {
     return $this->parentMessageID;
   }
 
   public function getIsNewObject() {
     return $this->isNewObject;
   }
 
   protected function getMentionedPHIDs() {
     return $this->mentionedPHIDs;
   }
 
   public function setIsPreview($is_preview) {
     $this->isPreview = $is_preview;
     return $this;
   }
 
   public function getIsPreview() {
     return $this->isPreview;
   }
 
   public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
     $this->isInverseEdgeEditor = $is_inverse_edge_editor;
     return $this;
   }
 
   public function getIsInverseEdgeEditor() {
     return $this->isInverseEdgeEditor;
   }
 
   public function setIsHeraldEditor($is_herald_editor) {
     $this->isHeraldEditor = $is_herald_editor;
     return $this;
   }
 
   public function getIsHeraldEditor() {
     return $this->isHeraldEditor;
   }
 
   /**
    * Prevent this editor from generating email when applying transactions.
    *
    * @param bool  True to disable email.
    * @return this
    */
   public function setDisableEmail($disable_email) {
     $this->disableEmail = $disable_email;
     return $this;
   }
 
   public function getDisableEmail() {
     return $this->disableEmail;
   }
 
   public function setUnmentionablePHIDMap(array $map) {
     $this->unmentionablePHIDMap = $map;
     return $this;
   }
 
   public function getUnmentionablePHIDMap() {
     return $this->unmentionablePHIDMap;
   }
 
   protected function shouldEnableMentions(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return true;
   }
 
   public function setApplicationEmail(
     PhabricatorMetaMTAApplicationEmail $email) {
     $this->applicationEmail = $email;
     return $this;
   }
 
   public function getApplicationEmail() {
     return $this->applicationEmail;
   }
 
   public function getTransactionTypes() {
     $types = array();
 
     if ($this->object instanceof PhabricatorSubscribableInterface) {
       $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
     }
 
     if ($this->object instanceof PhabricatorCustomFieldInterface) {
       $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
     }
 
     if ($this->object instanceof HarbormasterBuildableInterface) {
       $types[] = PhabricatorTransactions::TYPE_BUILDABLE;
     }
 
     if ($this->object instanceof PhabricatorTokenReceiverInterface) {
       $types[] = PhabricatorTransactions::TYPE_TOKEN;
     }
 
     if ($this->object instanceof PhabricatorProjectInterface ||
         $this->object instanceof PhabricatorMentionableInterface) {
       $types[] = PhabricatorTransactions::TYPE_EDGE;
     }
 
     return $types;
   }
 
   private function adjustTransactionValues(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     if ($xaction->shouldGenerateOldValue()) {
       $old = $this->getTransactionOldValue($object, $xaction);
       $xaction->setOldValue($old);
     }
 
     $new = $this->getTransactionNewValue($object, $xaction);
     $xaction->setNewValue($new);
   }
 
   private function getTransactionOldValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return array_values($this->subscribers);
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         return $object->getViewPolicy();
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         return $object->getEditPolicy();
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         return $object->getJoinPolicy();
       case PhabricatorTransactions::TYPE_EDGE:
         $edge_type = $xaction->getMetadataValue('edge:type');
         if (!$edge_type) {
           throw new Exception(
             pht(
               "Edge transaction has no '%s'!",
               'edge:type'));
         }
 
         $old_edges = array();
         if ($object->getPHID()) {
           $edge_src = $object->getPHID();
 
           $old_edges = id(new PhabricatorEdgeQuery())
             ->withSourcePHIDs(array($edge_src))
             ->withEdgeTypes(array($edge_type))
             ->needEdgeData(true)
             ->execute();
 
           $old_edges = $old_edges[$edge_src][$edge_type];
         }
         return $old_edges;
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         // NOTE: Custom fields have their old value pre-populated when they are
         // built by PhabricatorCustomFieldList.
         return $xaction->getOldValue();
       case PhabricatorTransactions::TYPE_COMMENT:
         return null;
       default:
         return $this->getCustomTransactionOldValue($object, $xaction);
     }
   }
 
   private function getTransactionNewValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return $this->getPHIDTransactionNewValue($xaction);
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_BUILDABLE:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_INLINESTATE:
         return $xaction->getNewValue();
       case PhabricatorTransactions::TYPE_EDGE:
         return $this->getEdgeTransactionNewValue($xaction);
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getCustomFieldForTransaction($object, $xaction);
         return $field->getNewValueFromApplicationTransactions($xaction);
       case PhabricatorTransactions::TYPE_COMMENT:
         return null;
       default:
         return $this->getCustomTransactionNewValue($object, $xaction);
     }
   }
 
   protected function getCustomTransactionOldValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     throw new Exception(pht('Capability not supported!'));
   }
 
   protected function getCustomTransactionNewValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     throw new Exception(pht('Capability not supported!'));
   }
 
   protected function transactionHasEffect(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         return $xaction->hasComment();
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getCustomFieldForTransaction($object, $xaction);
         return $field->getApplicationTransactionHasEffect($xaction);
       case PhabricatorTransactions::TYPE_EDGE:
         // A straight value comparison here doesn't always get the right
         // result, because newly added edges aren't fully populated. Instead,
         // compare the changes in a more granular way.
         $old = $xaction->getOldValue();
         $new = $xaction->getNewValue();
 
         $old_dst = array_keys($old);
         $new_dst = array_keys($new);
 
         // NOTE: For now, we don't consider edge reordering to be a change.
         // We have very few order-dependent edges and effectively no order
         // oriented UI. This might change in the future.
         sort($old_dst);
         sort($new_dst);
 
         if ($old_dst !== $new_dst) {
           // We've added or removed edges, so this transaction definitely
           // has an effect.
           return true;
         }
 
         // We haven't added or removed edges, but we might have changed
         // edge data.
         foreach ($old as $key => $old_value) {
           $new_value = $new[$key];
           if ($old_value['data'] !== $new_value['data']) {
             return true;
           }
         }
 
         return false;
     }
 
     return ($xaction->getOldValue() !== $xaction->getNewValue());
   }
 
   protected function shouldApplyInitialEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return false;
   }
 
   protected function applyInitialEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
     throw new PhutilMethodNotImplementedException();
   }
 
   private function applyInternalEffects(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getCustomFieldForTransaction($object, $xaction);
         return $field->applyApplicationTransactionInternalEffects($xaction);
       case PhabricatorTransactions::TYPE_BUILDABLE:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
       case PhabricatorTransactions::TYPE_INLINESTATE:
       case PhabricatorTransactions::TYPE_EDGE:
       case PhabricatorTransactions::TYPE_COMMENT:
         return $this->applyBuiltinInternalTransaction($object, $xaction);
     }
 
     return $this->applyCustomInternalTransaction($object, $xaction);
   }
 
   private function applyExternalEffects(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         $subeditor = id(new PhabricatorSubscriptionsEditor())
           ->setObject($object)
           ->setActor($this->requireActor());
 
         $old_map = array_fuse($xaction->getOldValue());
         $new_map = array_fuse($xaction->getNewValue());
 
         $subeditor->unsubscribe(
           array_keys(
             array_diff_key($old_map, $new_map)));
 
         $subeditor->subscribeExplicit(
           array_keys(
             array_diff_key($new_map, $old_map)));
 
         $subeditor->save();
 
         // for the rest of these edits, subscribers should include those just
         // added as well as those just removed.
         $subscribers = array_unique(array_merge(
           $this->subscribers,
           $xaction->getOldValue(),
           $xaction->getNewValue()));
         $this->subscribers = $subscribers;
         return $this->applyBuiltinExternalTransaction($object, $xaction);
 
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $field = $this->getCustomFieldForTransaction($object, $xaction);
         return $field->applyApplicationTransactionExternalEffects($xaction);
       case PhabricatorTransactions::TYPE_EDGE:
       case PhabricatorTransactions::TYPE_BUILDABLE:
       case PhabricatorTransactions::TYPE_TOKEN:
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
       case PhabricatorTransactions::TYPE_INLINESTATE:
       case PhabricatorTransactions::TYPE_COMMENT:
         return $this->applyBuiltinExternalTransaction($object, $xaction);
     }
 
     return $this->applyCustomExternalTransaction($object, $xaction);
   }
 
   protected function applyCustomInternalTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     $type = $xaction->getTransactionType();
     throw new Exception(
       pht(
         "Transaction type '%s' is missing an internal apply implementation!",
         $type));
   }
 
   protected function applyCustomExternalTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     $type = $xaction->getTransactionType();
     throw new Exception(
       pht(
         "Transaction type '%s' is missing an external apply implementation!",
         $type));
   }
 
   /**
    * @{class:PhabricatorTransactions} provides many built-in transactions
    * which should not require much - if any - code in specific applications.
    *
    * This method is a hook for the exceedingly-rare cases where you may need
    * to do **additional** work for built-in transactions. Developers should
    * extend this method, making sure to return the parent implementation
    * regardless of handling any transactions.
    *
    * See also @{method:applyBuiltinExternalTransaction}.
    */
   protected function applyBuiltinInternalTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         $object->setViewPolicy($xaction->getNewValue());
         break;
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         $object->setEditPolicy($xaction->getNewValue());
         break;
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         $object->setJoinPolicy($xaction->getNewValue());
         break;
     }
   }
 
   /**
    * See @{method::applyBuiltinInternalTransaction}.
    */
   protected function applyBuiltinExternalTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_EDGE:
         if ($this->getIsInverseEdgeEditor()) {
           // If we're writing an inverse edge transaction, don't actually
           // do anything. The initiating editor on the other side of the
           // transaction will take care of the edge writes.
           break;
         }
 
         $old = $xaction->getOldValue();
         $new = $xaction->getNewValue();
         $src = $object->getPHID();
         $const = $xaction->getMetadataValue('edge:type');
 
         $type = PhabricatorEdgeType::getByConstant($const);
         if ($type->shouldWriteInverseTransactions()) {
           $this->applyInverseEdgeTransactions(
             $object,
             $xaction,
             $type->getInverseEdgeConstant());
         }
 
         foreach ($new as $dst_phid => $edge) {
           $new[$dst_phid]['src'] = $src;
         }
 
         $editor = new PhabricatorEdgeEditor();
 
         foreach ($old as $dst_phid => $edge) {
           if (!empty($new[$dst_phid])) {
             if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
               continue;
             }
           }
           $editor->removeEdge($src, $const, $dst_phid);
         }
 
         foreach ($new as $dst_phid => $edge) {
           if (!empty($old[$dst_phid])) {
             if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
               continue;
             }
           }
 
           $data = array(
             'data' => $edge['data'],
           );
 
           $editor->addEdge($src, $const, $dst_phid, $data);
         }
 
         $editor->save();
         break;
     }
   }
 
   /**
    * Fill in a transaction's common values, like author and content source.
    */
   protected function populateTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $actor = $this->getActor();
 
     // TODO: This needs to be more sophisticated once we have meta-policies.
     $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
 
     if ($actor->isOmnipotent()) {
       $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
     } else {
       $xaction->setEditPolicy($this->getActingAsPHID());
     }
 
     $xaction->setAuthorPHID($this->getActingAsPHID());
     $xaction->setContentSource($this->getContentSource());
     $xaction->attachViewer($actor);
     $xaction->attachObject($object);
 
     if ($object->getPHID()) {
       $xaction->setObjectPHID($object->getPHID());
     }
 
     return $xaction;
   }
 
   protected function didApplyInternalEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $xactions;
   }
 
   protected function applyFinalEffects(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $xactions;
   }
 
   public function setContentSource(PhabricatorContentSource $content_source) {
     $this->contentSource = $content_source;
     return $this;
   }
 
   public function setContentSourceFromRequest(AphrontRequest $request) {
     return $this->setContentSource(
       PhabricatorContentSource::newFromRequest($request));
   }
 
   public function setContentSourceFromConduitRequest(
     ConduitAPIRequest $request) {
 
     $content_source = PhabricatorContentSource::newForSource(
       PhabricatorContentSource::SOURCE_CONDUIT,
       array());
 
     return $this->setContentSource($content_source);
   }
 
   public function getContentSource() {
     return $this->contentSource;
   }
 
   final public function applyTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $this->object = $object;
     $this->xactions = $xactions;
     $this->isNewObject = ($object->getPHID() === null);
 
     $this->validateEditParameters($object, $xactions);
 
     $actor = $this->requireActor();
 
     // NOTE: Some transaction expansion requires that the edited object be
     // attached.
     foreach ($xactions as $xaction) {
       $xaction->attachObject($object);
       $xaction->attachViewer($actor);
     }
 
     $xactions = $this->expandTransactions($object, $xactions);
     $xactions = $this->expandSupportTransactions($object, $xactions);
     $xactions = $this->combineTransactions($xactions);
 
     foreach ($xactions as $xaction) {
       $xaction = $this->populateTransaction($object, $xaction);
     }
 
     $is_preview = $this->getIsPreview();
     $read_locking = false;
     $transaction_open = false;
 
     if (!$is_preview) {
       $errors = array();
       $type_map = mgroup($xactions, 'getTransactionType');
       foreach ($this->getTransactionTypes() as $type) {
         $type_xactions = idx($type_map, $type, array());
         $errors[] = $this->validateTransaction($object, $type, $type_xactions);
       }
 
       $errors[] = $this->validateAllTransactions($object, $xactions);
       $errors = array_mergev($errors);
 
       $continue_on_missing = $this->getContinueOnMissingFields();
       foreach ($errors as $key => $error) {
         if ($continue_on_missing && $error->getIsMissingFieldError()) {
           unset($errors[$key]);
         }
       }
 
       if ($errors) {
         throw new PhabricatorApplicationTransactionValidationException($errors);
       }
 
       $file_phids = $this->extractFilePHIDs($object, $xactions);
 
       if ($object->getID()) {
         foreach ($xactions as $xaction) {
 
           // If any of the transactions require a read lock, hold one and
           // reload the object. We need to do this fairly early so that the
           // call to `adjustTransactionValues()` (which populates old values)
           // is based on the synchronized state of the object, which may differ
           // from the state when it was originally loaded.
 
           if ($this->shouldReadLock($object, $xaction)) {
             $object->openTransaction();
             $object->beginReadLocking();
             $transaction_open = true;
             $read_locking = true;
             $object->reload();
             break;
           }
         }
       }
 
       if ($this->shouldApplyInitialEffects($object, $xactions)) {
         if (!$transaction_open) {
           $object->openTransaction();
           $transaction_open = true;
         }
       }
     }
 
     if ($this->shouldApplyInitialEffects($object, $xactions)) {
       $this->applyInitialEffects($object, $xactions);
     }
 
     foreach ($xactions as $xaction) {
       $this->adjustTransactionValues($object, $xaction);
     }
 
     $xactions = $this->filterTransactions($object, $xactions);
 
     if (!$xactions) {
       if ($read_locking) {
         $object->endReadLocking();
         $read_locking = false;
       }
       if ($transaction_open) {
         $object->killTransaction();
         $transaction_open = false;
       }
       return array();
     }
 
     // Now that we've merged, filtered, and combined transactions, check for
     // required capabilities.
     foreach ($xactions as $xaction) {
       $this->requireCapabilities($object, $xaction);
     }
 
     $xactions = $this->sortTransactions($xactions);
 
     if ($is_preview) {
       $this->loadHandles($xactions);
       return $xactions;
     }
 
     $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
       ->setActor($actor)
       ->setActingAsPHID($this->getActingAsPHID())
       ->setContentSource($this->getContentSource());
 
     if (!$transaction_open) {
       $object->openTransaction();
     }
 
       foreach ($xactions as $xaction) {
         $this->applyInternalEffects($object, $xaction);
       }
 
       $xactions = $this->didApplyInternalEffects($object, $xactions);
 
       try {
         $object->save();
       } catch (AphrontDuplicateKeyQueryException $ex) {
         $object->killTransaction();
         throw $ex;
       }
 
       foreach ($xactions as $xaction) {
         $xaction->setObjectPHID($object->getPHID());
         if ($xaction->getComment()) {
           $xaction->setPHID($xaction->generatePHID());
           $comment_editor->applyEdit($xaction, $xaction->getComment());
         } else {
           $xaction->save();
         }
       }
 
       if ($file_phids) {
         $this->attachFiles($object, $file_phids);
       }
 
       foreach ($xactions as $xaction) {
         $this->applyExternalEffects($object, $xaction);
       }
 
       $xactions = $this->applyFinalEffects($object, $xactions);
 
       if ($read_locking) {
         $object->endReadLocking();
         $read_locking = false;
       }
 
     $object->saveTransaction();
 
     // Now that we've completely applied the core transaction set, try to apply
     // Herald rules. Herald rules are allowed to either take direct actions on
     // the database (like writing flags), or take indirect actions (like saving
     // some targets for CC when we generate mail a little later), or return
     // transactions which we'll apply normally using another Editor.
 
     // First, check if *this* is a sub-editor which is itself applying Herald
     // rules: if it is, stop working and return so we don't descend into
     // madness.
 
     // Otherwise, we're not a Herald editor, so process Herald rules (possibly
     // using a Herald editor to apply resulting transactions) and then send out
     // mail, notifications, and feed updates about everything.
 
     if ($this->getIsHeraldEditor()) {
       // We are the Herald editor, so stop work here and return the updated
       // transactions.
       return $xactions;
     } else if ($this->getIsInverseEdgeEditor()) {
       // If we're applying inverse edge transactions, don't trigger Herald.
       // From a product perspective, the current set of inverse edges (most
       // often, mentions) aren't things users would expect to trigger Herald.
       // From a technical perspective, objects loaded by the inverse editor may
       // not have enough data to execute rules. At least for now, just stop
       // Herald from executing when applying inverse edges.
     } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
       // We are not the Herald editor, so try to apply Herald rules.
       $herald_xactions = $this->applyHeraldRules($object, $xactions);
 
       if ($herald_xactions) {
         $xscript_id = $this->getHeraldTranscript()->getID();
         foreach ($herald_xactions as $herald_xaction) {
           $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
         }
 
         // NOTE: We're acting as the omnipotent user because rules deal with
         // their own policy issues. We use a synthetic author PHID (the
         // Herald application) as the author of record, so that transactions
         // will render in a reasonable way ("Herald assigned this task ...").
         $herald_actor = PhabricatorUser::getOmnipotentUser();
         $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
 
         // TODO: It would be nice to give transactions a more specific source
         // which points at the rule which generated them. You can figure this
         // out from transcripts, but it would be cleaner if you didn't have to.
 
         $herald_source = PhabricatorContentSource::newForSource(
           PhabricatorContentSource::SOURCE_HERALD,
           array());
 
         $herald_editor = newv(get_class($this), array())
           ->setContinueOnNoEffect(true)
           ->setContinueOnMissingFields(true)
           ->setParentMessageID($this->getParentMessageID())
           ->setIsHeraldEditor(true)
           ->setActor($herald_actor)
           ->setActingAsPHID($herald_phid)
           ->setContentSource($herald_source);
 
         $herald_xactions = $herald_editor->applyTransactions(
           $object,
           $herald_xactions);
 
         $adapter = $this->getHeraldAdapter();
         $this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
         $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
 
         // Merge the new transactions into the transaction list: we want to
         // send email and publish feed stories about them, too.
         $xactions = array_merge($xactions, $herald_xactions);
       }
     }
 
     $this->didApplyTransactions($xactions);
 
     if ($object instanceof PhabricatorCustomFieldInterface) {
       // Maybe this makes more sense to move into the search index itself? For
       // now I'm putting it here since I think we might end up with things that
       // need it to be up to date once the next page loads, but if we don't go
       // there we we could move it into search once search moves to the daemons.
 
       // It now happens in the search indexer as well, but the search indexer is
       // always daemonized, so the logic above still potentially holds. We could
       // possibly get rid of this. The major motivation for putting it in the
       // indexer was to enable reindexing to work.
 
       $fields = PhabricatorCustomField::getObjectFields(
         $object,
         PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
       $fields->readFieldsFromStorage($object);
       $fields->rebuildIndexes($object);
     }
 
     $herald_xscript = $this->getHeraldTranscript();
     if ($herald_xscript) {
       $herald_header = $herald_xscript->getXHeraldRulesHeader();
       $herald_header = HeraldTranscript::saveXHeraldRulesHeader(
         $object->getPHID(),
         $herald_header);
     } else {
       $herald_header = HeraldTranscript::loadXHeraldRulesHeader(
         $object->getPHID());
     }
     $this->heraldHeader = $herald_header;
 
     // We're going to compute some of the data we'll use to publish these
     // transactions here, before queueing a worker.
     //
     // Primarily, this is more correct: we want to publish the object as it
     // exists right now. The worker may not execute for some time, and we want
     // to use the current To/CC list, not respect any changes which may occur
     // between now and when the worker executes.
     //
     // As a secondary benefit, this tends to reduce the amount of state that
     // Editors need to pass into workers.
     $object = $this->willPublish($object, $xactions);
 
     if (!$this->getDisableEmail()) {
       if ($this->shouldSendMail($object, $xactions)) {
         $this->mailToPHIDs = $this->getMailTo($object);
         $this->mailCCPHIDs = $this->getMailCC($object);
       }
     }
 
     if ($this->shouldPublishFeedStory($object, $xactions)) {
       $this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs($object, $xactions);
       $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs($object, $xactions);
     }
 
     PhabricatorWorker::scheduleTask(
       'PhabricatorApplicationTransactionPublishWorker',
       array(
         'objectPHID' => $object->getPHID(),
         'actorPHID' => $this->getActingAsPHID(),
         'xactionPHIDs' => mpull($xactions, 'getPHID'),
         'state' => $this->getWorkerState(),
       ),
       array(
         'objectPHID' => $object->getPHID(),
         'priority' => PhabricatorWorker::PRIORITY_ALERTS,
       ));
 
     return $xactions;
   }
 
   public function publishTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     // Before sending mail or publishing feed stories, reload the object
     // subscribers to pick up changes caused by Herald (or by other side effects
     // in various transaction phases).
     $this->loadSubscribers($object);
     // Hook for other edges that may need (re-)loading
     $object = $this->willPublish($object, $xactions);
 
     $mailed = array();
     if (!$this->getDisableEmail()) {
       if ($this->shouldSendMail($object, $xactions)) {
         $mailed = $this->sendMail($object, $xactions);
       }
     }
 
     if ($this->supportsSearch()) {
       id(new PhabricatorSearchIndexer())
         ->queueDocumentForIndexing(
           $object->getPHID(),
           $this->getSearchContextParameter($object, $xactions));
     }
 
     if ($this->shouldPublishFeedStory($object, $xactions)) {
       $this->publishFeedStory(
         $object,
         $xactions,
         $mailed);
     }
 
     return $xactions;
   }
 
   protected function didApplyTransactions(array $xactions) {
     // Hook for subclasses.
     return;
   }
 
 
   /**
    * Determine if the editor should hold a read lock on the object while
    * applying a transaction.
    *
    * If the editor does not hold a lock, two editors may read an object at the
    * same time, then apply their changes without any synchronization. For most
    * transactions, this does not matter much. However, it is important for some
    * transactions. For example, if an object has a transaction count on it, both
    * editors may read the object with `count = 23`, then independently update it
    * and save the object with `count = 24` twice. This will produce the wrong
    * state: the object really has 25 transactions, but the count is only 24.
    *
    * Generally, transactions fall into one of four buckets:
    *
    *   - Append operations: Actions like adding a comment to an object purely
    *     add information to its state, and do not depend on the current object
    *     state in any way. These transactions never need to hold locks.
    *   - Overwrite operations: Actions like changing the title or description
    *     of an object replace the current value with a new value, so the end
    *     state is consistent without a lock. We currently do not lock these
    *     transactions, although we may in the future.
    *   - Edge operations: Edge and subscription operations have internal
    *     synchronization which limits the damage race conditions can cause.
    *     We do not currently lock these transactions, although we may in the
    *     future.
    *   - Update operations: Actions like incrementing a count on an object.
    *     These operations generally should use locks, unless it is not
    *     important that the state remain consistent in the presence of races.
    *
    * @param   PhabricatorLiskDAO  Object being updated.
    * @param   PhabricatorApplicationTransaction Transaction being applied.
    * @return  bool                True to synchronize the edit with a lock.
    */
   protected function shouldReadLock(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     return false;
   }
 
   private function loadHandles(array $xactions) {
     $phids = array();
     foreach ($xactions as $key => $xaction) {
       $phids[$key] = $xaction->getRequiredHandlePHIDs();
     }
     $handles = array();
     $merged = array_mergev($phids);
     if ($merged) {
       $handles = id(new PhabricatorHandleQuery())
         ->setViewer($this->requireActor())
         ->withPHIDs($merged)
         ->execute();
     }
     foreach ($xactions as $key => $xaction) {
       $xaction->setHandles(array_select_keys($handles, $phids[$key]));
     }
   }
 
   private function loadSubscribers(PhabricatorLiskDAO $object) {
     if ($object->getPHID() &&
         ($object instanceof PhabricatorSubscribableInterface)) {
       $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
         $object->getPHID());
       $this->subscribers = array_fuse($subs);
     } else {
       $this->subscribers = array();
     }
   }
 
   private function validateEditParameters(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     if (!$this->getContentSource()) {
       throw new PhutilInvalidStateException('setContentSource');
     }
 
     // Do a bunch of sanity checks that the incoming transactions are fresh.
     // They should be unsaved and have only "transactionType" and "newValue"
     // set.
 
     $types = array_fill_keys($this->getTransactionTypes(), true);
 
     assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
     foreach ($xactions as $xaction) {
       if ($xaction->getPHID() || $xaction->getID()) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht('You can not apply transactions which already have IDs/PHIDs!'));
       }
       if ($xaction->getObjectPHID()) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'You can not apply transactions which already have %s!',
             'objectPHIDs'));
       }
       if ($xaction->getAuthorPHID()) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'You can not apply transactions which already have %s!',
             'authorPHIDs'));
       }
       if ($xaction->getCommentPHID()) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'You can not apply transactions which already have %s!',
             'commentPHIDs'));
       }
       if ($xaction->getCommentVersion() !== 0) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'You can not apply transactions which already have '.
             'commentVersions!'));
       }
 
       $expect_value = !$xaction->shouldGenerateOldValue();
       $has_value = $xaction->hasOldValue();
 
       if ($expect_value && !$has_value) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'This transaction is supposed to have an %s set, but it does not!',
             'oldValue'));
       }
 
       if ($has_value && !$expect_value) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'This transaction should generate its %s automatically, '.
             'but has already had one set!',
             'oldValue'));
       }
 
       $type = $xaction->getTransactionType();
       if (empty($types[$type])) {
         throw new PhabricatorApplicationTransactionStructureException(
           $xaction,
           pht(
             'Transaction has type "%s", but that transaction type is not '.
             'supported by this editor (%s).',
             $type,
             get_class($this)));
       }
     }
   }
 
   protected function requireCapabilities(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     if ($this->getIsNewObject()) {
       return;
     }
 
     $actor = $this->requireActor();
     switch ($xaction->getTransactionType()) {
       case PhabricatorTransactions::TYPE_COMMENT:
         PhabricatorPolicyFilter::requireCapability(
           $actor,
           $object,
           PhabricatorPolicyCapability::CAN_VIEW);
         break;
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         PhabricatorPolicyFilter::requireCapability(
           $actor,
           $object,
           PhabricatorPolicyCapability::CAN_EDIT);
         break;
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         PhabricatorPolicyFilter::requireCapability(
           $actor,
           $object,
           PhabricatorPolicyCapability::CAN_EDIT);
         break;
       case PhabricatorTransactions::TYPE_JOIN_POLICY:
         PhabricatorPolicyFilter::requireCapability(
           $actor,
           $object,
           PhabricatorPolicyCapability::CAN_EDIT);
         break;
     }
   }
 
   private function buildSubscribeTransaction(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $blocks) {
 
     if (!($object instanceof PhabricatorSubscribableInterface)) {
       return null;
     }
 
     if ($this->shouldEnableMentions($object, $xactions)) {
       $texts = array_mergev($blocks);
       $phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
         $this->getActor(),
         $texts);
     } else {
       $phids = array();
     }
 
     $this->mentionedPHIDs = $phids;
 
     if ($object->getPHID()) {
       // Don't try to subscribe already-subscribed mentions: we want to generate
       // a dialog about an action having no effect if the user explicitly adds
       // existing CCs, but not if they merely mention existing subscribers.
       $phids = array_diff($phids, $this->subscribers);
     }
 
     if ($phids) {
       $users = id(new PhabricatorPeopleQuery())
         ->setViewer($this->getActor())
         ->withPHIDs($phids)
         ->execute();
       $users = mpull($users, null, 'getPHID');
 
       foreach ($phids as $key => $phid) {
         // Do not subscribe mentioned users
         // who do not have VIEW Permissions
         if ($object instanceof PhabricatorPolicyInterface
           && !PhabricatorPolicyFilter::hasCapability(
           $users[$phid],
           $object,
           PhabricatorPolicyCapability::CAN_VIEW)
         ) {
           unset($phids[$key]);
         } else {
           if ($object->isAutomaticallySubscribed($phid)) {
             unset($phids[$key]);
           }
         }
       }
       $phids = array_values($phids);
     }
     // No else here to properly return null should we unset all subscriber
     if (!$phids) {
       return null;
     }
 
     $xaction = newv(get_class(head($xactions)), array());
     $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
     $xaction->setNewValue(array('+' => $phids));
 
     return $xaction;
   }
 
   protected function getRemarkupBlocksFromTransaction(
     PhabricatorApplicationTransaction $transaction) {
     return $transaction->getRemarkupBlocks();
   }
 
   protected function mergeTransactions(
     PhabricatorApplicationTransaction $u,
     PhabricatorApplicationTransaction $v) {
 
     $type = $u->getTransactionType();
 
     switch ($type) {
       case PhabricatorTransactions::TYPE_SUBSCRIBERS:
         return $this->mergePHIDOrEdgeTransactions($u, $v);
       case PhabricatorTransactions::TYPE_EDGE:
         $u_type = $u->getMetadataValue('edge:type');
         $v_type = $v->getMetadataValue('edge:type');
         if ($u_type == $v_type) {
           return $this->mergePHIDOrEdgeTransactions($u, $v);
         }
         return null;
     }
 
     // By default, do not merge the transactions.
     return null;
   }
 
   /**
    * Optionally expand transactions which imply other effects. For example,
    * resigning from a revision in Differential implies removing yourself as
    * a reviewer.
    */
   private function expandTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $results = array();
     foreach ($xactions as $xaction) {
       foreach ($this->expandTransaction($object, $xaction) as $expanded) {
         $results[] = $expanded;
       }
     }
 
     return $results;
   }
 
   protected function expandTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     return array($xaction);
   }
 
 
   public function getExpandedSupportTransactions(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $xactions = array($xaction);
     $xactions = $this->expandSupportTransactions(
       $object,
       $xactions);
 
     if (count($xactions) == 1) {
       return array();
     }
 
     foreach ($xactions as $index => $cxaction) {
       if ($cxaction === $xaction) {
         unset($xactions[$index]);
         break;
       }
     }
 
     return $xactions;
   }
 
   private function expandSupportTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
     $this->loadSubscribers($object);
 
     $xactions = $this->applyImplicitCC($object, $xactions);
 
     $blocks = array();
     foreach ($xactions as $key => $xaction) {
       $blocks[$key] = $this->getRemarkupBlocksFromTransaction($xaction);
     }
 
     $subscribe_xaction = $this->buildSubscribeTransaction(
       $object,
       $xactions,
       $blocks);
     if ($subscribe_xaction) {
       $xactions[] = $subscribe_xaction;
     }
 
     // TODO: For now, this is just a placeholder.
     $engine = PhabricatorMarkupEngine::getEngine('extract');
     $engine->setConfig('viewer', $this->requireActor());
 
     $block_xactions = $this->expandRemarkupBlockTransactions(
       $object,
       $xactions,
       $blocks,
       $engine);
 
     foreach ($block_xactions as $xaction) {
       $xactions[] = $xaction;
     }
 
     return $xactions;
   }
 
   private function expandRemarkupBlockTransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     $blocks,
     PhutilMarkupEngine $engine) {
 
     $block_xactions = $this->expandCustomRemarkupBlockTransactions(
       $object,
       $xactions,
       $blocks,
       $engine);
 
     $mentioned_phids = array();
     if ($this->shouldEnableMentions($object, $xactions)) {
       foreach ($blocks as $key => $xaction_blocks) {
         foreach ($xaction_blocks as $block) {
           $engine->markupText($block);
           $mentioned_phids += $engine->getTextMetadata(
             PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
             array());
         }
       }
     }
 
     if (!$mentioned_phids) {
       return $block_xactions;
     }
 
     $mentioned_objects = id(new PhabricatorObjectQuery())
       ->setViewer($this->getActor())
       ->withPHIDs($mentioned_phids)
       ->execute();
 
     $mentionable_phids = array();
     if ($this->shouldEnableMentions($object, $xactions)) {
       foreach ($mentioned_objects as $mentioned_object) {
         if ($mentioned_object instanceof PhabricatorMentionableInterface) {
           $mentioned_phid = $mentioned_object->getPHID();
           if (idx($this->getUnmentionablePHIDMap(), $mentioned_phid)) {
             continue;
           }
           // don't let objects mention themselves
           if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
             continue;
           }
           $mentionable_phids[$mentioned_phid] = $mentioned_phid;
         }
       }
     }
 
     if ($mentionable_phids) {
       $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST;
       $block_xactions[] = newv(get_class(head($xactions)), array())
         ->setIgnoreOnNoEffect(true)
         ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
         ->setMetadataValue('edge:type', $edge_type)
         ->setNewValue(array('+' => $mentionable_phids));
     }
 
     return $block_xactions;
   }
 
   protected function expandCustomRemarkupBlockTransactions(
     PhabricatorLiskDAO $object,
     array $xactions,
     $blocks,
     PhutilMarkupEngine $engine) {
     return array();
   }
 
 
   /**
    * Attempt to combine similar transactions into a smaller number of total
    * transactions. For example, two transactions which edit the title of an
    * object can be merged into a single edit.
    */
   private function combineTransactions(array $xactions) {
     $stray_comments = array();
 
     $result = array();
     $types = array();
     foreach ($xactions as $key => $xaction) {
       $type = $xaction->getTransactionType();
       if (isset($types[$type])) {
         foreach ($types[$type] as $other_key) {
           $merged = $this->mergeTransactions($result[$other_key], $xaction);
           if ($merged) {
             $result[$other_key] = $merged;
 
             if ($xaction->getComment() &&
                 ($xaction->getComment() !== $merged->getComment())) {
               $stray_comments[] = $xaction->getComment();
             }
 
             if ($result[$other_key]->getComment() &&
                 ($result[$other_key]->getComment() !== $merged->getComment())) {
               $stray_comments[] = $result[$other_key]->getComment();
             }
 
             // Move on to the next transaction.
             continue 2;
           }
         }
       }
       $result[$key] = $xaction;
       $types[$type][] = $key;
     }
 
     // If we merged any comments away, restore them.
     foreach ($stray_comments as $comment) {
       $xaction = newv(get_class(head($result)), array());
       $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
       $xaction->setComment($comment);
       $result[] = $xaction;
     }
 
     return array_values($result);
   }
 
   protected function mergePHIDOrEdgeTransactions(
     PhabricatorApplicationTransaction $u,
     PhabricatorApplicationTransaction $v) {
 
     $result = $u->getNewValue();
     foreach ($v->getNewValue() as $key => $value) {
       if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
         if (empty($result[$key])) {
           $result[$key] = $value;
         } else {
           // We're merging two lists of edge adds, sets, or removes. Merge
           // them by merging individual PHIDs within them.
           $merged = $result[$key];
 
           foreach ($value as $dst => $v_spec) {
             if (empty($merged[$dst])) {
               $merged[$dst] = $v_spec;
             } else {
               // Two transactions are trying to perform the same operation on
               // the same edge. Normalize the edge data and then merge it. This
               // allows transactions to specify how data merges execute in a
               // precise way.
 
               $u_spec = $merged[$dst];
 
               if (!is_array($u_spec)) {
                 $u_spec = array('dst' => $u_spec);
               }
               if (!is_array($v_spec)) {
                 $v_spec = array('dst' => $v_spec);
               }
 
               $ux_data = idx($u_spec, 'data', array());
               $vx_data = idx($v_spec, 'data', array());
 
               $merged_data = $this->mergeEdgeData(
                 $u->getMetadataValue('edge:type'),
                 $ux_data,
                 $vx_data);
 
               $u_spec['data'] = $merged_data;
               $merged[$dst] = $u_spec;
             }
           }
 
           $result[$key] = $merged;
         }
       } else {
         $result[$key] = array_merge($value, idx($result, $key, array()));
       }
     }
     $u->setNewValue($result);
 
     // When combining an "ignore" transaction with a normal transaction, make
     // sure we don't propagate the "ignore" flag.
     if (!$v->getIgnoreOnNoEffect()) {
       $u->setIgnoreOnNoEffect(false);
     }
 
     return $u;
   }
 
   protected function mergeEdgeData($type, array $u, array $v) {
     return $v + $u;
   }
 
   protected function getPHIDTransactionNewValue(
     PhabricatorApplicationTransaction $xaction,
     $old = null) {
 
     if ($old !== null) {
       $old = array_fuse($old);
     } else {
       $old = array_fuse($xaction->getOldValue());
     }
 
     $new = $xaction->getNewValue();
     $new_add = idx($new, '+', array());
     unset($new['+']);
     $new_rem = idx($new, '-', array());
     unset($new['-']);
     $new_set = idx($new, '=', null);
     if ($new_set !== null) {
       $new_set = array_fuse($new_set);
     }
     unset($new['=']);
 
     if ($new) {
       throw new Exception(
         pht(
           "Invalid '%s' value for PHID transaction. Value should contain only ".
           "keys '%s' (add PHIDs), '%' (remove PHIDs) and '%s' (set PHIDS).",
           'new',
           '+',
           '-',
           '='));
     }
 
     $result = array();
 
     foreach ($old as $phid) {
       if ($new_set !== null && empty($new_set[$phid])) {
         continue;
       }
       $result[$phid] = $phid;
     }
 
     if ($new_set !== null) {
       foreach ($new_set as $phid) {
         $result[$phid] = $phid;
       }
     }
 
     foreach ($new_add as $phid) {
       $result[$phid] = $phid;
     }
 
     foreach ($new_rem as $phid) {
       unset($result[$phid]);
     }
 
     return array_values($result);
   }
 
   protected function getEdgeTransactionNewValue(
     PhabricatorApplicationTransaction $xaction) {
 
     $new = $xaction->getNewValue();
     $new_add = idx($new, '+', array());
     unset($new['+']);
     $new_rem = idx($new, '-', array());
     unset($new['-']);
     $new_set = idx($new, '=', null);
     unset($new['=']);
 
     if ($new) {
       throw new Exception(
         pht(
           "Invalid '%s' value for Edge transaction. Value should contain only ".
           "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
           'new',
           '+',
           '-',
           '='));
     }
 
     $old = $xaction->getOldValue();
 
     $lists = array($new_set, $new_add, $new_rem);
     foreach ($lists as $list) {
       $this->checkEdgeList($list);
     }
 
     $result = array();
     foreach ($old as $dst_phid => $edge) {
       if ($new_set !== null && empty($new_set[$dst_phid])) {
         continue;
       }
       $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
         $xaction,
         $edge,
         $dst_phid);
     }
 
     if ($new_set !== null) {
       foreach ($new_set as $dst_phid => $edge) {
         $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
           $xaction,
           $edge,
           $dst_phid);
       }
     }
 
     foreach ($new_add as $dst_phid => $edge) {
       $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
         $xaction,
         $edge,
         $dst_phid);
     }
 
     foreach ($new_rem as $dst_phid => $edge) {
       unset($result[$dst_phid]);
     }
 
     return $result;
   }
 
   private function checkEdgeList($list) {
     if (!$list) {
       return;
     }
     foreach ($list as $key => $item) {
       if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
         throw new Exception(
           pht(
             "Edge transactions must have destination PHIDs as in edge ".
             "lists (found key '%s').",
             $key));
       }
       if (!is_array($item) && $item !== $key) {
         throw new Exception(
           pht(
             "Edge transactions must have PHIDs or edge specs as values ".
             "(found value '%s').",
             $item));
       }
     }
   }
 
   private function normalizeEdgeTransactionValue(
     PhabricatorApplicationTransaction $xaction,
     $edge,
     $dst_phid) {
 
     if (!is_array($edge)) {
       if ($edge != $dst_phid) {
         throw new Exception(
           pht(
             'Transaction edge data must either be the edge PHID or an edge '.
             'specification dictionary.'));
       }
       $edge = array();
     } else {
       foreach ($edge as $key => $value) {
         switch ($key) {
           case 'src':
           case 'dst':
           case 'type':
           case 'data':
           case 'dateCreated':
           case 'dateModified':
           case 'seq':
           case 'dataID':
             break;
           default:
             throw new Exception(
               pht(
                 'Transaction edge specification contains unexpected key "%s".',
                 $key));
         }
       }
     }
 
     $edge['dst'] = $dst_phid;
 
     $edge_type = $xaction->getMetadataValue('edge:type');
     if (empty($edge['type'])) {
       $edge['type'] = $edge_type;
     } else {
       if ($edge['type'] != $edge_type) {
         $this_type = $edge['type'];
         throw new Exception(
           pht(
             "Edge transaction includes edge of type '%s', but ".
             "transaction is of type '%s'. Each edge transaction ".
             "must alter edges of only one type.",
             $this_type,
             $edge_type));
       }
     }
 
     if (!isset($edge['data'])) {
       $edge['data'] = array();
     }
 
     return $edge;
   }
 
   protected function sortTransactions(array $xactions) {
     $head = array();
     $tail = array();
 
     // Move bare comments to the end, so the actions precede them.
     foreach ($xactions as $xaction) {
       $type = $xaction->getTransactionType();
       if ($type == PhabricatorTransactions::TYPE_COMMENT) {
         $tail[] = $xaction;
       } else {
         $head[] = $xaction;
       }
     }
 
     return array_values(array_merge($head, $tail));
   }
 
 
   protected function filterTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $type_comment = PhabricatorTransactions::TYPE_COMMENT;
 
     $no_effect = array();
     $has_comment = false;
     $any_effect = false;
     foreach ($xactions as $key => $xaction) {
       if ($this->transactionHasEffect($object, $xaction)) {
         if ($xaction->getTransactionType() != $type_comment) {
           $any_effect = true;
         }
       } else if ($xaction->getIgnoreOnNoEffect()) {
         unset($xactions[$key]);
       } else {
         $no_effect[$key] = $xaction;
       }
       if ($xaction->hasComment()) {
         $has_comment = true;
       }
     }
 
     if (!$no_effect) {
       return $xactions;
     }
 
     if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
       throw new PhabricatorApplicationTransactionNoEffectException(
         $no_effect,
         $any_effect,
         $has_comment);
     }
 
     if (!$any_effect && !$has_comment) {
       // If we only have empty comment transactions, just drop them all.
       return array();
     }
 
     foreach ($no_effect as $key => $xaction) {
       if ($xaction->getComment()) {
         $xaction->setTransactionType($type_comment);
         $xaction->setOldValue(null);
         $xaction->setNewValue(null);
       } else {
         unset($xactions[$key]);
       }
     }
 
     return $xactions;
   }
 
 
   /**
    * Hook for validating transactions. This callback will be invoked for each
    * available transaction type, even if an edit does not apply any transactions
    * of that type. This allows you to raise exceptions when required fields are
    * missing, by detecting that the object has no field value and there is no
    * transaction which sets one.
    *
    * @param PhabricatorLiskDAO Object being edited.
    * @param string Transaction type to validate.
    * @param list<PhabricatorApplicationTransaction> Transactions of given type,
    *   which may be empty if the edit does not apply any transactions of the
    *   given type.
    * @return list<PhabricatorApplicationTransactionValidationError> List of
    *   validation errors.
    */
   protected function validateTransaction(
     PhabricatorLiskDAO $object,
     $type,
     array $xactions) {
 
     $errors = array();
     switch ($type) {
       case PhabricatorTransactions::TYPE_VIEW_POLICY:
         $errors[] = $this->validatePolicyTransaction(
           $object,
           $xactions,
           $type,
           PhabricatorPolicyCapability::CAN_VIEW);
         break;
       case PhabricatorTransactions::TYPE_EDIT_POLICY:
         $errors[] = $this->validatePolicyTransaction(
           $object,
           $xactions,
           $type,
           PhabricatorPolicyCapability::CAN_EDIT);
         break;
       case PhabricatorTransactions::TYPE_CUSTOMFIELD:
         $groups = array();
         foreach ($xactions as $xaction) {
           $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
         }
 
         $field_list = PhabricatorCustomField::getObjectFields(
           $object,
           PhabricatorCustomField::ROLE_EDIT);
         $field_list->setViewer($this->getActor());
 
         $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
         foreach ($field_list->getFields() as $field) {
           if (!$field->shouldEnableForRole($role_xactions)) {
             continue;
           }
           $errors[] = $field->validateApplicationTransactions(
             $this,
             $type,
             idx($groups, $field->getFieldKey(), array()));
         }
         break;
     }
 
     return array_mergev($errors);
   }
 
   private function validatePolicyTransaction(
     PhabricatorLiskDAO $object,
     array $xactions,
     $transaction_type,
     $capability) {
 
     $actor = $this->requireActor();
     $errors = array();
     // Note $this->xactions is necessary; $xactions is $this->xactions of
     // $transaction_type
     $policy_object = $this->adjustObjectForPolicyChecks(
       $object,
       $this->xactions);
 
     // Make sure the user isn't editing away their ability to $capability this
     // object.
     foreach ($xactions as $xaction) {
       try {
         PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
           $actor,
           $policy_object,
           $capability,
           $xaction->getNewValue());
       } catch (PhabricatorPolicyException $ex) {
         $errors[] = new PhabricatorApplicationTransactionValidationError(
           $transaction_type,
           pht('Invalid'),
           pht(
             'You can not select this %s policy, because you would no longer '.
             'be able to %s the object.',
             $capability,
             $capability),
           $xaction);
       }
     }
 
     if ($this->getIsNewObject()) {
       if (!$xactions) {
         $has_capability = PhabricatorPolicyFilter::hasCapability(
           $actor,
           $policy_object,
           $capability);
         if (!$has_capability) {
           $errors[] = new PhabricatorApplicationTransactionValidationError(
             $transaction_type,
             pht('Invalid'),
             pht(
               'The selected %s policy excludes you. Choose a %s policy '.
               'which allows you to %s the object.',
               $capability,
               $capability,
               $capability));
         }
       }
     }
 
     return $errors;
   }
 
   protected function adjustObjectForPolicyChecks(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     return clone $object;
   }
 
   protected function validateAllTransactions(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return array();
   }
 
   /**
    * Check for a missing text field.
    *
    * A text field is missing if the object has no value and there are no
    * transactions which set a value, or if the transactions remove the value.
    * This method is intended to make implementing @{method:validateTransaction}
    * more convenient:
    *
    *   $missing = $this->validateIsEmptyTextField(
    *     $object->getName(),
    *     $xactions);
    *
    * This will return `true` if the net effect of the object and transactions
    * is an empty field.
    *
    * @param wild Current field value.
    * @param list<PhabricatorApplicationTransaction> Transactions editing the
    *          field.
    * @return bool True if the field will be an empty text field after edits.
    */
   protected function validateIsEmptyTextField($field_value, array $xactions) {
     if (strlen($field_value) && empty($xactions)) {
       return false;
     }
 
     if ($xactions && strlen(last($xactions)->getNewValue())) {
       return false;
     }
 
     return true;
   }
 
 
 /* -(  Implicit CCs  )------------------------------------------------------- */
 
 
   /**
    * When a user interacts with an object, we might want to add them to CC.
    */
   final public function applyImplicitCC(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     if (!($object instanceof PhabricatorSubscribableInterface)) {
       // If the object isn't subscribable, we can't CC them.
       return $xactions;
     }
 
     $actor_phid = $this->getActingAsPHID();
 
     $type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
     if (phid_get_type($actor_phid) != $type_user) {
       // Transactions by application actors like Herald, Harbormaster and
       // Diffusion should not CC the applications.
       return $xactions;
     }
 
     if ($object->isAutomaticallySubscribed($actor_phid)) {
       // If they're auto-subscribed, don't CC them.
       return $xactions;
     }
 
     $should_cc = false;
     foreach ($xactions as $xaction) {
       if ($this->shouldImplyCC($object, $xaction)) {
         $should_cc = true;
         break;
       }
     }
 
     if (!$should_cc) {
       // Only some types of actions imply a CC (like adding a comment).
       return $xactions;
     }
 
     if ($object->getPHID()) {
       if (isset($this->subscribers[$actor_phid])) {
         // If the user is already subscribed, don't implicitly CC them.
         return $xactions;
       }
 
       $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $object->getPHID(),
         PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
       $unsub = array_fuse($unsub);
       if (isset($unsub[$actor_phid])) {
         // If the user has previously unsubscribed from this object explicitly,
         // don't implicitly CC them.
         return $xactions;
       }
     }
 
     $xaction = newv(get_class(head($xactions)), array());
     $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
     $xaction->setNewValue(array('+' => array($actor_phid)));
 
     array_unshift($xactions, $xaction);
 
     return $xactions;
   }
 
   protected function shouldImplyCC(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     return $xaction->isCommentTransaction();
   }
 
 
 /* -(  Sending Mail  )------------------------------------------------------- */
 
 
   /**
    * @task mail
    */
   protected function shouldSendMail(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return false;
   }
 
 
   /**
    * @task mail
    */
   protected function sendMail(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
-    // Check if any of the transactions are visible. If we don't have any
-    // visible transactions, don't send the mail.
-
-    $any_visible = false;
-    foreach ($xactions as $xaction) {
-      if (!$xaction->shouldHideForMail($xactions)) {
-        $any_visible = true;
-        break;
-      }
-    }
-
-    if (!$any_visible) {
-      return array();
-    }
-
     $email_to = $this->mailToPHIDs;
     $email_cc = $this->mailCCPHIDs;
     $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
 
     $targets = $this->buildReplyHandler($object)
       ->getMailTargets($email_to, $email_cc);
 
     // Set this explicitly before we start swapping out the effective actor.
     $this->setActingAsPHID($this->getActingAsPHID());
 
 
     $mailed = array();
     foreach ($targets as $target) {
       $original_actor = $this->getActor();
 
       $viewer = $target->getViewer();
       $this->setActor($viewer);
       $locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation());
 
       $caught = null;
+      $mail = null;
       try {
         // Reload handles for the new viewer.
         $this->loadHandles($xactions);
 
         $mail = $this->sendMailToTarget($object, $xactions, $target);
       } catch (Exception $ex) {
         $caught = $ex;
       }
 
       $this->setActor($original_actor);
       unset($locale);
 
       if ($caught) {
         throw $ex;
       }
 
-      foreach ($mail->buildRecipientList() as $phid) {
-        $mailed[$phid] = true;
+      if ($mail) {
+        foreach ($mail->buildRecipientList() as $phid) {
+          $mailed[$phid] = true;
+        }
       }
     }
 
     return array_keys($mailed);
   }
 
   private function sendMailToTarget(
     PhabricatorLiskDAO $object,
     array $xactions,
     PhabricatorMailTarget $target) {
 
+    // Check if any of the transactions are visible for this viewer. If we
+    // don't have any visible transactions, don't send the mail.
+
+    $any_visible = false;
+    foreach ($xactions as $xaction) {
+      if (!$xaction->shouldHideForMail($xactions)) {
+        $any_visible = true;
+        break;
+      }
+    }
+
+    if (!$any_visible) {
+      return null;
+    }
+
     $mail = $this->buildMailTemplate($object);
     $body = $this->buildMailBody($object, $xactions);
 
     $mail_tags = $this->getMailTags($object, $xactions);
     $action = $this->getMailAction($object, $xactions);
 
     if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
       $this->addEmailPreferenceSectionToMailBody(
         $body,
         $object,
         $xactions);
     }
 
     $mail
       ->setFrom($this->getActingAsPHID())
       ->setSubjectPrefix($this->getMailSubjectPrefix())
       ->setVarySubjectPrefix('['.$action.']')
       ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
       ->setRelatedPHID($object->getPHID())
       ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
       ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs)
       ->setMailTags($mail_tags)
       ->setIsBulk(true)
       ->setBody($body->render())
       ->setHTMLBody($body->renderHTML());
 
     foreach ($body->getAttachments() as $attachment) {
       $mail->addAttachment($attachment);
     }
 
     if ($this->heraldHeader) {
       $mail->addHeader('X-Herald-Rules', $this->heraldHeader);
     }
 
     if ($object instanceof PhabricatorProjectInterface) {
       $this->addMailProjectMetadata($object, $mail);
     }
 
     if ($this->getParentMessageID()) {
       $mail->setParentMessageID($this->getParentMessageID());
     }
 
     return $target->sendMail($mail);
   }
 
   private function addMailProjectMetadata(
     PhabricatorLiskDAO $object,
     PhabricatorMetaMTAMail $template) {
 
     $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
       $object->getPHID(),
       PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
 
     if (!$project_phids) {
       return;
     }
 
     // TODO: This viewer isn't quite right. It would be slightly better to use
     // the mail recipient, but that's not very easy given the way rendering
     // works today.
 
     $handles = id(new PhabricatorHandleQuery())
       ->setViewer($this->requireActor())
       ->withPHIDs($project_phids)
       ->execute();
 
     $project_tags = array();
     foreach ($handles as $handle) {
       if (!$handle->isComplete()) {
         continue;
       }
       $project_tags[] = '<'.$handle->getObjectName().'>';
     }
 
     if (!$project_tags) {
       return;
     }
 
     $project_tags = implode(', ', $project_tags);
     $template->addHeader('X-Phabricator-Projects', $project_tags);
   }
 
 
   protected function getMailThreadID(PhabricatorLiskDAO $object) {
     return $object->getPHID();
   }
 
 
   /**
    * @task mail
    */
   protected function getStrongestAction(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return last(msort($xactions, 'getActionStrength'));
   }
 
 
   /**
    * @task mail
    */
   protected function buildReplyHandler(PhabricatorLiskDAO $object) {
     throw new Exception(pht('Capability not supported.'));
   }
 
   /**
    * @task mail
    */
   protected function getMailSubjectPrefix() {
     throw new Exception(pht('Capability not supported.'));
   }
 
 
   /**
    * @task mail
    */
   protected function getMailTags(
     PhabricatorLiskDAO $object,
     array $xactions) {
     $tags = array();
 
     foreach ($xactions as $xaction) {
       $tags[] = $xaction->getMailTags();
     }
 
     return array_mergev($tags);
   }
 
   /**
    * @task mail
    */
   public function getMailTagsMap() {
     // TODO: We should move shared mail tags, like "comment", here.
     return array();
   }
 
 
   /**
    * @task mail
    */
   protected function getMailAction(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return $this->getStrongestAction($object, $xactions)->getActionName();
   }
 
 
   /**
    * @task mail
    */
   protected function buildMailTemplate(PhabricatorLiskDAO $object) {
     throw new Exception(pht('Capability not supported.'));
   }
 
 
   /**
    * @task mail
    */
   protected function getMailTo(PhabricatorLiskDAO $object) {
     throw new Exception(pht('Capability not supported.'));
   }
 
 
   /**
    * @task mail
    */
   protected function getMailCC(PhabricatorLiskDAO $object) {
     $phids = array();
     $has_support = false;
 
     if ($object instanceof PhabricatorSubscribableInterface) {
       $phids[] = $this->subscribers;
       $has_support = true;
     }
 
     if ($object instanceof PhabricatorProjectInterface) {
       $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $object->getPHID(),
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
 
       if ($project_phids) {
         $watcher_type = PhabricatorObjectHasWatcherEdgeType::EDGECONST;
 
         $query = id(new PhabricatorEdgeQuery())
           ->withSourcePHIDs($project_phids)
           ->withEdgeTypes(array($watcher_type));
         $query->execute();
 
         $watcher_phids = $query->getDestinationPHIDs();
         if ($watcher_phids) {
           // We need to do a visibility check for all the watchers, as
           // watching a project is not a guarantee that you can see objects
           // associated with it.
           $users = id(new PhabricatorPeopleQuery())
             ->setViewer($this->requireActor())
             ->withPHIDs($watcher_phids)
             ->execute();
 
           $watchers = array();
           foreach ($users as $user) {
             $can_see = PhabricatorPolicyFilter::hasCapability(
               $user,
               $object,
               PhabricatorPolicyCapability::CAN_VIEW);
             if ($can_see) {
               $watchers[] = $user->getPHID();
             }
           }
           $phids[] = $watchers;
         }
       }
 
       $has_support = true;
     }
 
     if (!$has_support) {
       throw new Exception(pht('Capability not supported.'));
     }
 
     return array_mergev($phids);
   }
 
 
   /**
    * @task mail
    */
   protected function buildMailBody(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $body = new PhabricatorMetaMTAMailBody();
     $body->setViewer($this->requireActor());
 
     $this->addHeadersAndCommentsToMailBody($body, $xactions);
     $this->addCustomFieldsToMailBody($body, $object, $xactions);
     return $body;
   }
 
 
   /**
    * @task mail
    */
   protected function addEmailPreferenceSectionToMailBody(
     PhabricatorMetaMTAMailBody $body,
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $href = PhabricatorEnv::getProductionURI(
       '/settings/panel/emailpreferences/');
     $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
   }
 
 
   /**
    * @task mail
    */
   protected function addHeadersAndCommentsToMailBody(
     PhabricatorMetaMTAMailBody $body,
     array $xactions) {
 
     $headers = array();
     $comments = array();
 
     foreach ($xactions as $xaction) {
       if ($xaction->shouldHideForMail($xactions)) {
         continue;
       }
 
       $header = $xaction->getTitleForMail();
       if ($header !== null) {
         $headers[] = $header;
       }
 
       $comment = $xaction->getBodyForMail();
       if ($comment !== null) {
         $comments[] = $comment;
       }
     }
     $body->addRawSection(implode("\n", $headers));
 
     foreach ($comments as $comment) {
       $body->addRemarkupSection($comment);
     }
   }
 
   /**
    * @task mail
    */
   protected function addCustomFieldsToMailBody(
     PhabricatorMetaMTAMailBody $body,
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     if ($object instanceof PhabricatorCustomFieldInterface) {
       $field_list = PhabricatorCustomField::getObjectFields(
         $object,
         PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
       $field_list->setViewer($this->getActor());
       $field_list->readFieldsFromStorage($object);
 
       foreach ($field_list->getFields() as $field) {
         $field->updateTransactionMailBody(
           $body,
           $this,
           $xactions);
       }
     }
   }
 
 
 
 /* -(  Publishing Feed Stories  )-------------------------------------------- */
 
 
   /**
    * @task feed
    */
   protected function shouldPublishFeedStory(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return false;
   }
 
 
   /**
    * @task feed
    */
   protected function getFeedStoryType() {
     return 'PhabricatorApplicationTransactionFeedStory';
   }
 
 
   /**
    * @task feed
    */
   protected function getFeedRelatedPHIDs(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $phids = array(
       $object->getPHID(),
       $this->getActingAsPHID(),
     );
 
     if ($object instanceof PhabricatorProjectInterface) {
       $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
         $object->getPHID(),
         PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
       foreach ($project_phids as $project_phid) {
         $phids[] = $project_phid;
       }
     }
 
     return $phids;
   }
 
 
   /**
    * @task feed
    */
   protected function getFeedNotifyPHIDs(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     return array_unique(array_merge(
       $this->getMailTo($object),
       $this->getMailCC($object)));
   }
 
 
   /**
    * @task feed
    */
   protected function getFeedStoryData(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $xactions = msort($xactions, 'getActionStrength');
     $xactions = array_reverse($xactions);
 
     return array(
       'objectPHID'        => $object->getPHID(),
       'transactionPHIDs'  => mpull($xactions, 'getPHID'),
     );
   }
 
 
   /**
    * @task feed
    */
   protected function publishFeedStory(
     PhabricatorLiskDAO $object,
     array $xactions,
     array $mailed_phids) {
 
     $xactions = mfilter($xactions, 'shouldHideForFeed', true);
 
     if (!$xactions) {
       return;
     }
 
     $related_phids = $this->feedRelatedPHIDs;
     $subscribed_phids = $this->feedNotifyPHIDs;
 
     $story_type = $this->getFeedStoryType();
     $story_data = $this->getFeedStoryData($object, $xactions);
 
     id(new PhabricatorFeedStoryPublisher())
       ->setStoryType($story_type)
       ->setStoryData($story_data)
       ->setStoryTime(time())
       ->setStoryAuthorPHID($this->getActingAsPHID())
       ->setRelatedPHIDs($related_phids)
       ->setPrimaryObjectPHID($object->getPHID())
       ->setSubscribedPHIDs($subscribed_phids)
       ->setMailRecipientPHIDs($mailed_phids)
       ->setMailTags($this->getMailTags($object, $xactions))
       ->publish();
   }
 
 
 /* -(  Search Index  )------------------------------------------------------- */
 
 
   /**
    * @task search
    */
   protected function supportsSearch() {
     return false;
   }
 
   /**
    * @task search
    */
   protected function getSearchContextParameter(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return null;
   }
 
 
 /* -(  Herald Integration )-------------------------------------------------- */
 
 
   protected function shouldApplyHeraldRules(
     PhabricatorLiskDAO $object,
     array $xactions) {
     return false;
   }
 
   protected function buildHeraldAdapter(
     PhabricatorLiskDAO $object,
     array $xactions) {
     throw new Exception(pht('No herald adapter specified.'));
   }
 
   private function setHeraldAdapter(HeraldAdapter $adapter) {
     $this->heraldAdapter = $adapter;
     return $this;
   }
 
   protected function getHeraldAdapter() {
     return $this->heraldAdapter;
   }
 
   private function setHeraldTranscript(HeraldTranscript $transcript) {
     $this->heraldTranscript = $transcript;
     return $this;
   }
 
   protected function getHeraldTranscript() {
     return $this->heraldTranscript;
   }
 
   private function applyHeraldRules(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $adapter = $this->buildHeraldAdapter($object, $xactions);
     $adapter->setContentSource($this->getContentSource());
     $adapter->setIsNewObject($this->getIsNewObject());
     if ($this->getApplicationEmail()) {
       $adapter->setApplicationEmail($this->getApplicationEmail());
     }
     $xscript = HeraldEngine::loadAndApplyRules($adapter);
 
     $this->setHeraldAdapter($adapter);
     $this->setHeraldTranscript($xscript);
 
     return array_merge(
       $this->didApplyHeraldRules($object, $adapter, $xscript),
       $adapter->getQueuedTransactions());
   }
 
   protected function didApplyHeraldRules(
     PhabricatorLiskDAO $object,
     HeraldAdapter $adapter,
     HeraldTranscript $transcript) {
     return array();
   }
 
 
 /* -(  Custom Fields  )------------------------------------------------------ */
 
 
   /**
    * @task customfield
    */
   private function getCustomFieldForTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     $field_key = $xaction->getMetadataValue('customfield:key');
     if (!$field_key) {
       throw new Exception(
         pht(
         "Custom field transaction has no '%s'!",
         'customfield:key'));
     }
 
     $field = PhabricatorCustomField::getObjectField(
       $object,
       PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
       $field_key);
 
     if (!$field) {
       throw new Exception(
         pht(
           "Custom field transaction has invalid '%s'; field '%s' ".
           "is disabled or does not exist.",
           'customfield:key',
           $field_key));
     }
 
     if (!$field->shouldAppearInApplicationTransactions()) {
       throw new Exception(
         pht(
           "Custom field transaction '%s' does not implement ".
           "integration for %s.",
           $field_key,
           'ApplicationTransactions'));
     }
 
     $field->setViewer($this->getActor());
 
     return $field;
   }
 
 
 /* -(  Files  )-------------------------------------------------------------- */
 
 
   /**
    * Extract the PHIDs of any files which these transactions attach.
    *
    * @task files
    */
   private function extractFilePHIDs(
     PhabricatorLiskDAO $object,
     array $xactions) {
 
     $blocks = array();
     foreach ($xactions as $xaction) {
       $blocks[] = $this->getRemarkupBlocksFromTransaction($xaction);
     }
     $blocks = array_mergev($blocks);
 
     $phids = array();
     if ($blocks) {
       $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
         $this->getActor(),
         $blocks);
     }
 
     foreach ($xactions as $xaction) {
       $phids[] = $this->extractFilePHIDsFromCustomTransaction(
         $object,
         $xaction);
     }
 
     $phids = array_unique(array_filter(array_mergev($phids)));
     if (!$phids) {
       return array();
     }
 
     // Only let a user attach files they can actually see, since this would
     // otherwise let you access any file by attaching it to an object you have
     // view permission on.
 
     $files = id(new PhabricatorFileQuery())
       ->setViewer($this->getActor())
       ->withPHIDs($phids)
       ->execute();
 
     return mpull($files, 'getPHID');
   }
 
   /**
    * @task files
    */
   protected function extractFilePHIDsFromCustomTransaction(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
     return array();
   }
 
 
   /**
    * @task files
    */
   private function attachFiles(
     PhabricatorLiskDAO $object,
     array $file_phids) {
 
     if (!$file_phids) {
       return;
     }
 
     $editor = new PhabricatorEdgeEditor();
 
     $src = $object->getPHID();
     $type = PhabricatorObjectHasFileEdgeType::EDGECONST;
     foreach ($file_phids as $dst) {
       $editor->addEdge($src, $type, $dst);
     }
 
     $editor->save();
   }
 
   private function applyInverseEdgeTransactions(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction,
     $inverse_type) {
 
     $old = $xaction->getOldValue();
     $new = $xaction->getNewValue();
 
     $add = array_keys(array_diff_key($new, $old));
     $rem = array_keys(array_diff_key($old, $new));
 
     $add = array_fuse($add);
     $rem = array_fuse($rem);
     $all = $add + $rem;
 
     $nodes = id(new PhabricatorObjectQuery())
       ->setViewer($this->requireActor())
       ->withPHIDs($all)
       ->execute();
 
     foreach ($nodes as $node) {
       if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
         continue;
       }
 
       $editor = $node->getApplicationTransactionEditor();
       $template = $node->getApplicationTransactionTemplate();
       $target = $node->getApplicationTransactionObject();
 
       if (isset($add[$node->getPHID()])) {
         $edge_edit_type = '+';
       } else {
         $edge_edit_type = '-';
       }
 
       $template
         ->setTransactionType($xaction->getTransactionType())
         ->setMetadataValue('edge:type', $inverse_type)
         ->setNewValue(
           array(
             $edge_edit_type => array($object->getPHID() => $object->getPHID()),
           ));
 
       $editor
         ->setContinueOnNoEffect(true)
         ->setContinueOnMissingFields(true)
         ->setParentMessageID($this->getParentMessageID())
         ->setIsInverseEdgeEditor(true)
         ->setActor($this->requireActor())
         ->setActingAsPHID($this->getActingAsPHID())
         ->setContentSource($this->getContentSource());
 
       $editor->applyTransactions($target, array($template));
     }
   }
 
 
 /* -(  Workers  )------------------------------------------------------------ */
 
 
   /**
    * Load any object state which is required to publish transactions.
    *
    * This hook is invoked in the main process before we compute data related
    * to publishing transactions (like email "To" and "CC" lists), and again in
    * the worker before publishing occurs.
    *
    * @return object Publishable object.
    * @task workers
    */
   protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
     return $object;
   }
 
 
   /**
    * Convert the editor state to a serializable dictionary which can be passed
    * to a worker.
    *
    * This data will be loaded with @{method:loadWorkerState} in the worker.
    *
    * @return dict<string, wild> Serializable editor state.
    * @task workers
    */
   final private function getWorkerState() {
     $state = array();
     foreach ($this->getAutomaticStateProperties() as $property) {
       $state[$property] = $this->$property;
     }
 
     $state += array(
       'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
       'custom' => $this->getCustomWorkerState(),
     );
 
     return $state;
   }
 
 
   /**
    * Hook; return custom properties which need to be passed to workers.
    *
    * @return dict<string, wild> Custom properties.
    * @task workers
    */
   protected function getCustomWorkerState() {
     return array();
   }
 
 
   /**
    * Load editor state using a dictionary emitted by @{method:getWorkerState}.
    *
    * This method is used to load state when running worker operations.
    *
    * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
    * @return this
    * @task workers
    */
   final public function loadWorkerState(array $state) {
     foreach ($this->getAutomaticStateProperties() as $property) {
       $this->$property = idx($state, $property);
     }
 
     $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
     $this->setExcludeMailRecipientPHIDs($exclude);
 
     $custom = idx($state, 'custom', array());
     $this->loadCustomWorkerState($custom);
 
     return $this;
   }
 
 
   /**
    * Hook; set custom properties on the editor from data emitted by
    * @{method:getCustomWorkerState}.
    *
    * @param dict<string, wild> Custom state,
    *   from @{method:getCustomWorkerState}.
    * @return this
    * @task workers
    */
   protected function loadCustomWorkerState(array $state) {
     return $this;
   }
 
 
   /**
    * Get a list of object properties which should be automatically sent to
    * workers in the state data.
    *
    * These properties will be automatically stored and loaded by the editor in
    * the worker.
    *
    * @return list<string> List of properties.
    * @task workers
    */
   private function getAutomaticStateProperties() {
     return array(
       'parentMessageID',
       'disableEmail',
       'isNewObject',
       'heraldEmailPHIDs',
       'heraldForcedEmailPHIDs',
       'heraldHeader',
       'mailToPHIDs',
       'mailCCPHIDs',
       'feedNotifyPHIDs',
       'feedRelatedPHIDs',
     );
   }
 
 }